import { useEffect, useState } from 'react' import type { GridCell } from '@/app/(admin)/ai/ocr-overlay/types' export interface WordPosition { xPct: number wPct: number text: string fontRatio: number } /** * "Slide from left" positioning algorithm. * * 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. * * 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. * * 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, cells: GridCell[], active: boolean, rotation: 0 | 180 = 0, ): Map { const [result, setResult] = useState>(new Map()) useEffect(() => { if (!active || cells.length === 0 || !imageUrl) return const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => { const imgW = img.naturalWidth const imgH = img.naturalHeight const canvas = document.createElement('canvas') canvas.width = imgW canvas.height = imgH const ctx = canvas.getContext('2d') if (!ctx) return if (rotation === 180) { ctx.translate(imgW, imgH) ctx.rotate(Math.PI) ctx.drawImage(img, 0, 0) ctx.setTransform(1, 0, 0, 1, 0, 0) } else { ctx.drawImage(img, 0, 0) } 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)) .sort((a, b) => a - b) const medianCh = cellHeights.length > 0 ? cellHeights[Math.floor(cellHeights.length / 2)] : 30 // 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() for (const cell of cells) { if (!cell.bbox_pct || !cell.text) continue 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) if (rotation === 180) { cx = Math.round((100 - cell.bbox_pct.x - cell.bbox_pct.w) / 100 * imgW) cy = Math.round((100 - cell.bbox_pct.y - cell.bbox_pct.h) / 100 * imgH) } else { cx = Math.round(cell.bbox_pct.x / 100 * imgW) cy = Math.round(cell.bbox_pct.y / 100 * imgH) } if (cw <= 0 || ch <= 0) continue if (cx < 0) cx = 0 if (cy < 0) cy = 0 if (cx + cw > imgW || cy + ch > imgH) continue // --- Dark-pixel projection --- const imageData = ctx.getImageData(cx, cy, cw, ch) const proj = new Float32Array(cw) for (let y = 0; y < ch; y++) { for (let x = 0; x < cw; x++) { const idx = (y * cw + x) * 4 const lum = 0.299 * imageData.data[idx] + 0.587 * imageData.data[idx + 1] + 0.114 * imageData.data[idx + 2] if (lum < 128) proj[x]++ } } const threshold = Math.max(1, ch * 0.03) const ink = new Uint8Array(cw) for (let x = 0; x < cw; x++) { ink[x] = proj[x] >= threshold ? 1 : 0 } if (rotation === 180) { ink.reverse() } // --- 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 // Measure each group's width in image pixels const groupWidthsPx = groups.map(g => Math.max(4, Math.round(ctx.measureText(g).width * measureScale)) ) // --- 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 gi = 0; gi < groups.length; gi++) { const groupW = groupWidthsPx[gi] // 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 for (let x = cursor; x <= cw - Math.min(groupW, cw); x++) { let inkCount = 0 const spanEnd = Math.min(x + groupW, cw) for (let dx = 0; dx < spanEnd - x; dx++) { inkCount += ink[x + dx] } if (inkCount >= coverageNeeded) { bestX = x foundInk = true break } } // 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 + (clampedX / cw) * cell.bbox_pct.w, wPct: (clampedW / cw) * cell.bbox_pct.w, text: groups[gi], fontRatio: 1.0, }) // Advance cursor past this group's ink region + gap cursor = clampedX + clampedW + minGap } if (wordPos.length > 0) { positions.set(cell.cell_id, wordPos) } } setResult(positions) } img.src = imageUrl }, [active, cells, imageUrl, rotation]) return result }