Repository navigation
feat(ocr): replace broken PDF text layers with OCR - #4654
yashikam19 wants to merge 15 commits into
Conversation
|
✅ DCO Check Passed Thanks @yashikam19, all your commits are properly signed off. 🎉 |
Merge Protections🟢 Merge protection satisfied — ready to merge. Show 1 satisfied protection🟢 Enforce conventional commitMake sure that we follow https://www.conventionalcommits.org/en/v1.0.0/
|
AI triage (advisory)Checked commit The PR adds the option CI safety: no concern found. A maintainer must still approve CI. 2 related pull request(s) in the same code
The |
|
Thanks for the triage note. #4643 and this PR solve the same problem, but they differ in design. A short comparison to help the review:
I ran the The region-level routing in #4643 is finer than the page-level replacement here. If maintainers prefer, I am happy to combine the two: region-level routing from #4643 behind the opt-in option and the detector of this PR. @Kshitij3h, let me know what you think. |
|
Hi @yashikam19, thanks for the ping and the comparison. I agree with your suggestion to combine the approaches. Region-level OCR routing is essential to avoid discarding valid digital text on mixed pages, and pairing that with refined detection heuristics handles the edge cases well. I'll refine the quality heuristics in #4643 to ensure they generalize cleanly across broader document formats, symbols, and presentation artifacts. Happy to follow the maintainers' guidance on how best to coordinate or combine the work. |
|
I am concerned that the text layer may in some cases have additional text bits that serve as accessibility enhancements, and that these additional bits (which may or may not be identifiable as being for accessibility) may confound the quality rating. A check-and-purge of garbage content in the text layer seems sensible, but the purge should probably be human-gated. |
|
Thanks @wittjeff, that is a fair concern. The option is off by default, so a person decides per run whether any page can be replaced, and a replaced page keeps its low But you are right about hidden text: the check reads all text cells of a page, including invisible ones (render modes 3 and 7), where accessibility text or an older OCR layer often sits. On a replaced page, those invisible cells are dropped from the output too. Docling already treats invisible text separately when it selects OCR regions and merges OCR with PDF text (
Would this address the concern? |
|
yashikam19 I think your check-invisible-text-only idea makes sense as a strong heuristic so I suggest going with that. But I admit I don't feel like I have enough experience to know how well this will fit the broad range of use cases out there, so I'm curious if anyone else has thoughts. |
Some PDFs carry a text layer that does not match the text drawn on the page, such as an unmapped font encoding or leftover noise from a scanner's OCR. Docling trusts that layer, so the noise ends up in the output, and the page's parse_score still reports 1.0 because the existing per-cell checks only look for glyph-name dumps. Add docling.utils.text_quality.is_broken_text_layer(), which judges a page's whole text layer on patterns only broken text produces: control, private-use and replacement characters, symbols inside words, non-ASCII Latin runs, punctuation soup, image file names left by scanners, and unusual characters on scanned pages. Symbols common in ordinary documents never count. Page preprocessing sets parse_score to 0.0 for a broken page, so the score, its poor grade and the document-level score reflect it. Add OcrOptions.replace_broken_text_layer (default False). When enabled, a page whose parse_score is graded poor (below 0.5) is OCR'd in full and its OCR text replaces its PDF text, as OcrMode.FULL_PAGE does, for that page only. The option lives in the base OCR model, so every engine, including external plugins, supports it. Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
Some PDFs carry a text layer that does not match the text drawn on the page, because a font's glyphs were not mapped to real characters. Docling keeps that text, so garbage reaches the output while parse_score still reports 1.0. Add OcrOptions.replace_broken_text_layer and the CLI flag --replace-broken-text-layer (default off). When enabled, page preprocessing checks each page's whole text layer with docling.utils.text_quality.is_broken_text_layer(), which flags text made mostly of control or private-use characters, or Latin text that is mostly non-ASCII letters. It relies on Unicode properties only, with no character lists. A broken page's parse_score drops to 0.0, and every page graded poor (below POOR_SCORE_BOUND, shared with QualityGrade) is OCR'd in full, with its OCR text replacing its PDF text, as OcrMode.FULL_PAGE does, for that page only. parse_score keeps describing the PDF text layer. With the option off, preprocessing and parse_score are unchanged. The option lives in the base OCR model, so every OCR engine, including plugins, supports it. Tests: unit tests on ordinary text (code, LaTeX, JSON, markdown, units, several scripts) and on unmapped-font patterns, plus end-to-end tests with Tesseract on a generated PDF, with the option on and off. The usage skill docs cover the new option. Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
is_broken_text_layer() looked only at the whole page, and only for control, private-use and non-ASCII Latin characters. It missed a broken font that covers only part of a page, and a broken font that maps glyphs to the wrong ASCII letters. Keep the existing checks and add: - Per-line checks, so one broken cell on a good page is found: a run of 4 or more different private-use characters (icons repeat a few code points), or a token with 2 or more control characters. - U+FFFD density rules: runs on short pages, many U+FFFD, U+FFFD in several lines, or long runs. One stray U+FFFD does not flag a page. - A shifted-letter check for broken ToUnicode maps. The letter-frequency profile keeps the shape of a language, but the letters are in the wrong places. It counts only tokens with a lowercase letter, so acronyms and part numbers are skipped, and it needs 200 letters and a low vowel share. The non-ASCII Latin check now also needs few ASCII letters on the page, to protect mixed-language and Vietnamese text. Tests: ordinary text long enough for the letter check (English, Czech, Welsh, Vietnamese, C code, part numbers, base64, DNA, icon ratings, a single U+FFFD, a pdfium hyphen marker), and broken text that the old checks missed (shifted letters, one broken cell, U+FFFD below the old share) Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
OcrAutoModel built the options of the selected engine from mode and lang only, so with OcrAutoOptions a broken page was graded poor but not OCR'd. Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
…ed page The broken-text-layer check reads visible text only, so an invisible OCR layer or accessibility text cannot change the grade of a page. A replaced page keeps its invisible PDF cells where no OCR cell overlaps them, as the OCR merge already does. Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
10d1da5 to
48a1eb8
Compare
Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
|
@geoHeil, thanks for the reproduction. Fixed in The accented-Latin rule counted IPA symbols ( Your vowel chart is a regression case in Could you re-review when you have time? |
geoHeil
left a comment
There was a problem hiding this comment.
Reviewed afb05c774f6a2a267df1e9dafcec7251bfb69512. The earlier blocker is fixed. LGTM.
_non_ascii_latin_share() no longer counts IPA symbols (U+0250–U+02AF and U+1D00–U+1DBF) as accented Latin. No 8-bit code page decodes to these symbols, so the change does not hide real mojibake. The vowel chart from my earlier review is now a regression case in tests/test_text_quality.py. This case fails with the rule at 031cff70 and passes at this head. is_broken_text_layer still detects the mojibake case of this PR and Cyrillic cp1251 text that is read as cp1252. The real-PDF reproduction now keeps all three IPA rows, and parse_score stays at 1.0.
Local validation: 52 text-quality and broken-text-layer tests pass with real Tesseract. 360 related OCR, CLI, and options tests pass. CI and Docs CI pass at this head.
Non-blocking follow-ups:
- The
replace_broken_text_layerfield description indocling/datamodel/pipeline_options.pystill says that a page in thepoorgrade "from this check or from the existing glyph-name checks" gets full-page OCR. Now onlyis_broken_text_layercauses the replacement (docling/models/base_ocr_model.py:155). The docstring atdocling/models/base_ocr_model.py:629-630has the same problem. Please update both texts. The field description is part of the public API. docs/concepts/OCR.mddoes not list the accented-Latin rule in the patterns that Docling detects.page_preprocessing_model.pyimports the private function_segregate_by_visibilityfrombase_ocr_model.- A page that lists Latin-1 letters one by one, for example a character table, is still flagged as broken. Such pages are rare, and the option is off by default.
…helper public Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
|
Thanks @geoHeil for the approval and the follow-ups. I addressed points 1 to 3 in
I left point 4 (a page that lists Latin-1 letters one by one) as a known limit, since such pages are rare and the option is off by default. |
There was a problem hiding this comment.
AI first review (advisory): no blocker found
This review covers the changes since 3532a125b8 at 364e4c0120. It read the code and did not run it.
The new U+FFFD rule can route good pages to full-page OCR. It flags a page when 3 or more lines hold one U+FFFD each and they are at least 10% of the lines. Check whether that rate happens on healthy pages, for example with an unmapped bullet glyph.
A maintainer makes the merge decision. This review never approves the pull request.
Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
|
@geoHeil, one more small change in |
geoHeil
left a comment
There was a problem hiding this comment.
Reviewed head 24889ceeb0b455a8bb700015add4413bac03ad11 (merge base d01c9c09). This re-review covers the changes since my approval at afb05c77: 364e4c01 and 24889cee. LGTM, on the condition that Run CI passes at this head. Run CI was still running when I posted this review. Run Docs CI and the fast checks pass.
Earlier follow-ups
- The field description of
replace_broken_text_layerand theBaseOcrModeldocstring: fixed. Both now say that onlyis_broken_text_layerreplaces a page. This agrees withbase_ocr_model.py:155. The CLI help,docs/concepts/OCR.md, and the usage-skill references contain no stale wording. docs/concepts/OCR.mdnow lists the accented-Latin rule.- The visibility helpers are now public (
segregate_by_visibility,is_invisible). No reference to the old private names remains. A merge with currentmain(36be1d24) has no conflicts and no old names. - A page that lists Latin-1 letters one by one: kept as a known limit. I agree.
The AI-review question about bullets is fixed for normal pages. A page with 3 lone U+FFFD bullets in 25 lines is kept. The new ORDINARY case bullet_replacement_markers fails with the afb05c77 detector and passes at this head.
Suggestion (non-blocking): short pages with unmapped bullets are still flagged
Location: docling/utils/text_quality.py:210 and text_quality.py:144.
24889cee changes only the "U+FFFD in many lines" rule. Two other rules still count a lone bullet U+FFFD: the 3% share of control characters and U+FFFD, and the rule for 3 lines at a 2.5% share. On a short page, a few bullets reach these shares.
- A slide with a title and 3 bullet items, where each bullet is its own cell (93 non-space characters): flagged.
- An agenda slide with 5 short items, each
U+FFFD+ space + text: flagged.
With the option on, these slides get full-page OCR, and the OCR text replaces their correct PDF text. The afb05c77 detector flags them too, so this is not a new regression, and the option is off by default.
One possible fix: remove a lone U+FFFD at the start of each line before all U+FFFD checks run, not only before the line-share rule. I tested this rule in a separate script. Both slides are then kept. All 34 ORDINARY cases and all 11 BROKEN cases give the same result as at this head.
Notes
- Cost of the bullet rule: when the only damage on a broken page is a lone U+FFFD followed by a space at the start of a line, the line-share rule does not flag the page. This occurs only when a font leaves one-letter words unmapped, so I accept it.
- Nit:
_has_replacement_in_text(text_quality.py:125and:127) uses a literal U+FFFD character. The rest of the module uses"�".
What I ran
- The head
tests/test_text_quality.pywith theafb05c77detector: 1 failed (bullet_replacement_markers), 45 passed. - A probe of
is_broken_text_layerwith slides, a page with a no-break space after the bullet, broken text with U+FFFD inside words, and the variant above. - A read of the diff since
afb05c77, a search for stale wording, and a merge check against currentmain.
What I did not run
- The test suites at this head. I rely on
Run CIfor them.
I made no source, test, or reference-data changes.
Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
|
Thanks @geoHeil for the approval and the suggestion. Both are in The push dismissed your approval automatically, because the repository dismisses approvals when new commits are pushed. Could you take another look when you have time? |
geoHeil
left a comment
There was a problem hiding this comment.
Reviewed head bec1a5e84f557af8d9e469edd2c88f61db07724e. This re-review covers the change since my approval at 24889cee: bec1a5e8. LGTM.
Earlier suggestion
Short pages with unmapped bullets: fixed. A lone U+FFFD at the start of a line is now removed before all U+FFFD checks run, not only before the line-share rule. The new ORDINARY cases bullet_slide and bullet_agenda fail with the 24889cee detector and pass at this head. The nit about the literal U+FFFD is fixed too.
A probe confirms that real damage is still detected: U+FFFD inside words (also when the line starts with a marker), a no-break space after U+FFFD, and �� at the start of a line. Bullets with a no-break space, indented bullets, and bullet-only cells are kept.
Configurable thresholds
I agree to keep the values in text_quality.py internal. They work together, and the option and OcrMode.FULL_PAGE give users control. A single setting can be added later without a breaking change.
What I ran
tests/test_text_quality.pyandtests/test_replace_broken_text_layer.pyat this head, with real Tesseract: 55 passed.- The head
tests/test_text_quality.pywith the24889ceedetector: 2 failed (bullet_agenda,bullet_slide), 46 passed. - A merge with current
main(53c11a20): no conflicts. On the merge,test_text_quality.py,test_replace_broken_text_layer.py,test_cli.py, andtest_options.py: 178 passed. Run CIandRun Docs CIpass at this head.
What I did not run
make checkand the full suite. I rely on CI for them.
Known limit, as accepted earlier: a broken page whose only damage is unmapped one-letter words at the start of lines is not flagged. The option is off by default.
I made no source, test, or reference-data changes.
|
@nikos-livathinos @cau-git , this PR is ready for review. CI passes, there are no conflicts, and @geoHeil reviewed and approved the current head. The summary comment above has the analysis on ParseBench and DocLayNet. Could you take a look when you have time, and merge it if it looks good to you? |
Some PDFs have a text layer that does not match the text drawn on the page. A font's glyphs are not mapped to real characters, so the page looks correct but the extracted text is garbage, for example
8VceZWZTReV CW 9VdZXBReZCBdfor "Certificate of Designations". Docling keeps that text. The per-cell checks inPagePreprocessingModel.rate_text_quality()can lowerparse_score, but nothing replaces the text.This PR adds
OcrOptions.replace_broken_text_layerand the CLI flag--replace-broken-text-layer. Both are off by default. When the option is on, Docling OCRs each page whose text layer is broken, and uses the OCR text for that page only.The change:
docling/utils/text_quality.pyaddsis_broken_text_layer(). Page preprocessing calls it on each page's text layer, only when the option is on. It returnsTrueonly for patterns that a broken font mapping produces, andFalsewhen it is not sure:Îò÷¸ò ïîêàçûâàåò). IPA symbols are not counted, because no 8-bit code page decodes to them.Wkh txduwhuo| uhsruw). The letter-frequency profile keeps the shape of a language, but the letters are in the wrong places. Only tokens with a lowercase letter and ≥3 letters count, so acronyms, part numbers and math variables are skipped.GLYPH<…>placeholders for unmapped glyphs are skipped too. The check needs ≥200 letters and a low vowel share.parse_score0.0.BaseOcrModel, a page that the new check flags is OCR'd in full and its PDF cells are dropped, as inOcrMode.FULL_PAGE, but for that page only. Other pages keep their PDF text. A low per-cell score alone does not replace a page, because unmapped bullet glyphs also cause it on pages with good text.parse_scorestill describes the PDF text layer.OcrOptions, so every engine supports it, including plugins.OcrAutoModelpasses it to the engine it selects.OcrMode.FULL_PAGEthe option has no effect, because Docling does not read the PDF text layer.parse_scoreand the output do not change.docs/concepts/OCR.mdhas a new section, "Replace a broken PDF text layer". The CLI and SDK references of the usage skill describe the option.Checklist:
Review evidence:
Before and after, on real PDFs with a broken text layer. The first column is
main. The second column is this branch with the option on. The engine is the defaultOcrAutoOptions(ocrmac on macOS). The Marathi page usesTesseractCliOcrOptions(lang=["mar"]), because ocrmac cannot read Marathi.main8VceZWZTReV CW 9VdZXBReZCBd hZeY cVdaVTeCertificate of Designations with respect to Series C Preferred StockPFDKFCF@>KQIV @E>KDB QEB @LJMBQFQFSBsignificantly change the competitive environment,75GD;FK (I@7DE:;B A8Security Ownership of Certain Beneficial Owners and Management���' ��(�������� ������$2.46 Definitions Related to Employer Contributions0P�PSICOK>GS- 5KUPUT9DCTJKCo-organizer: Hitotsubashi University International SeminarsH¥$VrH$m`©H«$_mMr {Xem४. कृतीकार्यक्रमाची दिशाIn the same run, the output of each PDF with a good text layer did not change.
Screenshots: page,
main, this branch (Markdown export as is, first lines)False positives, with the check run on each page's text cells:
tests/data(173 pages with text): 4 pages flagged, all with U+FFFD in place of real characters. No page with a good text layer was flagged.Document tree: on a replaced page, the text cells come from OCR only, as in
OcrMode.FULL_PAGE. Layout, tables and reading order then run on those cells. Pages that are not replaced do not change.Reference data: no change. The option is off by default, and no ground-truth file changes.
Tests:
tests/test_text_quality.py: text that must not be flagged (code, LaTeX, JSON, markdown, units, Greek, Cyrillic, Hebrew, Arabic, Thai, Devanagari, CJK, French, Czech, Welsh, Vietnamese, part numbers, base64, DNA, icon ratings, a single U+FFFD, a pdfium hyphen marker, math variables,GLYPH<…>placeholders in a figure, a dotted capital I, an IPA vowel chart, unmapped bullets in a list, a slide and an agenda)), and text that must be flagged (control codes, U+FFFD, one U+FFFD in each of many lines, private-use glyphs, accented-Latin mojibake, shifted letters, one broken cell on a good page).
tests/test_replace_broken_text_layer.py: end to end on a generated PDF, with Tesseract and withOcrAutoOptions. It covers the option on and off, a good text layer that must stay, and a page with a poor per-cell score alone that keeps its text layer. Without theOcrAutoModelchange, the auto-engine test fails.Commands and results:
uv run pytest tests/test_text_quality.py tests/test_replace_broken_text_layer.py: 55 passed.make validate: all hooks pass (Ruff, ty, Tach, license header, max lines).Scope:
docs/concepts/OCR.mdstates this.