Spaces:
Paused
Paused
File size: 12,729 Bytes
f66643d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 | """JSON Schema validation for Stage A output.
This module provides:
- ValidationError: Exception for validation failures
- ValidationResult: Result container with errors list
- validate_stage_output: Main validation function checking both JSON Schema
and runtime invariants not expressible in the schema
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class ValidationError:
"""A single validation error.
Attributes:
path: JSON path to the error location (e.g., "pages[0].elements[2].bbox_pdf")
message: Human-readable error description
code: Short error code for programmatic handling
"""
path: str
message: str
code: str
@dataclass
class ValidationResult:
"""Result of validation.
Attributes:
valid: True if no errors were found
errors: List of ValidationError instances
"""
valid: bool = True
errors: list[ValidationError] = field(default_factory=list)
def add_error(self, path: str, message: str, code: str) -> None:
"""Add an error and mark result as invalid."""
self.valid = False
self.errors.append(ValidationError(path=path, message=message, code=code))
def _load_schema() -> dict[str, Any]:
"""Load the JSON Schema definition from the bundled ``schema.json`` file.
The schema file lives alongside this module in the same package directory
and is used by :func:`validate_stage_output` for structural validation of
:class:`~pdf2zh.scanned.models.ParsedDocument` dictionaries.
Returns:
Parsed JSON Schema as a Python dictionary.
"""
schema_path = Path(__file__).parent / "schema.json"
return json.loads(schema_path.read_text(encoding="utf-8"))
def _is_finite(value: Any) -> bool:
"""Return True if *value* is a finite number (not NaN or ±Infinity).
Non-numeric values (e.g. strings, None) are considered valid and return
True so that callers can use this as a lightweight numeric guard without
performing isinstance checks themselves.
Args:
value: Any Python object to check. Only ``int`` and ``float`` values
are tested for NaN / Infinity; all other types pass through.
Returns:
``True`` if *value* is not numeric, or is a finite numeric value.
``False`` if *value* is ``float('nan')``, ``float('inf')``, or
``float('-inf')``.
"""
if not isinstance(value, (int, float)):
return True
return not (math.isnan(value) or math.isinf(value))
def _check_bbox_valid(bbox: list[float], path: str, result: ValidationResult) -> bool:
"""Validate basic bbox structural invariants and record any errors.
Checks that *bbox* is a 4-element list of finite numbers where
``x0 < x1`` and ``y0 < y1``. Errors are appended to *result*;
the function returns a boolean so callers can short-circuit further
checks that depend on a valid bbox.
Args:
bbox: Candidate bounding box ``[x0, y0, x1, y1]``.
path: JSON path string used as the error location (e.g.
``"pages[0].elements[2].bbox_pdf"``).
result: Mutable :class:`ValidationResult` that errors are appended to.
Returns:
``True`` if all invariants pass; ``False`` if any error was recorded.
"""
if not isinstance(bbox, list) or len(bbox) != 4:
result.add_error(path, "bbox must be a list of 4 numbers", "BBOX_FORMAT")
return False
for i, v in enumerate(bbox):
if not _is_finite(v):
result.add_error(
path, f"bbox[{i}] contains NaN or Infinity", "BBOX_INVALID_NUMBER"
)
return False
x0, y0, x1, y1 = bbox
if not (x0 < x1):
result.add_error(path, f"bbox x0 ({x0}) must be < x1 ({x1})", "BBOX_X_ORDER")
return False
if not (y0 < y1):
result.add_error(path, f"bbox y0 ({y0}) must be < y1 ({y1})", "BBOX_Y_ORDER")
return False
return True
def _check_bbox_within_page(
bbox: list[float],
page_width: float,
page_height: float,
path: str,
result: ValidationResult,
) -> None:
"""Verify that a bbox lies within the page boundaries and record violations.
A small floating-point tolerance (0.01 pt) is allowed on all sides to
accommodate rounding errors introduced by coordinate conversions.
Args:
bbox: Validated ``[x0, y0, x1, y1]`` in PDF points.
page_width: Page width in PDF points (maximum allowed x coordinate).
page_height: Page height in PDF points (maximum allowed y coordinate).
path: JSON path string used as the error location.
result: Mutable :class:`ValidationResult` that errors are appended to.
"""
x0, y0, x1, y1 = bbox
# Allow small tolerance for floating point issues
tolerance = 0.01
if x0 < -tolerance or y0 < -tolerance:
result.add_error(
path,
f"bbox ({x0}, {y0}, {x1}, {y1}) has negative coordinates",
"BBOX_NEGATIVE",
)
if x1 > page_width + tolerance:
result.add_error(
path,
f"bbox x1 ({x1}) exceeds page_width ({page_width})",
"BBOX_EXCEEDS_WIDTH",
)
if y1 > page_height + tolerance:
result.add_error(
path,
f"bbox y1 ({y1}) exceeds page_height ({page_height})",
"BBOX_EXCEEDS_HEIGHT",
)
def _check_cell_within_table(
cell_bbox: list[float],
table_bbox: list[float],
cell_path: str,
result: ValidationResult,
) -> None:
"""Verify that a cell bbox is contained within its parent table bbox.
A small floating-point tolerance (0.01 pt) is applied on all edges to
account for rounding during coordinate conversion from image to PDF space.
Args:
cell_bbox: Cell bounding box ``[x0, y0, x1, y1]`` in PDF points.
table_bbox: Parent table bounding box ``[x0, y0, x1, y1]`` in PDF points.
cell_path: JSON path string for the cell (used in error messages).
result: Mutable :class:`ValidationResult` that errors are appended to.
"""
tolerance = 0.01
cx0, cy0, cx1, cy1 = cell_bbox
tx0, ty0, tx1, ty1 = table_bbox
if cx0 < tx0 - tolerance or cy0 < ty0 - tolerance:
result.add_error(
cell_path,
f"cell bbox ({cx0}, {cy0}, {cx1}, {cy1}) starts before table bbox ({tx0}, {ty0}, {tx1}, {ty1})",
"CELL_OUTSIDE_TABLE",
)
if cx1 > tx1 + tolerance or cy1 > ty1 + tolerance:
result.add_error(
cell_path,
f"cell bbox ({cx0}, {cy0}, {cx1}, {cy1}) ends after table bbox ({tx0}, {ty0}, {tx1}, {ty1})",
"CELL_OUTSIDE_TABLE",
)
def validate_stage_output(
data: dict[str, Any], stage: str = "A", skip_json_schema: bool = False
) -> ValidationResult:
"""Validate ParsedDocument output against JSON Schema and runtime invariants.
Args:
data: Dictionary representation of ParsedDocument
stage: Current stage ("A", "B", "C", or "D") for stage-aware checks
skip_json_schema: If True, skip JSON Schema validation (useful for testing)
Returns:
ValidationResult with valid=True if all checks pass, otherwise errors list
Runtime invariants checked (not expressible in JSON Schema):
1. bbox_pdf[0] < bbox_pdf[2] (x0 < x1)
2. bbox_pdf[1] < bbox_pdf[3] (y0 < y1)
3. Element bboxes fit within [0, 0, page_width, page_height]
4. category == "BYPASS" -> source_text == ""
5. category == "EQUATION" -> source_text may contain surrounding text
6. category == "TABLE" -> len(cells) > 0
7. category != "TABLE" -> cells == []
8. Cell bboxes contained within parent TABLE bbox
9. (Integration test only) len(pages) matches actual PDF page count
10. chapter_id == "" for all pages after Stage A
11. chapters == [] after Stage A
12. end_page >= start_page for ChapterInfo
13. No NaN or Infinity in numeric fields
"""
result = ValidationResult()
# JSON Schema validation (optional)
if not skip_json_schema:
try:
import jsonschema
schema = _load_schema()
jsonschema.validate(data, schema)
except ImportError:
# jsonschema not installed, skip this validation
pass
except jsonschema.ValidationError as e:
result.add_error(
".".join(str(p) for p in e.absolute_path), e.message, "JSON_SCHEMA"
)
# Continue to check other invariants
# Check top-level fields
if "pdf_path" not in data:
result.add_error("pdf_path", "pdf_path is required", "MISSING_FIELD")
return result
pages = data.get("pages", [])
chapters = data.get("chapters", [])
# Invariant 11: chapters == [] after Stage A
if stage == "A" and chapters:
result.add_error(
"chapters", "chapters must be [] after Stage A", "STAGE_A_CHAPTERS"
)
# Check each page
for page_idx, page in enumerate(pages):
page_path = f"pages[{page_idx}]"
# Check page dimensions
page_width = page.get("page_width", 0)
page_height = page.get("page_height", 0)
if not _is_finite(page_width):
result.add_error(
f"{page_path}.page_width", "contains NaN or Infinity", "INVALID_NUMBER"
)
if not _is_finite(page_height):
result.add_error(
f"{page_path}.page_height", "contains NaN or Infinity", "INVALID_NUMBER"
)
# Invariant 10: chapter_id == "" after Stage A
if stage == "A" and page.get("chapter_id", "") != "":
result.add_error(
f"{page_path}.chapter_id",
"chapter_id must be '' after Stage A",
"STAGE_A_CHAPTER_ID",
)
# Check each element
elements = page.get("elements", [])
for elem_idx, elem in enumerate(elements):
elem_path = f"{page_path}.elements[{elem_idx}]"
# Check bbox
bbox = elem.get("bbox_pdf", [])
bbox_valid = _check_bbox_valid(bbox, f"{elem_path}.bbox_pdf", result)
# Invariant 3: bbox within page bounds
if bbox_valid and page_width > 0 and page_height > 0:
_check_bbox_within_page(
bbox, page_width, page_height, f"{elem_path}.bbox_pdf", result
)
category = elem.get("category", "")
source_text = elem.get("source_text", "")
cells = elem.get("cells", [])
# Invariant 4: BYPASS -> source_text == ""
if category == "BYPASS" and source_text != "":
result.add_error(
f"{elem_path}.source_text",
"source_text must be '' for BYPASS category",
"BYPASS_TEXT",
)
# Invariant 6: TABLE -> len(cells) > 0
if category == "TABLE" and len(cells) == 0:
result.add_error(
f"{elem_path}.cells",
"cells must not be empty for TABLE category",
"TABLE_NO_CELLS",
)
# Invariant 7: non-TABLE -> cells == []
if category != "TABLE" and len(cells) > 0:
result.add_error(
f"{elem_path}.cells",
"cells must be [] for non-TABLE category",
"NON_TABLE_HAS_CELLS",
)
# Check cells
for cell_idx, cell in enumerate(cells):
cell_path = f"{elem_path}.cells[{cell_idx}]"
cell_bbox = cell.get("bbox_pdf", [])
cell_bbox_valid = _check_bbox_valid(
cell_bbox, f"{cell_path}.bbox_pdf", result
)
# Invariant 8: cell bbox within table bbox
if cell_bbox_valid and bbox_valid:
_check_cell_within_table(cell_bbox, bbox, cell_path, result)
# Check chapters (for later stages)
for ch_idx, chapter in enumerate(chapters):
ch_path = f"chapters[{ch_idx}]"
start_page = chapter.get("start_page", 0)
end_page = chapter.get("end_page", 0)
# Invariant 12: end_page >= start_page
if end_page < start_page:
result.add_error(
ch_path,
f"end_page ({end_page}) must be >= start_page ({start_page})",
"CHAPTER_PAGE_ORDER",
)
return result
|