fix: Slide-Modus nutzt cell.text Tokens statt word_boxes Text (keine Woerter verloren)
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 27s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m8s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 22s

TEXT kommt aus cell.text (bereinigt, IPA-korrigiert).
POSITIONEN kommen aus word_boxes (exakte OCR-Koordinaten).
Tokens werden 1:1 in Leserichtung zugeordnet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-11 20:01:57 +01:00
parent 0ee92e7210
commit 35f2706098

View File

@@ -11,14 +11,20 @@ export interface WordPosition {
/** /**
* "Slide from left" positioning using OCR word bounding boxes. * "Slide from left" positioning using OCR word bounding boxes.
* *
* If the backend provides `word_boxes` (exact per-word coordinates from * TEXT comes from cell.text (cleaned, IPA-corrected).
* Tesseract/RapidOCR), we place each word directly at its OCR position. * POSITIONS come from word_boxes (exact OCR coordinates).
* This gives pixel-accurate overlay without any heuristic pixel scanning.
* *
* Fallback: if no word_boxes, slide tokens across dark-pixel projection * Tokens from cell.text are matched 1:1 (in order) to word_boxes
* (original slide algorithm). * sorted left-to-right. This guarantees:
* - ALL words from cell.text appear (no dropping)
* - Words preserve their reading order
* - Each word lands on its correct black-text position
* - No red words overlap each other
* *
* Font size: fontRatio = 1.0 for all (matches fallback rendering). * If token count != box count, extra tokens get estimated positions
* (spread across remaining space).
*
* Fallback: pixel-projection slide if no word_boxes available.
*/ */
export function useSlideWordPositions( export function useSlideWordPositions(
imageUrl: string, imageUrl: string,
@@ -37,26 +43,79 @@ export function useSlideWordPositions(
const imgW = img.naturalWidth const imgW = img.naturalWidth
const imgH = img.naturalHeight const imgH = img.naturalHeight
// Check if we can use word_boxes (fast path — no canvas needed)
const hasWordBoxes = cells.some(c => c.word_boxes && c.word_boxes.length > 0) const hasWordBoxes = cells.some(c => c.word_boxes && c.word_boxes.length > 0)
if (hasWordBoxes) { if (hasWordBoxes) {
// --- FAST PATH: use OCR word bounding boxes directly --- // --- WORD-BOX PATH: use OCR positions with cell.text tokens ---
const positions = new Map<string, WordPosition[]>() const positions = new Map<string, WordPosition[]>()
for (const cell of cells) { for (const cell of cells) {
if (!cell.bbox_pct || !cell.text) continue if (!cell.bbox_pct || !cell.text) continue
const boxes = cell.word_boxes
if (!boxes || boxes.length === 0) continue
const wordPos: WordPosition[] = boxes // Tokens from the CLEANED cell text (reading order)
const tokens = cell.text.split(/\s+/).filter(Boolean)
if (tokens.length === 0) continue
// Word boxes sorted left-to-right
const boxes = (cell.word_boxes || [])
.filter(wb => wb.text.trim()) .filter(wb => wb.text.trim())
.map(wb => ({ .sort((a, b) => a.left - b.left)
xPct: (wb.left / imgW) * 100,
wPct: (wb.width / imgW) * 100, if (boxes.length === 0) {
text: wb.text, // No boxes — place all tokens at cell start as fallback
const fallbackW = cell.bbox_pct.w / tokens.length
const wordPos = tokens.map((t, i) => ({
xPct: cell.bbox_pct.x + i * fallbackW,
wPct: fallbackW,
text: t,
fontRatio: 1.0, fontRatio: 1.0,
})) }))
positions.set(cell.cell_id, wordPos)
continue
}
const wordPos: WordPosition[] = []
if (tokens.length <= boxes.length) {
// More boxes than tokens: assign each token to a box in order.
// This handles the common case where box count matches or
// exceeds token count (e.g. OCR found extra fragments).
for (let ti = 0; ti < tokens.length; ti++) {
const box = boxes[ti]
wordPos.push({
xPct: (box.left / imgW) * 100,
wPct: (box.width / imgW) * 100,
text: tokens[ti],
fontRatio: 1.0,
})
}
} else {
// More tokens than boxes: assign boxes to first N tokens,
// then spread remaining tokens after the last box.
for (let ti = 0; ti < boxes.length; ti++) {
const box = boxes[ti]
wordPos.push({
xPct: (box.left / imgW) * 100,
wPct: (box.width / imgW) * 100,
text: tokens[ti],
fontRatio: 1.0,
})
}
// Remaining tokens: estimate position after last box
const lastBox = boxes[boxes.length - 1]
let cursorPx = lastBox.left + lastBox.width + 5
for (let ti = boxes.length; ti < tokens.length; ti++) {
// Estimate width from average box width
const avgW = boxes.reduce((s, b) => s + b.width, 0) / boxes.length
wordPos.push({
xPct: (cursorPx / imgW) * 100,
wPct: (avgW / imgW) * 100,
text: tokens[ti],
fontRatio: 1.0,
})
cursorPx += avgW + 5
}
}
if (wordPos.length > 0) { if (wordPos.length > 0) {
positions.set(cell.cell_id, wordPos) positions.set(cell.cell_id, wordPos)
@@ -67,7 +126,7 @@ export function useSlideWordPositions(
return return
} }
// --- SLOW PATH: pixel-projection slide (fallback if no word_boxes) --- // --- FALLBACK: pixel-projection slide (no word_boxes) ---
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = imgW canvas.width = imgW
canvas.height = imgH canvas.height = imgH