Skip to content

feat(pdf): mark superscript and subscript text from glyph size and baseline - #4815

Open
yashikam19 wants to merge 7 commits into
docling-project:mainfrom
yashikam19:feat/pdf-superscript-subscript
Open

yashikam19 wants to merge 7 commits into
docling-project:mainfrom
yashikam19:feat/pdf-superscript-subscript

Conversation

@yashikam19

@yashikam19 yashikam19 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Add an option that marks superscript and subscript text in PDFs. It is off by default.

Today, main converts E = mc² to E = mc 2 and 21st to 21 st, and H₂O loses its subscript and becomes H2O. With the option on, these characters become runs with formatting.script set to super or sub, as Docling already does for Word documents.

pipeline_options.script_position_options = ScriptPositionOptions(enabled=True)

A PDF has no label for superscript text. So the new step measures each character: a superscript is smaller than its line and drawn higher, a subscript is smaller and drawn lower.

Changes

  • docling/utils/pdf_char_geometry.py: reads the size and the baseline of each character with pypdfium2, for both PDF backends. Invisible text (render modes 3 and 7) is skipped.
  • docling/utils/script_position.py: finds the characters that are smaller than their line, raised or lowered, and next to the text they mark.
  • docling/models/stages/script_position/: a new stage after the heading-hierarchy stage. A text item that has such characters keeps its place and holds an inline group of its runs. A list item keeps its marker. Each run has one provenance item for each page it is on.
  • ScriptPositionOptions in pipeline_options.py, a docs section in docs/usage/advanced_options.md, and the new modules in tach.toml.

Body text, list items and footnotes are handled. Headings, captions, tables, code and formulas do not change. OCR text, invisible text, and image input are left unchanged.

Issue resolved by this Pull Request:
Resolves #520

Checklist:

  • Documentation has been updated, if necessary.
  • Examples have been added, if necessary.
  • Tests have been added, if necessary.

Review evidence:

  • Before and after, with the default backend. main is the Markdown export; this branch is the HTML export with the option on.

    document main this branch
    Word export (new fixture) mc 2, H2O, 21 st mc <sup>2</sup>, H <sub>2</sub> O, 21 <sup>st</sup>
    elsevier-00.pdf, authors Duarte a,b,c,* Duarte <sup>a,b,c,*</sup>
    2206.01062.pdf, footnote 1 https://developer.ibm.com/… <sup>1</sup> https://developer.ibm.com/…
    normal_4pages.pdf, Korean note markers 상해 1) 나 상해 <sup>1)</sup> 나

    Known limit until docling-core changes: docling-core's serializers join the runs of an inline group with " ". So with the option on, a script inside a word gets spaces in every text export: H2O becomes H 2 O in Markdown, plain text and chunks, and H <sub>2</sub> O in HTML. As agreed with @PeterStaar-IBM, this PR will update to the docling-core release with feat: make inline serializers concatenate runs faithfully docling-core#693, let the runs keep only the real spaces from the PDF, and recreate the ground truth.

    Screenshots: PDF page, main, this branch (rendered) 1_word_export 2_elsevier_authors 3_doclaynet_footnote 4_korean_note_markers
  • Reference data: no change. The option is off by default.

  • Tests:

    • tests/test_script_position.py: text that must be marked, text that must not be marked (such as %, °, small caps and the LaTeX logo), the stage on a DoclingDocument, a run that crosses a page break, and two paragraphs in a row in HTML.
    • tests/test_script_position_pdf.py: a PDF exported from Word, rotated and cropped pages, invisible text, and image input, with both backends.
  • Commands and results:

    • uv run pytest tests/test_script_position.py tests/test_script_position_pdf.py: 38 passed.
    • uv run pytest tests/test_options.py tests/test_heading_hierarchy.py tests/test_heading_hierarchy_pdf.py tests/test_backend_docling_parse.py tests/test_backend_docling_parse_legacy.py tests/test_backend_pdfium.py tests/test_backend_pdfium_rotation.py tests/test_pdf_outline.py tests/test_font_style.py: 168 passed.
    • make validate: passed.

…seline

A PDF draws superscript and subscript text as smaller glyphs that are moved
off the baseline. The PDF pipeline keeps them as plain text, so "E = mc²"
becomes "E = mc 2" and "H₂O" becomes "H 2 O".

Add ScriptPositionOptions, off by default. When it is on, a new stage runs
after the heading-hierarchy stage. It reads the baseline and the drawn size
of each character with pypdfium2, for each PDF backend. A character is a
superscript or subscript when it is smaller than the body text of its line
and raised or lowered against it, and it touches that text. A text item
with such characters becomes an inline group of runs with formatting.script,
as the Word backend produces. The stage removes the space that the text-line
builder adds before a script ("mc 2") when the PDF draws the two characters
side by side.

Body text, list items and footnotes are handled. Headings, captions, tables,
code, formulas and OCR text do not change. With the option off, the output
does not change.

Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
…aracters

Pick the body of a line from the groups set in the largest letters, so a large
symbol, such as a sum sign, does not make the text around it a superscript.

A line now ends where the baseline moves by most of a line height or where the
pen returns to the start of the line. A superscript stacked over a wide
subscript stays on its line.

A script must be moved by less than its own size. This rule replaces the
drop-cap rule, so a citation on the short last line of a paragraph is marked.

Measure only the characters that match the item's text, so characters from a
nearby line do not change the result. Join a surrogate pair from PDFium into
one character.

Compute every rewrite before the stage changes the document, so a failure
leaves the document unchanged. Read the source and the password from typed
attributes instead of with getattr.

Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
A run has no whitespace at its edges, as in the Word backend. The export
puts one space between runs, so a converted PDF and a Word document give
the same text, and no double space appears

Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ DCO Check Passed

Thanks @yashikam19, all your commits are properly signed off. 🎉

@mergify

mergify Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 1 of 2 protections blocking · waiting on 👀 reviews

Protection Waiting on
🔴 Require two reviewer for test updates 👀 reviews
🟢 Enforce conventional commit —

🔴 Require two reviewer for test updates

Waiting for

  • #approved-reviews-by >= 2
This rule is failing.

When test data is updated, we require two reviewers

  • #approved-reviews-by >= 2

Show 1 satisfied protection

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.+\))?(!)?:

@github-actions github-actions Bot added ai:ci-needs-care AI triage: check CI-safety findings before you approve CI enhancement New feature or request pdf PDF issue (except docling-parse) pdf parsing PDF issue related to docling-parse reading_order labels Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

AI triage (advisory)

Checked commit 2aaedddd49. The analysis read the diff only. It did not run code from this pull request.

Warning

The AI analysis did not finish. Only the deterministic checks are shown.

CI safety: needs maintainer care before you approve CI.

  • tach.toml: changes CI test selection

Possibly related pull requests

PR State Signal Assessment
#4781 open changes the same code not assessed
#4964 open changes the same code not assessed
#4908 open changes the same code not assessed
#4654 open changes the same code not assessed

The ai:* labels are advisory. They do not approve CI and they do not add tests:full. A new push runs this check again.

@codecov

codecov Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.20639% with 48 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ls/stages/script_position/script_position_model.py 81.37% 16 Missing and 11 partials ⚠️
docling/utils/script_position.py 93.56% 6 Missing and 5 partials ⚠️
docling/utils/pdf_char_geometry.py 88.23% 6 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
…subscript

Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>

# Conflicts:
#	docs/usage/advanced_options.md
@github-actions github-actions Bot added ai:ci-needs-care AI triage: check CI-safety findings before you approve CI and removed ai:ci-needs-care AI triage: check CI-safety findings before you approve CI labels Oct 21, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI first review (advisory): no blocker found

This review covers the full diff at 4ca9071696. It read the code and did not run it.

New opt-in stage that marks superscript and subscript text in PDF documents. The feature is off by default. The algorithm, locking, and document-mutation order are correct. No regressions to existing behavior. The reviewer should confirm that standalone page footnotes being rewritten into inline groups (not just footnotes referenced by tables/pictures) is the intended scope.

A maintainer makes the merge decision. This review never approves the pull request.

@github-actions github-actions Bot added the ai:review-lgtm AI first review found no blocker or major issue (advisory) label Oct 21, 2026
@yashikam19

Copy link
Copy Markdown
Contributor Author

@dolfim-ibm @PeterStaar-IBM @cau-git, this PR is ready for review. The branch is up to date with main.

Analysis on the current head (4ca90716)

I converted real PDFs from ParseBench twice with the default backend: with the option off (the same as main) and with the option on. Then I scored the HTML export with ParseBench's own is_sup and is_sub rules, and I read every mark and every miss that the rules could not explain.

Detection: 87 PDFs with superscript or subscript labels

ParseBench has one label per superscript or subscript text in a document, for example "(1) must be superscript".

Labels on PDFs with a text layer (263 labels, 56 PDFs) Option off Option on
Superscript (257) 0 239
Subscript (6) 1 6
Total 1 245 (93%)
  • 10 more labels are marked, but the text inside the mark differs a little from the label (a space or a different dash from the PDF text layer), so the rule does not match. With these, 255 of 263 (97%) are marked.
  • 8 labels are not marked. 4 are in headings or table cells, which the option does not change. 2 are not in the PDF text layer. 1 is a footnote marker on a line with no other letters (the text before it is redacted). For 1 label, I could not find the character it means.
  • 68 more labels are on scans, image-only pages, or broken text layers. The option does not change OCR text, so they are out of scope.

False marks: the option never marked normal text

Set Marks Wrong
The 87 labelled PDFs 348 0
100 random PDFs without labels 2 0
  • There are more marks than labels, because a document often has the same superscript more than once. For example, a footnote number is marked in the sentence and again at the footnote.
  • Most marks match a label. I read the others by hand, and all of them are real superscripts.
  • On the 100 PDFs without labels, the only 2 marks are m², which is correct.

Cost: for the 1-page PDFs, there is no measurable difference between off and on (about 0.25 s per document). On multi-page PDFs from tests/data: +0.26 s on 9 pages and +0.12 s on 18 pages.

Method and limits
  • The scores use ParseBench's FormattingRule, which passes a label when the output has <sup>text</sup> or <sub>text</sub>.
  • OCR was off, so scans have no text, and their labels cannot pass. The option does not change OCR text in any case.
  • The 100 PDFs without labels were sampled at random from the ParseBench text documents.
  • The timings come from single runs on one machine.

A note on the export

The option sets formatting.script on the runs in the DoclingDocument. The exporters show the runs as follows:

Export Option off Option on
HTML <p>Water is H2O …</p> <span class='inline-group'>Water is H <sub>2</sub> O …</span>
Markdown Water is H2O … Water is H 2 O …

docling-core's inline-group serializer puts a space between runs, and it writes an inline group as a <span> and not as a <p>. The Word backend shows the same output. So with the option on, Markdown gets a space around each subscript that had none in the PDF. Should I fix the spacing in docling-core first, or is the option acceptable as it is, since it is off by default?

Possible follow-ups

These are out of scope for this PR. I can open issues or PRs for them if they are useful.

  1. Headings and table cells. Ordinals in dates (22nd) and units in tables (m²) are common there. This PR excludes them to keep the change small.
  2. Spacing between runs in the HTML and Markdown export, so that H₂O stays H2O in Markdown and becomes H<sub>2</sub>O in HTML. This change belongs in docling-core.
  3. Superscript and subscript in the Markdown export, for example as an option to write <sup> and <sub> tags. This change belongs in docling-core.
  4. OCR text. OCR engines give word boxes but no character baselines, so scans need a different method.

@geoHeil geoHeil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 4ca90716963b154a9da48dc1869a4fff025c55ff. Two output defects remain.

  1. _run_prov keeps only the first page of a matched run. In a valid constructed DoclingDocument with H2O on page 1 and a continuation on page 2, the rewrite drops all page-2 provenance. Keep the provenance for each page. This reproduction checks the document model and stage; it is not a full cross-page PDF conversion.
  2. _apply replaces paragraph blocks with top-level InlineGroup nodes. HTML changes two source paragraphs from separate <p> blocks to adjacent <span> elements. The browser loses their paragraph separation. Keep the paragraph blocks.

Local validation: 35 tests pass, and both defects reproduce. Current CI and Docs CI pass. Codecov patch coverage is 88.27930%, but the tests do not detect these defects. The spacing and export question in the author's comment also remains open.

Local make check stops on file-length violations that already exist on base. The other read-only checks pass. I made no source or reference-data changes.

Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com>
@github-actions github-actions Bot added ai:ci-needs-care AI triage: check CI-safety findings before you approve CI and removed ai:ci-needs-care AI triage: check CI-safety findings before you approve CI ai:review-lgtm AI first review found no blocker or major issue (advisory) labels Oct 22, 2026
@yashikam19

Copy link
Copy Markdown
Contributor Author

Thanks @geoHeil, both fixed in 2aaedddd.

  1. A run now gets one provenance item for each page it is on, with the charspan of its text on that page. New test: a run that continues from page 1 onto page 2 keeps both pages.
  2. A rewritten item now keeps its place and holds the inline group of its runs, as list items already did. Paragraphs stay <p> blocks in HTML (<p><span class='inline-group'>…</span></p>). New test: two paragraphs in a row with scripts export as two <p> blocks.

Both new tests fail on 4ca90716 and pass on the latest commit, so they catch the two defects. Markdown, plain text and chunking keep the same words, and the ParseBench results in my comment above do not change.

In the same commit, the step now skips image input. Before, the PDF pipeline logged a false "recovery failed" warning for every image with the option on.

Two limits that come from docling-core's inline-group serializer, so I left them to the maintainers: the space between runs (H <sub>2</sub> O), and a hyperlink on a paragraph with a script becomes one link per run.

Could you re-review when you have time?

@PeterStaar-IBM

Copy link
Copy Markdown
Member

@yashikam19 I really like this output, but looking at your table,

document main this branch
Word export (new fixture) mc 2, H2O, 21 st mc <sup>2</sup>, H <sub>2</sub> O, 21 <sup>st</sup>
elsevier-00.pdf, authors Duarte a,b,c,* Duarte <sup>a,b,c,*</sup>
2206.01062.pdf, footnote 1 https://developer.ibm.com/… <sup>1</sup> https://developer.ibm.com/…
normal_4pages.pdf, Korean note markers 상해 1) 나 상해 <sup>1)</sup> 나

I would like to make sure that we do not have spaces (eg H <sub>2</sub> O should become H<sub>2</sub>O or <sup>1</sup> https://developer.ibm.com/… should become <sup>1</sup>https://developer.ibm.com/…)

@yashikam19

yashikam19 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @PeterStaar-IBM, agreed: there should be no space between a script and the text it touches. The PDFs confirm it: in the test fixture, H, 2 and O have no space between them, and in 2206.01062.pdf the raised footnote 1 is followed directly by https.

The space comes from docling-core: its HTML and Markdown serializers join the runs of an inline group with " ". To fit that join, the runs in this PR drop the whitespace at their edges, so a change in this PR alone cannot remove the space. docling-project/docling-core#843 (the rebase of docling-project/docling-core#693) changes the serializers to join runs with "" and lets each run carry its own whitespace. With that, the runs here would keep only the real spaces from the PDF, and the output would be H<sub>2</sub>O and <sup>1</sup>https://….

How would you like to handle this? Should this PR wait for docling-project/docling-core#843 and then require that docling-core version, or do you prefer another approach?

@PeterStaar-IBM

Copy link
Copy Markdown
Member

Thanks @PeterStaar-IBM, agreed: there should be no space between a script and the text it touches. The PDFs confirm it: in the test fixture, H, 2 and O have no space between them, and in 2206.01062.pdf the raised footnote 1 is followed directly by https.

The space comes from docling-core: its HTML and Markdown serializers join the runs of an inline group with " ". To fit that join, the runs in this PR drop the whitespace at their edges, so a change in this PR alone cannot remove the space. docling-project/docling-core#843 (the rebase of docling-project/docling-core#693) changes the serializers to join runs with "" and lets each run carry its own whitespace. With that, the runs here would keep only the real spaces from the PDF, and the output would be H<sub>2</sub>O and <sup>1</sup>https://….

How would you like to handle this? Should this PR wait for docling-project/docling-core#843 and then require that docling-core version, or do you prefer another approach?

OK, then I would first like to

  1. merge and release feat: make inline serializers concatenate runs faithfully docling-core#693
  2. recreate the GT in this branch with the latest docling-core release

@geoHeil geoHeil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 2aaedddd49f9eb35bec634c068d16780de2ea801. The two defects from my last review are fixed. One blocker remains: the spacing that @PeterStaar-IBM raised. It also changes Markdown, plain text, and chunks, not only HTML.

Fixed

  1. Each run now has one provenance item for each page, with its charspan on that page. test_run_crossing_a_page_break_keeps_both_pages fails with the stage code at 4ca90716 and passes at this head.
  2. A rewritten paragraph keeps its place and holds the inline group. HTML keeps two <p> blocks. test_paragraphs_stay_separate_blocks_in_html fails at 4ca90716 and passes at this head.
  3. Image input is skipped. test_image_input_is_skipped_without_a_warning also fails at 4ca90716 and passes at this head.

Blocker: with the option on, text with no space in the PDF gets spaces in every text export

I converted tests/data/pdf/script_position.pdf with the default backend, OCR off, and the option off and on:

export option off option on
Markdown Water is H2O and carbon dioxide is CO2. Water is H 2 O and carbon dioxide is CO 2 .
plain text Water is H2O and carbon dioxide is CO2. Water is H 2 O and carbon dioxide is CO 2 .
HTML Water is H2O and carbon dioxide is CO2. Water is H <sub>2</sub> O and carbon dioxide is CO <sub>2</sub> .

The Markdown and plain-text exports do not show the script, so in these formats the option only adds spaces. HierarchicalChunker chunks change in the same way. This does not agree with the statement that "Markdown, plain text and chunking keep the same words". Also, the PR description says that main converts H₂O to H 2 O. For this fixture, main gives H2O.

The cause is in docling-core: its serializers join the runs of an inline group with " ", and the runs drop their edge whitespace. As @PeterStaar-IBM decided, wait until docling-project/docling-core#693 is released. Then let each run keep its own whitespace, require that docling-core version, and recreate the ground truth. Please add a test that asserts the exact Markdown and HTML output for the H2O line, so that the space cannot come back.

Non-blocking

  • When a paragraph has a hyperlink, each run becomes a separate link. In 2206.01062.pdf, the footnote [1 https://developer.ibm.com/…](https://developer.ibm.com/…) becomes [1](https://developer.ibm.com/…) [https://developer.ibm.com/…](https://developer.ibm.com/…). You already reported this limit.
  • Question: in DocTags, each rewritten item becomes a nested <inline> block with one <text> element for each run, and the runs have no script mark. The words do not change. Please confirm that this DocTags structure is intended.

Validation

  • tests/test_script_position.py and tests/test_script_position_pdf.py: 38 passed at this head. The 3 new tests fail with the stage code from 4ca90716.
  • I compared the exports with the option off and on for the fixture, 2206.01062.pdf, elsevier-00.pdf, and normal_4pages.pdf. For the three real PDFs, plain text, HTML, and DocTags keep the same words. Only the link split above changes the Markdown of 2206.01062.pdf. A JSON round trip keeps the Markdown export for all four files.
  • ruff format --check, ruff check, ty check, tach check, the tach module coverage check, the skill-route check, dprint check, and uv lock --locked pass. check_max_lines fails only on files that already exceed the limit on base.
  • CI and Docs CI pass at this head on Python 3.10 to 3.14. Mergify needs two approvals because the PR adds test data.
  • Not run: the export comparison with OCR, with table structure, or with the pypdfium2 backend. The PR tests cover both PDF backends.

@yashikam19

Copy link
Copy Markdown
Contributor Author

Thanks @geoHeil for the careful re-review.

You are right about the exports, and I need to correct my earlier statement. I compared the words without spaces. With spaces, the option changes H2O to H 2 O in Markdown, plain text and chunks, not only in HTML. I corrected the PR description: on main, this fixture gives H2O, not H 2 O, and the description now states the spacing as a known limit.

As @PeterStaar-IBM decided, I will wait for the docling-core release with docling-project/docling-core#693. Then I will let each run keep its own whitespace from the PDF, require that docling-core version, recreate the ground truth, and add a test that asserts the exact Markdown and HTML output of the H2O line.

On DocTags: yes, this structure is expected. It is docling-core's DocTags output for an inline group, and the Word backend gives the same <inline><text>…</text></inline> structure. DocTags has no marks for run formatting, so bold and italic runs from Word documents have none either.

@morten-lagabote

Copy link
Copy Markdown
Contributor

Tested 2aaeddd with docling-core 2.104.0 on a real document: Finanstilsynet's guidelines for recovery plans (Norwegian FSA, 22 pages, 31 numbered footnotes).

With ScriptPositionOptions(enabled=True), all 31 reference marks in the body text and all 31 numbers at the start of the footnotes are runs with script=super. No other run is marked. The result is the same with layout_egret_medium.

The Markdown is the same as on main, with one difference: the 20 footnotes that are links now give two links. For example, main gives [1 Forskrift 2016-12-09-1502](https://lovdata.no/…), and this PR gives [1](https://lovdata.no/…) [Forskrift 2016-12-09-1502](https://lovdata.no/…).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai:ci-needs-care AI triage: check CI-safety findings before you approve CI enhancement New feature or request pdf parsing PDF issue related to docling-parse pdf PDF issue (except docling-parse) reading_order

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhancement Request: Ensure that subscript and superscript content is maintained.

4 participants