"""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