Skip to content

feat(cells): keep text clipped to a table cell in its cell - #443

Open
wittjeff wants to merge 2 commits into
docling-project:mainfrom
wittjeff:fix/clipped-cell-text
Open

wittjeff wants to merge 2 commits into
docling-project:mainfrom
wittjeff:fix/clipped-cell-text

Conversation

@wittjeff

@wittjeff wittjeff commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Standalone topic per #428: a clip box on text cells, and a contraction rule that uses it. Two commits, rebased on main (f4b12cf), no merge commits. This is the prototype proposed on #404 (2026-10-16, A/B updated 2026-10-29), now on current main.

Fixes #404. Refs docling-project/docling#4762 (the docling table matcher can place a clipped word by its clip box once it has one).

commit fix
1 Every char, word and line cell carries the bounding box of the clip it was painted under (has_clip_bbox, clip_x0..clip_y1, page coordinates; pybind + PdfTextCell.clip_bbox when the installed docling-core has the field). Two consecutive glyphs under different clip boxes, of which at least one is cut by its own clip, never join into one word or one line. Clip paths are tracked even when shapes are skipped (DecodeConfig.keep_clips, default true), since docling computes shapes only when it renders pages.
2 The sanitizer that moves cells to the origin of the crop or media box moves the clip box with them; without it the box sat beside its glyphs on pages whose box does not start at (0, 0).

Why the words merged

contract() starts a new word when the normalized forward gap exceeds the word gap, and clamps the gap at zero, so a glyph that overlaps its predecessor can never start a word. In the issue's clipped-table.pdf, row 1's Z ends at x=130.5 and R starts at x=125.0: gap −5.5 pt, clamped to 0, one word AB-1001-XYZReplacement. The clip that hides the tail was tracked only in pdf_state<SHAPE> for the render instruction; the cell got nothing. And every path and clip operator in shape.h returned early without keep_shapes, which docling sets only when it renders pages, so a parse with render_pages=False had no clip at all.

What "cut" means

The corpus has producers that wrap every text run, or every glyph, in a clip that does not cut it; that clip only the one glyph which overflows a cell and draw the rest of the word unclipped; that draw ink-tight clips around punctuation; and that repaint glyphs under sliver clips a fraction of a point wide. So the rule is narrower than "different clip means different word": a glyph counts as cut when its clip hides more than a fifth of its extent along the writing axis, or of its em if that is larger; a clip box shorter than one em along that axis never cuts, since it cannot be a cell; and when only one of the two glyphs has a clip box, the other still belongs to it while it lies inside that box. Each of those producer patterns was a false positive of a simpler rule before its guard went in, and each has a unit test in tests/test_unit_clipped_cell_text.py (9 tests, synthetic PDFs built with tests/pdf_builder).

Changes made while rebasing on #437

docling-core

The field is PdfTextCell.clip_bbox: Optional[BoundingBox], docling-project/docling-core#964 (the origin conversions move it with rect). docling-parse fills it only when the installed model has the field, so this PR does not raise the docling-core floor; the positional JSON record is unchanged.

Evidence and ground truth changes

I built main (f4b12cf) and this branch and dumped the word and line cells of every page of the regression page selection (tests/regression_page_selection.py: 453 documents, 950 pages, revision df881cc), with the parser configuration of test_reference_documents_from_filenames.

pages identical regrouped text changed
950 928 22 0

Clips are common: on 350 of the 950 pages at least one word cell carries a clip box (114,123 word cells in all), and only the 22 pages below regroup, which is why the rule asks for a cut glyph and not merely for different clips.

Every regrouped page is the pattern of this issue, found in the corpus without looking for it: a table whose producer clipped cell text to the cell. Ground truth changes on the 22 pages below because the rule separates what a producer put into different boxes; no page changes for any other reason, and no text changes.

11024633002361155703-1.pdf, p1: results table, name column clipped at x=160.7: Christophen°1059 splits into Christophe + n°1059, and the line splits at the cell border

Ground truth changes because the clipped tail of a cell no longer joins the first word of the next cell.

words on p1:

before after
Christophen°1059 Christophe / n°1059

11268045627910569931_002.pdf, pp. 1–3: region column clipped at x=216.7 (Yorke Peninsul, Outback - Nort is what the page shows): W12-<15 and Ma12-<15 split at the cell border, and those rows' lines split into the region cell and the months cell

Ground truth changes because the clipped tail of a cell no longer joins the first word of the next cell.

words on p1:

before after
W12-<15 / Ma12-<15 W / 12-<15 / Ma / 12-<15

3 pages; word cells 1389 → 1395, line cells 985 → 991.

1135949864700736255-1.pdf, p1: Japanese menu table: a dish name and its ingredient list, clipped to neighbouring cells, become two lines

Ground truth changes because a line no longer runs from a clipped cell into the next cell.

lines on p1:

before after
ちくわの磯辺揚げ(じゃがいも、まいたけ) ちくわ、あおのり、小麦粉、じゃがいも、まいたけ、油 ちくわの磯辺揚げ(じゃがいも、まいたけ) / ちくわ、あおのり、小麦粉、じゃがいも、まいたけ、油

16470238655385139760-1.pdf, p1: Japanese table: the label 2-2 separates from the clipped neighbour cell

Ground truth changes because the clipped tail of a cell no longer joins the first word of the next cell.

words on p1:

before after
建築物の環境効率(BEEランク&チャート)2-2 建築物の環境効率(BEEランク&チャート) / 2-2

9590626299437503566-1.pdf, p1: Japanese table: the unit kg separates from the clipped item name

Ground truth changes because the clipped tail of a cell no longer joins the first word of the next cell.

words on p1:

before after
混合廃棄物(石膏ボード系混入物)kg 混合廃棄物(石膏ボード系混入物) / kg

ddf7d91a5a010970_0020.pdf, p1: header Crédits separates from the clipped header of the next column; words unchanged

Ground truth changes because a line no longer runs from a clipped cell into the next cell.

lines on p1:

before after
Crédits OBSERVATIONS / Crédits OBSERVATIONS Crédits / OBSERVATIONS / Crédits / OBSERVATIONS

3763025099401294496-2.pdf, pp. 2–15: roster whose name column clips long names: no word changes, lines such as MUNOZ, BRANDON SD split into the name cell and the school cell

Ground truth changes because a line no longer runs from a clipped cell into the next cell.

lines on p2:

before after
MUNOZ, BRANDON SD / 0.0 MUNOZ, BRANDON SD / MUNOZ, BRANDON SD / 0.0 ARREOLA, NATHAN MON / ARREOLA, NATHAN MON MUNOZ, BRANDON / SD / 0.0 MUNOZ, BRANDON / SD / MUNOZ, BRANDON

14 pages; word cells 12765 → 12765, line cells 8633 → 8826.

All pages

11024633002361155703-1.pdf p1, 11268045627910569931_002.pdf p1, 11268045627910569931_002.pdf p2, 11268045627910569931_002.pdf p3, 1135949864700736255-1.pdf p1, 16470238655385139760-1.pdf p1, 9590626299437503566-1.pdf p1, ddf7d91a5a010970_0020.pdf p1, 3763025099401294496-2.pdf p2, 3763025099401294496-2.pdf p3, 3763025099401294496-2.pdf p4, 3763025099401294496-2.pdf p5, 3763025099401294496-2.pdf p6, 3763025099401294496-2.pdf p7, 3763025099401294496-2.pdf p8, 3763025099401294496-2.pdf p9, 3763025099401294496-2.pdf p10, 3763025099401294496-2.pdf p11, 3763025099401294496-2.pdf p12, 3763025099401294496-2.pdf p13, 3763025099401294496-2.pdf p14, 3763025099401294496-2.pdf p15

Before/after images

Every page of the A/B whose word or line cells change. Page render: pypdfium2. Left: cells that main emits and this branch does not (red). Right: the cells that replace them (green and blue alternate, so adjacent cells stay apart). Images and script: pr-evidence/404-pr.

11024633002361155703-1.pdf, p1

11024633002361155703-1.pdf page 1: cells before (main, left) and after (this PR, right)

11268045627910569931_002.pdf, pp. 1–3

11268045627910569931_002.pdf page 1: cells before (main, left) and after (this PR, right)

11268045627910569931_002.pdf page 2: cells before (main, left) and after (this PR, right)

11268045627910569931_002.pdf page 3: cells before (main, left) and after (this PR, right)

1135949864700736255-1.pdf, p1

1135949864700736255-1.pdf page 1: cells before (main, left) and after (this PR, right)

16470238655385139760-1.pdf, p1

16470238655385139760-1.pdf page 1: cells before (main, left) and after (this PR, right)

9590626299437503566-1.pdf, p1

9590626299437503566-1.pdf page 1: cells before (main, left) and after (this PR, right)

ddf7d91a5a010970_0020.pdf, p1

ddf7d91a5a010970_0020.pdf page 1: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf, pp. 2–15

3763025099401294496-2.pdf page 2: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 3: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 4: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 5: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 6: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 7: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 8: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 9: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 10: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 11: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 12: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 13: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 14: cells before (main, left) and after (this PR, right)

3763025099401294496-2.pdf page 15: cells before (main, left) and after (this PR, right)

Known limits

  • A non-rectangular clip is over-approximated by its box; fine for the table case, documented on the field.
  • Text that overflows nothing keeps joining across cells when the glyphs do (row 2 of clipped-table.pdf, AB-1002 Gutter cleaning is one line as before); the words still carry their cell boxes.
  • The knobs are the cut threshold (a fifth of max(extent, em)), the one-em minimum box width and the box-equality epsilon (CLIP_HIDDEN_FRACTION, CLIP_BBOX_EPS in cells.h); the A/B above is the evidence for them.
  • The cost with shapes skipped is the path construction on pages that clip; not measured.

Checklist (#428)

  • One topic per PR, rebased on main, no merge commits
  • No superseded PRs (this is the first PR for Text clipped at a table-cell border loses the cell it belongs to #404)
  • Regression dataset documents and pages named above; no dataset PR needed
  • Before/after evidence for each cited page
  • Rendering only: not applicable
  • Blend2D only: not applicable
  • Changes to existing ground truth listed with the reason for each page

🤖 Generated with Claude Code

wittjeff and others added 2 commits November 3, 2026 10:29
Some producers fit long text into a table cell by clipping it to the
cell's box (`q <cell> re W n ... Q`). The page shows the part that fits;
the text layer keeps the whole string, whose hidden end runs under the
next column. The clip was tracked for the renderer only, so the cells
carried the full glyph boxes, the hidden tail got the box of the column
it ran under, and the word contraction glued the tail to the first word
of that column: `AB-1001-XYZ` + `Replacement` came out as one word.

Every char, word and line cell now carries the bounding box of the clip
it was painted under (`has_clip_bbox`, `clip_x0..clip_y1`, page
coordinates; the intersection of the clip groups' boxes), computed once
per glyph from the clip state the render instruction gets. It rotates
with the page, is exposed through pybind, and reaches docling-core as
`PdfTextCell.clip_bbox` when the installed model has the field. A word
or line keeps the box when all of its glyphs share it.

Contraction: two consecutive glyphs under different clip boxes, of which
at least one is cut by its own clip, never join into one word or one
line. A glyph counts as cut when the clip hides more than a fifth of
its extent along the writing axis, or of its em size if that is larger;
a clip box shorter than one em along that axis never cuts, since a cell
holds at least one glyph while a clip drawn tight around the ink of a
period, or a sliver clip under which a producer repaints a glyph, is
narrower than the glyph. When only one of the two glyphs has a clip
box, the other still belongs to it while it lies inside that box: some
producers clip only the glyph that overflows the cell. Glyphs whole
under their clips always may join, since producers also wrap every text
run, or every glyph, in a clip that does not cut it. Each of these
producer patterns is in the regression corpus.

Clip paths are tracked even when shapes are skipped: path construction
and W/W* run whenever shapes or clips are kept, painting operators
register shapes only when shapes are kept. `DecodeConfig.keep_clips`
(default true) turns the tracking off. docling computes shapes only
when it renders pages, so without this the box would be missing from a
parse with `render_pages=False`.

Over the regression page selection (453 documents, 950 pages, dataset
revision df881cc) the only
pages whose words or lines change are tables with clipped cells.

Refs: docling-project#404, docling-project/docling#4762

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
The sanitizer moves cells to the origin of the crop or media box but left
clip_{x0,y0,x1,y1} in raw PDF coordinates. On a page whose box does not
start at (0, 0) the clip box sat beside its glyphs, so the contraction
rule saw cuts that the page does not show (eac6df0b2a6f9cf0_0002.pdf,
11268045627910569931_002.pdf) and the cells reported a wrong clip_bbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

✅ DCO Check Passed

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

@mergify

mergify Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🟢 Merge protection satisfied — ready to merge.

Show 1 satisfied protection

🟢 Enforce conventional commit

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

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

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Text clipped at a table-cell border loses the cell it belongs to

1 participant