Skip to content

Commit 1679413

Browse files
authored
feat(report): add table output structure signals (#12)
1 parent 229c074 commit 1679413

8 files changed

Lines changed: 408 additions & 7 deletions

File tree

README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,28 @@ Technical note: the traceable output is plain JSON. It is inspired by UIF, an in
1616

1717
## What The Report Checks
1818

19-
The report answers three practical questions:
19+
The report answers practical questions such as:
2020

2121
1. Does every extracted block have the basic fields a pipeline needs?
2222
2. Can each block be traced back to a source page and bounding box?
2323
3. Does the page contain layout noise that a human should review before using the output?
2424

25-
The six current checks are:
25+
The seven current checks are:
2626

2727
1. Required field coverage: are core fields such as `id`, `type`, `page_number`, `bbox`, and `reading_order_index` present?
28-
2. Provenance completeness: can each block be traced back to Docling metadata and the source PDF?
29-
3. BBox sanity: are bounding boxes present, finite, and ordered correctly?
30-
4. Content vs noise ratio: how much looks like document content compared with headers, footers, images, or other secondary blocks?
31-
5. Text usefulness: are text blocks empty, suspiciously short, or duplicated?
32-
6. Text extraction health: is extracted text available in the normalized blocks for review, without judging whether the text is correct?
28+
2. Table output structure signals: do table-labeled normalized blocks contain visible table-output structure signals?
29+
3. Provenance completeness: can each block be traced back to Docling metadata and the source PDF?
30+
4. BBox sanity: are bounding boxes present, finite, and ordered correctly?
31+
5. Content vs noise ratio: how much looks like document content compared with headers, footers, images, or other secondary blocks?
32+
6. Text usefulness: are text blocks empty, suspiciously short, or duplicated?
33+
7. Text extraction health: is extracted text available in the normalized blocks for review, without judging whether the text is correct?
3334

3435
Text usefulness flags local text-quality issues such as empty body blocks, very short text, or repeated fragments.
3536
Text extraction health summarizes whether the document contains enough extracted text overall for review.
37+
The table output structure signal check flags table-labeled normalized blocks that lack obvious structured table-output
38+
signals; it does not validate table reconstruction correctness against the PDF.
39+
Structured table-output signals include normalized grid fields such as `data_grid` or `rows`, optional `cells` / `html`,
40+
or delimiter-like `content.text`.
3641

3742
Warning-only checks can move the report to `REVIEW`; the report CLI still exits non-zero only for hard failures.
3843

examples/mdpi_pdf_elements/page_006/quality_report.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Details:
2929
Each section below is a separate check.
3030

3131
- Required Field Coverage checks whether required JSON fields are present.
32+
- Table Output Structure Signals checks whether table-labeled normalized blocks contain visible table-output structure signals.
3233
- Provenance Completeness checks whether source, page, and bounding-box metadata is present.
3334
- BBox Sanity checks whether bounding boxes look structurally valid.
3435
- Content vs Noise Ratio checks how much extracted content looks like main text versus layout/noise.
@@ -40,6 +41,20 @@ PASS
4041

4142
all required fields present
4243

44+
## Table Output Structure Signals
45+
PASS
46+
47+
no table-labeled blocks found
48+
49+
- table_blocks=0
50+
- structured_grid_blocks=0
51+
- alternate_structure_signal_blocks=0
52+
- text_structure_signal_blocks=0
53+
- plain_text_only_blocks=0
54+
- empty_or_short_table_text_blocks=0
55+
- inconsistent_grid_blocks=0
56+
- table_blocks=0; no table-labeled blocks found
57+
4358
## Provenance Completeness
4459
PASS
4560

examples/mdpi_pdf_elements/page_012/quality_report.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Details:
4040
Each section below is a separate check.
4141

4242
- Required Field Coverage checks whether required JSON fields are present.
43+
- Table Output Structure Signals checks whether table-labeled normalized blocks contain visible table-output structure signals.
4344
- Provenance Completeness checks whether source, page, and bounding-box metadata is present.
4445
- BBox Sanity checks whether bounding boxes look structurally valid.
4546
- Content vs Noise Ratio checks how much extracted content looks like main text versus layout/noise.
@@ -51,6 +52,20 @@ PASS
5152

5253
all required fields present
5354

55+
## Table Output Structure Signals
56+
PASS
57+
58+
no table-labeled blocks found
59+
60+
- table_blocks=0
61+
- structured_grid_blocks=0
62+
- alternate_structure_signal_blocks=0
63+
- text_structure_signal_blocks=0
64+
- plain_text_only_blocks=0
65+
- empty_or_short_table_text_blocks=0
66+
- inconsistent_grid_blocks=0
67+
- table_blocks=0; no table-labeled blocks found
68+
5469
## Provenance Completeness
5570
PASS
5671

src/pdf_quality_report/checks.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
LOW_TEXT_CHAR_THRESHOLD = 40
2020
IMAGE_LOW_TEXT_CHAR_THRESHOLD = 120
2121
EMPTY_TEXT_BLOCK_RATIO_THRESHOLD = 0.5
22+
SHORT_TABLE_TEXT_THRESHOLD = 20
2223

2324
TEXT_BODY_TYPES = {"heading", "paragraph", "list_item_text", "caption", "footnote", "table"}
2425
TEXT_BEARING_TYPES = TEXT_BODY_TYPES | {"header", "footer"}
@@ -46,6 +47,7 @@ def run_quality_checks(uif_document: dict[str, Any]) -> QualityReport:
4647
blocks = _blocks(uif_document)
4748
results = [
4849
check_required_field_coverage(uif_document),
50+
check_table_output_structure(blocks),
4951
check_provenance_completeness(blocks),
5052
check_bbox_sanity(blocks),
5153
check_content_vs_noise_ratio(blocks),
@@ -140,6 +142,90 @@ def check_provenance_completeness(blocks: list[dict[str, Any]]) -> CheckResult:
140142
return CheckResult("Provenance Completeness", status, summary, [*failures, *warnings])
141143

142144

145+
def check_table_output_structure(blocks: list[dict[str, Any]]) -> CheckResult:
146+
"""Check visible table-output structure signals without judging table correctness."""
147+
table_blocks = [
148+
(index, block)
149+
for index, block in enumerate(blocks)
150+
if str(block.get("type", "unknown")) == "table"
151+
]
152+
counts = Counter[str]()
153+
warnings: list[str] = []
154+
155+
for index, block in table_blocks:
156+
block_id = _block_id(block, index)
157+
text = _content_text(block)
158+
normalized_text = _normalize_text(text)
159+
grid = _table_grid_signal(block)
160+
has_grid_signal = _has_non_empty_grid(grid)
161+
has_alternate_structured_signal = _has_alternate_table_structure_signal(block)
162+
has_text_structure_signal = _has_table_text_structure_signal(text)
163+
164+
if has_grid_signal:
165+
counts["structured_grid_blocks"] += 1
166+
if has_alternate_structured_signal:
167+
counts["alternate_structure_signal_blocks"] += 1
168+
if has_text_structure_signal:
169+
counts["text_structure_signal_blocks"] += 1
170+
171+
row_widths = _row_widths(grid)
172+
if len(set(row_widths)) > 1:
173+
counts["inconsistent_grid_blocks"] += 1
174+
warnings.append(
175+
f"{block_id}: table data_grid has inconsistent row widths: {_format_int_values(row_widths)}"
176+
)
177+
178+
has_any_structure_signal = has_grid_signal or has_alternate_structured_signal or has_text_structure_signal
179+
if not normalized_text and not has_any_structure_signal:
180+
counts["empty_or_short_table_text_blocks"] += 1
181+
warnings.append(f"{block_id}: table block has no structured table signal and no markdown text")
182+
elif not has_any_structure_signal and len(normalized_text) <= SHORT_TABLE_TEXT_THRESHOLD:
183+
counts["empty_or_short_table_text_blocks"] += 1
184+
warnings.append(
185+
f"{block_id}: table block has very short table text and no obvious row/column structure signal "
186+
f"(chars={len(normalized_text)})"
187+
)
188+
elif not has_any_structure_signal:
189+
counts["plain_text_only_blocks"] += 1
190+
warnings.append(
191+
f"{block_id}: table block has plain text only and no obvious row/column structure signal "
192+
f"(chars={len(normalized_text)})"
193+
)
194+
elif has_grid_signal and not normalized_text:
195+
counts["empty_or_short_table_text_blocks"] += 1
196+
warnings.append(f"{block_id}: table block has structured grid signal but empty markdown content.text")
197+
198+
details = [
199+
f"table_blocks={len(table_blocks)}",
200+
f"structured_grid_blocks={counts['structured_grid_blocks']}",
201+
f"alternate_structure_signal_blocks={counts['alternate_structure_signal_blocks']}",
202+
f"text_structure_signal_blocks={counts['text_structure_signal_blocks']}",
203+
f"plain_text_only_blocks={counts['plain_text_only_blocks']}",
204+
f"empty_or_short_table_text_blocks={counts['empty_or_short_table_text_blocks']}",
205+
f"inconsistent_grid_blocks={counts['inconsistent_grid_blocks']}",
206+
]
207+
if not table_blocks:
208+
return CheckResult(
209+
"Table Output Structure Signals",
210+
"PASS",
211+
"no table-labeled blocks found",
212+
[*details, "table_blocks=0; no table-labeled blocks found"],
213+
)
214+
if warnings:
215+
return CheckResult(
216+
"Table Output Structure Signals",
217+
"WARN",
218+
f"{len(warnings)} table output structure warning(s)",
219+
[*details, *warnings],
220+
)
221+
return CheckResult(
222+
"Table Output Structure Signals",
223+
"PASS",
224+
"table-labeled blocks include visible structure signals",
225+
details,
226+
)
227+
228+
143229
def check_bbox_sanity(blocks: list[dict[str, Any]]) -> CheckResult:
144230
"""Check bbox shape, ordering, units, origin, and raw Docling consistency."""
145231
details: list[str] = []
@@ -366,6 +452,78 @@ def _normalize_text(text: str) -> str:
366452
return re.sub(r"\s+", " ", text).strip()
367453

368454

455+
def _table_grid_signal(block: dict[str, Any]) -> list[list[str]]:
456+
for value in (
457+
block.get("data_grid"),
458+
_dict_or_empty(block.get("content")).get("data_grid"),
459+
_dict_or_empty(block.get("content")).get("rows"),
460+
block.get("rows"),
461+
):
462+
grid = _coerce_grid(value)
463+
if grid:
464+
return grid
465+
return []
466+
467+
468+
def _coerce_grid(value: Any) -> list[list[str]]:
469+
if not isinstance(value, list):
470+
return []
471+
rows: list[list[str]] = []
472+
for row in value:
473+
if isinstance(row, list):
474+
rows.append([_cell_text(cell) for cell in row])
475+
return rows
476+
477+
478+
def _cell_text(value: Any) -> str:
479+
if isinstance(value, dict):
480+
text = value.get("text")
481+
return text if isinstance(text, str) else ""
482+
return str(value) if value is not None else ""
483+
484+
485+
def _has_non_empty_grid(grid: list[list[str]]) -> bool:
486+
return any(cell.strip() for row in grid for cell in row)
487+
488+
489+
def _has_alternate_table_structure_signal(block: dict[str, Any]) -> bool:
490+
content = _dict_or_empty(block.get("content"))
491+
return (
492+
_has_non_empty_cells(block.get("cells"))
493+
or _has_non_empty_cells(content.get("cells"))
494+
or _has_non_empty_html(block.get("html"))
495+
or _has_non_empty_html(content.get("html"))
496+
)
497+
498+
499+
def _has_non_empty_cells(value: Any) -> bool:
500+
if not isinstance(value, list):
501+
return False
502+
return any(_cell_text(cell).strip() for cell in value)
503+
504+
505+
def _has_non_empty_html(value: Any) -> bool:
506+
return isinstance(value, str) and bool(value.strip())
507+
508+
509+
def _has_table_text_structure_signal(text: str) -> bool:
510+
if "\t" in text or "|" in text:
511+
return True
512+
lines = [line.strip() for line in text.splitlines() if line.strip()]
513+
if len(lines) < 2:
514+
return False
515+
column_counts = [len(re.split(r"\s{2,}", line)) for line in lines]
516+
return min(column_counts) > 1 and len(set(column_counts)) == 1
517+
518+
519+
def _row_widths(grid: list[list[str]]) -> list[int]:
520+
return [len(row) for row in grid if row]
521+
522+
523+
def _format_int_values(values: list[int]) -> str:
524+
return ", ".join(str(value) for value in sorted(set(values)))
525+
526+
369527
def _block_id(block: dict[str, Any], index: int) -> str:
370528
raw_id = block.get("id")
371529
return raw_id if isinstance(raw_id, str) and raw_id else f"block[{index}]"

src/pdf_quality_report/interpret.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def _interpret_result(result: CheckResult, signals: NoiseLayoutSignals) -> list[
4343
return _interpret_text_usefulness(result)
4444
if result.name == "Text Extraction Health":
4545
return _interpret_text_extraction_health(result)
46+
if result.name == "Table Output Structure Signals":
47+
return _interpret_table_output_structure(result)
4648
if result.name == "Content vs Noise Ratio":
4749
return _interpret_content_vs_noise(result, signals)
4850
if result.name == "BBox Sanity":
@@ -163,6 +165,52 @@ def _interpret_text_extraction_health(result: CheckResult) -> list[str]:
163165
return bullets
164166

165167

168+
def _interpret_table_output_structure(result: CheckResult) -> list[str]:
169+
counts = _detail_counts(result.details)
170+
table_blocks = counts.get("table_blocks")
171+
plain_text_only_blocks = counts.get("plain_text_only_blocks")
172+
empty_or_short_blocks = counts.get("empty_or_short_table_text_blocks")
173+
inconsistent_grid_blocks = counts.get("inconsistent_grid_blocks")
174+
175+
if table_blocks is None:
176+
return [
177+
f"{result.name} is {result.status}: {result.summary}. "
178+
"This check reviews table-output structure signals in normalized blocks; it does not judge table "
179+
"correctness."
180+
]
181+
182+
issue_counts = [
183+
count
184+
for count in (plain_text_only_blocks, empty_or_short_blocks, inconsistent_grid_blocks)
185+
if count is not None and count > 0
186+
]
187+
bullets = [
188+
_table_output_structure_count_bullet(result, table_blocks)
189+
]
190+
if issue_counts:
191+
bullets.append(
192+
"Some table-labeled blocks lack obvious structured table-output signals or have inconsistent row-width "
193+
"signals in the normalized parser output."
194+
)
195+
bullets.append(
196+
"This check does not validate table reconstruction correctness against the PDF, infer PDF ground truth, "
197+
"or judge parser accuracy."
198+
)
199+
examples = _warning_detail_examples(result.details)
200+
if examples:
201+
bullets.append(f"Examples: {examples}.")
202+
return bullets
203+
204+
205+
def _table_output_structure_count_bullet(result: CheckResult, table_blocks: int) -> str:
206+
if table_blocks == 0:
207+
return f"{result.name} is {result.status}: There are no table-labeled normalized blocks to review."
208+
return (
209+
f"{result.name} is {result.status}: This report found {table_blocks} table-labeled normalized "
210+
f"{_plural(table_blocks, 'block')}."
211+
)
212+
213+
166214
def _interpret_generic_result(result: CheckResult) -> str:
167215
examples = _detail_examples(result.details)
168216
suffix = f" Examples: {examples}." if examples else ""
@@ -196,6 +244,11 @@ def _interpret_noise_layout_signals(signals: NoiseLayoutSignals) -> list[str]:
196244
return bullets
197245

198246

247+
def _warning_detail_examples(details: list[str], limit: int = 2) -> str:
248+
warning_details = [detail for detail in details if ": " in detail]
249+
return "; ".join(warning_details[:limit])
250+
251+
199252
def _detail_examples(details: list[str], limit: int = 2) -> str:
200253
return "; ".join(details[:limit])
201254

src/pdf_quality_report/report.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
CHECK_RESULT_EXPLANATIONS = (
1616
"Each section below is a separate check.",
1717
"Required Field Coverage checks whether required JSON fields are present.",
18+
"Table Output Structure Signals checks whether table-labeled normalized blocks contain visible table-output "
19+
"structure signals.",
1820
"Provenance Completeness checks whether source, page, and bounding-box metadata is present.",
1921
"BBox Sanity checks whether bounding boxes look structurally valid.",
2022
"Content vs Noise Ratio checks how much extracted content looks like main text versus layout/noise.",

0 commit comments

Comments
 (0)