| 1 | """Slide dimensions, format detection, EMU conversion, and constants.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import re |
| 6 | import sys |
| 7 | from pathlib import Path |
| 8 | from xml.etree import ElementTree as ET |
| 9 | |
| 10 | from ..canvas_contract import ( |
| 11 | CanvasContractError, |
| 12 | ProjectViewBox, |
| 13 | parse_project_viewbox, |
| 14 | read_project_viewbox, |
| 15 | require_consistent_project_viewboxes, |
| 16 | ) |
| 17 | |
| 18 | # Import project utility modules |
| 19 | sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
| 20 | try: |
| 21 | from project_utils import get_project_info |
| 22 | from config import CANVAS_FORMATS |
| 23 | except ImportError: |
| 24 | CANVAS_FORMATS = { |
| 25 | 'ppt169': {'name': 'PPT 16:9', 'dimensions': '1280×720', 'viewbox': '0 0 1280 720'}, |
| 26 | } |
| 27 | |
| 28 | def get_project_info(path: str) -> dict: |
| 29 | return {'format': 'unknown', 'name': Path(path).name} |
| 30 | |
| 31 | # EMU conversion constants |
| 32 | EMU_PER_INCH = 914400 |
| 33 | EMU_PER_PIXEL = EMU_PER_INCH / 96 |
| 34 | |
| 35 | # XML namespaces |
| 36 | NAMESPACES = { |
| 37 | 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', |
| 38 | 'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', |
| 39 | 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main', |
| 40 | 'asvg': 'http://schemas.microsoft.com/office/drawing/2016/SVG/main', |
| 41 | } |
| 42 | |
| 43 | # Register namespaces for ElementTree output |
| 44 | for prefix, uri in NAMESPACES.items(): |
| 45 | ET.register_namespace(prefix, uri) |
| 46 | |
| 47 | |
| 48 | def get_slide_dimensions( |
| 49 | canvas_format: str, |
| 50 | custom_pixels: tuple[int, int] | None = None, |
| 51 | ) -> tuple[int, int]: |
| 52 | """Get slide dimensions in EMU units. |
| 53 | |
| 54 | Args: |
| 55 | canvas_format: Canvas format key (e.g. 'ppt169'). |
| 56 | custom_pixels: Optional custom pixel dimensions override. |
| 57 | |
| 58 | Returns: |
| 59 | (width_emu, height_emu) tuple. |
| 60 | """ |
| 61 | if custom_pixels: |
| 62 | width_px, height_px = custom_pixels |
| 63 | else: |
| 64 | if canvas_format not in CANVAS_FORMATS: |
| 65 | canvas_format = 'ppt169' |
| 66 | |
| 67 | dimensions = CANVAS_FORMATS[canvas_format]['dimensions'] |
| 68 | match = re.match(r'(\d+)[×x](\d+)', dimensions) |
| 69 | if match: |
| 70 | width_px = int(match.group(1)) |
| 71 | height_px = int(match.group(2)) |
| 72 | else: |
| 73 | width_px, height_px = 1280, 720 |
| 74 | |
| 75 | return int(width_px * EMU_PER_PIXEL), int(height_px * EMU_PER_PIXEL) |
| 76 | |
| 77 | |
| 78 | def get_pixel_dimensions( |
| 79 | canvas_format: str, |
| 80 | custom_pixels: tuple[int, int] | None = None, |
| 81 | ) -> tuple[int, int]: |
| 82 | """Get canvas pixel dimensions. |
| 83 | |
| 84 | Args: |
| 85 | canvas_format: Canvas format key. |
| 86 | custom_pixels: Optional custom pixel dimensions override. |
| 87 | |
| 88 | Returns: |
| 89 | (width_px, height_px) tuple. |
| 90 | """ |
| 91 | if custom_pixels: |
| 92 | return custom_pixels |
| 93 | |
| 94 | if canvas_format not in CANVAS_FORMATS: |
| 95 | canvas_format = 'ppt169' |
| 96 | |
| 97 | dimensions = CANVAS_FORMATS[canvas_format]['dimensions'] |
| 98 | match = re.match(r'(\d+)[×x](\d+)', dimensions) |
| 99 | if match: |
| 100 | return int(match.group(1)), int(match.group(2)) |
| 101 | return 1280, 720 |
| 102 | |
| 103 | |
| 104 | def get_viewbox_dimensions(svg_path: Path) -> tuple[float, float]: |
| 105 | """Extract pixel dimensions from SVG viewBox. |
| 106 | |
| 107 | Args: |
| 108 | svg_path: Path to the SVG file. |
| 109 | |
| 110 | Returns: |
| 111 | (width, height) in SVG pixels. |
| 112 | |
| 113 | Raises: |
| 114 | CanvasContractError: The root canvas is missing or invalid. |
| 115 | """ |
| 116 | return read_project_viewbox(svg_path).pixel_dimensions |
| 117 | |
| 118 | |
| 119 | def detect_format_from_svg(svg_path: Path) -> str | None: |
| 120 | """Detect canvas format from an SVG file's viewBox. |
| 121 | |
| 122 | Args: |
| 123 | svg_path: Path to the SVG file. |
| 124 | |
| 125 | Returns: |
| 126 | Canvas format key (e.g. 'ppt169'), or None if not detected. |
| 127 | """ |
| 128 | viewbox = read_project_viewbox(svg_path) |
| 129 | for fmt_key, fmt_info in CANVAS_FORMATS.items(): |
| 130 | expected = parse_project_viewbox( |
| 131 | fmt_info['viewbox'], |
| 132 | context=f"registered canvas {fmt_key!r}", |
| 133 | ) |
| 134 | if viewbox == expected: |
| 135 | return fmt_key |
| 136 | return None |
| 137 | |
| 138 | |
| 139 | def resolve_svg_canvas( |
| 140 | svg_files: list[Path], |
| 141 | *, |
| 142 | canvas_format: str | None = None, |
| 143 | expected_viewbox: str | None = None, |
| 144 | ) -> tuple[ProjectViewBox, str | None]: |
| 145 | """Resolve one fail-closed canvas for every public/internal SVG.""" |
| 146 | format_viewbox: str | None = None |
| 147 | if canvas_format is not None: |
| 148 | if canvas_format not in CANVAS_FORMATS: |
| 149 | raise CanvasContractError(f"Unsupported canvas format: {canvas_format}") |
| 150 | format_viewbox = CANVAS_FORMATS[canvas_format]['viewbox'] |
| 151 | |
| 152 | if expected_viewbox is not None and format_viewbox is not None: |
| 153 | locked = parse_project_viewbox( |
| 154 | expected_viewbox, |
| 155 | context="locked canvas viewBox", |
| 156 | ) |
| 157 | selected = parse_project_viewbox( |
| 158 | format_viewbox, |
| 159 | context=f"canvas format {canvas_format!r}", |
| 160 | ) |
| 161 | if locked != selected: |
| 162 | raise CanvasContractError( |
| 163 | f"canvas format {canvas_format!r} ({selected.canonical}) conflicts " |
| 164 | f"with the locked canvas ({locked.canonical})" |
| 165 | ) |
| 166 | |
| 167 | required = expected_viewbox if expected_viewbox is not None else format_viewbox |
| 168 | if expected_viewbox is not None: |
| 169 | expected_label = "the locked canvas" |
| 170 | elif canvas_format is not None: |
| 171 | expected_label = f"canvas format {canvas_format!r}" |
| 172 | else: |
| 173 | expected_label = "the first SVG canvas" |
| 174 | viewbox = require_consistent_project_viewboxes( |
| 175 | svg_files, |
| 176 | expected_viewbox=required, |
| 177 | expected_label=expected_label, |
| 178 | ) |
| 179 | detected_format = canvas_format |
| 180 | if detected_format is None: |
| 181 | for fmt_key, fmt_info in CANVAS_FORMATS.items(): |
| 182 | registered = parse_project_viewbox( |
| 183 | fmt_info['viewbox'], |
| 184 | context=f"registered canvas {fmt_key!r}", |
| 185 | ) |
| 186 | if viewbox == registered: |
| 187 | detected_format = fmt_key |
| 188 | break |
| 189 | return viewbox, detected_format |
| 190 |