diff --git a/docling/.agents/skills/docling/references/python-sdk.md b/docling/.agents/skills/docling/references/python-sdk.md index be17238dad..90132c017f 100644 --- a/docling/.agents/skills/docling/references/python-sdk.md +++ b/docling/.agents/skills/docling/references/python-sdk.md @@ -67,6 +67,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 910b11cca4..8ba250ed9c 100644 --- a/docling/datamodel/pipeline_options.py +++ b/docling/datamodel/pipeline_options.py @@ -2083,6 +2083,33 @@ 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 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 + step is skipped with a warning when pypdfium2 is not installed. + - Body text, list items and footnotes are handled; headings, captions, tables, code, + formulas, OCR text and invisible text (such as an OCR layer over a scan) 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. @@ -2268,6 +2295,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..5c3f7ed41a --- /dev/null +++ b/docling/models/stages/script_position/script_position_model.py @@ -0,0 +1,322 @@ +# 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 keeps its place, and its text moves into an inline group of +runs with ``formatting.script``, which the item holds. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass + +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.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 ( + 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.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.""" + 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 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), + r=max(b[2] for b in boxes), + t=max(b[3] for b in boxes), + 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] + # The provenance of each run, one per page it is on. + provs: list[list[ProvenanceItem]] + + +class ScriptPositionModel: + def __init__(self, options: ScriptPositionOptions): + self.options = options + + def __call__(self, conv_res: ConversionResult) -> DoclingDocument: + document = conv_res.document + backend = conv_res.input._backend + # 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( + "Superscript and subscript recovery needs pypdfium2, which is not " + "installed; skipping it." + ) + return document + password = ( + backend.options.password + if isinstance(backend.options, PdfBackendOptions) + else 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( + backend.path_or_stream, + password.get_secret_value() if password else None, + ) as pdf: + 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 apply_scripts( + self, document: DoclingDocument, rewrites: list[_Rewrite] + ) -> None: + """Move the text of each rewritten item into an inline group of its runs.""" + for rewrite in rewrites: + _apply(document, rewrite) + + 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] + } + 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_provs(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)) + return chars, owners + + +def _run_provs( + item: TextItem, run: ScriptRun, owners: list[tuple[int, int]], page_for: PageLookup +) -> 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 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 _, i in by_page[page_no]]), + charspan=(start, end), + ) + ) + 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: + """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 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 if isinstance(item, ListItem) else item.label + + 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} + ) + text_item = document.add_text( + label=label, + text=run.text, + orig=run.text, + prov=provs[0], + parent=group, + formatting=formatting, + hyperlink=item.hyperlink, + content_layer=item.content_layer, + ) + text_item.prov.extend(provs[1:]) + + item.text = "" + item.orig = "" diff --git a/docling/pipeline/standard_pdf_pipeline.py b/docling/pipeline/standard_pdf_pipeline.py index f5480a3b2a..507d7fff1b 100644 --- a/docling/pipeline/standard_pdf_pipeline.py +++ b/docling/pipeline/standard_pdf_pipeline.py @@ -88,6 +88,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, @@ -692,6 +695,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, @@ -1170,6 +1176,7 @@ def _assemble_document(self, conv_res: ConversionResult) -> ConversionResult: conv_res, time_budget_s=time_budget_s ) 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..d13376769f --- /dev/null +++ b/docling/utils/pdf_char_geometry.py @@ -0,0 +1,183 @@ +# 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, replace +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 +# PDF 32000 text rendering modes that paint no ink: invisible (3) and clipping only (7). +_INVISIBLE_RENDER_MODES = frozenset({3, 7}) + + +@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 + # 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]): + # 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)) + 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, + ) + ) + # 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 new file mode 100644 index 0000000000..9296babd21 --- /dev/null +++ b/docling/utils/script_position.py @@ -0,0 +1,353 @@ +# 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, within the line. +""" + +from __future__ import annotations + +import difflib +import itertools +import statistics +import unicodedata +from collections.abc import Sequence +from dataclasses import dataclass +from typing import 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 letter size can be the body. +BODY_SIZE_RATIO = 0.9 +# Largest distance (share of the body size) between a script and the text it marks. +TOUCH_GAP = 0.6 +# Letters this close to body letters on both sides sit inside a word ("LaTeX"). +IN_WORD_GAP = 0.15 +# 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("*†‡§¶‖#") +# 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 + +_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, ...] + # Position in ``text`` of each of ``chars``. + offsets: tuple[int, ...] = () + + +def script_positions(chars: Sequence[ScriptChar]) -> list[Script]: + """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(): + positions[index] = script + return positions + + +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, 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: + return None + matched = sorted(set(mapping.values())) + positions = dict( + zip(matched, script_positions([chars[i] for i in matched]), strict=True) + ) + + roles: list[Script | None] = [None] * len(text) + for index, char_index in mapping.items(): + roles[index] = positions[char_index] + 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()] + # 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 + # Whitespace inside a run belongs to it; whitespace between runs is left out. + for left, right in itertools.pairwise(solid): + if roles[left] == roles[right]: + for i in range(left + 1, right): + roles[i] = roles[left] + + 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, piece_offsets = pieces[-1] + if i in mapping and mapping[i] not in piece_chars: + piece_chars.append(mapping[i]) + 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: + 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. + + 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] = [] + line_size = 0.0 + for index, char in enumerate(chars): + if not _measurable(char): + continue + 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) + line_size = max(line_size, char.size) + 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 _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.""" + groups = _baseline_groups(chars, line) + if len(groups) < 2: + return {} + # 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( + (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) + min_shift = max(MIN_SHIFT_POINTS, MIN_SHIFT * body_size) + + shifted: dict[int, Script] = {} + for index in line: + char = chars[index] + shift = char.baseline - body_baseline + # 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 + + scripts: dict[int, Script] = {} + 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], + *, + line: list[int], + shifted: dict[int, Script], + body_size: float, +) -> bool: + """Tell a script from other small, shifted text on the line.""" + 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( + 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(k in shifted and shifted[k] != shifted[run[0]] for k 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( + k not in shifted + and chars[k].text.isalpha() + and d <= IN_WORD_GAP * body_size + for k, d in zip(neighbours, distances) + ) + ) + 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]]: + """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]) -> 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 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 diff --git a/docs/usage/advanced_options.md b/docs/usage/advanced_options.md index b548926d63..ed28704dca 100644 --- a/docs/usage/advanced_options.md +++ b/docs/usage/advanced_options.md @@ -344,6 +344,33 @@ 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 keeps its place, and its text moves into +an inline group of runs that the item holds. + ### PowerPoint and Excel charts A chart on a PowerPoint slide or an Excel sheet becomes a picture classified by diff --git a/tach.toml b/tach.toml index b8635b2581..41ed679de2 100644 --- a/tach.toml +++ b/tach.toml @@ -187,6 +187,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" @@ -381,6 +386,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" @@ -545,6 +559,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 0000000000..de94c083fc Binary files /dev/null and b/tests/data/pdf/script_position.pdf differ diff --git a/tests/test_script_position.py b/tests/test_script_position.py new file mode 100644 index 0000000000..11377adf00 --- /dev/null +++ b/tests/test_script_position.py @@ -0,0 +1,337 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""Tests for finding superscript and subscript text from PDF character geometry.""" + +import re + +import pytest +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.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 + + +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) + 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", + ), + 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): + 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"), + # 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): + 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" + + +@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_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) == [ + ("E = mc", B), + ("2", SUP), + ("in 1905.", B), + ] + + +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) + 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) is None + chars = _line(("E = mc", 12, 0), ("2", 7, 4.5)) + 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)], + ] + # 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 new file mode 100644 index 0000000000..8b293b74d0 --- /dev/null +++ b/tests/test_script_position_pdf.py @@ -0,0 +1,225 @@ +# 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 PIL import Image + +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, + ImageFormatOption, + 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), + ] + # 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), + ] + # 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() + + +# 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, render_mode: int = 0) -> None: + """Write a one-page PDF of ``_LINES`` with the given page box entries.""" + ops = ["BT", f"{render_mode} Tr", "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), + ] + + +@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)) == [] + + +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()]