Spaces:
Paused
Paused
| """ | |
| Coordinate Mapping Utilities | |
| Provides data structures and utilities for mapping character offsets | |
| to format-specific coordinates (page/bbox for PDF, row/col for spreadsheets, etc.). | |
| Usage: | |
| from potato.format_handlers.coordinate_mapping import ( | |
| CoordinateMapper, | |
| PDFCoordinate, | |
| SpreadsheetCoordinate, | |
| ) | |
| # Build a coordinate map during extraction | |
| mapper = CoordinateMapper() | |
| mapper.add_mapping(0, 100, PDFCoordinate(page=1, bbox=[10, 20, 200, 30])) | |
| # Look up coordinates for a span | |
| coords = mapper.get_coords_for_range(50, 75) | |
| """ | |
| from dataclasses import dataclass, field | |
| from typing import Dict, List, Any, Optional, Tuple, Union | |
| from bisect import bisect_left, bisect_right | |
| import json | |
| class CharacterCoordinate: | |
| """Base coordinate type representing a character position.""" | |
| offset: int | |
| format_type: str = "character" | |
| def to_dict(self) -> Dict[str, Any]: | |
| return {"format": self.format_type, "offset": self.offset} | |
| class PDFCoordinate: | |
| """ | |
| Coordinate for PDF documents. | |
| Attributes: | |
| page: Page number (1-indexed) | |
| bbox: Bounding box [x0, y0, x1, y1] in PDF points | |
| line: Optional line number on the page | |
| """ | |
| page: int | |
| bbox: List[float] = field(default_factory=list) | |
| line: Optional[int] = None | |
| format_type: str = "pdf" | |
| def to_dict(self) -> Dict[str, Any]: | |
| result = { | |
| "format": self.format_type, | |
| "page": self.page, | |
| } | |
| if self.bbox: | |
| result["bbox"] = self.bbox | |
| if self.line is not None: | |
| result["line"] = self.line | |
| return result | |
| class SpreadsheetCoordinate: | |
| """ | |
| Coordinate for spreadsheet documents. | |
| Attributes: | |
| row: Row number (0-indexed internally, displayed as 1-indexed) | |
| col: Column number (0-indexed internally) | |
| cell_ref: Cell reference in A1 notation (e.g., "B5") | |
| sheet: Sheet name (for multi-sheet documents) | |
| """ | |
| row: int | |
| col: Optional[int] = None | |
| cell_ref: Optional[str] = None | |
| sheet: Optional[str] = None | |
| format_type: str = "spreadsheet" | |
| def to_dict(self) -> Dict[str, Any]: | |
| result = { | |
| "format": self.format_type, | |
| "row": self.row + 1, # Convert to 1-indexed for output | |
| } | |
| if self.col is not None: | |
| result["col"] = self.col + 1 | |
| if self.cell_ref: | |
| result["cell_ref"] = self.cell_ref | |
| if self.sheet: | |
| result["sheet"] = self.sheet | |
| return result | |
| class DocumentCoordinate: | |
| """ | |
| Coordinate for document formats (DOCX, Markdown). | |
| Attributes: | |
| paragraph_id: Unique identifier for the paragraph | |
| local_offset: Character offset within the paragraph | |
| section: Optional section name/number | |
| heading_level: If in a heading, its level (1-6) | |
| """ | |
| paragraph_id: str | |
| local_offset: int = 0 | |
| section: Optional[str] = None | |
| heading_level: Optional[int] = None | |
| format_type: str = "document" | |
| def to_dict(self) -> Dict[str, Any]: | |
| result = { | |
| "format": self.format_type, | |
| "paragraph_id": self.paragraph_id, | |
| "local_offset": self.local_offset, | |
| } | |
| if self.section: | |
| result["section"] = self.section | |
| if self.heading_level: | |
| result["heading_level"] = self.heading_level | |
| return result | |
| class CodeCoordinate: | |
| """ | |
| Coordinate for source code files. | |
| Attributes: | |
| line: Line number (1-indexed) | |
| column: Column number (1-indexed) | |
| function_name: Name of the containing function (if any) | |
| class_name: Name of the containing class (if any) | |
| """ | |
| line: int | |
| column: int = 1 | |
| function_name: Optional[str] = None | |
| class_name: Optional[str] = None | |
| format_type: str = "code" | |
| def to_dict(self) -> Dict[str, Any]: | |
| result = { | |
| "format": self.format_type, | |
| "line": self.line, | |
| "column": self.column, | |
| } | |
| if self.function_name: | |
| result["function_name"] = self.function_name | |
| if self.class_name: | |
| result["class_name"] = self.class_name | |
| return result | |
| class BoundingBoxCoordinate: | |
| """ | |
| Coordinate for bounding box annotations on images/PDF pages. | |
| Attributes: | |
| page: Page number (1-indexed, for PDFs) | |
| bbox: Bounding box [x, y, width, height] in normalized coordinates (0-1) | |
| bbox_pixels: Optional bounding box in pixel coordinates | |
| label: Label/class for the bounding box | |
| confidence: Optional confidence score (0-1) | |
| """ | |
| page: int | |
| bbox: List[float] # [x, y, width, height] normalized 0-1 | |
| bbox_pixels: Optional[List[float]] = None # [x, y, width, height] in pixels | |
| label: Optional[str] = None | |
| confidence: Optional[float] = None | |
| format_type: str = "bounding_box" | |
| def to_dict(self) -> Dict[str, Any]: | |
| result = { | |
| "format": self.format_type, | |
| "page": self.page, | |
| "bbox": self.bbox, | |
| } | |
| if self.bbox_pixels: | |
| result["bbox_pixels"] = self.bbox_pixels | |
| if self.label: | |
| result["label"] = self.label | |
| if self.confidence is not None: | |
| result["confidence"] = self.confidence | |
| return result | |
| def from_pixel_coords( | |
| cls, | |
| page: int, | |
| x: float, | |
| y: float, | |
| width: float, | |
| height: float, | |
| page_width: float, | |
| page_height: float, | |
| label: Optional[str] = None | |
| ) -> "BoundingBoxCoordinate": | |
| """ | |
| Create a BoundingBoxCoordinate from pixel coordinates. | |
| Args: | |
| page: Page number (1-indexed) | |
| x, y, width, height: Bounding box in pixels | |
| page_width, page_height: Page dimensions in pixels | |
| label: Optional label for the box | |
| Returns: | |
| BoundingBoxCoordinate with normalized coords | |
| """ | |
| return cls( | |
| page=page, | |
| bbox=[ | |
| x / page_width, | |
| y / page_height, | |
| width / page_width, | |
| height / page_height | |
| ], | |
| bbox_pixels=[x, y, width, height], | |
| label=label | |
| ) | |
| def to_pixel_coords( | |
| self, | |
| page_width: float, | |
| page_height: float | |
| ) -> List[float]: | |
| """ | |
| Convert normalized coordinates to pixels. | |
| Args: | |
| page_width: Page width in pixels | |
| page_height: Page height in pixels | |
| Returns: | |
| [x, y, width, height] in pixels | |
| """ | |
| return [ | |
| self.bbox[0] * page_width, | |
| self.bbox[1] * page_height, | |
| self.bbox[2] * page_width, | |
| self.bbox[3] * page_height | |
| ] | |
| # Type alias for any coordinate type | |
| Coordinate = Union[ | |
| CharacterCoordinate, | |
| PDFCoordinate, | |
| SpreadsheetCoordinate, | |
| DocumentCoordinate, | |
| CodeCoordinate, | |
| BoundingBoxCoordinate, | |
| ] | |
| class CoordinateMapping: | |
| """ | |
| Maps a character range to format-specific coordinates. | |
| Attributes: | |
| start: Start character offset (inclusive) | |
| end: End character offset (exclusive) | |
| coordinate: Format-specific coordinate data | |
| """ | |
| start: int | |
| end: int | |
| coordinate: Coordinate | |
| def contains(self, offset: int) -> bool: | |
| """Check if an offset falls within this mapping.""" | |
| return self.start <= offset < self.end | |
| def overlaps(self, start: int, end: int) -> bool: | |
| """Check if a range overlaps with this mapping.""" | |
| return self.start < end and start < self.end | |
| class CoordinateMapper: | |
| """ | |
| Manages mappings from character offsets to format-specific coordinates. | |
| Provides efficient lookup of coordinates for character ranges. | |
| """ | |
| def __init__(self): | |
| self._mappings: List[CoordinateMapping] = [] | |
| self._sorted = True | |
| self._start_offsets: List[int] = [] # For binary search | |
| def add_mapping( | |
| self, | |
| start: int, | |
| end: int, | |
| coordinate: Coordinate | |
| ) -> None: | |
| """ | |
| Add a mapping from character range to coordinate. | |
| Args: | |
| start: Start character offset (inclusive) | |
| end: End character offset (exclusive) | |
| coordinate: Format-specific coordinate data | |
| """ | |
| mapping = CoordinateMapping(start=start, end=end, coordinate=coordinate) | |
| self._mappings.append(mapping) | |
| self._sorted = False | |
| def _ensure_sorted(self) -> None: | |
| """Sort mappings by start offset if needed.""" | |
| if not self._sorted: | |
| self._mappings.sort(key=lambda m: m.start) | |
| self._start_offsets = [m.start for m in self._mappings] | |
| self._sorted = True | |
| def get_coordinate_at(self, offset: int) -> Optional[Coordinate]: | |
| """ | |
| Get the coordinate at a specific character offset. | |
| Args: | |
| offset: Character offset | |
| Returns: | |
| Coordinate if found, None otherwise | |
| """ | |
| self._ensure_sorted() | |
| # Binary search for the potential mapping | |
| idx = bisect_right(self._start_offsets, offset) - 1 | |
| if idx >= 0 and self._mappings[idx].contains(offset): | |
| return self._mappings[idx].coordinate | |
| return None | |
| def get_coords_for_range( | |
| self, | |
| start: int, | |
| end: int | |
| ) -> Optional[Dict[str, Any]]: | |
| """ | |
| Get format coordinates for a character range. | |
| For spans crossing multiple coordinates (e.g., multiple PDF pages), | |
| returns the coordinates of the first character. | |
| Args: | |
| start: Start character offset (inclusive) | |
| end: End character offset (exclusive) | |
| Returns: | |
| Dictionary with format-specific coordinates, or None if not found | |
| """ | |
| coord = self.get_coordinate_at(start) | |
| if coord: | |
| return coord.to_dict() | |
| return None | |
| def get_all_coords_for_range( | |
| self, | |
| start: int, | |
| end: int | |
| ) -> List[Dict[str, Any]]: | |
| """ | |
| Get all coordinates that overlap with a character range. | |
| Useful for spans crossing multiple structural elements. | |
| Args: | |
| start: Start character offset (inclusive) | |
| end: End character offset (exclusive) | |
| Returns: | |
| List of coordinate dictionaries | |
| """ | |
| self._ensure_sorted() | |
| coords = [] | |
| for mapping in self._mappings: | |
| if mapping.overlaps(start, end): | |
| coords.append(mapping.coordinate.to_dict()) | |
| elif mapping.start >= end: | |
| break # No more overlapping mappings | |
| return coords | |
| def get_mapping_count(self) -> int: | |
| """Get the number of mappings stored.""" | |
| return len(self._mappings) | |
| def to_dict(self) -> Dict[str, Any]: | |
| """ | |
| Export mappings as a dictionary. | |
| Returns: | |
| Dictionary representation of all mappings | |
| """ | |
| self._ensure_sorted() | |
| return { | |
| "mappings": [ | |
| { | |
| "start": m.start, | |
| "end": m.end, | |
| "coordinate": m.coordinate.to_dict() | |
| } | |
| for m in self._mappings | |
| ] | |
| } | |
| def to_json(self) -> str: | |
| """Export mappings as JSON string.""" | |
| return json.dumps(self.to_dict(), indent=2) | |
| def from_dict(cls, data: Dict[str, Any]) -> "CoordinateMapper": | |
| """ | |
| Create a CoordinateMapper from a dictionary. | |
| Args: | |
| data: Dictionary with mappings | |
| Returns: | |
| New CoordinateMapper instance | |
| """ | |
| mapper = cls() | |
| for m in data.get("mappings", []): | |
| coord_data = m["coordinate"] | |
| format_type = coord_data.get("format", "character") | |
| # Recreate coordinate object based on format type | |
| if format_type == "pdf": | |
| coord = PDFCoordinate( | |
| page=coord_data["page"], | |
| bbox=coord_data.get("bbox", []), | |
| line=coord_data.get("line"), | |
| ) | |
| elif format_type == "spreadsheet": | |
| coord = SpreadsheetCoordinate( | |
| row=coord_data["row"] - 1, # Convert back to 0-indexed | |
| col=coord_data.get("col", 1) - 1 if coord_data.get("col") else None, | |
| cell_ref=coord_data.get("cell_ref"), | |
| sheet=coord_data.get("sheet"), | |
| ) | |
| elif format_type == "document": | |
| coord = DocumentCoordinate( | |
| paragraph_id=coord_data["paragraph_id"], | |
| local_offset=coord_data.get("local_offset", 0), | |
| section=coord_data.get("section"), | |
| heading_level=coord_data.get("heading_level"), | |
| ) | |
| elif format_type == "code": | |
| coord = CodeCoordinate( | |
| line=coord_data["line"], | |
| column=coord_data.get("column", 1), | |
| function_name=coord_data.get("function_name"), | |
| class_name=coord_data.get("class_name"), | |
| ) | |
| elif format_type == "bounding_box": | |
| coord = BoundingBoxCoordinate( | |
| page=coord_data["page"], | |
| bbox=coord_data["bbox"], | |
| bbox_pixels=coord_data.get("bbox_pixels"), | |
| label=coord_data.get("label"), | |
| confidence=coord_data.get("confidence"), | |
| ) | |
| else: | |
| coord = CharacterCoordinate(offset=coord_data.get("offset", m["start"])) | |
| mapper.add_mapping(m["start"], m["end"], coord) | |
| return mapper | |
| def get_column_letter(col_idx: int) -> str: | |
| """ | |
| Convert a 0-indexed column number to Excel column letter. | |
| Args: | |
| col_idx: 0-indexed column number | |
| Returns: | |
| Column letter (A, B, ..., Z, AA, AB, ...) | |
| """ | |
| result = "" | |
| col_idx += 1 # Convert to 1-indexed | |
| while col_idx > 0: | |
| col_idx, remainder = divmod(col_idx - 1, 26) | |
| result = chr(65 + remainder) + result | |
| return result | |
| def get_cell_reference(row: int, col: int) -> str: | |
| """ | |
| Get A1-style cell reference. | |
| Args: | |
| row: 0-indexed row number | |
| col: 0-indexed column number | |
| Returns: | |
| Cell reference like "A1", "B5", "AA100" | |
| """ | |
| return f"{get_column_letter(col)}{row + 1}" | |