mergeReceiptOcrPages accepts four kinds of non-overlap as a proven seam, deleting real receipt rows and leaving duplicates while reporting isComplete: true.
All four were found while porting this merger to the sibling react-native-receipt-scanner, across six review rounds on PR #19. Each was reproduced there against a faithful port of this algorithm.
The defects
1. A single identical row proves a seam
Buying two of one item prints the identical row twice. A page ending with that row and the next page beginning with it match exactly without overlapping at all.
leftCount = 1, rightCount = 1 → 25 normalized characters clears _minimumSingleLineCharacters (24), exactMatch gives similarity = 1.0 ≥ _minimumSingleLineSimilarity (0.92) → accepted. One real purchase is deleted.
2. Single-line approximate match deletes a repeat purchase
서울우유 1L 흰우유 990ml 1 2,000
서울우유 1L 흰우유 990ml 2 4,000
25 normalized characters, similarity 0.9200 — exactly at _minimumSingleLineSimilarity. Accepted; the second purchase is deleted.
3. The same mechanism one window wider
Page 0 ends with quantities 1 and 2, page 1 begins with quantities 3 and 4 of the same product, with no overlap between the captures:
39 normalized characters, length ratio 0, similarity 0.8974 ≥ _minimumWindowSimilarity (0.85). Accepted; two real purchases are deleted.
Tightening the single-line floor does not fix this — that was tried in the RN port and this case cleared the multi-line floor by the identical mechanism. The root cause is that the digits carry all the meaning and almost none of the edit distance, so no similarity floor separates "the same line, misread" from "a different purchase" on receipt text.
4. A shallow coincidence beats a deeper true overlap
_maxWindowLines = 8, so a nine-line overlap is never built as a window. A receipt repeating a separator and a section header at both ends of the overlapped region — [A, B, C, D, E, F, G, A, B] — matches at two lines with similarity = 1.0.
Result: two lines dropped, seven left duplicated, seam reported proven. Measured in the RN port as 18 output lines where 11 were correct.
This one is not fixed by requiring exact equality — both the shallow and the deep match are exact.
What the RN port did
- Removed approximate matching entirely. Seam matching requires normalized equality; the Levenshtein pass, the 512-character cost guard, the length-ratio prefilter and both similarity floors are gone. Closes 2 and 3.
- Removed the window cap and scan suffix/prefix depths deepest-first, returning the first depth that matches. Closes 4, and overlaps of any depth now merge.
- Require the matched region to hold two distinct lines. Closes 1, and also the variant where four identical rows split 2/2 across the boundary match at depth 2.
Net effect there: ~80 lines deleted, and the ten-page benchmark went from 59 ms to 3 ms — comparing pre-normalized line arrays with an early exit is cheaper than building joined windows and running an edit distance, even though the deepest-first scan is O(n²) worst case.
The deliberate cost is that a genuine seam whose OCR differs by one character is reported unproven instead of merged. That is the safe direction: a reported seam is visible in unmatchedBoundaryIndexes with the text intact, while a deleted row is invisible. If device data later shows too many unproven seams, the upgrade path is a digit-aware comparison (equal digit runs, fuzz elsewhere), never a looser floor.
Also in scope
docs/specs/0001-long-receipt-ocr-merge/spec.md describes the tie-break as "then the shorter normalized character count, then minimizes prefix lines removed, then minimizes suffix lines consumed", while the shipped _Overlap.isBetterThan breaks ties on the larger comparedCharacters and stops. The spec also omits the 512-character cost guard and the length-ratio prefilter that the implementation has. Whatever is decided here, the spec and the code should end up describing one algorithm.
Evidence basis
flutter_receipt_scanner/lib/src/ocr_page_merger.dart was read directly and the constants and control flow above are quoted from it. The similarity values were measured against the RN port, whose _similarity and _comparisonText equivalents behave identically. The Dart code itself was not executed — before fixing, reproduce all four as failing tests against mergeReceiptOcrPages rather than trusting this transfer.
mergeReceiptOcrPagesaccepts four kinds of non-overlap as a proven seam, deleting real receipt rows and leaving duplicates while reportingisComplete: true.All four were found while porting this merger to the sibling
react-native-receipt-scanner, across six review rounds on PR #19. Each was reproduced there against a faithful port of this algorithm.The defects
1. A single identical row proves a seam
Buying two of one item prints the identical row twice. A page ending with that row and the next page beginning with it match exactly without overlapping at all.
leftCount = 1,rightCount = 1→ 25 normalized characters clears_minimumSingleLineCharacters(24),exactMatchgivessimilarity = 1.0≥_minimumSingleLineSimilarity(0.92) → accepted. One real purchase is deleted.2. Single-line approximate match deletes a repeat purchase
25 normalized characters, similarity 0.9200 — exactly at
_minimumSingleLineSimilarity. Accepted; the second purchase is deleted.3. The same mechanism one window wider
Page 0 ends with quantities 1 and 2, page 1 begins with quantities 3 and 4 of the same product, with no overlap between the captures:
39 normalized characters, length ratio 0, similarity 0.8974 ≥
_minimumWindowSimilarity(0.85). Accepted; two real purchases are deleted.Tightening the single-line floor does not fix this — that was tried in the RN port and this case cleared the multi-line floor by the identical mechanism. The root cause is that the digits carry all the meaning and almost none of the edit distance, so no similarity floor separates "the same line, misread" from "a different purchase" on receipt text.
4. A shallow coincidence beats a deeper true overlap
_maxWindowLines = 8, so a nine-line overlap is never built as a window. A receipt repeating a separator and a section header at both ends of the overlapped region —[A, B, C, D, E, F, G, A, B]— matches at two lines withsimilarity = 1.0.Result: two lines dropped, seven left duplicated, seam reported proven. Measured in the RN port as 18 output lines where 11 were correct.
This one is not fixed by requiring exact equality — both the shallow and the deep match are exact.
What the RN port did
Net effect there: ~80 lines deleted, and the ten-page benchmark went from 59 ms to 3 ms — comparing pre-normalized line arrays with an early exit is cheaper than building joined windows and running an edit distance, even though the deepest-first scan is O(n²) worst case.
The deliberate cost is that a genuine seam whose OCR differs by one character is reported unproven instead of merged. That is the safe direction: a reported seam is visible in
unmatchedBoundaryIndexeswith the text intact, while a deleted row is invisible. If device data later shows too many unproven seams, the upgrade path is a digit-aware comparison (equal digit runs, fuzz elsewhere), never a looser floor.Also in scope
docs/specs/0001-long-receipt-ocr-merge/spec.mddescribes the tie-break as "then the shorter normalized character count, then minimizes prefix lines removed, then minimizes suffix lines consumed", while the shipped_Overlap.isBetterThanbreaks ties on the largercomparedCharactersand stops. The spec also omits the 512-character cost guard and the length-ratio prefilter that the implementation has. Whatever is decided here, the spec and the code should end up describing one algorithm.Evidence basis
flutter_receipt_scanner/lib/src/ocr_page_merger.dartwas read directly and the constants and control flow above are quoted from it. The similarity values were measured against the RN port, whose_similarityand_comparisonTextequivalents behave identically. The Dart code itself was not executed — before fixing, reproduce all four as failing tests againstmergeReceiptOcrPagesrather than trusting this transfer.