Skip to content

Dehyphenation merges line-final hyphens that belong to the word, losing them irrecoverably (regression in 2.118.0) #4151

Description

@fwwwrrr

Bug

When a hyphenated word is broken across a line, Docling joins the halves and deletes the hyphen without checking whether the hyphen is part of the word. n-tridecanal becomes ntridecanal.

Mid-line hyphens are handled correctly, so the logic is already position-aware. The gap is that a line-final hyphen can also be a real hyphen.

The loss is unrecoverable: once the hyphen is gone, nothing downstream can tell whether the page printed ntridecanal or n-tridecanal. The opposite failure — leaving n- tridecanal unjoined, which 2.107.0 does — is wrong but repairable by anyone later.

Steps to reproduce

Open-access paper, CC-BY: 10.1371/journal.pone.0260149

from docling.document_converter import DocumentConverter
doc = DocumentConverter().convert("pone.0260149.pdf").document
print(doc.export_to_markdown())

The same word, three times in one document

where how the PDF prints it docling 2.124.0
p1, line ends …undecanal or n-, next line begins tridecanal broken at the line end undecanal or ntridecanal ❌
p9, line ends …n-dodecanal (77) and n-, next line begins tridecanal (84) broken at the line end and ntridecanal (84) ❌
p11, mid-line n-octanal (10) and n-tridecanal (84) could be and n-tridecanal (84) ✅

Docling's own output already contains the correct form. The third row is preserved because the hyphen is not at a line break. The first two are destroyed because it is.

Line position alone cannot resolve these: the hyphen is line-final and part of the word. But the document says so plainly elsewhere:

  • The p1 line reads …n-octanal, n-nonanal, n-decanal, n-dodecanal, undecanal or n- — four instances of the same construction, unbroken, on the very line that then breaks at n-.
  • The p9 line reads …n-undecanal (52), n-dodecanal (77) and n-, likewise.
  • n-tridecanal itself appears unbroken on p11, and Docling extracts it correctly there.

A dehyphenation pass that checked the forms already present in the document before deleting a hyphen would get all three right.

Why this matters beyond the character count

The losses fall on chemical nomenclature, where the hyphen is part of the name: n-decanal, 4,5-dimethylthiazole, 6-methyl-5-hepten-2-one. ndecanal is not a variant spelling but a different string, and it breaks exact search and entity linking against any chemical vocabulary. The same applies to gene and compound identifiers.

For comparison, PyMuPDF preserves the hyphen in all three positions.

Docling version

version p1 (line-final) p9 (line-final)
2.107.0 n- tridecanal ntridecanal
2.117.0 n- tridecanal ntridecanal
2.118.0 ntridecanal ntridecanal
2.121.0 / 2.122.0 / 2.124.0 ntridecanal ntridecanal

The p9 case merges in every version tested. The p1 case changed at 2.118.0, whose release notes carry "Dehyphenate hard continuations".

Tested docling 2.107.0, 2.117.0, 2.118.0, 2.121.0, 2.122.0, 2.124.0. PyPdfiumDocumentBackend. Identical results under torch 2.13.0+cpu and 2.14.0.

Python version

Python 3.12.13, Linux

Activity

  1. fwwwrrr commented on Sep 3, 2026

    @fwwwrrr
    Author

    Worth adding, because the issue as written can be read as "revert the dehyphenation or
    keep it", and that is a false choice. There is a rule that keeps every prose gain and
    still fixes these cases.

    When a word is broken at a line end, check whether the hyphenated or the joined form
    occurs elsewhere in the same document, and prefer the attested one. If neither occurs,
    do what you do now.

    Measured over the two papers in this report, 179 words broken at a line end:

    count
    join, exactly as today 119 ana-lyzed → analyzed, gam-biae → gambiae, mos-quito → mosquito
    keep the hyphen 4 n-tridecanal, n-octanal, i-butyric, blood-feeding
    neither form attested 56 fall back to current behaviour

    The rule changes the answer on 2.2% of line breaks, and the four it changes are
    exactly the ones being lost. No prose dehyphenation is given up.

    It also handles compounds with a real internal hyphen: blood-feed-|ing joins to
    blood-feeding, non-pre-|ferred to non-preferred, human-spe-|cific to
    human-specific — the second hyphen closes, the first survives, because that is what
    the document attests.

    The lookup needs the document's own token frequencies, which are available by the time
    dehyphenation runs. No dictionary and no language model.

  2. wittjeff commented on Sep 3, 2026

    @wittjeff
    Contributor

    I reproduced this end-to-end on 2.124.0 with the PDF you cite, and traced it to two separate join sites. Some findings that may help scope a fix.

    The document loses eight tokens, not two.

    output correct same form elsewhere in the same output?
    ntridecanal ×2 n-tridecanal yes, ×1
    noctanal n-octanal yes, ×3
    ibutyric i-butyric yes, ×2
    bloodfeeding blood-feeding yes, ×5
    6-methyl5-hepten-2-one 6-methyl-5-hepten-2-one no
    6-methyl-5-hepten2-one 6-methyl-5-hepten-2-one no
    Plasmodiumassociated Plasmodium-associated no

    One line of the export carries the destroyed and the intact form side by side:

    ...compounds: ibutyric acid, i-valeric acid, n-octanal, n-nonanal, n-decanal, n-dodecanal, undecanal or ntridecanal, from each blend...

    Two join sites, and the version table maps onto them exactly

    I instrumented both and attributed every one of the eight:

    So a patch to either site alone fixes at most half of this. The two also disagree with each other today: _merge_elements requires the continuation to be lowercase, sanitize_text requires only isalnum() on both sides.

    Both still merge on current main (deb45fb9). The hyphen_attached_to_word guard added since 2.124.0 does not change these cases — n- has an alnum character before the hyphen, so it still closes up.

    A note on how this PDF marks the break

    Worth recording because it shapes what a fix can rely on: in this document the line-break hyphen is U+0002, not -. It accounts for 103 of the 104 line-final hyphens and never appears mid-line, and page_assemble_model.py#L201 rewrites it to - before any of the above runs.

    It is not a usable signal on its own — the typesetter emits the same \x02 for n-|tridecanal and for ana-|lyzed, so it only restates the line position we already know. But it does yield a clean 103-case corpus for measuring candidate rules, which is what the numbers below come from.

    Your proposed rule, measured on those 103 breaks

    count
    flips the answer to keep the hyphen 4 i-butyric, n-tridecanal, n-octanal, blood-feeding — all correct
    agrees with current behaviour (join) 72 `ana
    neither form attested, falls back 27

    No false flips, and no prose dehyphenation given up — matching your measurement. It recovers 5 of the 8 losses.

    Two document-independent guards close most of the rest

    Measured on the same 103 breaks, each also with no false positives:

    • continuation begins with a digit → keep the hyphen. 2 hits, both 6-methyl cases. No syllable break puts a digit after the hyphen.
    • single-character token before the hyphen → keep. 4 hits (i-, n- ×2, plus one spurious cross-column join it correctly declines). \lefthyphenmin is ≥2 in every language TeX ships; no typesetter leaves one letter alone at a line end.

    Together with the attested-form rule this recovers 7 of the 8. Only Plasmodium-associated resists — unattested, and structurally indistinguishable from Wagen-|ingen.

    One structural catch for the attested-form rule

    sanitize_text runs inside the threaded per-page streaming stage, while reading_order_model runs later, over the whole document. The site responsible for 7 of the 8 losses therefore has no document-wide vocabulary available, and by the time one exists the line breaks have already been erased.

    Implementing the lookup means deferring the join decision out of page assembly — leaving a marker for reading-order to resolve, or re-deriving line breaks from TextElement.cluster.cells, which does survive into that stage. The two guards above carry no such constraint and could land independently.

    Happy to open a PR for the two document-independent guards with regression tests over this PDF's breaks, if that split is useful to the maintainers.

  3. morten-lagabote commented on Oct 4, 2026

    @morten-lagabote
    Contributor

    Data points from Norwegian legal and government PDFs, where a line-final hyphen often belongs to a provision number or a compound. All on main (51fe9ff), default options:

    PDF page main printed
    Supreme Court, HR-2013-2663-A 2, 8 arbeidsmiljøloven § 1412 eller arbeidsmiljøloven § 14-13 § 14-12
    Prop. 74 L (2016–2017) 39–42 § 115, § 118, § 217 bokstav c, og 1110 § 11-5, § 11-8, § 21-7, 11-10
    Supreme Court, HR-2025-2425-A 16 EØSrettslige, helseog sosialtjenester EØS-rettslige, helse- og sosialtjenester
    Public procurement complaints board, KOFA 2022/1807 2–3 EØSterskelverdien, byggeog anleggskontrakt EØS-terskelverdien, bygge- og anleggskontrakt

    The first two rows are fixed by the digit guard of both #4211 (348219e) and #4387 (7c768b7). For a provision number, the hyphen is part of the reference. In three of the documents the intact form is also attested elsewhere (§ 14-12, helse- og, bygge- og), as in the original report.

    Two classes remain with both PRs:

    • an uppercase letter before the hyphen and a lowercase continuation (EØS-rettslige): an abbreviation compound, not a syllable break. Like the digit guard, it needs no vocabulary;
    • a suspended compound before a conjunction (helse- og), which needs the hyphen and the space. The same happens in English in tests/data/pdf/groundtruth/2203.01017v2.md, line 13: complex columnand row-headers (column- and row-headers).

    I can open a follow-up for the first one once #4211 or #4387 is merged. The second needs the conjunctions of each language, so it may be out of scope.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions