diff --git a/admin-lehrer/components/ocr-overlay/useSlideWordPositions.ts b/admin-lehrer/components/ocr-overlay/useSlideWordPositions.ts index 1ab5722..76d1798 100644 --- a/admin-lehrer/components/ocr-overlay/useSlideWordPositions.ts +++ b/admin-lehrer/components/ocr-overlay/useSlideWordPositions.ts @@ -11,20 +11,17 @@ export interface WordPosition { /** * "Slide from left" positioning algorithm. * - * Takes ALL recognised words per cell and slides them left-to-right across - * the row's dark-pixel projection until each word "locks" onto its ink. + * Groups (separated by 3+ spaces in the OCR text) are slid left-to-right + * across the dark-pixel projection until each group locks onto its ink. + * Each group becomes one WordPosition — no words are dropped. * - * Font size: fontRatio = 1.0 for all tokens. The renderer computes the - * actual font size as medianCellHeightPx * fontRatio * fontScale, which - * matches the fallback rendering exactly. The user controls size via the - * font-scale slider. + * The key difference from the cluster algorithm: instead of matching groups + * to detected clusters (which can fail when cluster count != group count), + * we slide each group sequentially and let it find its own ink. * - * Position: each token's x-position is found by sliding a cursor from left - * to right and looking for dark-pixel coverage. Token width (wPct) is - * computed from canvas measureText proportional to the median cell height, - * giving visually correct character widths. - * - * Guarantees: no words dropped, no complex matching rules needed. + * Font size: fontRatio = 1.0 for all (same as fallback rendering). + * Width: each group's wPct is measured from canvas measureText, scaled to + * match the rendered font size, so text fills its container exactly. */ export function useSlideWordPositions( imageUrl: string, @@ -58,7 +55,11 @@ export function useSlideWordPositions( ctx.drawImage(img, 0, 0) } - // --- Compute median cell height in image pixels --- + const refFontSize = 40 + const fontFam = "'Liberation Sans', Arial, sans-serif" + ctx.font = `${refFontSize}px ${fontFam}` + + // --- Median cell height for consistent font sizing --- const cellHeights = cells .filter(c => c.bbox_pct && c.bbox_pct.h > 0) .map(c => Math.round(c.bbox_pct.h / 100 * imgH)) @@ -67,30 +68,9 @@ export function useSlideWordPositions( ? cellHeights[Math.floor(cellHeights.length / 2)] : 30 - // The renderer computes: fontSize = medianCellHeightPx * fontRatio * fontScale - // With fontRatio=1.0 and fontScale=0.7 (default), that's 70% of median cell height. - // We need to know how wide each token is at THAT rendered font size, - // expressed in image pixels. - // - // The rendered container is reconWidth px wide = imgW image pixels. - // So 1 image pixel = reconWidth/imgW display pixels. - // Rendered font size (display px) = medianCellHeightPx_display * 1.0 * fontScale - // medianCellHeightPx_display = medianCh * (reconWidth / imgW) - // So rendered font = medianCh * (reconWidth/imgW) * fontScale - // In image-pixel units: medianCh * fontScale - // - // measureText at refFontSize=40 gives pixel widths. - // Scale from refFontSize → actual image-pixel font size: - const refFontSize = 40 - const fontFam = "'Liberation Sans', Arial, sans-serif" - ctx.font = `${refFontSize}px ${fontFam}` - - // Approximate rendered font size in image pixels. - // fontScale default is 0.7 but we don't know it here. - // Use 0.7 as approximation — the slide positions will still be correct - // because we only use this for relative token widths (proportional). - const approxFontScale = 0.7 - const renderedFontImgPx = medianCh * approxFontScale + // Scale: measureText (at refFontSize=40) → image pixels at rendered font. + // Rendered font in image-pixel units ≈ medianCh * fontScale(0.7). + const renderedFontImgPx = medianCh * 0.7 const measureScale = renderedFontImgPx / refFontSize const positions = new Map() @@ -98,7 +78,6 @@ export function useSlideWordPositions( for (const cell of cells) { if (!cell.bbox_pct || !cell.text) continue - // --- Cell rectangle in image pixels --- let cx: number, cy: number const cw = Math.round(cell.bbox_pct.w / 100 * imgW) const ch = Math.round(cell.bbox_pct.h / 100 * imgH) @@ -127,71 +106,119 @@ export function useSlideWordPositions( } const threshold = Math.max(1, ch * 0.03) - - // Binary ink mask const ink = new Uint8Array(cw) for (let x = 0; x < cw; x++) { ink[x] = proj[x] >= threshold ? 1 : 0 } - if (rotation === 180) { ink.reverse() } - // --- Tokens --- - const tokens = cell.text.split(/\s+/).filter(Boolean) - if (tokens.length === 0) continue + // --- Split into GROUPS by 3+ spaces (preserving column structure) --- + // Then fall back to the full text as a single group. + let groups = cell.text.split(/\s{3,}/).map(s => s.trim()).filter(Boolean) + if (groups.length === 0) groups = [cell.text.trim()] + if (groups.length === 0 || !groups[0]) continue - // Token widths in image pixels at the approximate rendered font size - const tokenWidthsPx = tokens.map(t => - Math.max(4, Math.round(ctx.measureText(t).width * measureScale)) + // Measure each group's width in image pixels + const groupWidthsPx = groups.map(g => + Math.max(4, Math.round(ctx.measureText(g).width * measureScale)) ) - const spaceWidthPx = Math.max(2, Math.round(ctx.measureText(' ').width * measureScale)) - // --- Slide each token left-to-right --- + // --- Find dark-pixel clusters (contiguous inked regions) --- + // Used to determine the ACTUAL ink width for each group (for wPct). + const minGap = Math.max(5, Math.round(cw * 0.02)) + const clusters: { start: number; end: number }[] = [] + let inCluster = false + let clStart = 0 + let gap = 0 + + for (let x = 0; x < cw; x++) { + if (ink[x]) { + if (!inCluster) { clStart = x; inCluster = true } + gap = 0 + } else if (inCluster) { + gap++ + if (gap > minGap) { + clusters.push({ start: clStart, end: x - gap }) + inCluster = false + gap = 0 + } + } + } + if (inCluster) clusters.push({ start: clStart, end: cw - 1 - gap }) + + // Filter narrow clusters (box borders / noise) + const minClusterW = Math.max(3, Math.round(cw * 0.005)) + const filteredClusters = clusters.filter(c => (c.end - c.start + 1) > minClusterW) + + // --- Slide each group left-to-right to find its ink --- const wordPos: WordPosition[] = [] let cursor = 0 - for (let ti = 0; ti < tokens.length; ti++) { - const tokenW = tokenWidthsPx[ti] + for (let gi = 0; gi < groups.length; gi++) { + const groupW = groupWidthsPx[gi] - // Find first x from cursor where ≥20% of span has ink - const coverageNeeded = Math.max(1, Math.round(tokenW * 0.20)) + // Find the first cluster (from cursor) that has substantial ink + // coverage under this group's expected width. + const coverageNeeded = Math.max(1, Math.round(groupW * 0.15)) let bestX = cursor + let foundInk = false - const searchLimit = Math.max(cursor, cw - tokenW) - - for (let x = cursor; x <= searchLimit; x++) { + for (let x = cursor; x <= cw - Math.min(groupW, cw); x++) { let inkCount = 0 - const spanEnd = Math.min(x + tokenW, cw) + const spanEnd = Math.min(x + groupW, cw) for (let dx = 0; dx < spanEnd - x; dx++) { inkCount += ink[x + dx] } if (inkCount >= coverageNeeded) { bestX = x - break - } - // Safety: don't scan more than 40% of cell width past cursor - if (x > cursor + cw * 0.4 && ti > 0) { - bestX = cursor + foundInk = true break } } - // Clamp to cell bounds - if (bestX + tokenW > cw) { - bestX = Math.max(0, cw - tokenW) + // If no ink found, try placing at the matching cluster position + if (!foundInk && filteredClusters.length > gi) { + bestX = filteredClusters[gi].start + } else if (!foundInk) { + bestX = cursor } + // Determine width: use the ink span from bestX to the next gap, + // but at least the measured text width. + let inkEnd = bestX + groupW + // Extend to cover the actual ink region starting at bestX + for (let x = bestX; x < cw; x++) { + if (!ink[x]) { + gap = 0 + for (let gx = x; gx < Math.min(x + minGap + 1, cw); gx++) { + if (!ink[gx]) gap++ + else break + } + if (gap > minGap) { + inkEnd = x + break + } + } + inkEnd = x + 1 + } + // Use the larger of: measured text width or actual ink span + const actualW = Math.max(groupW, inkEnd - bestX) + + // Clamp + const clampedX = Math.min(bestX, cw - 1) + const clampedW = Math.min(actualW, cw - clampedX) + wordPos.push({ - xPct: cell.bbox_pct.x + (bestX / cw) * cell.bbox_pct.w, - wPct: (tokenW / cw) * cell.bbox_pct.w, - text: tokens[ti], + xPct: cell.bbox_pct.x + (clampedX / cw) * cell.bbox_pct.w, + wPct: (clampedW / cw) * cell.bbox_pct.w, + text: groups[gi], fontRatio: 1.0, }) - // Advance cursor: past this token + space - cursor = bestX + tokenW + spaceWidthPx + // Advance cursor past this group's ink region + gap + cursor = clampedX + clampedW + minGap } if (wordPos.length > 0) {