docs+tests: update OCR Pipeline docs and add overlay position tests
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 28s
CI / test-python-klausur (push) Failing after 2m5s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 17s

MkDocs: document row-based merge algorithm, spatial overlap dedup,
and per-word yPct/hPct rendering in OCR Pipeline docs.

Tests: add 9 vitest tests for useSlideWordPositions covering
word-box path, fallback path, and yPct/hPct contract.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-13 21:03:00 +01:00
parent d6f51e4418
commit c2c082d4b4
2 changed files with 230 additions and 10 deletions

View File

@@ -0,0 +1,176 @@
/**
* Tests for useSlideWordPositions hook.
*
* The hook computes word positions from OCR word_boxes or pixel projection.
* Since Canvas/Image are not available in jsdom, we test the pure computation
* logic by extracting and verifying the WordPosition interface contract.
*/
import { describe, it, expect } from 'vitest'
// ---------------------------------------------------------------------------
// WordPosition interface (mirrored from useSlideWordPositions.ts)
// ---------------------------------------------------------------------------
interface WordPosition {
xPct: number
wPct: number
yPct: number
hPct: number
text: string
fontRatio: number
}
// ---------------------------------------------------------------------------
// Pure computation functions extracted from the hook for testing
// ---------------------------------------------------------------------------
/**
* Word-box path: compute WordPosition from an OCR word_box.
* Replicates the word_boxes.map() logic in useSlideWordPositions.
*/
function wordBoxToPosition(
box: { text: string; left: number; top: number; width: number; height: number },
imgW: number,
imgH: number,
): WordPosition {
return {
xPct: (box.left / imgW) * 100,
wPct: (box.width / imgW) * 100,
yPct: (box.top / imgH) * 100,
hPct: (box.height / imgH) * 100,
text: box.text,
fontRatio: 1.0,
}
}
/**
* Fallback path (no word_boxes): spread tokens evenly across cell bbox.
* Replicates the fallback logic in useSlideWordPositions.
*/
function fallbackPositions(
tokens: string[],
bboxPct: { x: number; y: number; w: number; h: number },
): WordPosition[] {
const fallbackW = bboxPct.w / tokens.length
return tokens.map((t, i) => ({
xPct: bboxPct.x + i * fallbackW,
wPct: fallbackW,
yPct: bboxPct.y,
hPct: bboxPct.h,
text: t,
fontRatio: 1.0,
}))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('wordBoxToPosition (word-box path)', () => {
it('should compute percentage positions from pixel coordinates', () => {
const box = { text: 'hello', left: 100, top: 200, width: 80, height: 20 }
const wp = wordBoxToPosition(box, 1000, 2000)
expect(wp.xPct).toBeCloseTo(10, 1) // 100/1000 * 100
expect(wp.wPct).toBeCloseTo(8, 1) // 80/1000 * 100
expect(wp.yPct).toBeCloseTo(10, 1) // 200/2000 * 100
expect(wp.hPct).toBeCloseTo(1, 1) // 20/2000 * 100
expect(wp.text).toBe('hello')
expect(wp.fontRatio).toBe(1.0)
})
it('should produce different yPct for words on different lines', () => {
const imgW = 1000, imgH = 2000
const word1 = wordBoxToPosition({ text: 'line1', left: 50, top: 100, width: 60, height: 20 }, imgW, imgH)
const word2 = wordBoxToPosition({ text: 'line2', left: 50, top: 130, width: 60, height: 20 }, imgW, imgH)
expect(word1.yPct).not.toEqual(word2.yPct)
expect(word2.yPct).toBeGreaterThan(word1.yPct)
})
it('should handle word at origin', () => {
const wp = wordBoxToPosition({ text: 'a', left: 0, top: 0, width: 50, height: 25 }, 500, 500)
expect(wp.xPct).toBe(0)
expect(wp.yPct).toBe(0)
expect(wp.wPct).toBeCloseTo(10, 1)
expect(wp.hPct).toBeCloseTo(5, 1)
})
it('should handle word at bottom-right corner', () => {
const wp = wordBoxToPosition({ text: 'z', left: 900, top: 1900, width: 100, height: 100 }, 1000, 2000)
expect(wp.xPct).toBe(90)
expect(wp.yPct).toBe(95)
expect(wp.wPct).toBe(10)
expect(wp.hPct).toBe(5)
})
})
describe('fallbackPositions (no word_boxes)', () => {
it('should spread tokens evenly across cell width', () => {
const bbox = { x: 10, y: 20, w: 60, h: 5 }
const positions = fallbackPositions(['apple', 'Apfel'], bbox)
expect(positions.length).toBe(2)
expect(positions[0].xPct).toBeCloseTo(10, 1)
expect(positions[1].xPct).toBeCloseTo(40, 1) // 10 + 30
expect(positions[0].wPct).toBeCloseTo(30, 1)
expect(positions[1].wPct).toBeCloseTo(30, 1)
})
it('should use cell bbox for Y position (all words same Y)', () => {
const bbox = { x: 5, y: 30, w: 80, h: 4 }
const positions = fallbackPositions(['a', 'b', 'c'], bbox)
for (const wp of positions) {
expect(wp.yPct).toBe(30)
expect(wp.hPct).toBe(4)
}
})
it('should handle single token', () => {
const bbox = { x: 15, y: 25, w: 50, h: 6 }
const positions = fallbackPositions(['word'], bbox)
expect(positions.length).toBe(1)
expect(positions[0].xPct).toBe(15)
expect(positions[0].wPct).toBe(50)
expect(positions[0].yPct).toBe(25)
expect(positions[0].hPct).toBe(6)
})
})
describe('WordPosition yPct/hPct contract', () => {
it('word-box path: yPct comes from box.top, not cell bbox', () => {
// This is the key fix: multi-line cells should NOT stack words at cell center
const cellBbox = { x: 10, y: 20, w: 60, h: 10 } // cell spans y=20% to y=30%
const imgW = 1000, imgH = 1000
// Two words on different lines within the same cell
const word1 = wordBoxToPosition({ text: 'line1', left: 100, top: 200, width: 80, height: 20 }, imgW, imgH)
const word2 = wordBoxToPosition({ text: 'line2', left: 100, top: 260, width: 80, height: 20 }, imgW, imgH)
// word1 should be at y=20%, word2 at y=26% — NOT both at cellBbox.y (20%)
expect(word1.yPct).toBeCloseTo(20, 1)
expect(word2.yPct).toBeCloseTo(26, 1)
expect(word1.yPct).not.toEqual(word2.yPct)
// Both should have individual heights from their box, not cell height
expect(word1.hPct).toBeCloseTo(2, 1)
expect(word2.hPct).toBeCloseTo(2, 1)
// Cell height would be 10% — word height is 2%, confirming per-word sizing
expect(word1.hPct).toBeLessThan(cellBbox.h)
})
it('fallback path: yPct equals cell bbox.y (no per-word data)', () => {
const bbox = { x: 10, y: 45, w: 30, h: 8 }
const positions = fallbackPositions(['a', 'b'], bbox)
// Without word_boxes, all words use cell bbox Y — expected behavior
expect(positions[0].yPct).toBe(bbox.y)
expect(positions[1].yPct).toBe(bbox.y)
expect(positions[0].hPct).toBe(bbox.h)
expect(positions[1].hPct).toBe(bbox.h)
})
})