feat: CSS Grid editor with OCR-measured column widths and row heights
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 28s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 1m59s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 18s

Backend: add layout_metrics (avg_row_height_px, font_size_suggestion_px)
to build-grid response for faithful grid reconstruction.

Frontend: rewrite GridTable from HTML <table> to CSS Grid layout.
Column widths are now proportional to the OCR-measured x_min/x_max
positions. Row heights use the average content row height from the
scan. Column and row resize via drag handles (Excel-like).

Font: add Noto Sans (supports IPA characters) via next/font/google.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-17 13:48:47 +01:00
parent ab30e8b17a
commit b1cdb2531c
5 changed files with 343 additions and 79 deletions

View File

@@ -1,5 +1,6 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import localFont from 'next/font/local' import localFont from 'next/font/local'
import { Noto_Sans } from 'next/font/google'
import './globals.css' import './globals.css'
const inter = localFont({ const inter = localFont({
@@ -8,6 +9,12 @@ const inter = localFont({
display: 'swap', display: 'swap',
}) })
const notoSans = Noto_Sans({
subsets: ['latin', 'latin-ext'],
variable: '--font-noto-sans',
display: 'swap',
})
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'BreakPilot Admin Lehrer KI', title: 'BreakPilot Admin Lehrer KI',
description: 'Neues Admin-Frontend mit verbesserter Navigation und Rollen-System', description: 'Neues Admin-Frontend mit verbesserter Navigation und Rollen-System',
@@ -20,7 +27,7 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="de"> <html lang="de">
<body className={inter.className}>{children}</body> <body className={`${inter.className} ${notoSans.variable}`}>{children}</body>
</html> </html>
) )
} }

View File

@@ -195,6 +195,7 @@ export function GridEditor({ sessionId, onNext }: GridEditorProps) {
> >
<GridTable <GridTable
zone={zone} zone={zone}
layoutMetrics={grid.layout_metrics}
selectedCell={selectedCell} selectedCell={selectedCell}
onSelectCell={setSelectedCell} onSelectCell={setSelectedCell}
onCellTextChange={updateCellText} onCellTextChange={updateCellText}

View File

@@ -1,10 +1,11 @@
'use client' 'use client'
import { useCallback, useRef } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import type { GridZone } from './types' import type { GridZone, LayoutMetrics } from './types'
interface GridTableProps { interface GridTableProps {
zone: GridZone zone: GridZone
layoutMetrics?: LayoutMetrics
selectedCell: string | null selectedCell: string | null
onSelectCell: (cellId: string) => void onSelectCell: (cellId: string) => void
onCellTextChange: (cellId: string, text: string) => void onCellTextChange: (cellId: string, text: string) => void
@@ -13,8 +14,18 @@ interface GridTableProps {
onNavigate: (cellId: string, direction: 'up' | 'down' | 'left' | 'right') => void onNavigate: (cellId: string, direction: 'up' | 'down' | 'left' | 'right') => void
} }
/** Gutter width for row numbers (px). */
const ROW_NUM_WIDTH = 36
/** Minimum column width in px so columns remain usable. */
const MIN_COL_WIDTH = 40
/** Minimum row height in px. */
const MIN_ROW_HEIGHT = 26
export function GridTable({ export function GridTable({
zone, zone,
layoutMetrics,
selectedCell, selectedCell,
onSelectCell, onSelectCell,
onCellTextChange, onCellTextChange,
@@ -22,8 +33,69 @@ export function GridTable({
onToggleRowHeader, onToggleRowHeader,
onNavigate, onNavigate,
}: GridTableProps) { }: GridTableProps) {
const tableRef = useRef<HTMLTableElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
// ----------------------------------------------------------------
// Observe container width for scaling
// ----------------------------------------------------------------
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width)
})
ro.observe(el)
return () => ro.disconnect()
}, [])
// ----------------------------------------------------------------
// Compute column widths from OCR measurements
// ----------------------------------------------------------------
const zoneWidthPx = zone.bbox_px.w || layoutMetrics?.page_width_px || 1
const scale = containerWidth > 0 ? (containerWidth - ROW_NUM_WIDTH) / zoneWidthPx : 1
// Column widths in original pixels, then scaled to container
const [colWidthOverrides, setColWidthOverrides] = useState<number[] | null>(null)
const columnWidthsPx = zone.columns.map((col) => col.x_max_px - col.x_min_px)
const effectiveColWidths = (colWidthOverrides ?? columnWidthsPx).map(
(w) => Math.max(MIN_COL_WIDTH, w * scale),
)
// ----------------------------------------------------------------
// Compute row heights from OCR measurements
// ----------------------------------------------------------------
const avgRowHeightPx = layoutMetrics?.avg_row_height_px ?? 30
const [rowHeightOverrides, setRowHeightOverrides] = useState<Map<number, number>>(new Map())
const getRowHeight = (rowIndex: number, isHeader: boolean): number => {
if (rowHeightOverrides.has(rowIndex)) {
return rowHeightOverrides.get(rowIndex)!
}
const row = zone.rows.find((r) => r.index === rowIndex)
if (!row) return Math.max(MIN_ROW_HEIGHT, avgRowHeightPx * scale)
if (isHeader) {
// Headers keep their measured height
const measuredH = row.y_max_px - row.y_min_px
return Math.max(MIN_ROW_HEIGHT, measuredH * scale)
}
// Content rows use average for uniformity
return Math.max(MIN_ROW_HEIGHT, avgRowHeightPx * scale)
}
// ----------------------------------------------------------------
// Font size from layout metrics
// ----------------------------------------------------------------
const baseFontSize = layoutMetrics?.font_size_suggestion_px
? Math.max(11, layoutMetrics.font_size_suggestion_px * scale)
: 13
// ----------------------------------------------------------------
// Keyboard navigation
// ----------------------------------------------------------------
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent, cellId: string) => { (e: React.KeyboardEvent, cellId: string) => {
if (e.key === 'Tab') { if (e.key === 'Tab') {
@@ -45,7 +117,9 @@ export function GridTable({
[onNavigate], [onNavigate],
) )
// Build row→col cell lookup // ----------------------------------------------------------------
// Cell lookup
// ----------------------------------------------------------------
const cellMap = new Map<string, (typeof zone.cells)[0]>() const cellMap = new Map<string, (typeof zone.cells)[0]>()
for (const cell of zone.cells) { for (const cell of zone.cells) {
cellMap.set(`${cell.row_index}_${cell.col_index}`, cell) cellMap.set(`${cell.row_index}_${cell.col_index}`, cell)
@@ -62,38 +136,121 @@ export function GridTable({
return null return null
} }
// ----------------------------------------------------------------
// Column resize (drag)
// ----------------------------------------------------------------
const handleColResizeStart = useCallback(
(colIndex: number, startX: number) => {
const baseWidths = colWidthOverrides ?? [...columnWidthsPx]
const handleMouseMove = (e: MouseEvent) => {
const deltaPx = (e.clientX - startX) / scale
const newWidths = [...baseWidths]
newWidths[colIndex] = Math.max(20, baseWidths[colIndex] + deltaPx)
// Steal from next column to keep total constant
if (colIndex + 1 < newWidths.length) {
newWidths[colIndex + 1] = Math.max(20, baseWidths[colIndex + 1] - deltaPx)
}
setColWidthOverrides(newWidths)
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[colWidthOverrides, columnWidthsPx, scale],
)
// ----------------------------------------------------------------
// Row resize (drag)
// ----------------------------------------------------------------
const handleRowResizeStart = useCallback(
(rowIndex: number, startY: number, currentHeight: number) => {
const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientY - startY
const newH = Math.max(MIN_ROW_HEIGHT, currentHeight + delta)
setRowHeightOverrides((prev) => {
const next = new Map(prev)
next.set(rowIndex, newH)
return next
})
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[],
)
const isBoxZone = zone.zone_type === 'box' const isBoxZone = zone.zone_type === 'box'
const numCols = zone.columns.length
// CSS Grid template for columns: row-number gutter + proportional columns
const gridTemplateCols = `${ROW_NUM_WIDTH}px ${effectiveColWidths.map((w) => `${w.toFixed(1)}px`).join(' ')}`
return ( return (
<div className={`overflow-x-auto ${isBoxZone ? 'border-2 border-gray-400 dark:border-gray-500 rounded-lg' : ''}`}> <div
ref={containerRef}
className={`overflow-x-auto ${isBoxZone ? 'border-2 border-gray-400 dark:border-gray-500 rounded-lg' : ''}`}
>
{/* Zone label */} {/* Zone label */}
<div className="flex items-center gap-2 px-2 py-1 text-xs text-gray-500 dark:text-gray-400"> <div className="flex items-center gap-2 px-2 py-1 text-xs text-gray-500 dark:text-gray-400">
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${ <span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${
isBoxZone isBoxZone
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800' ? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800'
: 'bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700' : 'bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700'
}`}> }`}
>
{isBoxZone ? 'Box' : 'Inhalt'} Zone {zone.zone_index} {isBoxZone ? 'Box' : 'Inhalt'} Zone {zone.zone_index}
</span> </span>
<span>{zone.columns.length} Spalten, {zone.rows.length} Zeilen, {zone.cells.length} Zellen</span> <span>
{zone.columns.length} Spalten, {zone.rows.length} Zeilen, {zone.cells.length} Zellen
</span>
</div> </div>
<table ref={tableRef} className="w-full border-collapse text-sm"> {/* ============================================================ */}
{/* Column headers */} {/* CSS Grid — column headers */}
<thead> {/* ============================================================ */}
<tr> <div
{/* Row number header */} style={{
<th className="w-8 px-1 py-1.5 text-[10px] text-gray-400 dark:text-gray-500 font-normal border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50" /> display: 'grid',
{zone.columns.map((col) => ( gridTemplateColumns: gridTemplateCols,
<th fontFamily: "var(--font-noto-sans, 'Noto Sans'), 'Inter', system-ui, sans-serif",
fontSize: `${baseFontSize}px`,
}}
>
{/* Header: row-number corner */}
<div className="sticky left-0 z-10 px-1 py-1.5 text-[10px] text-gray-400 dark:text-gray-500 border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50" />
{/* Header: column labels with resize handles */}
{zone.columns.map((col, ci) => (
<div
key={col.index} key={col.index}
className={`px-2 py-1.5 text-xs font-medium border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${ className={`relative px-2 py-1.5 text-xs font-medium border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${
col.bold ? 'text-teal-700 dark:text-teal-300' : 'text-gray-600 dark:text-gray-400' col.bold ? 'text-teal-700 dark:text-teal-300' : 'text-gray-600 dark:text-gray-400'
}`} }`}
onClick={() => onToggleColumnBold(zone.zone_index, col.index)} onClick={() => onToggleColumnBold(zone.zone_index, col.index)}
title={`Spalte ${col.index + 1} — Klick fuer Fett-Toggle`} title={`Spalte ${col.index + 1} — Klick fuer Fett-Toggle`}
> >
<div className="flex items-center gap-1 justify-center"> <div className="flex items-center gap-1 justify-center truncate">
<span>{col.label}</span> <span>{col.label}</span>
{col.bold && ( {col.bold && (
<span className="text-[9px] px-1 py-0 rounded bg-teal-100 dark:bg-teal-900/40 text-teal-600 dark:text-teal-400"> <span className="text-[9px] px-1 py-0 rounded bg-teal-100 dark:bg-teal-900/40 text-teal-600 dark:text-teal-400">
@@ -101,61 +258,131 @@ export function GridTable({
</span> </span>
)} )}
</div> </div>
</th> {/* Right-edge resize handle */}
{ci < numCols - 1 && (
<div
className="absolute top-0 right-0 w-[5px] h-full cursor-col-resize hover:bg-teal-400/40 z-20"
onMouseDown={(e) => {
e.stopPropagation()
handleColResizeStart(ci, e.clientX)
}}
/>
)}
</div>
))} ))}
</tr>
</thead>
<tbody> {/* ============================================================ */}
{zone.rows.map((row) => ( {/* Data rows */}
<tr key={row.index} className={row.is_header ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}> {/* ============================================================ */}
{/* Row number */} {zone.rows.map((row) => {
<td const rowH = getRowHeight(row.index, row.is_header)
className={`w-8 px-1 py-1 text-center text-[10px] border-b border-r border-gray-200 dark:border-gray-700 cursor-pointer select-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${ const isSpanning = zone.cells.some(
(c) => c.row_index === row.index && c.col_type === 'spanning_header',
)
return (
<div key={row.index} style={{ display: 'contents' }}>
{/* Row number cell */}
<div
className={`relative sticky left-0 z-10 flex items-center justify-center text-[10px] border-b border-r border-gray-200 dark:border-gray-700 cursor-pointer select-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${
row.is_header row.is_header
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium'
: 'bg-gray-50 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500' : 'bg-gray-50 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500'
}`} }`}
style={{ height: `${rowH}px` }}
onClick={() => onToggleRowHeader(zone.zone_index, row.index)} onClick={() => onToggleRowHeader(zone.zone_index, row.index)}
title={`Zeile ${row.index + 1} — Klick fuer Header-Toggle`} title={`Zeile ${row.index + 1} — Klick fuer Header-Toggle`}
> >
{row.index + 1} {row.index + 1}
{row.is_header && <span className="block text-[8px]">H</span>} {row.is_header && <span className="block text-[8px]">H</span>}
</td> {/* Bottom-edge resize handle */}
<div
className="absolute bottom-0 left-0 w-full h-[4px] cursor-row-resize hover:bg-teal-400/40 z-20"
onMouseDown={(e) => {
e.stopPropagation()
handleRowResizeStart(row.index, e.clientY, rowH)
}}
/>
</div>
{/* Cells */} {/* Cells — spanning header or normal columns */}
{zone.columns.map((col) => { {isSpanning ? (
<div
className="border-b border-r border-gray-200 dark:border-gray-700 bg-blue-50/50 dark:bg-blue-900/10 flex items-center"
style={{
gridColumn: `2 / ${numCols + 2}`,
height: `${rowH}px`,
}}
>
{(() => {
const spanCell = zone.cells.find(
(c) => c.row_index === row.index && c.col_type === 'spanning_header',
)
if (!spanCell) return null
const cellId = spanCell.cell_id
const isSelected = selectedCell === cellId
const cellColor = getCellColor(spanCell)
return (
<div className="flex items-center w-full">
{cellColor && (
<span
className="flex-shrink-0 w-1.5 self-stretch rounded-l-sm"
style={{ backgroundColor: cellColor }}
/>
)}
<input
id={`cell-${cellId}`}
type="text"
value={spanCell.text}
onChange={(e) => onCellTextChange(cellId, e.target.value)}
onFocus={() => onSelectCell(cellId)}
onKeyDown={(e) => handleKeyDown(e, cellId)}
className={`w-full px-3 py-1 bg-transparent border-0 outline-none text-center ${
isSelected ? 'ring-2 ring-teal-500 ring-inset rounded' : ''
}`}
style={{ color: cellColor || undefined }}
spellCheck={false}
/>
</div>
)
})()}
</div>
) : (
zone.columns.map((col) => {
const cell = cellMap.get(`${row.index}_${col.index}`) const cell = cellMap.get(`${row.index}_${col.index}`)
const cellId = cell?.cell_id ?? `Z${zone.zone_index}_R${String(row.index).padStart(2, '0')}_C${col.index}` const cellId =
cell?.cell_id ??
`Z${zone.zone_index}_R${String(row.index).padStart(2, '0')}_C${col.index}`
const isSelected = selectedCell === cellId const isSelected = selectedCell === cellId
const isBold = col.bold || cell?.is_bold const isBold = col.bold || cell?.is_bold
const isLowConf = cell && cell.confidence > 0 && cell.confidence < 60 const isLowConf = cell && cell.confidence > 0 && cell.confidence < 60
const cellColor = getCellColor(cell) const cellColor = getCellColor(cell)
const hasColoredWords = cell?.word_boxes?.some( const hasColoredWords =
cell?.word_boxes?.some(
(wb) => wb.color_name && wb.color_name !== 'black', (wb) => wb.color_name && wb.color_name !== 'black',
) ?? false ) ?? false
return ( return (
<td <div
key={col.index} key={col.index}
className={`border-b border-r border-gray-200 dark:border-gray-700 p-0 transition-shadow ${ className={`relative border-b border-r border-gray-200 dark:border-gray-700 flex items-center ${
isSelected ? 'ring-2 ring-teal-500 ring-inset z-10 relative' : '' isSelected ? 'ring-2 ring-teal-500 ring-inset z-10' : ''
} ${isLowConf ? 'bg-amber-50/50 dark:bg-amber-900/10' : ''}`} } ${isLowConf ? 'bg-amber-50/50 dark:bg-amber-900/10' : ''} ${
row.is_header ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''
}`}
style={{ height: `${rowH}px` }}
> >
<div className="flex items-center">
{cellColor && ( {cellColor && (
<span <span
className="flex-shrink-0 w-1.5 h-full min-h-[28px] rounded-l-sm" className="flex-shrink-0 w-1.5 self-stretch rounded-l-sm"
style={{ backgroundColor: cellColor }} style={{ backgroundColor: cellColor }}
title={`Farbe: ${cell?.word_boxes?.find(wb => wb.color_name !== 'black')?.color_name}`} title={`Farbe: ${cell?.word_boxes?.find((wb) => wb.color_name !== 'black')?.color_name}`}
/> />
)} )}
{/* Per-word colored display when not editing */} {/* Per-word colored display when not editing */}
{hasColoredWords && !isSelected ? ( {hasColoredWords && !isSelected ? (
<div <div
className={`w-full px-2 py-1.5 cursor-text truncate ${ className={`w-full px-2 cursor-text truncate ${isBold ? 'font-bold' : 'font-normal'}`}
isBold ? 'font-bold' : 'font-normal'
} ${row.is_header ? 'text-base' : 'text-sm'}`}
onClick={() => { onClick={() => {
onSelectCell(cellId) onSelectCell(cellId)
setTimeout(() => document.getElementById(`cell-${cellId}`)?.focus(), 0) setTimeout(() => document.getElementById(`cell-${cellId}`)?.focus(), 0)
@@ -185,20 +412,20 @@ export function GridTable({
}} }}
onFocus={() => onSelectCell(cellId)} onFocus={() => onSelectCell(cellId)}
onKeyDown={(e) => handleKeyDown(e, cellId)} onKeyDown={(e) => handleKeyDown(e, cellId)}
className={`w-full px-2 py-1.5 bg-transparent border-0 outline-none ${ className={`w-full px-2 bg-transparent border-0 outline-none ${
isBold ? 'font-bold' : 'font-normal' isBold ? 'font-bold' : 'font-normal'
} ${row.is_header ? 'text-base' : 'text-sm'}`} }`}
spellCheck={false} spellCheck={false}
/> />
)} )}
</div> </div>
</td> )
})
)}
</div>
) )
})} })}
</tr> </div>
))}
</tbody>
</table>
</div> </div>
) )
} }

View File

@@ -3,6 +3,14 @@ import type { OcrWordBox } from '@/app/(admin)/ai/ocr-pipeline/types'
// Re-export for convenience // Re-export for convenience
export type { OcrWordBox } export type { OcrWordBox }
/** Layout metrics derived from OCR word positions for faithful grid reconstruction. */
export interface LayoutMetrics {
page_width_px: number
page_height_px: number
avg_row_height_px: number
font_size_suggestion_px: number
}
/** A complete structured grid with zones, ready for the Excel-like editor. */ /** A complete structured grid with zones, ready for the Excel-like editor. */
export interface StructuredGrid { export interface StructuredGrid {
session_id: string session_id: string
@@ -12,6 +20,7 @@ export interface StructuredGrid {
boxes_detected: number boxes_detected: number
summary: GridSummary summary: GridSummary
formatting: GridFormatting formatting: GridFormatting
layout_metrics?: LayoutMetrics
duration_seconds: number duration_seconds: number
edited?: boolean edited?: boolean
} }

View File

@@ -991,6 +991,20 @@ async def build_grid(session_id: str):
cn = wb.get("color_name", "black") cn = wb.get("color_name", "black")
color_stats[cn] = color_stats.get(cn, 0) + 1 color_stats[cn] = color_stats.get(cn, 0) + 1
# Compute layout metrics for faithful grid reconstruction
all_content_row_heights: List[float] = []
for z in zones_data:
for row in z.get("rows", []):
if not row.get("is_header", False):
h = row.get("y_max_px", 0) - row.get("y_min_px", 0)
if h > 0:
all_content_row_heights.append(h)
avg_row_height = (
sum(all_content_row_heights) / len(all_content_row_heights)
if all_content_row_heights else 30.0
)
font_size_suggestion = max(10, int(avg_row_height * 0.6))
result = { result = {
"session_id": session_id, "session_id": session_id,
"image_width": img_w, "image_width": img_w,
@@ -1010,6 +1024,12 @@ async def build_grid(session_id: str):
"bold_columns": [], "bold_columns": [],
"header_rows": [], "header_rows": [],
}, },
"layout_metrics": {
"page_width_px": img_w,
"page_height_px": img_h,
"avg_row_height_px": round(avg_row_height, 1),
"font_size_suggestion_px": font_size_suggestion,
},
"duration_seconds": round(duration, 2), "duration_seconds": round(duration, 2),
} }