docling-project / docling-project/docling
TableFormer ignores drawn horizontal rules, mis-pairing rows when a cell wraps over several lines
- Dominant language
- Python
- Stars
- 66.4k
- Forks
- 4.8k
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 84
Description
### What happens
On a ruled two-column table whose right-hand cells wrap over several text lines, TableFormer's row segmentation cuts between the wrapped lines in the wrong place. The tail of one row's cell is emitted as the **head** of the next row's cell.
The table's *shape* is perfect — correct column count, correct row count, almost no empty cells — so nothing downstream flags it. Only the content is wrong.
The PDF draws a horizontal rule at every true row boundary. Those rules are in the page's vector layer, and TableFormer does not appear to use them.
### Why it matters
This is the standard layout for glossaries, acronym registers and regulatory response matrices — a term or ID on the left, a multi-sentence definition or response on the right. In that family the wrapped cell is the norm, not the exception.
On a 32-page environmental-statement glossary (born-digital, ruled) we measured **4 of 5** spot-checked terms carrying the previous definition's tail. Because table output is flattened to markdown before retrieval, every downstream consumer inherits the corruption with no signal that anything is wrong.
### Reproduction
The script at the end of this issue writes the fixture — stdlib only, no reportlab, no docling. A four-page ruled glossary: 8pt Helvetica, 9pt leading, two columns (term / definition), definitions wrapping over four to six lines, a 0.5pt rule drawn at every row boundary, and the table running from page 1 to page 4.
```bash
python make_ruled_glossary.py ruled-glossary.pdf
```
Options used:
```python
from docling.datamodel.pipeline_options import (
PdfPipelineOptions, TableFormerMode, TableStructureOptions,
)
options = PdfPipelineOptions(
do_table_structure=True,
table_structure_options=TableStructureOptions(
do_cell_matching=True, mode=TableFormerMode.ACCURATE
),
)
```
**Observed** — table 0, rows 0 and 1 of the converted fixture, verbatim:
```
| Air Quality Management Area | An area designated by a local authority under Part IV of the
Environment Act 1995 where one or more of the national air
quality objectives set out in the Air Quality Standards
Regulations are not being met, or are not expected to be met,
and in respect of which the authority is required to prepare |
| Ambient noise | an air quality action plan. The totally encompassing sound in
a given situation at a given time, usually composed of sound
from many sources both near and far, ... |
```
**Expected** — "an air quality action plan." closes the first definition; "Ambient noise" opens with "The totally encompassing sound".
Note both halves of the symptom: the first row is truncated mid-sentence ("required to prepare"), and the second row opens with the stolen tail, lowercase, before its own text.
### What isolates it
Seven fixture variants were built. Five do **not** reproduce it, which is what makes the recipe meaningful:
| Fixture | Reproduces? |
| --- | --- |
| Minimal 2x2 ruled table, one wrapped definition | no |
| Realistic single-page 10-entry glossary, real column geometry | no |
| Same + page header/footer furniture + a logo image XObject | no |
| Same, rules drawn as strokes instead of thin filled rectangles | no |
| Same, rows packed so the drawn rule is the *only* row cue | no |
| **Dense: 8pt/9pt leading, 4-6 line definitions, table over 4 pages** | **yes** |
| The same dense content on a **single** page | no |
The single-page control is the sharpest evidence: identical rows, identical rules, identical fonts — 12/12 terms paired correctly. Add the page continuation and it mis-pairs. So neither page furniture, nor a competing image, nor rule stroke style is the trigger; density with long wrapped cells **plus a table continuing across pages** is.
Honest caveat: the synthetic reproduction is milder than the real document — 1 of 12 sampled terms mis-paired here against 4 of 5 on the real 32-page glossary. Same failure, not the same severity. The real document is a client file and cannot be shared.
### Things we ruled out
- **`do_cell_matching=False`** — repairs some rows but loses whole terms (entries vanish from the two-column shape entirely). Not a workaround.
- **Upgrading 2.95.0 → 2.120.3** with identical options — byte-identical table counts (31 tables / 441 rows on the real document) and the *same* 4/5 mis-pairing. Not fixed by the version bump.
- **A pdfplumber comparison**, in fairness to TableFormer: with the `lines` strategy pdfplumber pairs 5/5 of the same terms correctly, but its table *shape* is far worse (76 fragments, ghost columns, 64% empty cells). Its row *segmentation* is what is right, because it reads the drawn rules.
### Suggested direction
Where the page's vector layer draws horizontal rules spanning a detected table's bbox, prefer those rules as row boundaries over the predicted ones — or reconcile the two.
We implemented this downstream as a deterministic post-pass: rules read via pypdfium2, cell text re-bound to the ruled bands by position, declining wherever no rules are found so borderless and scanned tables are untouched. It takes the sampled terms from 11/12 to 12/12 on this fixture and from 1/5 to 5/5 on the real document, with no loss of text, and on a correctly-parsed ruled table it is a faithful no-op.
Two things we learned building it that are worth passing on if you take this direction:
1. Region text extraction admits a glyph whose box merely *intersects* the band. Where a rule is drawn inside the ascenders of the line beneath it, every band harvests the tops of the row below. Guard on it — we require the table's alphanumeric count to be conserved within 2% in **both** directions, since a lower bound alone only catches loss, not this.
2. A row cut by a page break has a rule on one side only, so the table's own top and bottom edges need admitting as implicit boundaries.
We would rather this lived in docling than in every consumer, and are happy to contribute it.
### Environment
- docling 2.120.3 (also reproduced on 2.95.0)
- `TableFormerMode.ACCURATE`, `do_cell_matching=True`
- CPU accelerator, Python 3.12, Linux and macOS
### Fixture generator
make_ruled_glossary.py — stdlib only
```python
#!/usr/bin/env python3
"""Minimal reproducer for docling issue: TableFormer ignores drawn row rules.
Stdlib only — writes a four-page ruled glossary PDF by hand (no reportlab, no
docling). Run it, then convert the output with the options in the issue body.
python make_ruled_glossary.py ruled-glossary.pdf
"""
from __future__ import annotations
import sys
from pathlib import Path
PAGE_W, PAGE_H = 595.0, 842.0
FONT_SIZE, LEADING, ROW_GAP = 8.0, 9.0, 1.0
TERM_X, DEF_X, TABLE_RIGHT = 71.0, 200.0, 538.0
TOP, BOTTOM, PAGES = 770.0, 70.0, 4
ENTRIES = [
(
"Air Quality Management Area",
[
"An area designated by a local authority under Part IV of",
"the Environment Act 1995 where one or more of the national",
"air quality objectives set out in the Air Quality Standards",
"Regulations are not being met, or are not expected to be met,",
"and in respect of which the authority is required to prepare",
"an air quality action plan.",
],
),
(
"Ambient noise",
[
"The totally encompassing sound in a given situation at a given",
"time, usually composed of sound from many sources both near",
"and far, and measured in accordance with the methodology set",
"out in BS 7445 as applied to the assessment locations agreed",
"with the environmental health officer.",
],
),
(
"Baseline",
[
"A reference level of environmental conditions, described for",
"the year in which the assessment was carried out, against",
"which the effects of the proposed development and of the",
"reasonable alternatives considered are assessed and reported",
"in this Environmental Statement.",
],
),
(
"Best Practicable Means",
[
"Measures that are reasonably practicable having regard to",
"local conditions and circumstances, to the current state of",
"technical knowledge and to the financial implications, as",
"defined in section 79(9) of the Environmental Protection Act",
"1990 and applied throughout this chapter.",
],
),
(
"Competent Authority",
[
"The body responsible for determining an application for",
"development consent under the relevant regulations, which for",
"the purposes of this application is the Secretary of State",
"acting through the Planning Inspectorate.",
],
),
(
"Cumulative effects",
[
"Effects that result from incremental changes caused by the",
"proposed development together with other past, present or",
"reasonably foreseeable future projects, assessed in accordance",
"with the methodology described in Chapter 18 of this volume.",
],
),
(
"Designated site",
[
"A site afforded statutory protection for its ecological,",
"geological, landscape or heritage importance, including sites",
"designated under the Conservation of Habitats and Species",
"Regulations 2017 and sites notified under the Wildlife and",
"Countryside Act 1981.",
],
),
(
"Embedded mitigation",
[
"Measures incorporated into the design of the scheme before the",
"assessment was undertaken, as distinct from measures added in",
"response to a predicted significant effect, and secured through",
"the draft requirements appended to the draft Order.",
],
),
(
"Environmental Statement",
[
"The document reporting the findings of the environmental impact",
"assessment undertaken in respect of the proposed development",
"and submitted with the application for development consent in",
"accordance with the 2017 Regulations.",
],
),
(
"Receptor",
[
"A person, property, habitat, species or feature of the",
"environment that may be affected by an environmental change",
"arising from the construction, operation or decommissioning of",
"the proposed development.",
],
),
(
"Scoping",
[
"The process of deciding which of the likely environmental",
"effects of a proposed development are significant and therefore",
"require assessment and reporting, informed by the scoping",
"opinion issued by the competent authority.",
],
),
(
"Zone of Influence",
[
"The area over which a receptor may reasonably be expected to",
"experience an effect arising from the proposed development,",
"determined separately for each environmental topic and set out",
"in the relevant technical chapter.",
],
),
]
def _write_pdf(path, rules_by_page, texts_by_page):
objects = []
def add(body):
objects.append(body)
return len(objects)
catalog_no = add(b"")
pages_no = add(b"")
font_no = add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
kids = []
for rules, texts in zip(rules_by_page, texts_by_page):
parts = ["%.2f %.2f %.2f 0.5 re f" % (x0, y, x1 - x0) for y, x0, x1 in rules]
for x, y, text in texts:
esc = text.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)")
parts.append("BT /F1 %g Tf %.2f %.2f Td (%s) Tj ET" % (FONT_SIZE, x, y, esc))
stream = "\n".join(parts).encode("latin-1")
content_no = add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream")
kids.append(
add(
("<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %s %s] /Resources << /Font << /F1 %d 0 R >> >> "
"/Contents %d 0 R >>" % (pages_no, PAGE_W, PAGE_H, font_no, content_no)).encode("latin-1")
)
)
objects[catalog_no - 1] = ("<< /Type /Catalog /Pages %d 0 R >>" % pages_no).encode("latin-1")
objects[pages_no - 1] = ("<< /Type /Pages /Kids [%s] /Count %d >>"
% (" ".join("%d 0 R" % k for k in kids), len(kids))).encode("latin-1")
out = bytearray(b"%PDF-1.4\n")
offsets = []
for number, body in enumerate(objects, start=1):
offsets.append(len(out))
out += ("%d 0 obj\n" % number).encode() + body + b"\nendobj\n"
xref_at = len(out)
out += ("xref\n0 %d\n" % (len(objects) + 1)).encode()
out += b"0000000000 65535 f \n"
for offset in offsets:
out += ("%010d 00000 n \n" % offset).encode()
out += ("trailer\n<< /Size %d /Root %d 0 R >>\nstartxref\n%d\n%%%%EOF\n"
% (len(objects) + 1, catalog_no, xref_at)).encode()
Path(path).write_bytes(bytes(out))
def main(path):
rules_by_page, texts_by_page = [], []
entries = list(ENTRIES) * ((PAGES * 12) // len(ENTRIES) + 1)
rules, texts, y = [], [], TOP
rules.append((y + 5.0, TERM_X - 5.0, TABLE_RIGHT))
for term, lines in entries:
if y - (LEADING * len(lines) + ROW_GAP) < BOTTOM:
rules_by_page.append(rules)
texts_by_page.append(texts)
if len(rules_by_page) >= PAGES:
break
rules, texts, y = [], [], TOP
rules.append((y + 5.0, TERM_X - 5.0, TABLE_RIGHT))
texts.append((TERM_X, y, term))
for index, line in enumerate(lines):
texts.append((DEF_X, y - index * LEADING, line))
y -= LEADING * len(lines) + ROW_GAP
rules.append((y + 5.0, TERM_X - 5.0, TABLE_RIGHT))
else:
rules_by_page.append(rules)
texts_by_page.append(texts)
_write_pdf(path, rules_by_page, texts_by_page)
print("wrote", path)
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "ruled-glossary.pdf")
```
Contributor guide
Research direction
Run make_ruled_glossary.py and convert the generated PDF with the documented PdfPipelineOptions and TableFormerMode.ACCURATE settings. Start by tracing TableFormer's row segmentation and how page vector rules are exposed; compare the converted rows with the expected boundary at “an air quality action plan.” Done means dense ruled tables continuing across pages preserve row text, while borderless and scanned tables remain unaffected.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- data
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100