From dafe53730a4c91e94cff8c5b42166de9d54e3f59 Mon Sep 17 00:00:00 2001 From: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:04:26 +0530 Subject: [PATCH 1/6] feat(pdf): mark superscript and subscript text from glyph size and baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../skills/docling/references/python-sdk.md | 1 + docling/datamodel/pipeline_options.py | 37 ++ .../models/stages/script_position/__init__.py | 2 + .../script_position/script_position_model.py | 272 +++++++++++++ docling/pipeline/standard_pdf_pipeline.py | 7 + docling/utils/pdf_char_geometry.py | 151 ++++++++ docling/utils/script_position.py | 366 ++++++++++++++++++ docs/usage/advanced_options.md | 26 ++ tach.toml | 15 + tests/data/pdf/script_position.pdf | Bin 0 -> 14593 bytes tests/test_script_position.py | 178 +++++++++ tests/test_script_position_pdf.py | 99 +++++ 12 files changed, 1154 insertions(+) create mode 100644 docling/models/stages/script_position/__init__.py create mode 100644 docling/models/stages/script_position/script_position_model.py create mode 100644 docling/utils/pdf_char_geometry.py create mode 100644 docling/utils/script_position.py create mode 100644 tests/data/pdf/script_position.pdf create mode 100644 tests/test_script_position.py create mode 100644 tests/test_script_position_pdf.py diff --git a/docling/.agents/skills/docling/references/python-sdk.md b/docling/.agents/skills/docling/references/python-sdk.md index 6b764830bb..11989e27ce 100644 --- a/docling/.agents/skills/docling/references/python-sdk.md +++ b/docling/.agents/skills/docling/references/python-sdk.md @@ -66,6 +66,7 @@ Useful `PdfPipelineOptions` / base fields: | `ocr_options` | Choose/parametrize the OCR engine (see below) | | `table_structure_options` | e.g. `TableFormerMode.ACCURATE` vs `FAST` | | `heading_hierarchy_options` | Infer section-header levels; off by default (see below) | +| `script_position_options` | Mark superscript and subscript runs; off by default | | `use_reading_order_separators` | Use visible PDF rules as reading-order signals; on by default | | `images_scale` / `generate_page_images` | Control rasterization | | `accelerator_options` | Pick device / thread count | diff --git a/docling/datamodel/pipeline_options.py b/docling/datamodel/pipeline_options.py index 0da23a7be2..75de1b9910 100644 --- a/docling/datamodel/pipeline_options.py +++ b/docling/datamodel/pipeline_options.py @@ -2077,6 +2077,32 @@ class HeadingHierarchyOptions(BaseModel): ] = 0.8 +class ScriptPositionOptions(BaseModel): + """Options for recovering superscript and subscript text in the PDF pipeline. + + When ``enabled``, characters drawn smaller than their line and raised or lowered against + it (footnote markers, "1st", "mc2", "H2O") are marked with ``formatting.script``. A text + item holding them becomes an inline group of runs, as the Word backend produces. + + Notes: + - Character positions are read with pypdfium2 whichever backend parsed the page; the + step is skipped with a warning when pypdfium2 is not installed. + - Body text, list items and footnotes are handled; headings, captions, tables, code, + formulas and OCR text are left unchanged. + """ + + enabled: Annotated[ + bool, + Field( + description=( + "Enable recovery of superscript and subscript text for the PDF pipeline. " + "When disabled (default), raised and lowered text stays part of its plain " + "text item (unchanged behavior)." + ) + ), + ] = False + + class PdfPipelineOptions(PaginatedPipelineOptions): """Configuration options for the PDF document processing pipeline. @@ -2247,6 +2273,17 @@ class PdfPipelineOptions(PaginatedPipelineOptions): ) ), ] = HeadingHierarchyOptions() + script_position_options: Annotated[ + ScriptPositionOptions, + Field( + description=( + "Configuration for recovering superscript and subscript text, such as " + "footnote markers, ordinals and chemical formulas. Disabled by default; when " + "enabled, text items holding raised or lowered characters become inline " + "groups whose runs carry formatting.script." + ) + ), + ] = ScriptPositionOptions() ### Arguments for threaded PDF pipeline with batching and backpressure control diff --git a/docling/models/stages/script_position/__init__.py b/docling/models/stages/script_position/__init__.py new file mode 100644 index 0000000000..c4d8f5f986 --- /dev/null +++ b/docling/models/stages/script_position/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT diff --git a/docling/models/stages/script_position/script_position_model.py b/docling/models/stages/script_position/script_position_model.py new file mode 100644 index 0000000000..7940b4dfd5 --- /dev/null +++ b/docling/models/stages/script_position/script_position_model.py @@ -0,0 +1,272 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""Mark superscript and subscript text in documents converted from PDF. + +A text item holding such text becomes an inline group of runs with ``formatting.script``, +as the Word backend produces. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +import numpy as np +from docling_core.types.doc import ( + BoundingBox, + CoordOrigin, + DocItemLabel, + DoclingDocument, + ProvenanceItem, +) +from docling_core.types.doc.document import ( + Formatting, + InlineGroup, + ListItem, + NodeItem, + Script, + TextItem, +) + +from docling.datamodel.document import ConversionResult +from docling.datamodel.pipeline_options import ScriptPositionOptions +from docling.utils.pdf_char_geometry import ( + PdfChar, + PdfPageChars, + open_pdf, + pypdfium2_available, + read_page_chars, +) +from docling.utils.profiling import ProfilingScope, TimeRecorder +from docling.utils.script_position import ( + ScriptRun, + script_positions, + script_runs, +) + +_log = logging.getLogger(__name__) + +# List items are handled too: they keep their item and get the runs as a child. +_TEXT_LABELS = frozenset( + {DocItemLabel.TEXT, DocItemLabel.PARAGRAPH, DocItemLabel.FOOTNOTE} +) +# Slack (points) when testing whether a character's centre lies in an item's box. +_BOX_TOLERANCE = 0.5 + + +class _Page: + """A page's characters, with their centres in Docling's page frame.""" + + def __init__(self, page: PdfPageChars): + self.chars = page.chars + centres = [] + for char in page.chars: + left, bottom, right, top = page.to_display_frame( + (char.left, char.bottom, char.right, char.top) + ) + centres.append(((left + right) / 2, (bottom + top) / 2)) + self.centres = np.array(centres, dtype=float).reshape(-1, 2) + self.page = page + + def indexes_in(self, box: BoundingBox) -> list[int]: + """Return the characters whose centre is in a bottom-left-origin box.""" + if not len(self.centres): + return [] + x, y = self.centres[:, 0], self.centres[:, 1] + inside = ( + (x >= box.l - _BOX_TOLERANCE) + & (x <= box.r + _BOX_TOLERANCE) + & (y >= box.b - _BOX_TOLERANCE) + & (y <= box.t + _BOX_TOLERANCE) + ) + return np.flatnonzero(inside).tolist() + + def display_box(self, indexes: list[int]) -> BoundingBox: + boxes = [ + self.page.to_display_frame((c.left, c.bottom, c.right, c.top)) + for c in (self.chars[i] for i in indexes) + ] + return BoundingBox( + l=min(b[0] for b in boxes), + b=min(b[1] for b in boxes), + r=max(b[2] for b in boxes), + t=max(b[3] for b in boxes), + coord_origin=CoordOrigin.BOTTOMLEFT, + ) + + +class ScriptPositionModel: + def __init__(self, options: ScriptPositionOptions): + self.options = options + + def __call__(self, conv_res: ConversionResult) -> DoclingDocument: + document = conv_res.document + if not self.options.enabled: + return document + backend = conv_res.input._backend + source = getattr(backend, "path_or_stream", None) + if source is None: + return document + if not pypdfium2_available(): + _log.warning( + "Superscript and subscript recovery needs pypdfium2, which is not " + "installed; skipping it." + ) + return document + + password = getattr(getattr(backend, "options", None), "password", None) + with TimeRecorder(conv_res, "script_position", scope=ProfilingScope.DOCUMENT): + try: + with open_pdf( + source, + password.get_secret_value() if password else None, + ) as pdf: + pages: dict[int, Optional[_Page]] = {} + + def page_for(page_no: int) -> Optional[_Page]: + if page_no not in pages: + try: + pages[page_no] = _Page( + read_page_chars(pdf, page_no - 1) + ) + except Exception as exc: + _log.debug( + f"Cannot read characters of page {page_no}: {exc}" + ) + pages[page_no] = None + return pages[page_no] + + self.mark_scripts(document, page_for) + except Exception as exc: + _log.warning(f"Superscript and subscript recovery failed: {exc}") + return document + + def mark_scripts(self, document: DoclingDocument, page_for) -> None: + """Rewrite the text items holding superscript or subscript text. + + ``page_for`` returns the characters of a 1-based page, or None. + """ + referenced = { + ref.cref + for item in [*document.tables, *document.pictures] + for ref in [*item.captions, *item.footnotes, *item.references] + } + replaced: list[NodeItem] = [] + try: + for item, _ in list(document.iterate_items(with_groups=False)): + if not isinstance(item, TextItem) or not item.prov or not item.text: + continue + if item.self_ref in referenced or isinstance( + item.parent.resolve(document) if item.parent else None, + InlineGroup, + ): + continue + is_list_item = isinstance(item, ListItem) + if not is_list_item and item.label not in _TEXT_LABELS: + continue + + located = self._item_chars(document, item, page_for) + if located is None: + continue + chars, owners = located + runs = script_runs(item.text, chars, script_positions(chars)) + if runs is None: + continue + + self._write_runs(document, item, runs, owners, page_for) + if not is_list_item: + replaced.append(item) + finally: + # An item already rewritten as a group must not stay beside it. + if replaced: + document.delete_items(node_items=replaced) + + @staticmethod + def _item_chars(document: DoclingDocument, item: TextItem, page_for): + """Return the PDF characters in the item's boxes, with (page, index) of each.""" + chars: list[PdfChar] = [] + owners: list[tuple[int, int]] = [] + seen: set[tuple[int, int]] = set() + for prov in item.prov: + page = page_for(prov.page_no) + doc_page = document.pages.get(prov.page_no) + if page is None or doc_page is None: + continue + box = prov.bbox.to_bottom_left_origin(doc_page.size.height) + for index in page.indexes_in(box): + if (prov.page_no, index) in seen: + continue + seen.add((prov.page_no, index)) + chars.append(page.chars[index]) + owners.append((prov.page_no, index)) + if not any(not c.text.isspace() for c in chars): + return None + return chars, owners + + @staticmethod + def _write_runs( + document: DoclingDocument, + item: TextItem, + runs: list[ScriptRun], + owners: list[tuple[int, int]], + page_for, + ) -> None: + if isinstance(item, ListItem): + # As in the Word backend, the list item keeps its marker and holds the runs. + if item.children: + group = document.insert_inline_group( + sibling=item.children[0].resolve(document), + content_layer=item.content_layer, + after=False, + ) + else: + group = document.add_inline_group( + parent=item, content_layer=item.content_layer + ) + label = DocItemLabel.TEXT + item.text = "" + item.orig = "" + else: + group = document.insert_inline_group( + sibling=item, content_layer=item.content_layer, after=False + ) + label = item.label + + base = item.formatting + for run in runs: + formatting = None + if run.script != Script.BASELINE or base is not None: + formatting = (base or Formatting()).model_copy( + update={"script": run.script} + ) + document.add_text( + label=label, + text=run.text, + orig=run.text, + prov=_run_prov(item, run, owners, page_for), + parent=group, + formatting=formatting, + hyperlink=item.hyperlink, + content_layer=item.content_layer, + ) + + +def _run_prov( + item: TextItem, run: ScriptRun, owners: list[tuple[int, int]], page_for +) -> ProvenanceItem: + """Box the run's characters, falling back to the item's first box.""" + hits = [owners[k] for k in run.chars] + if hits: + page_no = hits[0][0] + page = page_for(page_no) + if page is not None: + return ProvenanceItem( + page_no=page_no, + bbox=page.display_box([i for p, i in hits if p == page_no]), + charspan=(0, len(run.text)), + ) + prov = item.prov[0] + return ProvenanceItem( + page_no=prov.page_no, bbox=prov.bbox, charspan=(0, len(run.text)) + ) diff --git a/docling/pipeline/standard_pdf_pipeline.py b/docling/pipeline/standard_pdf_pipeline.py index 2632f57ddf..f3952efa4e 100644 --- a/docling/pipeline/standard_pdf_pipeline.py +++ b/docling/pipeline/standard_pdf_pipeline.py @@ -87,6 +87,9 @@ ReadingOrderModel, ReadingOrderOptions, ) +from docling.models.stages.script_position.script_position_model import ( + ScriptPositionModel, +) from docling.pipeline.base_pipeline import ( ConvertPipeline, get_expected_page_nos, @@ -687,6 +690,9 @@ def _init_models(self) -> None: self.heading_hierarchy_model = HeadingHierarchyModel( options=self.pipeline_options.heading_hierarchy_options ) + self.script_position_model = ScriptPositionModel( + options=self.pipeline_options.script_position_options + ) # --- optional enrichment ------------------------------------------------ # Create a copy to avoid mutating pipeline_options in-place, @@ -1127,6 +1133,7 @@ def _assemble_document(self, conv_res: ConversionResult) -> ConversionResult: ) conv_res.document = self.reading_order_model(conv_res) conv_res.document = self.heading_hierarchy_model(conv_res) + conv_res.document = self.script_position_model(conv_res) # Generate page images in the output if self.pipeline_options.generate_page_images: diff --git a/docling/utils/pdf_char_geometry.py b/docling/utils/pdf_char_geometry.py new file mode 100644 index 0000000000..4443f0cf64 --- /dev/null +++ b/docling/utils/pdf_char_geometry.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""Read the baseline and drawn size of each character of a PDF page with PDFium. + +Neither PDF backend exposes these on its text cells: docling-parse character boxes follow +each glyph's ink, and the pypdfium2 backend builds no character cells. +""" + +from __future__ import annotations + +import ctypes +import importlib.util +import math +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path +from typing import TYPE_CHECKING, Optional, Union + +from docling_core.types.doc import Size + +from docling.utils.locks import pypdfium2_lock + +if TYPE_CHECKING: + import pypdfium2 as pdfium + +# Characters rotated by more than this (radians) are not horizontal text. +_MAX_HORIZONTAL_ANGLE = 0.01 + + +@dataclass(frozen=True) +class PdfChar: + """One character, in PDFium's unrotated user space (bottom-left origin).""" + + text: str + # Box from the font metrics, so its height does not depend on the glyph's shape. + left: float + bottom: float + right: float + top: float + baseline: float + # Font size scaled by the text matrix: many PDFs set size 1 and scale the matrix. + size: float + horizontal: bool + + +@dataclass(frozen=True) +class PdfPageChars: + """The characters of one page, with what is needed to map them onto Docling's page.""" + + chars: list[PdfChar] + rotation: int + display_size: Size + origin: tuple[float, float] + + def to_display_frame( + self, box: tuple[float, float, float, float] + ) -> tuple[float, float, float, float]: + """Map a box to the page's display frame (bottom-left origin).""" + # lazy import: pypdfium2 is an optional dependency + from docling.backend.pypdfium2_backend import _rect_to_display_frame + + return _rect_to_display_frame( + box, self.rotation, self.display_size, self.origin + ) + + +def pypdfium2_available() -> bool: + """Tell whether pypdfium2, an optional dependency, is installed.""" + return importlib.util.find_spec("pypdfium2") is not None + + +@contextmanager +def open_pdf( + path_or_stream: Union[Path, BytesIO], password: Optional[str] = None +) -> Iterator[pdfium.PdfDocument]: + """Open a PDF with PDFium for the duration of the block.""" + import pypdfium2 as pdfium + + source = ( + path_or_stream.getvalue() + if isinstance(path_or_stream, BytesIO) + else path_or_stream + ) + with pypdfium2_lock: + pdf = pdfium.PdfDocument(source, password=password) + try: + yield pdf + finally: + with pypdfium2_lock: + pdf.close() + + +def read_page_chars(pdf: pdfium.PdfDocument, page_index: int) -> PdfPageChars: + """Return the characters of a page (0-based), in content order.""" + import pypdfium2.raw as pdfium_c + + with pypdfium2_lock: + page = pdf[page_index] + try: + width, height = page.get_size() + rotation = page.get_rotation() + left_x, bottom_y = page.get_bbox()[:2] + text_page = page.get_textpage() + try: + chars = _read_chars(text_page.raw, text_page.count_chars(), pdfium_c) + finally: + text_page.close() + finally: + page.close() + return PdfPageChars( + chars=chars, + rotation=rotation, + display_size=Size(width=width, height=height), + origin=(left_x, bottom_y), + ) + + +def _read_chars(raw, count: int, pdfium_c) -> list[PdfChar]: + box = pdfium_c.FS_RECTF() + matrix = pdfium_c.FS_MATRIX() + origin_x, origin_y = ctypes.c_double(), ctypes.c_double() + chars: list[PdfChar] = [] + for index in range(count): + code = pdfium_c.FPDFText_GetUnicode(raw, index) + if code <= 0 or not pdfium_c.FPDFText_GetLooseCharBox(raw, index, box): + continue + if not pdfium_c.FPDFText_GetCharOrigin(raw, index, origin_x, origin_y): + continue + if 0xD800 <= code <= 0xDFFF: # lone surrogate from a broken ToUnicode map + code = 0xFFFD + size = float(pdfium_c.FPDFText_GetFontSize(raw, index)) + if pdfium_c.FPDFText_GetMatrix(raw, index, matrix): + size *= math.sqrt(abs(matrix.a * matrix.d - matrix.b * matrix.c)) + angle = float(pdfium_c.FPDFText_GetCharAngle(raw, index)) + chars.append( + PdfChar( + text=chr(code), + left=box.left, + bottom=box.bottom, + right=box.right, + top=box.top, + baseline=origin_y.value, + size=size, + horizontal=0 <= angle <= _MAX_HORIZONTAL_ANGLE + or abs(angle - 2 * math.pi) <= _MAX_HORIZONTAL_ANGLE, + ) + ) + return chars diff --git a/docling/utils/script_position.py b/docling/utils/script_position.py new file mode 100644 index 0000000000..ed7785345c --- /dev/null +++ b/docling/utils/script_position.py @@ -0,0 +1,366 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""Find superscript and subscript text from the size and baseline of PDF characters. + +A character is a superscript or subscript when it is drawn smaller than the body text of +its line and raised or lowered against the body's baseline. +""" + +from __future__ import annotations + +import difflib +import itertools +import statistics +import unicodedata +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Optional, Protocol + +from docling_core.types.doc.document import Script + +# A script is drawn at most this share of the body size... +MAX_SCRIPT_SIZE = 0.85 +# ...and shifted by at least this share of it. Word lowers a subscript by under a tenth. +MIN_SHIFT = 0.05 +MIN_SHIFT_POINTS = 0.4 +# Baselines this close (share of the largest size) are the same baseline. +BASELINE_TOLERANCE = 0.04 +BASELINE_TOLERANCE_POINTS = 0.25 +# Baseline groups at least this share of the largest size can be the body. +BODY_SIZE_RATIO = 0.9 +# Largest gap (share of the body size) between a script and the text it marks. +TOUCH_GAP = 0.6 +# Letters tighter than this to body letters on both sides sit inside a word ("LaTeX"). +IN_WORD_GAP = 0.15 +# A body of this few letters beside a longer raised run is a drop cap. +MAX_DROP_CAP = 3 +# A new line starts where the pen moves left, or the baseline jumps, by this share of the size. +LINE_BACKTRACK = 0.5 +LINE_JUMP = 1.2 +# Marks raised as footnote references with no letter or digit. +FOOTNOTE_MARKS = frozenset("*\u2020\u2021\u00a7\u00b6\u2016#") +# Share of an item's characters that must be found among the PDF characters. +MIN_ALIGNED = 0.8 +# Matching is quadratic, so longer items are skipped. +MAX_ALIGN_CHARS = 5000 +# Gap (share of the size) under which two characters are set side by side. +MAX_JOIN_GAP = 0.15 + +_PUNCTUATION_FOLD = str.maketrans( + { + "\N{LEFT SINGLE QUOTATION MARK}": "'", + "\N{RIGHT SINGLE QUOTATION MARK}": "'", + "\N{SINGLE LOW-9 QUOTATION MARK}": "'", + "\N{PRIME}": "'", + "\N{LEFT DOUBLE QUOTATION MARK}": '"', + "\N{RIGHT DOUBLE QUOTATION MARK}": '"', + "\N{DOUBLE LOW-9 QUOTATION MARK}": '"', + "\N{DOUBLE PRIME}": '"', + "\N{HYPHEN}": "-", + "\N{NON-BREAKING HYPHEN}": "-", + "\N{FIGURE DASH}": "-", + "\N{EN DASH}": "-", + "\N{EM DASH}": "-", + "\N{MINUS SIGN}": "-", + } +) + + +class ScriptChar(Protocol): + """A character as detection needs it, see ``pdf_char_geometry.PdfChar``.""" + + @property + def text(self) -> str: ... + @property + def left(self) -> float: ... + @property + def right(self) -> float: ... + @property + def baseline(self) -> float: ... + @property + def size(self) -> float: ... + @property + def horizontal(self) -> bool: ... + + +@dataclass(frozen=True) +class ScriptRun: + """A piece of a text item's text at one script position.""" + + text: str + script: Script + # Indexes of the PDF characters matched to the run. + chars: tuple[int, ...] + + +def script_positions(chars: Sequence[ScriptChar]) -> list[Script]: + """Return the script position of each character of one text item, in content order.""" + positions = [Script.BASELINE] * len(chars) + for line in _split_lines(chars): + for index, script in _line_scripts(chars, line).items(): + positions[index] = script + return positions + + +def script_runs( + text: str, chars: Sequence[ScriptChar], positions: Sequence[Script] +) -> Optional[list[ScriptRun]]: + """Split an item's text into baseline, superscript and subscript runs. + + The text is matched to the PDF characters first, since it differs from them in + ligatures, dashes and spaces. A space between a character and its script that the PDF + does not have ("mc 2") is dropped. Runs keep their own whitespace. Returns None when the + text has no script or cannot be matched. + """ + if not any(p != Script.BASELINE for p in positions): + return None + mapping = _align(text, chars) + if mapping is None: + return None + + roles: list[Optional[Script]] = [None] * len(text) + for index, char_index in mapping.items(): + roles[index] = positions[char_index] + if not any(r not in (None, Script.BASELINE) for r in roles): + return None + + solid = [i for i, ch in enumerate(text) if not ch.isspace()] + # Unmatched characters take the position of matched neighbours that agree. + for k, index in enumerate(solid): + if roles[index] is not None: + continue + before = next( + (roles[j] for j in reversed(solid[:k]) if roles[j] is not None), None + ) + after = next((roles[j] for j in solid[k + 1 :] if roles[j] is not None), None) + roles[index] = before if before == after and before else Script.BASELINE + + drop: set[int] = set() + for left, right in itertools.pairwise(solid): + gap = range(left + 1, right) + if not gap: + continue + if roles[left] == roles[right]: + for i in gap: + roles[i] = roles[left] + continue + for i in gap: + roles[i] = Script.BASELINE + if ( + left in mapping + and right in mapping + and _set_side_by_side(chars, mapping[left], mapping[right]) + ): + drop.update(gap) + for i in range(len(text)): + if roles[i] is None: # leading or trailing whitespace + roles[i] = Script.BASELINE + + pieces: list[tuple[str, Script, list[int]]] = [] + for i, ch in enumerate(text): + if i in drop: + continue + role = roles[i] + assert role is not None + if not pieces or pieces[-1][1] != role: + pieces.append(("", role, [])) + piece_text, _, piece_chars = pieces[-1] + if i in mapping and mapping[i] not in piece_chars: + piece_chars.append(mapping[i]) + pieces[-1] = (piece_text + ch, role, piece_chars) + return [ScriptRun(t, role, tuple(c)) for t, role, c in pieces] + + +def _measurable(char: ScriptChar) -> bool: + return char.horizontal and char.size > 0 and not char.text.isspace() + + +def _split_lines(chars: Sequence[ScriptChar]) -> list[list[int]]: + """Split the measurable characters into visual lines, as lists of indexes.""" + lines: list[list[int]] = [] + line: list[int] = [] + previous: Optional[ScriptChar] = None + for index, char in enumerate(chars): + if not _measurable(char): + continue + if previous is not None and ( + previous.left - char.left > LINE_BACKTRACK * max(char.size, previous.size) + or abs(char.baseline - previous.baseline) + > LINE_JUMP * max(char.size, previous.size) + ): + lines.append(line) + line = [] + line.append(index) + previous = char + if line: + lines.append(line) + return lines + + +def _baseline_groups(chars: Sequence[ScriptChar], line: list[int]) -> list[list[int]]: + largest = max(chars[i].size for i in line) + tolerance = max(BASELINE_TOLERANCE_POINTS, BASELINE_TOLERANCE * largest) + groups: list[list[int]] = [] + for index in sorted(line, key=lambda i: chars[i].baseline): + if ( + groups + and abs( + chars[index].baseline + - statistics.median(chars[i].baseline for i in groups[-1]) + ) + <= tolerance + ): + groups[-1].append(index) + else: + groups.append([index]) + return groups + + +def _line_scripts(chars: Sequence[ScriptChar], line: list[int]) -> dict[int, Script]: + """Find the superscript and subscript characters of one visual line.""" + if len(line) < 2: + return {} + groups = _baseline_groups(chars, line) + if len(groups) < 2: + return {} + # The body: among the groups in the largest type, the one with the most letters. + largest = max(chars[i].size for i in line) + candidates = [ + group + for group in groups + if max(chars[i].size for i in group) >= BODY_SIZE_RATIO * largest + ] + body = max( + candidates, + key=lambda group: (sum(chars[i].text.isalnum() for i in group), len(group)), + ) + body_size = statistics.median(chars[i].size for i in body) + body_baseline = statistics.median(chars[i].baseline for i in body) + min_shift = max(MIN_SHIFT_POINTS, MIN_SHIFT * body_size) + + shifted: dict[int, Script] = {} + for index in line: + char = chars[index] + if char.size > MAX_SCRIPT_SIZE * body_size: + continue + shift = char.baseline - body_baseline + if abs(shift) >= min_shift: + shifted[index] = Script.SUPER if shift > 0 else Script.SUB + + body_letters = sum(chars[i].text.isalnum() for i in line if i not in shifted) + scripts: dict[int, Script] = {} + position = 0 + while position < len(line): + if line[position] not in shifted: + position += 1 + continue + end = position + script = shifted[line[position]] + while end + 1 < len(line) and shifted.get(line[end + 1]) == script: + end += 1 + run = line[position : end + 1] + before = line[position - 1] if position > 0 else None + after = line[end + 1] if end + 1 < len(line) else None + if _keep_run(chars, run, before, after, shifted, body_size, body_letters): + for index in run: + scripts[index] = script + position = end + 1 + return scripts + + +def _keep_run( + chars: Sequence[ScriptChar], + run: list[int], + before: Optional[int], + after: Optional[int], + shifted: dict[int, Script], + body_size: float, + body_letters: int, +) -> bool: + """Tell a script from other small, shifted text on the line.""" + script = shifted[run[0]] + neighbours = [] + if before is not None: + neighbours.append((before, chars[run[0]].left - chars[before].right)) + if after is not None: + neighbours.append((after, chars[after].left - chars[run[-1]].right)) + + # It touches the text it marks, unlike a small page number or URL further along. + touches_body = any( + index not in shifted and -0.5 * body_size <= gap <= TOUCH_GAP * body_size + for index, gap in neighbours + ) + # A subscript and a superscript stacked over one base. + stacked = any( + index in shifted and shifted[index] != script for index, _ in neighbours + ) + if not (touches_body or stacked): + return False + + # Symbol fonts (arrows, bullets) are often drawn small and off the baseline. + text = [chars[i].text for i in run] + if not (any(t.isalnum() for t in text) or all(t in FOOTNOTE_MARKS for t in text)): + return False + + in_word = ( + len(neighbours) == 2 + and all(t.isalpha() for t in text) + and all( + index not in shifted + and chars[index].text.isalpha() + and gap <= IN_WORD_GAP * body_size + for index, gap in neighbours + ) + ) + drop_cap = len(run) > MAX_DROP_CAP and body_letters <= MAX_DROP_CAP + return not (in_word or drop_cap) + + +def _folded(text: str) -> list[tuple[str, int]]: + """Fold the non-space characters for matching, each with its index in ``text``.""" + out = [] + for index, ch in enumerate(text): + if ch.isspace(): + continue + # NFKC unfolds ligatures into several characters. + for part in unicodedata.normalize("NFKC", ch).translate(_PUNCTUATION_FOLD): + if not part.isspace(): + out.append((part, index)) + return out + + +def _align(text: str, chars: Sequence[ScriptChar]) -> Optional[dict[int, int]]: + """Map the characters of ``text`` to PDF character indexes, or None if too few match.""" + ours = _folded(text) + theirs = _folded("".join(c.text if len(c.text) == 1 else " " for c in chars)) + if not ours: + return None + if [a for a, _ in ours] == [b for b, _ in theirs]: + blocks = [(0, 0, len(ours))] + elif max(len(ours), len(theirs)) > MAX_ALIGN_CHARS: + return None + else: + matcher = difflib.SequenceMatcher( + None, + "".join(a for a, _ in ours), + "".join(b for b, _ in theirs), + autojunk=False, + ) + blocks = [tuple(block) for block in matcher.get_matching_blocks()] + mapping: dict[int, int] = {} + for a, b, size in blocks: + for k in range(size): + mapping.setdefault(ours[a + k][1], theirs[b + k][1]) + solid = sum(not ch.isspace() for ch in text) + if len(mapping) < MIN_ALIGNED * solid: + return None + return mapping + + +def _set_side_by_side(chars: Sequence[ScriptChar], left: int, right: int) -> bool: + """Tell whether the PDF draws two characters with no space and no gap between them.""" + if right <= left or any(c.text.isspace() for c in chars[left + 1 : right]): + return False + a, b = chars[left], chars[right] + return b.left - a.right <= MAX_JOIN_GAP * max(a.size, b.size) diff --git a/docs/usage/advanced_options.md b/docs/usage/advanced_options.md index 79b51b38e2..5e91b4b0b7 100644 --- a/docs/usage/advanced_options.md +++ b/docs/usage/advanced_options.md @@ -266,6 +266,32 @@ doc_converter = DocumentConverter( See [PDF heading levels](./heading_levels.md) for the signals, their precedence and all options. +### Recover superscript and subscript in PDFs + +A PDF draws superscript and subscript text as smaller glyphs moved off the baseline, so by +default "E = mc²" comes out as "E = mc 2". Docling can mark these runs with +`formatting.script`, which the HTML export renders as `` and ``: + +```python +from docling.datamodel.base_models import InputFormat +from docling.datamodel.pipeline_options import ( + PdfPipelineOptions, + ScriptPositionOptions, +) +from docling.document_converter import DocumentConverter, PdfFormatOption + +pipeline_options = PdfPipelineOptions() +pipeline_options.script_position_options = ScriptPositionOptions(enabled=True) + +doc_converter = DocumentConverter( + format_options={ + InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options) + } +) +``` + +A text item holding superscript or subscript text becomes an inline group of runs. + ### PowerPoint charts A chart on a PowerPoint slide becomes a picture classified by its kind, with diff --git a/tach.toml b/tach.toml index 0b2160d683..6a29e9e4c9 100644 --- a/tach.toml +++ b/tach.toml @@ -181,6 +181,11 @@ path = "docling.utils.nemotron_parse_utils" layer = "core" depends_on = ["docling.backend", "docling.datamodel"] +[[modules]] +path = "docling.utils.pdf_char_geometry" +layer = "core" +depends_on = ["docling.backend", "docling.utils"] + [[modules]] path = "docling.utils" layer = "core" @@ -366,6 +371,15 @@ path = "docling.models.stages.reading_order" layer = "models" depends_on = ["docling.datamodel", "docling.utils"] +[[modules]] +path = "docling.models.stages.script_position" +layer = "models" +depends_on = [ + "docling.datamodel", + "docling.utils", + "docling.utils.pdf_char_geometry", +] + [[modules]] path = "docling.models.stages.table_structure" layer = "core" @@ -528,6 +542,7 @@ depends_on = [ "docling.models.stages.page_assemble", "docling.models.stages.page_preprocessing", "docling.models.stages.reading_order", + "docling.models.stages.script_position", "docling.pipeline.base_pipeline", "docling.utils", ] diff --git a/tests/data/pdf/script_position.pdf b/tests/data/pdf/script_position.pdf new file mode 100644 index 0000000000000000000000000000000000000000..de94c083fc601f8e653c9c340c66174fa0eaa58e GIT binary patch literal 14593 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 E0727`!vFvP literal 0 HcmV?d00001 diff --git a/tests/test_script_position.py b/tests/test_script_position.py new file mode 100644 index 0000000000..745f963b7d --- /dev/null +++ b/tests/test_script_position.py @@ -0,0 +1,178 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""Tests for finding superscript and subscript text from PDF character geometry.""" + +import pytest +from docling_core.types.doc.document import Script + +from docling.utils.pdf_char_geometry import PdfChar +from docling.utils.script_position import script_positions, script_runs + +B, SUP, SUB = Script.BASELINE, Script.SUPER, Script.SUB + + +def _line(*segments, y: float = 700.0, x: float = 50.0) -> list[PdfChar]: + """Lay out (text, size, rise) segments left to right on one baseline.""" + chars = [] + for text, size, rise in segments: + for ch in text: + width = 0.5 * size + chars.append( + PdfChar( + text=ch, + left=x, + bottom=y + rise - 0.2 * size, + right=x + width, + top=y + rise + 0.8 * size, + baseline=y + rise, + size=size, + horizontal=True, + ) + ) + x += width + return chars + + +def _runs(text: str, chars: list[PdfChar]) -> list[tuple[str, Script]]: + runs = script_runs(text, chars, script_positions(chars)) + assert runs is not None + return [(r.text, r.script) for r in runs] + + +def _marked(chars: list[PdfChar]) -> str: + """Render the positions found as / markup, ignoring spaces.""" + out = "" + for char, script in zip(chars, script_positions(chars)): + if char.text.isspace(): + continue + out += ( + char.text + if script == B + else f"<{script.value}>{char.text}" + ) + return out.replace("", "").replace("", "") + + +@pytest.mark.parametrize( + "segments, expected", + [ + pytest.param( + [("E = mc", 12, 0), ("2", 7, 4.5)], "E=mc2", id="exponent" + ), + pytest.param( + [("H", 12, 0), ("2", 7, -2.5), ("O", 12, 0)], "H2O", id="formula" + ), + pytest.param( + # Word lowers a subscript by under a tenth of the font size. + [("CO", 12, 0), ("2", 7, -1.1), (" gas", 12, 0)], + "CO2gas", + id="word-subscript", + ), + pytest.param( + [("the 1", 12, 0), ("st", 7, 4.5), (" of May", 12, 0)], + "the1stofMay", + id="ordinal", + ), + pytest.param( + [("as shown", 12, 0), ("1,2\u20134", 7, 4.5), (". Next", 12, 0)], + "asshown1,2\u20134.Next", + id="citation", + ), + pytest.param( + [("3", 6, 3), ("This footnote", 9, 0)], + "3Thisfootnote", + id="footnote-marker-first", + ), + pytest.param( + [("Author", 12, 0), ("*", 7, 4.5), (" and", 12, 0)], + "Author*and", + id="footnote-mark", + ), + ], +) +def test_marks_scripts(segments, expected): + assert _marked(_line(*segments)) == expected + + +@pytest.mark.parametrize( + "segments", + [ + # Drawn high but full size: not a script. + pytest.param([("grew 12% at 25\u00b0C", 12, 0)], id="percent-degree"), + # Smaller but on the baseline. + pytest.param( + [("S", 12, 0), ("MALL ", 9, 0), ("C", 12, 0), ("APS", 9, 0)], + id="small-caps", + ), + # Shifted but full size. + pytest.param( + [("baseline ", 12, 0), ("jitter", 12, 1.5), (" here", 12, 0)], + id="wavy-baseline", + ), + # A symbol font drawn small and raised. + pytest.param( + [("Concepts", 12, 0), ("\u2192", 7, 2), ("Docs", 12, 0)], id="arrow" + ), + # The raised A of the LaTeX logo sits inside the word. + pytest.param([("L", 12, 0), ("A", 8, 3), ("TeX", 12, 0)], id="latex-logo"), + # A drop cap is set larger and lower than the line it starts. + pytest.param([("T", 36, -24), ("his line goes on", 12, 0)], id="drop-cap"), + ], +) +def test_leaves_other_text_alone(segments): + chars = _line(*segments) + assert all(script == B for script in script_positions(chars)) + + +def test_small_text_away_from_body_is_not_a_script(): + chars = _line(("Report 2024", 12, 0)) + _line(("page 3", 7, 4.5), x=400.0) + assert all(script == B for script in script_positions(chars)) + + +def test_measures_each_line_against_its_own_baseline(): + chars = _line(("x", 12, 0), ("2", 7, 4.5), y=700.0) + _line( + ("H", 12, 0), ("2", 7, -2.5), ("O", 12, 0), y=680.0 + ) + assert _marked(chars) == "x2H2O" + + +def test_stacked_sub_and_superscript(): + chars = _line(("x", 12, 0), ("i", 7, -2.5)) + chars += _line(("2", 7, 4.5), x=chars[-1].left) + assert script_positions(chars) == [B, SUB, SUP] + + +def test_runs_drop_the_space_the_pdf_does_not_have(): + chars = _line(("E = mc", 12, 0), ("2", 7, 4.5), (" in 1905.", 12, 0)) + assert _runs("E = mc 2 in 1905.", chars) == [ + ("E = mc", B), + ("2", SUP), + (" in 1905.", B), + ] + + +def test_runs_keep_a_space_the_pdf_has(): + chars = _line(("agrees ", 12, 0), ("12", 7, 4.5)) + assert _runs("agrees 12", chars) == [("agrees ", B), ("12", SUP)] + + +def test_runs_match_through_ligatures_and_dashes(): + chars = _line(("\ufb01rst", 12, 0), ("1\u20132", 7, 4.5)) + assert _runs("first1-2", chars) == [("first", B), ("1-2", SUP)] + + +def test_runs_point_to_their_characters(): + chars = _line(("H", 12, 0), ("2", 7, -2.5), ("O", 12, 0)) + runs = script_runs("H2O", chars, script_positions(chars)) + assert runs is not None + assert [r.chars for r in runs] == [(0,), (1,), (2,)] + + +def test_no_runs_without_scripts_or_when_text_does_not_match(): + plain = _line(("plain text", 12, 0)) + assert script_runs("plain text", plain, script_positions(plain)) is None + chars = _line(("E = mc", 12, 0), ("2", 7, 4.5)) + assert ( + script_runs("something else entirely", chars, script_positions(chars)) is None + ) diff --git a/tests/test_script_position_pdf.py b/tests/test_script_position_pdf.py new file mode 100644 index 0000000000..6036b8fe17 --- /dev/null +++ b/tests/test_script_position_pdf.py @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""End-to-end tests for superscript and subscript recovery in the PDF pipeline.""" + +from pathlib import Path + +import pytest +from docling_core.types.doc.document import InlineGroup, Script, TextItem + +from docling.backend.docling_parse_backend import ThreadedDoclingParseDocumentBackend +from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend +from docling.datamodel.base_models import InputFormat +from docling.datamodel.pipeline_options import ( + PdfPipelineOptions, + ScriptPositionOptions, +) +from docling.document_converter import DocumentConverter, PdfFormatOption + +pytestmark = pytest.mark.ml_pdf_model + +# Exported from Word-style formatting (LibreOffice), with an embedded font. +_PDF = Path("tests/data/pdf/script_position.pdf") + + +def _convert(backend, enabled: bool): + options = PdfPipelineOptions(do_ocr=False, do_table_structure=False) + options.script_position_options = ScriptPositionOptions(enabled=enabled) + converter = DocumentConverter( + format_options={ + InputFormat.PDF: PdfFormatOption(pipeline_options=options, backend=backend) + } + ) + return converter.convert(_PDF).document + + +def _runs(document) -> list[list[tuple[str, Script]]]: + out = [] + for group in document.groups: + if not isinstance(group, InlineGroup): + continue + runs = [] + for ref in group.children: + item = ref.resolve(document) + script = item.formatting.script if item.formatting else Script.BASELINE + runs.append((item.text, script)) + out.append(runs) + return out + + +def _scripts(runs) -> list[tuple[str, Script]]: + return [ + (text.strip(), script) + for group in runs + for text, script in group + if script != Script.BASELINE + ] + + +def test_marks_superscript_and_subscript_runs(): + document = _convert(ThreadedDoclingParseDocumentBackend, enabled=True) + runs = _runs(document) + assert _scripts(runs) == [ + ("2", Script.SUPER), + ("2", Script.SUB), + ("2", Script.SUB), + ("st", Script.SUPER), + ("rd", Script.SUPER), + ("[12]", Script.SUPER), + ] + # The space the text-line builder puts before a script ("mc 2") is gone. + assert runs[0][:2] == [ + ("Einstein wrote E = mc", Script.BASELINE), + ("2", Script.SUPER), + ] + # A line without scripts stays a plain text item. + assert any( + isinstance(item, TextItem) and item.text.startswith("Plain line") + for item in document.texts + ) + + +def test_marks_scripts_with_the_pypdfium2_backend(): + document = _convert(PyPdfiumDocumentBackend, enabled=True) + # This backend puts the "2" of "mc2" in a text item of its own, so it has no line to + # be measured against. + assert _scripts(_runs(document)) == [ + ("2", Script.SUB), + ("2", Script.SUB), + ("st", Script.SUPER), + ("rd", Script.SUPER), + ("[12]", Script.SUPER), + ] + + +def test_disabled_leaves_the_document_unchanged(): + document = _convert(ThreadedDoclingParseDocumentBackend, enabled=False) + assert not _runs(document) + assert "E = mc 2 in 1905." in document.export_to_markdown() From b7a4c99762d218b278e40ec87210472b21cc89a3 Mon Sep 17 00:00:00 2001 From: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:28:33 +0530 Subject: [PATCH 2/6] fix(pdf): measure superscript lines from their letters and matched characters 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> --- .../script_position/script_position_model.py | 314 ++++++++++-------- docling/utils/pdf_char_geometry.py | 29 +- docling/utils/script_position.py | 188 ++++++----- tests/test_script_position.py | 123 ++++++- tests/test_script_position_pdf.py | 85 +++++ 5 files changed, 493 insertions(+), 246 deletions(-) diff --git a/docling/models/stages/script_position/script_position_model.py b/docling/models/stages/script_position/script_position_model.py index 7940b4dfd5..703bc80a7f 100644 --- a/docling/models/stages/script_position/script_position_model.py +++ b/docling/models/stages/script_position/script_position_model.py @@ -10,7 +10,8 @@ from __future__ import annotations import logging -from typing import Optional +from collections.abc import Callable +from dataclasses import dataclass import numpy as np from docling_core.types.doc import ( @@ -29,6 +30,7 @@ TextItem, ) +from docling.datamodel.backend_options import PdfBackendOptions from docling.datamodel.document import ConversionResult from docling.datamodel.pipeline_options import ScriptPositionOptions from docling.utils.pdf_char_geometry import ( @@ -59,20 +61,15 @@ class _Page: """A page's characters, with their centres in Docling's page frame.""" def __init__(self, page: PdfPageChars): - self.chars = page.chars - centres = [] - for char in page.chars: - left, bottom, right, top = page.to_display_frame( - (char.left, char.bottom, char.right, char.top) - ) - centres.append(((left + right) / 2, (bottom + top) / 2)) - self.centres = np.array(centres, dtype=float).reshape(-1, 2) self.page = page + self.chars = page.chars + boxes = [self._display_box(char) for char in page.chars] + self.centres = np.array( + [((b[0] + b[2]) / 2, (b[1] + b[3]) / 2) for b in boxes], dtype=float + ).reshape(-1, 2) def indexes_in(self, box: BoundingBox) -> list[int]: """Return the characters whose centre is in a bottom-left-origin box.""" - if not len(self.centres): - return [] x, y = self.centres[:, 0], self.centres[:, 1] inside = ( (x >= box.l - _BOX_TOLERANCE) @@ -82,11 +79,8 @@ def indexes_in(self, box: BoundingBox) -> list[int]: ) return np.flatnonzero(inside).tolist() - def display_box(self, indexes: list[int]) -> BoundingBox: - boxes = [ - self.page.to_display_frame((c.left, c.bottom, c.right, c.top)) - for c in (self.chars[i] for i in indexes) - ] + def box_of(self, indexes: list[int]) -> BoundingBox: + boxes = [self._display_box(self.chars[i]) for i in indexes] return BoundingBox( l=min(b[0] for b in boxes), b=min(b[1] for b in boxes), @@ -95,6 +89,24 @@ def display_box(self, indexes: list[int]) -> BoundingBox: coord_origin=CoordOrigin.BOTTOMLEFT, ) + def _display_box(self, char: PdfChar) -> tuple[float, float, float, float]: + return self.page.to_display_frame( + (char.left, char.bottom, char.right, char.top) + ) + + +# Returns the characters of a 1-based page, or None when they cannot be read. +PageLookup = Callable[[int], "_Page | None"] + + +@dataclass +class _Rewrite: + """The runs computed for one text item, applied once every item is computed.""" + + item: TextItem + runs: list[ScriptRun] + provs: list[ProvenanceItem] + class ScriptPositionModel: def __init__(self, options: ScriptPositionOptions): @@ -102,11 +114,8 @@ def __init__(self, options: ScriptPositionOptions): def __call__(self, conv_res: ConversionResult) -> DoclingDocument: document = conv_res.document - if not self.options.enabled: - return document backend = conv_res.input._backend - source = getattr(backend, "path_or_stream", None) - if source is None: + if not self.options.enabled or backend.path_or_stream is None: return document if not pypdfium2_available(): _log.warning( @@ -114,146 +123,124 @@ def __call__(self, conv_res: ConversionResult) -> DoclingDocument: "installed; skipping it." ) return document + password = ( + backend.options.password + if isinstance(backend.options, PdfBackendOptions) + else None + ) - password = getattr(getattr(backend, "options", None), "password", None) with TimeRecorder(conv_res, "script_position", scope=ProfilingScope.DOCUMENT): + # The step is optional: a failure keeps the document as it is. try: with open_pdf( - source, + backend.path_or_stream, password.get_secret_value() if password else None, ) as pdf: - pages: dict[int, Optional[_Page]] = {} - - def page_for(page_no: int) -> Optional[_Page]: - if page_no not in pages: - try: - pages[page_no] = _Page( - read_page_chars(pdf, page_no - 1) - ) - except Exception as exc: - _log.debug( - f"Cannot read characters of page {page_no}: {exc}" - ) - pages[page_no] = None - return pages[page_no] - - self.mark_scripts(document, page_for) + rewrites = self.find_scripts(document, _PageCache(pdf).get) except Exception as exc: _log.warning(f"Superscript and subscript recovery failed: {exc}") + return document + self.apply_scripts(document, rewrites) return document - def mark_scripts(self, document: DoclingDocument, page_for) -> None: - """Rewrite the text items holding superscript or subscript text. + def apply_scripts( + self, document: DoclingDocument, rewrites: list[_Rewrite] + ) -> None: + """Replace each rewritten item by an inline group of its runs.""" + for rewrite in rewrites: + _apply(document, rewrite) + replaced: list[NodeItem] = [ + r.item for r in rewrites if not isinstance(r.item, ListItem) + ] + if replaced: + document.delete_items(node_items=replaced) - ``page_for`` returns the characters of a 1-based page, or None. - """ + def find_scripts( + self, document: DoclingDocument, page_for: PageLookup + ) -> list[_Rewrite]: + """Compute the runs of every text item that holds superscript or subscript text.""" referenced = { ref.cref for item in [*document.tables, *document.pictures] for ref in [*item.captions, *item.footnotes, *item.references] } - replaced: list[NodeItem] = [] - try: - for item, _ in list(document.iterate_items(with_groups=False)): - if not isinstance(item, TextItem) or not item.prov or not item.text: - continue - if item.self_ref in referenced or isinstance( - item.parent.resolve(document) if item.parent else None, - InlineGroup, - ): - continue - is_list_item = isinstance(item, ListItem) - if not is_list_item and item.label not in _TEXT_LABELS: - continue - - located = self._item_chars(document, item, page_for) - if located is None: - continue - chars, owners = located - runs = script_runs(item.text, chars, script_positions(chars)) - if runs is None: - continue - - self._write_runs(document, item, runs, owners, page_for) - if not is_list_item: - replaced.append(item) - finally: - # An item already rewritten as a group must not stay beside it. - if replaced: - document.delete_items(node_items=replaced) - - @staticmethod - def _item_chars(document: DoclingDocument, item: TextItem, page_for): - """Return the PDF characters in the item's boxes, with (page, index) of each.""" - chars: list[PdfChar] = [] - owners: list[tuple[int, int]] = [] - seen: set[tuple[int, int]] = set() - for prov in item.prov: - page = page_for(prov.page_no) - doc_page = document.pages.get(prov.page_no) - if page is None or doc_page is None: - continue - box = prov.bbox.to_bottom_left_origin(doc_page.size.height) - for index in page.indexes_in(box): - if (prov.page_no, index) in seen: - continue + rewrites = [] + for item, _ in document.iterate_items(with_groups=False): + if isinstance(item, TextItem) and _is_candidate(document, item, referenced): + rewrite = _rewrite_for(document, item, page_for) + if rewrite is not None: + rewrites.append(rewrite) + return rewrites + + +class _PageCache: + """Read each page's characters once, when an item on it is first looked at.""" + + def __init__(self, pdf): + self.pdf = pdf + self.pages: dict[int, _Page | None] = {} + + def get(self, page_no: int) -> _Page | None: + if page_no not in self.pages: + # PDFium reports a page it cannot read only by raising. + try: + self.pages[page_no] = _Page(read_page_chars(self.pdf, page_no - 1)) + except Exception as exc: + _log.debug(f"Cannot read characters of page {page_no}: {exc}") + self.pages[page_no] = None + return self.pages[page_no] + + +def _is_candidate( + document: DoclingDocument, item: TextItem, referenced: set[str] +) -> bool: + if not item.prov or not item.text: + return False + # Captions and footnotes of tables and pictures are referenced from them. + if item.self_ref in referenced: + return False + if item.parent is not None and isinstance( + item.parent.resolve(document), InlineGroup + ): + return False + return isinstance(item, ListItem) or item.label in _TEXT_LABELS + + +def _rewrite_for( + document: DoclingDocument, item: TextItem, page_for: PageLookup +) -> _Rewrite | None: + chars, owners = _item_chars(document, item, page_for) + # Cheap check first: matching the text to the characters costs more. + if all(p == Script.BASELINE for p in script_positions(chars)): + return None + runs = script_runs(item.text, chars) + if runs is None: + return None + return _Rewrite(item, runs, [_run_prov(item, r, owners, page_for) for r in runs]) + + +def _item_chars( + document: DoclingDocument, item: TextItem, page_for: PageLookup +) -> tuple[list[PdfChar], list[tuple[int, int]]]: + """Return the PDF characters in the item's boxes, with (page, index) of each.""" + chars: list[PdfChar] = [] + owners: list[tuple[int, int]] = [] + seen: set[tuple[int, int]] = set() + for prov in item.prov: + page = page_for(prov.page_no) + if page is None or prov.page_no not in document.pages: + continue + box = prov.bbox.to_bottom_left_origin(document.pages[prov.page_no].size.height) + for index in page.indexes_in(box): + if (prov.page_no, index) not in seen: seen.add((prov.page_no, index)) chars.append(page.chars[index]) owners.append((prov.page_no, index)) - if not any(not c.text.isspace() for c in chars): - return None - return chars, owners - - @staticmethod - def _write_runs( - document: DoclingDocument, - item: TextItem, - runs: list[ScriptRun], - owners: list[tuple[int, int]], - page_for, - ) -> None: - if isinstance(item, ListItem): - # As in the Word backend, the list item keeps its marker and holds the runs. - if item.children: - group = document.insert_inline_group( - sibling=item.children[0].resolve(document), - content_layer=item.content_layer, - after=False, - ) - else: - group = document.add_inline_group( - parent=item, content_layer=item.content_layer - ) - label = DocItemLabel.TEXT - item.text = "" - item.orig = "" - else: - group = document.insert_inline_group( - sibling=item, content_layer=item.content_layer, after=False - ) - label = item.label - - base = item.formatting - for run in runs: - formatting = None - if run.script != Script.BASELINE or base is not None: - formatting = (base or Formatting()).model_copy( - update={"script": run.script} - ) - document.add_text( - label=label, - text=run.text, - orig=run.text, - prov=_run_prov(item, run, owners, page_for), - parent=group, - formatting=formatting, - hyperlink=item.hyperlink, - content_layer=item.content_layer, - ) + return chars, owners def _run_prov( - item: TextItem, run: ScriptRun, owners: list[tuple[int, int]], page_for + item: TextItem, run: ScriptRun, owners: list[tuple[int, int]], page_for: PageLookup ) -> ProvenanceItem: """Box the run's characters, falling back to the item's first box.""" hits = [owners[k] for k in run.chars] @@ -263,10 +250,55 @@ def _run_prov( if page is not None: return ProvenanceItem( page_no=page_no, - bbox=page.display_box([i for p, i in hits if p == page_no]), + bbox=page.box_of([i for p, i in hits if p == page_no]), charspan=(0, len(run.text)), ) - prov = item.prov[0] return ProvenanceItem( - page_no=prov.page_no, bbox=prov.bbox, charspan=(0, len(run.text)) + page_no=item.prov[0].page_no, + bbox=item.prov[0].bbox, + charspan=(0, len(run.text)), ) + + +def _apply(document: DoclingDocument, rewrite: _Rewrite) -> None: + """Add the inline group of an item's runs; the caller deletes the replaced items.""" + item = rewrite.item + if isinstance(item, ListItem): + # As in the Word backend, the list item keeps its marker and holds the runs. + if item.children: + group = document.insert_inline_group( + sibling=item.children[0].resolve(document), + content_layer=item.content_layer, + after=False, + ) + else: + group = document.add_inline_group( + parent=item, content_layer=item.content_layer + ) + label = DocItemLabel.TEXT + else: + group = document.insert_inline_group( + sibling=item, content_layer=item.content_layer, after=False + ) + label = item.label + + for run, prov in zip(rewrite.runs, rewrite.provs, strict=True): + formatting = None + if run.script != Script.BASELINE or item.formatting is not None: + formatting = (item.formatting or Formatting()).model_copy( + update={"script": run.script} + ) + document.add_text( + label=label, + text=run.text, + orig=run.text, + prov=prov, + parent=group, + formatting=formatting, + hyperlink=item.hyperlink, + content_layer=item.content_layer, + ) + + if isinstance(item, ListItem): + item.text = "" + item.orig = "" diff --git a/docling/utils/pdf_char_geometry.py b/docling/utils/pdf_char_geometry.py index 4443f0cf64..518d13551c 100644 --- a/docling/utils/pdf_char_geometry.py +++ b/docling/utils/pdf_char_geometry.py @@ -14,7 +14,7 @@ import math from collections.abc import Iterator from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, replace from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING, Optional, Union @@ -129,8 +129,21 @@ def _read_chars(raw, count: int, pdfium_c) -> list[PdfChar]: continue if not pdfium_c.FPDFText_GetCharOrigin(raw, index, origin_x, origin_y): continue - if 0xD800 <= code <= 0xDFFF: # lone surrogate from a broken ToUnicode map - code = 0xFFFD + if 0xDC00 <= code <= 0xDFFF and chars and _is_high_surrogate(chars[-1]): + # Where wchar_t is 16 bits, PDFium splits a character beyond the BMP (a math + # italic x) into a surrogate pair; join the halves. + high = chars.pop() + chars.append( + replace( + high, + text=chr( + 0x10000 + ((ord(high.text) - 0xD800) << 10) + (code - 0xDC00) + ), + right=max(high.right, box.right), + top=max(high.top, box.top), + ) + ) + continue size = float(pdfium_c.FPDFText_GetFontSize(raw, index)) if pdfium_c.FPDFText_GetMatrix(raw, index, matrix): size *= math.sqrt(abs(matrix.a * matrix.d - matrix.b * matrix.c)) @@ -148,4 +161,12 @@ def _read_chars(raw, count: int, pdfium_c) -> list[PdfChar]: or abs(angle - 2 * math.pi) <= _MAX_HORIZONTAL_ANGLE, ) ) - return chars + # A half of a surrogate pair left on its own comes from a broken ToUnicode map. + return [ + replace(c, text="\ufffd") if 0xD800 <= ord(c.text) <= 0xDFFF else c + for c in chars + ] + + +def _is_high_surrogate(char: PdfChar) -> bool: + return 0xD800 <= ord(char.text) <= 0xDBFF diff --git a/docling/utils/script_position.py b/docling/utils/script_position.py index ed7785345c..7a222b19b6 100644 --- a/docling/utils/script_position.py +++ b/docling/utils/script_position.py @@ -4,7 +4,7 @@ """Find superscript and subscript text from the size and baseline of PDF characters. A character is a superscript or subscript when it is drawn smaller than the body text of -its line and raised or lowered against the body's baseline. +its line and raised or lowered against the body's baseline, within the line. """ from __future__ import annotations @@ -15,7 +15,7 @@ import unicodedata from collections.abc import Sequence from dataclasses import dataclass -from typing import Optional, Protocol +from typing import Protocol from docling_core.types.doc.document import Script @@ -27,19 +27,19 @@ # Baselines this close (share of the largest size) are the same baseline. BASELINE_TOLERANCE = 0.04 BASELINE_TOLERANCE_POINTS = 0.25 -# Baseline groups at least this share of the largest size can be the body. +# Baseline groups at least this share of the largest letter size can be the body. BODY_SIZE_RATIO = 0.9 -# Largest gap (share of the body size) between a script and the text it marks. +# Largest distance (share of the body size) between a script and the text it marks. TOUCH_GAP = 0.6 -# Letters tighter than this to body letters on both sides sit inside a word ("LaTeX"). +# Letters this close to body letters on both sides sit inside a word ("LaTeX"). IN_WORD_GAP = 0.15 -# A body of this few letters beside a longer raised run is a drop cap. -MAX_DROP_CAP = 3 -# A new line starts where the pen moves left, or the baseline jumps, by this share of the size. -LINE_BACKTRACK = 0.5 -LINE_JUMP = 1.2 +# A new line starts where the baseline moves by this share of the line's size, or where +# the pen returns to the line's start (within this share of the size; a narrow base +# letter, as under a stacked script, is wider). +LINE_JUMP = 0.85 +LINE_START = 0.15 # Marks raised as footnote references with no letter or digit. -FOOTNOTE_MARKS = frozenset("*\u2020\u2021\u00a7\u00b6\u2016#") +FOOTNOTE_MARKS = frozenset("*†‡§¶‖#") # Share of an item's characters that must be found among the PDF characters. MIN_ALIGNED = 0.8 # Matching is quadratic, so longer items are skipped. @@ -95,7 +95,7 @@ class ScriptRun: def script_positions(chars: Sequence[ScriptChar]) -> list[Script]: - """Return the script position of each character of one text item, in content order.""" + """Return the script position of each character, in content order.""" positions = [Script.BASELINE] * len(chars) for line in _split_lines(chars): for index, script in _line_scripts(chars, line).items(): @@ -103,26 +103,27 @@ def script_positions(chars: Sequence[ScriptChar]) -> list[Script]: return positions -def script_runs( - text: str, chars: Sequence[ScriptChar], positions: Sequence[Script] -) -> Optional[list[ScriptRun]]: +def script_runs(text: str, chars: Sequence[ScriptChar]) -> list[ScriptRun] | None: """Split an item's text into baseline, superscript and subscript runs. The text is matched to the PDF characters first, since it differs from them in - ligatures, dashes and spaces. A space between a character and its script that the PDF - does not have ("mc 2") is dropped. Runs keep their own whitespace. Returns None when the - text has no script or cannot be matched. + ligatures, dashes and spaces, and only the matched characters are measured. A space + between a character and its script that the PDF does not have ("mc 2") is dropped. + Runs keep their own whitespace. Returns None when the text has no script or cannot be + matched. """ - if not any(p != Script.BASELINE for p in positions): - return None mapping = _align(text, chars) if mapping is None: return None + matched = sorted(set(mapping.values())) + positions = dict( + zip(matched, script_positions([chars[i] for i in matched]), strict=True) + ) - roles: list[Optional[Script]] = [None] * len(text) + roles: list[Script | None] = [None] * len(text) for index, char_index in mapping.items(): roles[index] = positions[char_index] - if not any(r not in (None, Script.BASELINE) for r in roles): + if all(r in (None, Script.BASELINE) for r in roles): return None solid = [i for i, ch in enumerate(text) if not ch.isspace()] @@ -153,16 +154,12 @@ def script_runs( and _set_side_by_side(chars, mapping[left], mapping[right]) ): drop.update(gap) - for i in range(len(text)): - if roles[i] is None: # leading or trailing whitespace - roles[i] = Script.BASELINE pieces: list[tuple[str, Script, list[int]]] = [] for i, ch in enumerate(text): if i in drop: continue - role = roles[i] - assert role is not None + role = roles[i] or Script.BASELINE # leading or trailing whitespace if not pieces or pieces[-1][1] != role: pieces.append(("", role, [])) piece_text, _, piece_chars = pieces[-1] @@ -177,22 +174,33 @@ def _measurable(char: ScriptChar) -> bool: def _split_lines(chars: Sequence[ScriptChar]) -> list[list[int]]: - """Split the measurable characters into visual lines, as lists of indexes.""" + """Split the measurable characters into visual lines, as lists of indexes. + + A line ends where the baseline moves by most of a line height, or where the pen returns + to the line's start. A script stacked over another (x with i below, 2 above) does + neither, however wide it is. + """ lines: list[list[int]] = [] line: list[int] = [] - previous: Optional[ScriptChar] = None + line_size = 0.0 for index, char in enumerate(chars): if not _measurable(char): continue - if previous is not None and ( - previous.left - char.left > LINE_BACKTRACK * max(char.size, previous.size) - or abs(char.baseline - previous.baseline) - > LINE_JUMP * max(char.size, previous.size) - ): - lines.append(line) - line = [] + if line: + size = max(char.size, line_size) + previous = chars[line[-1]] + returns_to_start = ( + char.left < previous.left + and char.left <= chars[line[0]].left + LINE_START * size + ) + if ( + abs(char.baseline - previous.baseline) > LINE_JUMP * size + or returns_to_start + ): + lines.append(line) + line, line_size = [], 0.0 line.append(index) - previous = char + line_size = max(line_size, char.size) if line: lines.append(line) return lines @@ -217,23 +225,24 @@ def _baseline_groups(chars: Sequence[ScriptChar], line: list[int]) -> list[list[ return groups +def _letter_size(chars: Sequence[ScriptChar], group: list[int]) -> float: + """Return the largest size of the group's letters and digits, 0 if it has none.""" + return max((chars[i].size for i in group if chars[i].text.isalnum()), default=0.0) + + def _line_scripts(chars: Sequence[ScriptChar], line: list[int]) -> dict[int, Script]: """Find the superscript and subscript characters of one visual line.""" - if len(line) < 2: - return {} groups = _baseline_groups(chars, line) if len(groups) < 2: return {} - # The body: among the groups in the largest type, the one with the most letters. - largest = max(chars[i].size for i in line) - candidates = [ - group - for group in groups - if max(chars[i].size for i in group) >= BODY_SIZE_RATIO * largest - ] + # The body: among the groups set in the largest letters, the one with the most + # letters. Letters set the size of text; a large symbol (a sum sign) does not. + largest = max(_letter_size(chars, group) for group in groups) + if largest == 0: + return {} body = max( - candidates, - key=lambda group: (sum(chars[i].text.isalnum() for i in group), len(group)), + (g for g in groups if _letter_size(chars, g) >= BODY_SIZE_RATIO * largest), + key=lambda g: (sum(chars[i].text.isalnum() for i in g), len(g)), ) body_size = statistics.median(chars[i].size for i in body) body_baseline = statistics.median(chars[i].baseline for i in body) @@ -242,59 +251,52 @@ def _line_scripts(chars: Sequence[ScriptChar], line: list[int]) -> dict[int, Scr shifted: dict[int, Script] = {} for index in line: char = chars[index] - if char.size > MAX_SCRIPT_SIZE * body_size: - continue shift = char.baseline - body_baseline - if abs(shift) >= min_shift: + # A script sits within its line: it is moved by less than its own size. A drop cap + # or text from another line is moved further. + if ( + char.size <= MAX_SCRIPT_SIZE * body_size + and min_shift <= abs(shift) <= char.size + ): shifted[index] = Script.SUPER if shift > 0 else Script.SUB - body_letters = sum(chars[i].text.isalnum() for i in line if i not in shifted) scripts: dict[int, Script] = {} - position = 0 - while position < len(line): - if line[position] not in shifted: - position += 1 - continue - end = position - script = shifted[line[position]] - while end + 1 < len(line) and shifted.get(line[end + 1]) == script: - end += 1 - run = line[position : end + 1] - before = line[position - 1] if position > 0 else None - after = line[end + 1] if end + 1 < len(line) else None - if _keep_run(chars, run, before, after, shifted, body_size, body_letters): - for index in run: - scripts[index] = script - position = end + 1 + for script, run in itertools.groupby(line, key=shifted.get): + run = list(run) + if script is not None and _keep_run( + chars, run, line=line, shifted=shifted, body_size=body_size + ): + scripts.update(dict.fromkeys(run, script)) return scripts def _keep_run( chars: Sequence[ScriptChar], run: list[int], - before: Optional[int], - after: Optional[int], + *, + line: list[int], shifted: dict[int, Script], body_size: float, - body_letters: int, ) -> bool: """Tell a script from other small, shifted text on the line.""" - script = shifted[run[0]] - neighbours = [] - if before is not None: - neighbours.append((before, chars[run[0]].left - chars[before].right)) - if after is not None: - neighbours.append((after, chars[after].left - chars[run[-1]].right)) + position = line.index(run[0]) + neighbours = [ + k + for k in ( + line[position - 1] if position > 0 else None, + line[position + len(run)] if position + len(run) < len(line) else None, + ) + if k is not None + ] + distances = [_distance(chars[k], chars, run) for k in neighbours] # It touches the text it marks, unlike a small page number or URL further along. touches_body = any( - index not in shifted and -0.5 * body_size <= gap <= TOUCH_GAP * body_size - for index, gap in neighbours + k not in shifted and d <= TOUCH_GAP * body_size + for k, d in zip(neighbours, distances) ) # A subscript and a superscript stacked over one base. - stacked = any( - index in shifted and shifted[index] != script for index, _ in neighbours - ) + stacked = any(k in shifted and shifted[k] != shifted[run[0]] for k in neighbours) if not (touches_body or stacked): return False @@ -307,14 +309,20 @@ def _keep_run( len(neighbours) == 2 and all(t.isalpha() for t in text) and all( - index not in shifted - and chars[index].text.isalpha() - and gap <= IN_WORD_GAP * body_size - for index, gap in neighbours + k not in shifted + and chars[k].text.isalpha() + and d <= IN_WORD_GAP * body_size + for k, d in zip(neighbours, distances) ) ) - drop_cap = len(run) > MAX_DROP_CAP and body_letters <= MAX_DROP_CAP - return not (in_word or drop_cap) + return not in_word + + +def _distance(char: ScriptChar, chars: Sequence[ScriptChar], run: list[int]) -> float: + """Return the horizontal distance between a character and a run, 0 if they overlap.""" + left = min(chars[i].left for i in run) + right = max(chars[i].right for i in run) + return max(0.0, char.left - right, left - char.right) def _folded(text: str) -> list[tuple[str, int]]: @@ -330,10 +338,10 @@ def _folded(text: str) -> list[tuple[str, int]]: return out -def _align(text: str, chars: Sequence[ScriptChar]) -> Optional[dict[int, int]]: +def _align(text: str, chars: Sequence[ScriptChar]) -> dict[int, int] | None: """Map the characters of ``text`` to PDF character indexes, or None if too few match.""" ours = _folded(text) - theirs = _folded("".join(c.text if len(c.text) == 1 else " " for c in chars)) + theirs = _folded("".join(c.text for c in chars)) if not ours: return None if [a for a, _ in ours] == [b for b, _ in theirs]: diff --git a/tests/test_script_position.py b/tests/test_script_position.py index 745f963b7d..5ee3d3aff6 100644 --- a/tests/test_script_position.py +++ b/tests/test_script_position.py @@ -4,9 +4,22 @@ """Tests for finding superscript and subscript text from PDF character geometry.""" import pytest -from docling_core.types.doc.document import Script +from docling_core.types.doc import ( + BoundingBox, + CoordOrigin, + DocItemLabel, + DoclingDocument, + ProvenanceItem, + Size, +) +from docling_core.types.doc.document import InlineGroup, ListItem, Script, TextItem -from docling.utils.pdf_char_geometry import PdfChar +from docling.datamodel.pipeline_options import ScriptPositionOptions +from docling.models.stages.script_position.script_position_model import ( + ScriptPositionModel, + _Page, +) +from docling.utils.pdf_char_geometry import PdfChar, PdfPageChars from docling.utils.script_position import script_positions, script_runs B, SUP, SUB = Script.BASELINE, Script.SUPER, Script.SUB @@ -35,7 +48,7 @@ def _line(*segments, y: float = 700.0, x: float = 50.0) -> list[PdfChar]: def _runs(text: str, chars: list[PdfChar]) -> list[tuple[str, Script]]: - runs = script_runs(text, chars, script_positions(chars)) + runs = script_runs(text, chars) assert runs is not None return [(r.text, r.script) for r in runs] @@ -89,6 +102,12 @@ def _marked(chars: list[PdfChar]) -> str: "Author*and", id="footnote-mark", ), + pytest.param( + # The last line of a paragraph can hold fewer letters than its citation. + [("al.", 12, 0), ("[12]", 7, 4.5)], + "al.[12]", + id="citation-on-short-line", + ), ], ) def test_marks_scripts(segments, expected): @@ -118,6 +137,10 @@ def test_marks_scripts(segments, expected): pytest.param([("L", 12, 0), ("A", 8, 3), ("TeX", 12, 0)], id="latex-logo"), # A drop cap is set larger and lower than the line it starts. pytest.param([("T", 36, -24), ("his line goes on", 12, 0)], id="drop-cap"), + # A large symbol does not set the size of the text around it. + pytest.param( + [("sum ", 10, 0), ("\u2211", 18, -3), (" of x", 10, 0)], id="large-symbol" + ), ], ) def test_leaves_other_text_alone(segments): @@ -137,10 +160,19 @@ def test_measures_each_line_against_its_own_baseline(): assert _marked(chars) == "x2H2O" -def test_stacked_sub_and_superscript(): - chars = _line(("x", 12, 0), ("i", 7, -2.5)) - chars += _line(("2", 7, 4.5), x=chars[-1].left) - assert script_positions(chars) == [B, SUB, SUP] +@pytest.mark.parametrize("subscript", ["i", "ijk"]) +def test_stacked_sub_and_superscript(subscript): + chars = _line(("x", 12, 0), (subscript, 7, -2.5)) + # The superscript is drawn back over the start of the subscript, however wide. + chars += _line(("2", 7, 4.5), x=chars[1].left) + assert script_positions(chars) == [B, *[SUB] * len(subscript), SUP] + + +def test_footnote_marker_starting_a_tightly_set_line(): + # Lines 9 points apart: the raised marker is less than a line below the line above. + chars = _line(("the line above it", 9, 0), y=700.0) + chars += _line(("3", 6, 3), ("Note text", 9, 0), y=691.0) + assert _marked(chars) == "thelineaboveit3Notetext" def test_runs_drop_the_space_the_pdf_does_not_have(): @@ -164,15 +196,84 @@ def test_runs_match_through_ligatures_and_dashes(): def test_runs_point_to_their_characters(): chars = _line(("H", 12, 0), ("2", 7, -2.5), ("O", 12, 0)) - runs = script_runs("H2O", chars, script_positions(chars)) + runs = script_runs("H2O", chars) assert runs is not None assert [r.chars for r in runs] == [(0,), (1,), (2,)] def test_no_runs_without_scripts_or_when_text_does_not_match(): plain = _line(("plain text", 12, 0)) - assert script_runs("plain text", plain, script_positions(plain)) is None + assert script_runs("plain text", plain) is None chars = _line(("E = mc", 12, 0), ("2", 7, 4.5)) - assert ( - script_runs("something else entirely", chars, script_positions(chars)) is None + assert script_runs("something else entirely", chars) is None + + +def test_runs_ignore_characters_of_a_neighbouring_line(): + # The item's box also catches a raised character of the line below it. + chars = _line(("E = mc", 12, 0), ("2", 7, 4.5), y=700.0) + chars += _line(("x", 12, 0), ("3", 7, 4.5), y=686.0) + assert _runs("E = mc 2", chars) == [("E = mc", B), ("2", SUP)] + + +def _box(chars: list[PdfChar]) -> BoundingBox: + return BoundingBox( + l=min(c.left for c in chars), + b=min(c.bottom for c in chars), + r=max(c.right for c in chars), + t=max(c.top for c in chars), + coord_origin=CoordOrigin.BOTTOMLEFT, ) + + +def _item_runs( + document: DoclingDocument, group: InlineGroup +) -> list[tuple[str, Script]]: + runs = [] + for ref in group.children: + item = ref.resolve(document) + assert isinstance(item, TextItem) + runs.append((item.text, item.formatting.script if item.formatting else B)) + return runs + + +def test_stage_rewrites_paragraphs_and_list_items(): + paragraph = _line(("E = mc", 12, 0), ("2", 7, 4.5), (" here", 12, 0), y=700.0) + list_item = _line(("H", 12, 0), ("2", 7, -2.5), ("O", 12, 0), y=650.0) + document = DoclingDocument(name="test") + document.add_page(page_no=1, size=Size(width=612, height=792)) + document.add_text( + label=DocItemLabel.TEXT, + text="E = mc 2 here", + prov=ProvenanceItem(page_no=1, bbox=_box(paragraph), charspan=(0, 13)), + ) + document.add_list_item( + text="H 2 O", + parent=document.add_list_group(), + prov=ProvenanceItem(page_no=1, bbox=_box(list_item), charspan=(0, 5)), + ) + page = _Page( + PdfPageChars( + chars=paragraph + list_item, + rotation=0, + display_size=Size(width=612, height=792), + origin=(0.0, 0.0), + ) + ) + + model = ScriptPositionModel(ScriptPositionOptions(enabled=True)) + model.apply_scripts(document, model.find_scripts(document, {1: page}.get)) + + groups = [g for g in document.groups if isinstance(g, InlineGroup)] + assert [_item_runs(document, g) for g in groups] == [ + [("E = mc", B), ("2", SUP), (" here", B)], + [("H", B), ("2", SUB), ("O", B)], + ] + # The paragraph is replaced; the list item keeps its marker and holds the runs. + assert all(t.text != "E = mc 2 here" for t in document.texts) + list_items = [t for t in document.texts if isinstance(t, ListItem)] + assert len(list_items) == 1 and list_items[0].text == "" + assert groups[1].parent == list_items[0].get_ref() + # A run's box is the box of its own characters. + superscript = groups[0].children[1].resolve(document) + assert isinstance(superscript, TextItem) + assert superscript.prov[0].bbox == _box([paragraph[6]]) diff --git a/tests/test_script_position_pdf.py b/tests/test_script_position_pdf.py index 6036b8fe17..47aeeaf07c 100644 --- a/tests/test_script_position_pdf.py +++ b/tests/test_script_position_pdf.py @@ -97,3 +97,88 @@ def test_disabled_leaves_the_document_unchanged(): document = _convert(ThreadedDoclingParseDocumentBackend, enabled=False) assert not _runs(document) assert "E = mc 2 in 1905." in document.export_to_markdown() + + +# One paragraph in Helvetica, as (text, size, rise) runs per line. +_LINES = [ + [ + ("Water is H", 11, 0), + ("2", 7, -2), + ("O and carbon dioxide is CO", 11, 0), + ("2", 7, -2), + (" in the air", 11, 0), + ], + [ + ("we can see that E = mc", 11, 0), + ("2", 7, 4), + (" holds on the 21", 11, 0), + ("st", 7, 4), + (" of June", 11, 0), + ], + [ + ("as reported before", 11, 0), + ("12", 7, 4), + (" and confirmed by the team today", 11, 0), + ], +] + + +def _write_pdf(path: Path, page: str) -> None: + """Write a one-page PDF of ``_LINES`` with the given page box entries.""" + ops = ["BT", "13 TL", "72 700 Td"] + for number, line in enumerate(_LINES): + if number: + ops.append("T*") + ops += [f"/F1 {size} Tf {rise} Ts ({text}) Tj" for text, size, rise in line] + stream = "\n".join([*ops, "0 Ts", "ET"]).encode() + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + f"<< /Type /Page /Parent 2 0 R {page} /Contents 4 0 R " + "/Resources << /Font << /F1 5 0 R >> >> >>".encode(), + b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for number, body in enumerate(objects, 1): + offsets.append(len(out)) + out += b"%d 0 obj\n" % number + body + b"\nendobj\n" + xref = len(out) + out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1) + out += b"".join(b"%010d 00000 n \n" % offset for offset in offsets) + out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % ( + len(objects) + 1, + xref, + ) + path.write_bytes(bytes(out)) + + +@pytest.mark.parametrize( + "backend", [ThreadedDoclingParseDocumentBackend, PyPdfiumDocumentBackend] +) +@pytest.mark.parametrize( + "page", + [ + pytest.param("/MediaBox [0 0 612 792] /Rotate 90", id="rotated"), + # The visible page does not start at the origin of the PDF coordinates. + pytest.param("/MediaBox [0 0 700 900] /CropBox [40 60 652 852]", id="cropped"), + ], +) +def test_matches_characters_on_rotated_and_cropped_pages(tmp_path, backend, page): + pdf = tmp_path / "page.pdf" + _write_pdf(pdf, page) + options = PdfPipelineOptions(do_ocr=False, do_table_structure=False) + options.script_position_options = ScriptPositionOptions(enabled=True) + converter = DocumentConverter( + format_options={ + InputFormat.PDF: PdfFormatOption(pipeline_options=options, backend=backend) + } + ) + assert _scripts(_runs(converter.convert(pdf).document)) == [ + ("2", Script.SUB), + ("2", Script.SUB), + ("2", Script.SUPER), + ("st", Script.SUPER), + ("12", Script.SUPER), + ] From cdf7e01bb276979c796d14490107ceab5354777d Mon Sep 17 00:00:00 2001 From: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:26:59 +0530 Subject: [PATCH 3/6] fix(pdf): leave the whitespace between superscript runs to the export 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> --- docling/utils/script_position.py | 38 ++++++-------------------------- tests/test_script_position.py | 14 +++++------- 2 files changed, 12 insertions(+), 40 deletions(-) diff --git a/docling/utils/script_position.py b/docling/utils/script_position.py index 7a222b19b6..61f2a44171 100644 --- a/docling/utils/script_position.py +++ b/docling/utils/script_position.py @@ -44,8 +44,6 @@ MIN_ALIGNED = 0.8 # Matching is quadratic, so longer items are skipped. MAX_ALIGN_CHARS = 5000 -# Gap (share of the size) under which two characters are set side by side. -MAX_JOIN_GAP = 0.15 _PUNCTUATION_FOLD = str.maketrans( { @@ -107,10 +105,9 @@ def script_runs(text: str, chars: Sequence[ScriptChar]) -> list[ScriptRun] | Non """Split an item's text into baseline, superscript and subscript runs. The text is matched to the PDF characters first, since it differs from them in - ligatures, dashes and spaces, and only the matched characters are measured. A space - between a character and its script that the PDF does not have ("mc 2") is dropped. - Runs keep their own whitespace. Returns None when the text has no script or cannot be - matched. + ligatures, dashes and spaces, and only the matched characters are measured. As in the + Word backend, a run has no whitespace at its edges. Returns None when the text has no + script or cannot be matched. """ mapping = _align(text, chars) if mapping is None: @@ -136,30 +133,17 @@ def script_runs(text: str, chars: Sequence[ScriptChar]) -> list[ScriptRun] | Non ) after = next((roles[j] for j in solid[k + 1 :] if roles[j] is not None), None) roles[index] = before if before == after and before else Script.BASELINE - - drop: set[int] = set() + # Whitespace inside a run belongs to it; whitespace between runs is left out. for left, right in itertools.pairwise(solid): - gap = range(left + 1, right) - if not gap: - continue if roles[left] == roles[right]: - for i in gap: + for i in range(left + 1, right): roles[i] = roles[left] - continue - for i in gap: - roles[i] = Script.BASELINE - if ( - left in mapping - and right in mapping - and _set_side_by_side(chars, mapping[left], mapping[right]) - ): - drop.update(gap) pieces: list[tuple[str, Script, list[int]]] = [] for i, ch in enumerate(text): - if i in drop: + role = roles[i] + if role is None: continue - role = roles[i] or Script.BASELINE # leading or trailing whitespace if not pieces or pieces[-1][1] != role: pieces.append(("", role, [])) piece_text, _, piece_chars = pieces[-1] @@ -364,11 +348,3 @@ def _align(text: str, chars: Sequence[ScriptChar]) -> dict[int, int] | None: if len(mapping) < MIN_ALIGNED * solid: return None return mapping - - -def _set_side_by_side(chars: Sequence[ScriptChar], left: int, right: int) -> bool: - """Tell whether the PDF draws two characters with no space and no gap between them.""" - if right <= left or any(c.text.isspace() for c in chars[left + 1 : right]): - return False - a, b = chars[left], chars[right] - return b.left - a.right <= MAX_JOIN_GAP * max(a.size, b.size) diff --git a/tests/test_script_position.py b/tests/test_script_position.py index 5ee3d3aff6..76cc3364fe 100644 --- a/tests/test_script_position.py +++ b/tests/test_script_position.py @@ -175,20 +175,16 @@ def test_footnote_marker_starting_a_tightly_set_line(): assert _marked(chars) == "thelineaboveit3Notetext" -def test_runs_drop_the_space_the_pdf_does_not_have(): +def test_runs_have_no_whitespace_at_their_edges(): + # As the Word backend's runs: the space between two runs is left to the export. chars = _line(("E = mc", 12, 0), ("2", 7, 4.5), (" in 1905.", 12, 0)) - assert _runs("E = mc 2 in 1905.", chars) == [ + assert _runs(" E = mc 2 in 1905. ", chars) == [ ("E = mc", B), ("2", SUP), - (" in 1905.", B), + ("in 1905.", B), ] -def test_runs_keep_a_space_the_pdf_has(): - chars = _line(("agrees ", 12, 0), ("12", 7, 4.5)) - assert _runs("agrees 12", chars) == [("agrees ", B), ("12", SUP)] - - def test_runs_match_through_ligatures_and_dashes(): chars = _line(("\ufb01rst", 12, 0), ("1\u20132", 7, 4.5)) assert _runs("first1-2", chars) == [("first", B), ("1-2", SUP)] @@ -265,7 +261,7 @@ def test_stage_rewrites_paragraphs_and_list_items(): groups = [g for g in document.groups if isinstance(g, InlineGroup)] assert [_item_runs(document, g) for g in groups] == [ - [("E = mc", B), ("2", SUP), (" here", B)], + [("E = mc", B), ("2", SUP), ("here", B)], [("H", B), ("2", SUB), ("O", B)], ] # The paragraph is replaced; the list item keeps its marker and holds the runs. From 7e940e597512dfdb95df2fd341d8d4197def66a7 Mon Sep 17 00:00:00 2001 From: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:44:16 +0530 Subject: [PATCH 4/6] test(pdf): describe the run whitespace in the superscript test Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> --- tests/test_script_position_pdf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_script_position_pdf.py b/tests/test_script_position_pdf.py index 47aeeaf07c..d57e2e36d8 100644 --- a/tests/test_script_position_pdf.py +++ b/tests/test_script_position_pdf.py @@ -68,7 +68,7 @@ def test_marks_superscript_and_subscript_runs(): ("rd", Script.SUPER), ("[12]", Script.SUPER), ] - # The space the text-line builder puts before a script ("mc 2") is gone. + # As in the Word backend, a run has no whitespace at its edges. assert runs[0][:2] == [ ("Einstein wrote E = mc", Script.BASELINE), ("2", Script.SUPER), From ebcd21c90f2233fe3f98d6940c9d7c6a6b88a315 Mon Sep 17 00:00:00 2001 From: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:19:53 +0530 Subject: [PATCH 5/6] fix(pdf): skip invisible text when marking superscript and subscript Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> --- docling/datamodel/pipeline_options.py | 3 ++- docling/utils/pdf_char_geometry.py | 11 +++++++++++ tests/test_script_position_pdf.py | 24 ++++++++++++++++++++++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docling/datamodel/pipeline_options.py b/docling/datamodel/pipeline_options.py index 75de1b9910..63403b6cc8 100644 --- a/docling/datamodel/pipeline_options.py +++ b/docling/datamodel/pipeline_options.py @@ -2088,7 +2088,8 @@ class ScriptPositionOptions(BaseModel): - Character positions are read with pypdfium2 whichever backend parsed the page; the step is skipped with a warning when pypdfium2 is not installed. - Body text, list items and footnotes are handled; headings, captions, tables, code, - formulas and OCR text are left unchanged. + formulas, OCR text and invisible text (such as an OCR layer over a scan) are left + unchanged. """ enabled: Annotated[ diff --git a/docling/utils/pdf_char_geometry.py b/docling/utils/pdf_char_geometry.py index 518d13551c..d13376769f 100644 --- a/docling/utils/pdf_char_geometry.py +++ b/docling/utils/pdf_char_geometry.py @@ -28,6 +28,8 @@ # Characters rotated by more than this (radians) are not horizontal text. _MAX_HORIZONTAL_ANGLE = 0.01 +# PDF 32000 text rendering modes that paint no ink: invisible (3) and clipping only (7). +_INVISIBLE_RENDER_MODES = frozenset({3, 7}) @dataclass(frozen=True) @@ -127,6 +129,15 @@ def _read_chars(raw, count: int, pdfium_c) -> list[PdfChar]: code = pdfium_c.FPDFText_GetUnicode(raw, index) if code <= 0 or not pdfium_c.FPDFText_GetLooseCharBox(raw, index, box): continue + # Invisible text, such as an OCR layer over a scan, follows the scanned lines + # and not a typeset baseline. + text_object = pdfium_c.FPDFText_GetTextObject(raw, index) + if ( + text_object + and pdfium_c.FPDFTextObj_GetTextRenderMode(text_object) + in _INVISIBLE_RENDER_MODES + ): + continue if not pdfium_c.FPDFText_GetCharOrigin(raw, index, origin_x, origin_y): continue if 0xDC00 <= code <= 0xDFFF and chars and _is_high_surrogate(chars[-1]): diff --git a/tests/test_script_position_pdf.py b/tests/test_script_position_pdf.py index d57e2e36d8..8f5520252f 100644 --- a/tests/test_script_position_pdf.py +++ b/tests/test_script_position_pdf.py @@ -123,9 +123,9 @@ def test_disabled_leaves_the_document_unchanged(): ] -def _write_pdf(path: Path, page: str) -> None: +def _write_pdf(path: Path, page: str, render_mode: int = 0) -> None: """Write a one-page PDF of ``_LINES`` with the given page box entries.""" - ops = ["BT", "13 TL", "72 700 Td"] + ops = ["BT", f"{render_mode} Tr", "13 TL", "72 700 Td"] for number, line in enumerate(_LINES): if number: ops.append("T*") @@ -182,3 +182,23 @@ def test_matches_characters_on_rotated_and_cropped_pages(tmp_path, backend, page ("st", Script.SUPER), ("12", Script.SUPER), ] + + +@pytest.mark.parametrize( + "backend", [ThreadedDoclingParseDocumentBackend, PyPdfiumDocumentBackend] +) +def test_leaves_invisible_text_unchanged(tmp_path, backend): + """An invisible text layer, such as OCR over a scan, has no typeset baseline.""" + pdf = tmp_path / "page.pdf" + _write_pdf(pdf, "/MediaBox [0 0 612 792]", render_mode=3) + options = PdfPipelineOptions(do_ocr=False, do_table_structure=False) + options.script_position_options = ScriptPositionOptions(enabled=True) + converter = DocumentConverter( + format_options={ + InputFormat.PDF: PdfFormatOption(pipeline_options=options, backend=backend) + } + ) + document = converter.convert(pdf).document + + assert "confirmed by the team today" in document.export_to_markdown() + assert _scripts(_runs(document)) == [] From 2aaedddd49f9eb35bec634c068d16780de2ea801 Mon Sep 17 00:00:00 2001 From: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:47:10 +0530 Subject: [PATCH 6/6] fix(pdf): keep paragraph blocks and per-page provenance of script runs Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> --- docling/datamodel/pipeline_options.py | 2 +- .../script_position/script_position_model.py | 116 ++++++++++-------- docling/utils/script_position.py | 13 +- docs/usage/advanced_options.md | 3 +- tests/test_script_position.py | 72 ++++++++++- tests/test_script_position_pdf.py | 23 +++- 6 files changed, 167 insertions(+), 62 deletions(-) diff --git a/docling/datamodel/pipeline_options.py b/docling/datamodel/pipeline_options.py index d8c45afb1f..8ba250ed9c 100644 --- a/docling/datamodel/pipeline_options.py +++ b/docling/datamodel/pipeline_options.py @@ -2088,7 +2088,7 @@ class ScriptPositionOptions(BaseModel): When ``enabled``, characters drawn smaller than their line and raised or lowered against it (footnote markers, "1st", "mc2", "H2O") are marked with ``formatting.script``. A text - item holding them becomes an inline group of runs, as the Word backend produces. + item holding them keeps its place, and its text moves into an inline group of runs. Notes: - Character positions are read with pypdfium2 whichever backend parsed the page; the diff --git a/docling/models/stages/script_position/script_position_model.py b/docling/models/stages/script_position/script_position_model.py index 703bc80a7f..5c3f7ed41a 100644 --- a/docling/models/stages/script_position/script_position_model.py +++ b/docling/models/stages/script_position/script_position_model.py @@ -3,8 +3,8 @@ """Mark superscript and subscript text in documents converted from PDF. -A text item holding such text becomes an inline group of runs with ``formatting.script``, -as the Word backend produces. +A text item holding such text keeps its place, and its text moves into an inline group of +runs with ``formatting.script``, which the item holds. """ from __future__ import annotations @@ -31,6 +31,7 @@ ) from docling.datamodel.backend_options import PdfBackendOptions +from docling.datamodel.base_models import InputFormat from docling.datamodel.document import ConversionResult from docling.datamodel.pipeline_options import ScriptPositionOptions from docling.utils.pdf_char_geometry import ( @@ -105,7 +106,8 @@ class _Rewrite: item: TextItem runs: list[ScriptRun] - provs: list[ProvenanceItem] + # The provenance of each run, one per page it is on. + provs: list[list[ProvenanceItem]] class ScriptPositionModel: @@ -115,7 +117,12 @@ def __init__(self, options: ScriptPositionOptions): def __call__(self, conv_res: ConversionResult) -> DoclingDocument: document = conv_res.document backend = conv_res.input._backend - if not self.options.enabled or backend.path_or_stream is None: + # The PDF pipeline also converts images, which have no PDF characters to read. + if ( + not self.options.enabled + or conv_res.input.format != InputFormat.PDF + or backend.path_or_stream is None + ): return document if not pypdfium2_available(): _log.warning( @@ -146,14 +153,9 @@ def __call__(self, conv_res: ConversionResult) -> DoclingDocument: def apply_scripts( self, document: DoclingDocument, rewrites: list[_Rewrite] ) -> None: - """Replace each rewritten item by an inline group of its runs.""" + """Move the text of each rewritten item into an inline group of its runs.""" for rewrite in rewrites: _apply(document, rewrite) - replaced: list[NodeItem] = [ - r.item for r in rewrites if not isinstance(r.item, ListItem) - ] - if replaced: - document.delete_items(node_items=replaced) def find_scripts( self, document: DoclingDocument, page_for: PageLookup @@ -216,7 +218,7 @@ def _rewrite_for( runs = script_runs(item.text, chars) if runs is None: return None - return _Rewrite(item, runs, [_run_prov(item, r, owners, page_for) for r in runs]) + return _Rewrite(item, runs, [_run_provs(item, r, owners, page_for) for r in runs]) def _item_chars( @@ -239,66 +241,82 @@ def _item_chars( return chars, owners -def _run_prov( +def _run_provs( item: TextItem, run: ScriptRun, owners: list[tuple[int, int]], page_for: PageLookup -) -> ProvenanceItem: - """Box the run's characters, falling back to the item's first box.""" - hits = [owners[k] for k in run.chars] - if hits: - page_no = hits[0][0] +) -> list[ProvenanceItem]: + """Box the run's characters on each page they are on, else use the item's first box. + + A run can cross a page break, as the text after a script at the end of a page. + """ + # Per page, the position in the run's text and the index of each character. + by_page: dict[int, list[tuple[int, int]]] = {} + for k, offset in zip(run.chars, run.offsets, strict=True): + page_no, index = owners[k] + by_page.setdefault(page_no, []).append((offset, index)) + pages = sorted(by_page, key=lambda p: min(o for o, _ in by_page[p])) + provs = [] + for n, page_no in enumerate(pages): page = page_for(page_no) - if page is not None: - return ProvenanceItem( + if page is None: + continue + start = 0 if n == 0 else min(o for o, _ in by_page[page_no]) + end = ( + len(run.text) + if n == len(pages) - 1 + else min(o for o, _ in by_page[pages[n + 1]]) + ) + provs.append( + ProvenanceItem( page_no=page_no, - bbox=page.box_of([i for p, i in hits if p == page_no]), - charspan=(0, len(run.text)), + bbox=page.box_of([i for _, i in by_page[page_no]]), + charspan=(start, end), ) - return ProvenanceItem( - page_no=item.prov[0].page_no, - bbox=item.prov[0].bbox, - charspan=(0, len(run.text)), - ) + ) + if provs: + return provs + return [ + ProvenanceItem( + page_no=item.prov[0].page_no, + bbox=item.prov[0].bbox, + charspan=(0, len(run.text)), + ) + ] def _apply(document: DoclingDocument, rewrite: _Rewrite) -> None: - """Add the inline group of an item's runs; the caller deletes the replaced items.""" + """Move an item's text into an inline group of its runs, held by the item. + + The item keeps its place, so a paragraph stays a paragraph block in the export and + a list item keeps its marker, as in the Word backend. + """ item = rewrite.item - if isinstance(item, ListItem): - # As in the Word backend, the list item keeps its marker and holds the runs. - if item.children: - group = document.insert_inline_group( - sibling=item.children[0].resolve(document), - content_layer=item.content_layer, - after=False, - ) - else: - group = document.add_inline_group( - parent=item, content_layer=item.content_layer - ) - label = DocItemLabel.TEXT - else: + if item.children: group = document.insert_inline_group( - sibling=item, content_layer=item.content_layer, after=False + sibling=item.children[0].resolve(document), + content_layer=item.content_layer, + after=False, ) - label = item.label + else: + group = document.add_inline_group(parent=item, content_layer=item.content_layer) + label = DocItemLabel.TEXT if isinstance(item, ListItem) else item.label - for run, prov in zip(rewrite.runs, rewrite.provs, strict=True): + for run, provs in zip(rewrite.runs, rewrite.provs, strict=True): formatting = None if run.script != Script.BASELINE or item.formatting is not None: formatting = (item.formatting or Formatting()).model_copy( update={"script": run.script} ) - document.add_text( + text_item = document.add_text( label=label, text=run.text, orig=run.text, - prov=prov, + prov=provs[0], parent=group, formatting=formatting, hyperlink=item.hyperlink, content_layer=item.content_layer, ) + text_item.prov.extend(provs[1:]) - if isinstance(item, ListItem): - item.text = "" - item.orig = "" + item.text = "" + item.orig = "" diff --git a/docling/utils/script_position.py b/docling/utils/script_position.py index 61f2a44171..9296babd21 100644 --- a/docling/utils/script_position.py +++ b/docling/utils/script_position.py @@ -90,6 +90,8 @@ class ScriptRun: script: Script # Indexes of the PDF characters matched to the run. chars: tuple[int, ...] + # Position in ``text`` of each of ``chars``. + offsets: tuple[int, ...] = () def script_positions(chars: Sequence[ScriptChar]) -> list[Script]: @@ -139,18 +141,19 @@ def script_runs(text: str, chars: Sequence[ScriptChar]) -> list[ScriptRun] | Non for i in range(left + 1, right): roles[i] = roles[left] - pieces: list[tuple[str, Script, list[int]]] = [] + pieces: list[tuple[str, Script, list[int], list[int]]] = [] for i, ch in enumerate(text): role = roles[i] if role is None: continue if not pieces or pieces[-1][1] != role: - pieces.append(("", role, [])) - piece_text, _, piece_chars = pieces[-1] + pieces.append(("", role, [], [])) + piece_text, _, piece_chars, piece_offsets = pieces[-1] if i in mapping and mapping[i] not in piece_chars: piece_chars.append(mapping[i]) - pieces[-1] = (piece_text + ch, role, piece_chars) - return [ScriptRun(t, role, tuple(c)) for t, role, c in pieces] + piece_offsets.append(len(piece_text)) + pieces[-1] = (piece_text + ch, role, piece_chars, piece_offsets) + return [ScriptRun(t, role, tuple(c), tuple(o)) for t, role, c, o in pieces] def _measurable(char: ScriptChar) -> bool: diff --git a/docs/usage/advanced_options.md b/docs/usage/advanced_options.md index 3cffb53ef6..ed28704dca 100644 --- a/docs/usage/advanced_options.md +++ b/docs/usage/advanced_options.md @@ -368,7 +368,8 @@ doc_converter = DocumentConverter( ) ``` -A text item holding superscript or subscript text becomes an inline group of runs. +A text item holding superscript or subscript text keeps its place, and its text moves into +an inline group of runs that the item holds. ### PowerPoint and Excel charts diff --git a/tests/test_script_position.py b/tests/test_script_position.py index 76cc3364fe..11377adf00 100644 --- a/tests/test_script_position.py +++ b/tests/test_script_position.py @@ -3,6 +3,8 @@ """Tests for finding superscript and subscript text from PDF character geometry.""" +import re + import pytest from docling_core.types.doc import ( BoundingBox, @@ -264,12 +266,72 @@ def test_stage_rewrites_paragraphs_and_list_items(): [("E = mc", B), ("2", SUP), ("here", B)], [("H", B), ("2", SUB), ("O", B)], ] - # The paragraph is replaced; the list item keeps its marker and holds the runs. - assert all(t.text != "E = mc 2 here" for t in document.texts) - list_items = [t for t in document.texts if isinstance(t, ListItem)] - assert len(list_items) == 1 and list_items[0].text == "" - assert groups[1].parent == list_items[0].get_ref() + # Each item keeps its place and holds its runs; the list item keeps its marker. + paragraph_item, list_item_item = document.texts[0], document.texts[1] + assert isinstance(list_item_item, ListItem) + assert paragraph_item.text == "" and list_item_item.text == "" + assert groups[0].parent == paragraph_item.get_ref() + assert groups[1].parent == list_item_item.get_ref() # A run's box is the box of its own characters. superscript = groups[0].children[1].resolve(document) assert isinstance(superscript, TextItem) assert superscript.prov[0].bbox == _box([paragraph[6]]) + + +def _stage(document: DoclingDocument, pages: dict[int, list[PdfChar]]) -> None: + lookup = { + page_no: _Page( + PdfPageChars( + chars=chars, + rotation=0, + display_size=Size(width=612, height=792), + origin=(0.0, 0.0), + ) + ) + for page_no, chars in pages.items() + } + model = ScriptPositionModel(ScriptPositionOptions(enabled=True)) + model.apply_scripts(document, model.find_scripts(document, lookup.get)) + + +def test_paragraphs_stay_separate_blocks_in_html(): + """Two paragraphs in a row with scripts must not run together in a browser.""" + first = _line(("E = mc", 12, 0), ("2", 7, 4.5), y=700.0) + second = _line(("H", 12, 0), ("2", 7, -2.5), ("O", 12, 0), y=650.0) + document = DoclingDocument(name="test") + document.add_page(page_no=1, size=Size(width=612, height=792)) + for text, chars in (("E = mc 2", first), ("H 2 O", second)): + document.add_text( + label=DocItemLabel.TEXT, + text=text, + prov=ProvenanceItem(page_no=1, bbox=_box(chars), charspan=(0, len(text))), + ) + _stage(document, {1: first + second}) + + html = document.export_to_html() + assert len(re.findall(r"

\s*", html)) == 2 + + +def test_run_crossing_a_page_break_keeps_both_pages(): + """The text after a script can continue on the next page.""" + page_1 = _line(("Water is H", 12, 0), ("2", 7, -2.5), ("O and", 12, 0), y=60.0) + page_2 = _line(("more text", 12, 0), y=740.0) + document = DoclingDocument(name="test") + for page_no in (1, 2): + document.add_page(page_no=page_no, size=Size(width=612, height=792)) + text = "Water is H 2 O and more text" + item = document.add_text( + label=DocItemLabel.TEXT, + text=text, + prov=ProvenanceItem(page_no=1, bbox=_box(page_1), charspan=(0, 18)), + ) + item.prov.append( + ProvenanceItem(page_no=2, bbox=_box(page_2), charspan=(18, len(text))) + ) + _stage(document, {1: page_1, 2: page_2}) + + last = next(t for t in document.texts if t.text.startswith("O")) + assert last.text == "O and more text" + assert [p.page_no for p in last.prov] == [1, 2] + assert [last.text[slice(*p.charspan)] for p in last.prov] == ["O and ", "more text"] + assert last.prov[1].bbox == _box(page_2) diff --git a/tests/test_script_position_pdf.py b/tests/test_script_position_pdf.py index 8f5520252f..8b293b74d0 100644 --- a/tests/test_script_position_pdf.py +++ b/tests/test_script_position_pdf.py @@ -7,6 +7,7 @@ import pytest from docling_core.types.doc.document import InlineGroup, Script, TextItem +from PIL import Image from docling.backend.docling_parse_backend import ThreadedDoclingParseDocumentBackend from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend @@ -15,7 +16,11 @@ PdfPipelineOptions, ScriptPositionOptions, ) -from docling.document_converter import DocumentConverter, PdfFormatOption +from docling.document_converter import ( + DocumentConverter, + ImageFormatOption, + PdfFormatOption, +) pytestmark = pytest.mark.ml_pdf_model @@ -202,3 +207,19 @@ def test_leaves_invisible_text_unchanged(tmp_path, backend): assert "confirmed by the team today" in document.export_to_markdown() assert _scripts(_runs(document)) == [] + + +def test_image_input_is_skipped_without_a_warning(tmp_path, caplog): + """The PDF pipeline also converts images, which have no PDF characters.""" + image = tmp_path / "page.png" + Image.new("RGB", (200, 100), "white").save(image) + options = PdfPipelineOptions(do_ocr=False, do_table_structure=False) + options.script_position_options = ScriptPositionOptions(enabled=True) + converter = DocumentConverter( + format_options={InputFormat.IMAGE: ImageFormatOption(pipeline_options=options)} + ) + + with caplog.at_level("WARNING", logger="docling.models.stages.script_position"): + converter.convert(image) + + assert not [r for r in caplog.records if "recovery" in r.getMessage()]