kitchen/backend/services/pdf_rotation.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

334 lines
12 KiB
Python

"""
PDF Rotation Service
Handles rotation of PDF pages based on Azure OCR angle data and transforms
OCR coordinates to match the corrected orientation.
This should be called as the FIRST post-processing step after Azure returns,
before any other processing extracts data from raw_json.
"""
import fitz # PyMuPDF
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def normalize_angle(angle: Optional[float]) -> int:
"""
Round angle to nearest 90 degrees.
Azure returns angle in degrees (can be negative for CCW rotation).
We round to nearest 90° for correction.
"""
if angle is None or angle == 0:
return 0
# Round to nearest 90 degrees, preserving sign
rounded = round(angle / 90) * 90
# Normalize to -180 to 180 range for cleaner math
while rounded > 180:
rounded -= 360
while rounded < -180:
rounded += 360
return int(rounded)
def transform_polygon(polygon: list, rotation: int, page_width: float, page_height: float) -> list:
"""
Transform polygon coordinates based on rotation.
The rotation is the detected angle (can be negative for CCW).
We compute the correction and transform coordinates to match the corrected PDF.
Args:
polygon: List of [x, y] coordinate pairs in inches
rotation: Detected angle in degrees (can be negative, e.g., -90 for CCW)
page_width: Original page width in inches (before rotation)
page_height: Original page height in inches (before rotation)
Returns:
Transformed polygon with coordinates adjusted for rotation
"""
# Calculate correction angle (opposite of detected)
# -90° detected -> +90° correction
correction = -rotation
# Normalize to positive 0-360 range
correction = int(((correction % 360) + 360) % 360)
transformed = []
for point in polygon:
x, y = point[0], point[1]
if correction == 90:
# Applied 90° CW rotation to PDF
# For +90° CW: (x, y) -> (height - y, x)
new_x = page_height - y
new_y = x
elif correction == 180:
# Applied 180° rotation to PDF
# (x, y) -> (width - x, height - y)
new_x = page_width - x
new_y = page_height - y
elif correction == 270:
# Applied 270° CW (90° CCW) rotation to PDF
# For +270° CW: (x, y) -> (y, width - x)
new_x = y
new_y = page_width - x
else:
new_x, new_y = x, y
transformed.append([new_x, new_y])
return transformed
def transform_bounding_regions(regions: list, rotation: int, page_width: float, page_height: float) -> list:
"""Transform bounding regions for a rotated page."""
for region in regions:
if 'polygon' in region:
region['polygon'] = transform_polygon(
region['polygon'],
rotation,
page_width,
page_height
)
return regions
def transform_fields_coordinates(fields: dict, rotations: dict[int, int], pages: list):
"""
Recursively transform bounding regions in fields.
Args:
fields: Dictionary of field name -> field data
rotations: Dictionary of page_number -> rotation angle
pages: List of page info dictionaries (with original dimensions)
"""
for field_name, field_data in fields.items():
if not isinstance(field_data, dict):
continue
# Get page info for this field
page_num = 1
if 'bounding_regions' in field_data and field_data['bounding_regions']:
page_num = field_data['bounding_regions'][0].get('page_number', 1)
rotation = rotations.get(page_num, 0)
if rotation != 0:
page_info = next((p for p in pages if p.get('page_number') == page_num), None)
if page_info and 'bounding_regions' in field_data:
transform_bounding_regions(
field_data['bounding_regions'],
rotation,
page_info.get('width', 8.5),
page_info.get('height', 11)
)
# Recurse into nested value
if 'value' in field_data:
val = field_data['value']
if isinstance(val, dict):
transform_fields_coordinates(val, rotations, pages)
elif isinstance(val, list):
for item in val:
if isinstance(item, dict):
# Handle bounding_regions at item level
item_page = 1
if 'bounding_regions' in item and item['bounding_regions']:
item_page = item['bounding_regions'][0].get('page_number', 1)
item_rotation = rotations.get(item_page, 0)
if item_rotation != 0 and 'bounding_regions' in item:
item_page_info = next((p for p in pages if p.get('page_number') == item_page), None)
if item_page_info:
transform_bounding_regions(
item['bounding_regions'],
item_rotation,
item_page_info.get('width', 8.5),
item_page_info.get('height', 11)
)
# Recurse into item value
if 'value' in item and isinstance(item['value'], dict):
transform_fields_coordinates(item['value'], rotations, pages)
def transform_ocr_coordinates(ocr_json: dict, rotations: dict[int, int]) -> dict:
"""
Transform all coordinates in OCR JSON based on page rotations.
Args:
ocr_json: The raw OCR JSON from Azure
rotations: Dictionary mapping page_number to rotation angle
Returns:
Updated OCR JSON with transformed coordinates
"""
# Store original dimensions before updating
original_pages = []
for page_info in ocr_json.get('pages', []):
original_pages.append({
'page_number': page_info.get('page_number', 1),
'width': page_info.get('width'),
'height': page_info.get('height')
})
# Update page dimensions (swap width/height for 90/270 corrections)
for page_info in ocr_json.get('pages', []):
page_num = page_info.get('page_number', 1)
rotation = rotations.get(page_num, 0)
# Calculate correction angle (opposite of detected)
correction = int(((-rotation % 360) + 360) % 360)
if correction in (90, 270):
# Swap width and height
old_width = page_info.get('width')
old_height = page_info.get('height')
page_info['width'] = old_height
page_info['height'] = old_width
logger.debug(f"Page {page_num}: swapped dimensions {old_width}x{old_height} -> {old_height}x{old_width}")
# Reset angle to 0 since we've corrected it
page_info['angle'] = 0
# Transform bounding regions in documents using ORIGINAL dimensions
for doc in ocr_json.get('documents', []):
transform_fields_coordinates(doc.get('fields', {}), rotations, original_pages)
return ocr_json
def rotate_pdf_pages(pdf_path: str, ocr_raw_json: dict) -> tuple[bool, dict]:
"""
Rotate PDF pages based on OCR angle data and transform coordinates.
This should be called immediately after Azure OCR returns, before any
other post-processing extracts data from raw_json.
Args:
pdf_path: Path to the PDF file
ocr_raw_json: The serialized OCR result containing page angles
Returns:
Tuple of (modified: bool, updated_ocr_json: dict)
- modified: True if any pages were rotated
- updated_ocr_json: OCR JSON with transformed coordinates
"""
pages_info = ocr_raw_json.get('pages', [])
rotations_needed = {}
# Check which pages need rotation
for page_info in pages_info:
page_num = page_info.get('page_number', 1)
raw_angle = page_info.get('angle', 0)
angle = normalize_angle(raw_angle)
logger.info(f"Page {page_num}: raw angle={raw_angle}, normalized={angle}")
if angle != 0:
rotations_needed[page_num] = angle
if not rotations_needed:
logger.debug("No page rotations needed")
return False, ocr_raw_json
logger.info(f"Rotating pages: {rotations_needed}")
try:
import tempfile
import shutil
import os
# Open source PDF for reading
src_doc = fitz.open(pdf_path)
# Create a new document for output
out_doc = fitz.open()
# Process each page in order
for page_idx in range(len(src_doc)):
page_num = page_idx + 1
src_page = src_doc[page_idx]
rect = src_page.rect
rotation = rotations_needed.get(page_num, 0)
if rotation == 0:
# No rotation needed - just copy the page as-is
out_doc.insert_pdf(src_doc, from_page=page_idx, to_page=page_idx)
logger.debug(f"Page {page_num}: no rotation needed, copied as-is")
else:
# Calculate correction: -90° content needs +90° rotation to appear upright
correction = -rotation
# Normalize to 0-360 for PyMuPDF
correction = int(((correction % 360) + 360) % 360)
logger.info(f"Page {page_num}: detected angle={rotation}°, applying correction={correction}°")
logger.info(f"Page {page_num}: original size {rect.width}x{rect.height}")
# Render page to pixmap at high resolution
# Use 2x scale for better quality
mat = fitz.Matrix(2, 2)
pix = src_page.get_pixmap(matrix=mat)
# Rotate the pixmap
# PyMuPDF Pixmap doesn't have direct rotate, so we use PIL
from PIL import Image
import io
# Convert pixmap to PIL Image
img_data = pix.tobytes("png")
img = Image.open(io.BytesIO(img_data))
# Rotate image (PIL rotates counter-clockwise, we need clockwise)
# correction=90 means rotate 90° CW, which is -90° in PIL (or 270° CCW)
pil_rotation = (360 - correction) % 360
if pil_rotation != 0:
img = img.rotate(pil_rotation, expand=True)
logger.info(f"Page {page_num}: rotated image {pil_rotation}° CCW (={correction}° CW)")
# For 90° or 270° rotation, swap width and height
if correction in (90, 270):
new_width, new_height = rect.height, rect.width
else:
new_width, new_height = rect.width, rect.height
# Create new page with correct dimensions
new_page = out_doc.new_page(width=new_width, height=new_height)
# Convert PIL image back to bytes
img_buffer = io.BytesIO()
img.save(img_buffer, format='PNG')
img_buffer.seek(0)
# Insert the rotated image into the new page
new_page.insert_image(
fitz.Rect(0, 0, new_width, new_height),
stream=img_buffer.read()
)
logger.info(f"Page {page_num}: re-rendered with {correction}° rotation, new size {new_width}x{new_height}")
src_doc.close()
# Save to temp file first, then replace original
temp_fd, temp_path = tempfile.mkstemp(suffix='.pdf')
try:
os.close(temp_fd) # Close the file descriptor, we just need the path
out_doc.save(temp_path, garbage=4, deflate=True)
out_doc.close()
# Replace original with rotated version
shutil.move(temp_path, pdf_path)
except Exception:
# Clean up temp file on error
if os.path.exists(temp_path):
os.unlink(temp_path)
raise
logger.info(f"PDF saved with rotated pages: {pdf_path}")
except Exception as e:
logger.error(f"Error rotating PDF: {e}")
# Return original JSON if rotation fails
return False, ocr_raw_json
# Transform OCR coordinates to match new orientation
updated_json = transform_ocr_coordinates(ocr_raw_json, rotations_needed)
return True, updated_json