|
19 | 19 | LOW_TEXT_CHAR_THRESHOLD = 40 |
20 | 20 | IMAGE_LOW_TEXT_CHAR_THRESHOLD = 120 |
21 | 21 | EMPTY_TEXT_BLOCK_RATIO_THRESHOLD = 0.5 |
| 22 | +SHORT_TABLE_TEXT_THRESHOLD = 20 |
22 | 23 |
|
23 | 24 | TEXT_BODY_TYPES = {"heading", "paragraph", "list_item_text", "caption", "footnote", "table"} |
24 | 25 | TEXT_BEARING_TYPES = TEXT_BODY_TYPES | {"header", "footer"} |
@@ -46,6 +47,7 @@ def run_quality_checks(uif_document: dict[str, Any]) -> QualityReport: |
46 | 47 | blocks = _blocks(uif_document) |
47 | 48 | results = [ |
48 | 49 | check_required_field_coverage(uif_document), |
| 50 | + check_table_output_structure(blocks), |
49 | 51 | check_provenance_completeness(blocks), |
50 | 52 | check_bbox_sanity(blocks), |
51 | 53 | check_content_vs_noise_ratio(blocks), |
@@ -140,6 +142,90 @@ def check_provenance_completeness(blocks: list[dict[str, Any]]) -> CheckResult: |
140 | 142 | return CheckResult("Provenance Completeness", status, summary, [*failures, *warnings]) |
141 | 143 |
|
142 | 144 |
|
| 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 | + |
143 | 229 | def check_bbox_sanity(blocks: list[dict[str, Any]]) -> CheckResult: |
144 | 230 | """Check bbox shape, ordering, units, origin, and raw Docling consistency.""" |
145 | 231 | details: list[str] = [] |
@@ -366,6 +452,78 @@ def _normalize_text(text: str) -> str: |
366 | 452 | return re.sub(r"\s+", " ", text).strip() |
367 | 453 |
|
368 | 454 |
|
| 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 | + |
369 | 527 | def _block_id(block: dict[str, Any], index: int) -> str: |
370 | 528 | raw_id = block.get("id") |
371 | 529 | return raw_id if isinstance(raw_id, str) and raw_id else f"block[{index}]" |
|
0 commit comments