返回 ppt-master
checker.py
1 #!/usr/bin/env python3
2 """PPT Master SVG quality-check implementation.
3
4 Owns SVG, project-contract, template, and report validation. The stable CLI and
5 compatibility import surface remain in ``scripts/svg_quality_checker.py``.
6
7 Usage:
8 Import through ``svg_quality_checker`` or invoke the stable script.
9
10 Examples:
11 from svg_quality_checker import SVGQualityChecker
12
13 Dependencies:
14 Standard library plus local PPT Master validation modules.
15 """
16
17 import copy
18 import hashlib
19 import html
20 import json
21 import math
22 import re
23 from pathlib import Path
24 from collections import Counter, defaultdict
25 from typing import Dict, List, Tuple
26 from urllib.parse import unquote, urlsplit
27 from xml.etree import ElementTree as ET
28
29 from native_payloads import NativePayloadError, hydrate_native_payload_refs
30 from slide_roster import discover_slide_svgs
31
32 from . import svg_contracts
33 from .xml_support import (
34 SVG_NS,
35 XLINK_NS,
36 element_label as _element_label,
37 local_name as _local_name,
38 )
39
40 try:
41 from project_utils import (
42 CANVAS_FORMATS,
43 validate_communication_trace,
44 )
45 except ImportError:
46 print("Warning: Unable to import project_utils")
47 CANVAS_FORMATS = {}
48 validate_communication_trace = None
49
50 from svg_to_pptx.canvas_contract import (
51 CanvasContractError,
52 parse_project_svg_root,
53 parse_project_viewbox,
54 )
55
56 try:
57 from project_management.project_specs import (
58 parse_spec_lock as _parse_spec_lock,
59 parse_spec_lock_image_value as _parse_spec_lock_image_value,
60 )
61 except ImportError:
62 _parse_spec_lock = None # spec_lock anchor comparison will be skipped
63 _parse_spec_lock_image_value = None
64
65 try:
66 from svg_to_pptx.animation_config import (
67 load_animation_config as _load_animation_config,
68 usable_animation_group_id as _usable_animation_group_id,
69 validate_animation_config as _validate_animation_config,
70 validate_animation_config_errors as _validate_animation_config_errors,
71 validate_transition_config as _validate_transition_config,
72 )
73 except ImportError as exc:
74 _load_animation_config = None
75 _validate_animation_config = None
76 _validate_animation_config_errors = None
77 _validate_transition_config = None
78 _animation_config_import_error = str(exc)
79
80 def _usable_animation_group_id(raw: str | None) -> str | None:
81 return raw if raw and raw.strip() else None
82 else:
83 _animation_config_import_error = None
84
85 try:
86 from svg_to_pptx.drawingml.utils import (
87 IDENTITY_MATRIX as _IDENTITY_MATRIX,
88 PROJECT_PAINT_PROPERTIES as _PAINT_PROPERTIES,
89 PROJECT_TEXT_IMAGE_FILL_ATTR as _TEXT_IMAGE_FILL_ATTR,
90 detect_text_lang as _detect_text_lang,
91 matrix_multiply as _matrix_multiply,
92 parse_inline_style as _parse_inline_style,
93 parse_project_geometry_length as _parse_project_geometry_length,
94 parse_project_image_aspect_ratio as _parse_project_image_aspect_ratio,
95 parse_project_opacity as _parse_project_opacity,
96 parse_svg_color as _parse_export_color,
97 parse_transform_matrix as _parse_transform_matrix,
98 project_mask_errors as _project_mask_errors,
99 rect_to_dml_xfrm as _rect_to_dml_xfrm,
100 transform_point as _transform_point,
101 unsafe_exported_font_faces as _unsafe_exported_font_faces,
102 validate_dml_shape_matrix as _validate_dml_shape_matrix,
103 )
104 except ImportError:
105 _IDENTITY_MATRIX = None
106 _PAINT_PROPERTIES = None
107 _TEXT_IMAGE_FILL_ATTR = 'data-pptx-text-image-fill'
108 _detect_text_lang = None
109 _matrix_multiply = None
110 _parse_inline_style = None
111 _parse_project_geometry_length = None
112 _parse_project_image_aspect_ratio = None
113 _parse_project_opacity = None
114 _parse_export_color = None
115 _parse_transform_matrix = None
116 _project_mask_errors = None
117 _rect_to_dml_xfrm = None
118 _transform_point = None
119 _unsafe_exported_font_faces = None
120 _validate_dml_shape_matrix = None
121
122 try:
123 from hyperlink_contract import (
124 SHAPE_HYPERLINK_ATTR as _SHAPE_HYPERLINK_ATTR,
125 project_hyperlink_errors as _project_hyperlink_errors,
126 )
127 except ImportError:
128 _SHAPE_HYPERLINK_ATTR = 'data-pptx-shape-hyperlink'
129 _project_hyperlink_errors = None
130
131 try:
132 from svg_to_pptx.drawingml.converter import (
133 SvgNativeConversionError as _SvgNativeConversionError,
134 collect_unsupported_visuals as _collect_unsupported_visuals,
135 preserved_native_text_body as _preserved_native_text_body,
136 )
137 except ImportError:
138 _SvgNativeConversionError = None
139 _collect_unsupported_visuals = None
140 _preserved_native_text_body = None
141
142 try:
143 from svg_to_pptx.drawingml.elements import (
144 drawingml_text_frame_width_emu as _drawingml_text_frame_width_emu,
145 estimate_single_line_text_frame_width as _estimate_single_line_text_frame_width,
146 project_image_errors as _project_image_errors,
147 validate_single_line_text_run_advances as _validate_single_line_text_run_advances,
148 validate_preset_geometry_metadata as _validate_preset_geometry_metadata,
149 )
150 except ImportError:
151 _drawingml_text_frame_width_emu = None
152 _estimate_single_line_text_frame_width = None
153 _project_image_errors = None
154 _validate_single_line_text_run_advances = None
155 _validate_preset_geometry_metadata = None
156
157 try:
158 from svg_to_pptx.drawingml.text_properties import (
159 normalize_project_text_segments as _normalize_project_text_segments,
160 parse_project_font_weight as _parse_project_font_weight,
161 parse_project_text_anchor as _parse_project_text_anchor,
162 resolve_project_xml_space as _resolve_project_xml_space,
163 resolve_project_font_sizes as _resolve_project_font_sizes,
164 resolve_project_letter_spacings as _resolve_project_letter_spacings,
165 )
166 except ImportError:
167 _normalize_project_text_segments = None
168 _parse_project_font_weight = None
169 _parse_project_text_anchor = None
170 _resolve_project_xml_space = None
171 _resolve_project_font_sizes = None
172 _resolve_project_letter_spacings = None
173
174 try:
175 from pptx_to_svg.preset_authoring import (
176 AUTHORING_ATTR as _AUTHORING_ATTR,
177 authored_preset_encoding as _authored_preset_encoding,
178 validate_authored_preset_group as _validate_authored_preset_group,
179 validate_authored_preset_tree as _validate_authored_preset_tree,
180 )
181 except ImportError:
182 _AUTHORING_ATTR = 'data-pptx-authoring'
183 _authored_preset_encoding = None
184 _validate_authored_preset_group = None
185 _validate_authored_preset_tree = None
186
187 try:
188 from pptx_shapes import (
189 CONNECTOR_PRESET_TYPES as _CONNECTOR_PRESET_TYPES,
190 resolve_preset_preview_hash as _resolve_preset_preview_hash,
191 svg_preset_preview_fingerprint as _svg_preset_preview_fingerprint,
192 )
193 except ImportError:
194 _CONNECTOR_PRESET_TYPES = frozenset()
195 _resolve_preset_preview_hash = None
196 _svg_preset_preview_fingerprint = None
197
198 try:
199 from svg_to_pptx.native_objects import (
200 validate_native_object_marker as _validate_native_object_marker,
201 )
202 except ImportError:
203 _validate_native_object_marker = None
204
205 try:
206 from svg_to_pptx.native_objects import (
207 validate_native_object_marker_with_warnings as _validate_native_object_marker_with_warnings,
208 )
209 except ImportError:
210 _validate_native_object_marker_with_warnings = None
211
212 try:
213 from svg_to_pptx.native_objects import (
214 native_object_marker_warnings as _native_object_marker_warnings,
215 )
216 except ImportError:
217 _native_object_marker_warnings = None
218
219 try:
220 from svg_to_pptx.native_objects import (
221 INLINE_FORMULA_ATTR as _INLINE_FORMULA_ATTR,
222 estimate_inline_formula_vertical_extent as _estimate_inline_formula_vertical_extent,
223 native_fallback_kind as _native_fallback_kind,
224 inline_formula_marker_errors as _inline_formula_marker_errors,
225 native_marker_legacy_warnings as _native_marker_legacy_warnings,
226 native_replacement_kind as _native_replacement_kind,
227 native_replacement_status as _native_replacement_status,
228 )
229 except ImportError:
230 _INLINE_FORMULA_ATTR = 'data-pptx-inline-formula'
231 _estimate_inline_formula_vertical_extent = None
232 _native_fallback_kind = None
233 _inline_formula_marker_errors = None
234 _native_marker_legacy_warnings = None
235 _native_replacement_kind = None
236 _native_replacement_status = None
237
238 try:
239 from svg_to_pptx.native_objects.marker_status import (
240 native_marker_release_block_reason as _native_marker_release_block_reason,
241 native_marker_status_errors as _native_marker_status_errors,
242 )
243 except ImportError:
244 _native_marker_release_block_reason = None
245 _native_marker_status_errors = None
246
247 try:
248 from svg_to_pptx.semantic_markers import (
249 SEMANTIC_ATTRS as _SEMANTIC_ATTRS,
250 is_static_page_frame as _is_static_page_frame,
251 validate_semantic_markers as _validate_semantic_markers,
252 )
253 except ImportError:
254 _SEMANTIC_ATTRS = frozenset({
255 'data-pptx-page-role',
256 'data-pptx-role',
257 })
258 _is_static_page_frame = None
259 _validate_semantic_markers = None
260
261 try:
262 from svg_to_pptx.use_expander import (
263 UseExpansionError as _UseExpansionError,
264 expand_local_use_references as _expand_local_use_references,
265 )
266 except ImportError:
267 _UseExpansionError = None
268 _expand_local_use_references = None
269
270 try:
271 from svg_to_pptx.tspan_flattener import (
272 flatten_positional_tspans as _flatten_positional_tspans,
273 nested_positional_tspan_errors as _nested_positional_tspan_errors,
274 )
275 except ImportError:
276 _flatten_positional_tspans = None
277 _nested_positional_tspan_errors = None
278
279 try:
280 from svg_to_pptx.pptx_package.template_structure import (
281 TemplateStructureError as _TemplateStructureError,
282 _is_authored_preset_atom as _is_authored_preset_atom,
283 load_pptx_structure_lock as _load_pptx_structure_lock,
284 parse_template_slide as _parse_template_structure_slide,
285 parse_template_slides as _parse_template_structure_slides,
286 _structure_subtree_signature as _structure_subtree_signature,
287 template_lock_errors as _template_lock_errors,
288 template_prototype_errors as _template_prototype_errors,
289 validate_template_svg as _validate_template_structure_svg,
290 )
291 except ImportError:
292 _TemplateStructureError = None
293 _is_authored_preset_atom = None
294 _load_pptx_structure_lock = None
295 _parse_template_structure_slide = None
296 _parse_template_structure_slides = None
297 _structure_subtree_signature = None
298 _template_lock_errors = None
299 _template_prototype_errors = None
300 _validate_template_structure_svg = None
301
302 try:
303 from svg_to_pptx.drawingml.theme_colors import (
304 ThemeColorError as _ThemeColorError,
305 load_theme_color_spec as _load_theme_color_spec,
306 )
307 from svg_to_pptx.drawingml.theme_fonts import (
308 ThemeFontError as _ThemeFontError,
309 load_master_text_style_spec as _load_master_text_style_spec,
310 load_theme_font_spec as _load_theme_font_spec,
311 )
312 except ImportError:
313 _ThemeColorError = None
314 _ThemeFontError = None
315 _load_theme_color_spec = None
316 _load_master_text_style_spec = None
317 _load_theme_font_spec = None
318
319 try:
320 from svg_finalize.embed_icons import (
321 resolve_icon_path as _resolve_icon_path,
322 suggest_icon_name as _suggest_icon_name,
323 )
324 except ImportError:
325 _resolve_icon_path = None
326 _suggest_icon_name = None
327
328 try:
329 from resource_paths import (
330 SVG_WORK_DIR_NAMES as _SVG_WORK_DIR_NAMES,
331 icon_search_dirs_for_svg as _icon_search_dirs_for_svg,
332 project_root_for_svg_path as _project_root_for_svg_path,
333 resolve_external_image_reference as _resolve_external_image_reference,
334 )
335 except ImportError:
336 _SVG_WORK_DIR_NAMES = frozenset()
337 _icon_search_dirs_for_svg = None
338 _project_root_for_svg_path = None
339 _resolve_external_image_reference = None
340
341
342 HEX_VALUE_RE = re.compile(
343 r"#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})"
344 )
345
346 # Master/Layout preflight validation. Structured deck/layout-template projects
347 # are checked at authoring time; the exporter remains the final OOXML/package
348 # authority. Flat projects only receive the negative guard that rejects authored
349 # structure metadata. Template roster/placeholder checks always run. Current
350 # bundled templates opt in to complete structure validation through their
351 # native_structure_mode: structured declaration. Legacy template-mode packages
352 # fail closed; Create Template must author a new current-contract workspace.
353 _CHECK_PPTX_STRUCTURED_PROJECT = True
354
355 _BARE_HEX_VALUE_RE = re.compile(
356 r"(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})"
357 )
358 _NON_VISUAL_SVG_TAGS = frozenset({
359 'defs',
360 'desc',
361 'metadata',
362 'style',
363 'title',
364 })
365 _BOUNDS_ATTR = 'data-pptx-bounds'
366 _MORPH_STAGING_ATTR = 'data-pptx-morph-staging'
367 _BOUNDS_OVERFLOW_TOLERANCE = 1.0
368 _BOUNDS_OVERFLOW_ERROR_RATIO = 0.05
369 _PARAGRAPH_LINE_GAP_MIN_RATIO = 0.9
370 _PARAGRAPH_LINE_GAP_MAX_RATIO = 2.05
371 _PARAGRAPH_LINE_X_TOLERANCE = 0.5
372 _PARAGRAPH_LINE_MIN_TOTAL_CHARS = 12
373 _PARAGRAPH_LINE_MIN_LONGEST_CHARS = 8
374 _PARAGRAPH_LINE_TERMINATOR_RE = re.compile(r'[.!?。!?;;]["\'”’))]*$')
375 _PARAGRAPH_LIST_MARKER_RE = re.compile(
376 r'^\s*(?:[•·・▪◦‣]\s*|[-–—*]\s+|\d+[.)、]\s+|[((]\d+[))]\s*)\S+'
377 )
378 _LEGACY_PPTX_ATTRIBUTE_RENAMES = {
379 'data-pptx-module-bounds': _BOUNDS_ATTR,
380 'data-pptx-placeholder-bounds': _BOUNDS_ATTR,
381 'data-pptx-placeholder-carrier': 'data-pptx-carrier',
382 'data-pptx-placeholder-binding': 'data-pptx-binding',
383 'data-pptx-placeholder-idx': 'data-pptx-idx',
384 }
385 _PPTX_ROOT_STRUCTURE_ATTRS = (
386 'data-pptx-master',
387 'data-pptx-master-name',
388 'data-pptx-layout',
389 'data-pptx-layout-name',
390 )
391 _PPTX_ROOT_VISIBILITY_ATTRS = (
392 'data-pptx-show-master-shapes',
393 'data-pptx-show-inherited-shapes',
394 )
395 _PPTX_STRUCTURE_ATTRS = frozenset({
396 *_PPTX_ROOT_STRUCTURE_ATTRS,
397 *_PPTX_ROOT_VISIBILITY_ATTRS,
398 'data-pptx-layer',
399 'data-pptx-layout-kind',
400 'data-pptx-placeholder',
401 'data-pptx-binding',
402 'data-pptx-carrier',
403 'data-pptx-idx',
404 })
405 _PPTX_PLACEHOLDER_DETAIL_ATTRS = frozenset({
406 'data-pptx-binding',
407 'data-pptx-idx',
408 })
409 _PPTX_STRUCTURE_SECTION_RE = re.compile(
410 r"(?ms)^##[ \t]+pptx_structure[ \t]*\r?\n(.*?)(?=^##[ \t]+|\Z)"
411 )
412 _PPTX_STRUCTURE_MODE_RE = re.compile(
413 r"(?m)^-[ \t]+mode[ \t]*:[ \t]*([^\s#]+)[ \t]*(?:#.*)?$"
414 )
415 def _compact_preset_ancestor_paint(
416 root: ET.Element,
417 ) -> list[tuple[str, tuple[str, ...]]]:
418 """Return compact presets affected by compatible ancestor paint."""
419 if (
420 _authored_preset_encoding is None
421 or _validate_authored_preset_group is None
422 ):
423 return []
424 parents = {
425 child: parent
426 for parent in root.iter()
427 for child in parent
428 }
429 affected: list[tuple[str, tuple[str, ...]]] = []
430 for group in root.iter():
431 if (
432 _authored_preset_encoding(group) != 'compact'
433 or _validate_authored_preset_group(group)
434 ):
435 continue
436 relevant = {'opacity'}
437 if group.get('fill') != 'none' and group.get('fill-opacity') is None:
438 relevant.add('fill-opacity')
439 if group.get('stroke') != 'none':
440 for name in (
441 'stroke-opacity',
442 'stroke-dasharray',
443 'stroke-linecap',
444 'stroke-linejoin',
445 ):
446 if group.get(name) is None:
447 relevant.add(name)
448
449 inherited: set[str] = set()
450 ancestor = parents.get(group)
451 while ancestor is not None:
452 declarations = {
453 name: ancestor.get(name) or ''
454 for name in relevant
455 if ancestor.get(name) is not None
456 }
457 for declaration in (ancestor.get('style') or '').split(';'):
458 name, separator, value = declaration.partition(':')
459 name = name.strip().lower()
460 if separator and name in relevant:
461 declarations[name] = value.strip()
462 for name, value in declarations.items():
463 normalized = value.strip().lower()
464 if name in {'opacity', 'fill-opacity', 'stroke-opacity'}:
465 try:
466 if float(normalized) == 1:
467 continue
468 except ValueError:
469 pass
470 elif name == 'stroke-dasharray' and normalized == 'none':
471 continue
472 elif name == 'stroke-linecap' and normalized == 'butt':
473 continue
474 elif name == 'stroke-linejoin' and normalized == 'miter':
475 continue
476 inherited.add(name)
477 ancestor = parents.get(ancestor)
478 if inherited:
479 affected.append((
480 group.get('id') or '(no id)',
481 tuple(sorted(inherited)),
482 ))
483 return affected
484
485
486 def _declared_pptx_structure_mode(project_path: Path) -> str | None:
487 """Return the explicitly locked SVG structure mode without a fallback."""
488 lock_path = project_path / 'spec_lock.md'
489 try:
490 content = lock_path.read_text(encoding='utf-8')
491 except OSError:
492 return None
493 section_match = _PPTX_STRUCTURE_SECTION_RE.search(content)
494 if section_match is None:
495 return None
496 mode_match = _PPTX_STRUCTURE_MODE_RE.search(section_match.group(1))
497 return mode_match.group(1).strip().lower() if mode_match else None
498
499
500 def _generated_theme_contract_errors(project_path: Path) -> List[str]:
501 """Validate the current-project theme contract required by release export."""
502 if (
503 _ThemeColorError is None
504 or _ThemeFontError is None
505 or _load_theme_color_spec is None
506 or _load_master_text_style_spec is None
507 or _load_theme_font_spec is None
508 ):
509 return [
510 "PowerPoint theme contract validation is unavailable because the "
511 "theme loader modules could not be imported."
512 ]
513 try:
514 theme_font_spec = _load_theme_font_spec(project_path)
515 _load_master_text_style_spec(project_path)
516 theme_color_spec = _load_theme_color_spec(project_path)
517 except (_ThemeFontError, _ThemeColorError) as exc:
518 return [str(exc)]
519
520 missing: List[str] = []
521 if theme_font_spec is None:
522 missing.append("typography font_family/title_family/body_family")
523 if theme_color_spec is None:
524 missing.append("colors")
525 if not missing:
526 return []
527 return [
528 "spec_lock.md generated PowerPoint theme contract is missing: "
529 + ", ".join(missing)
530 ]
531
532
533 def _parse_positive_bounds(
534 value: str,
535 ) -> Tuple[float, float, float, float]:
536 """Parse one positive x/y/width/height boundary."""
537 raw_values = [item for item in re.split(r"[\s,]+", value.strip()) if item]
538 if len(raw_values) != 4:
539 raise ValueError("must contain exactly four numbers: x y width height")
540 try:
541 values = tuple(float(item) for item in raw_values)
542 except ValueError as exc:
543 raise ValueError("must contain only numeric values") from exc
544 if not all(math.isfinite(item) for item in values):
545 raise ValueError("must contain only finite values")
546 if values[2] <= 0 or values[3] <= 0:
547 raise ValueError("must use positive width and height")
548 return values
549
550
551 def _placeholder_bounds_error(value: str) -> str | None:
552 """Return a concise error for invalid design-zone bounds."""
553 try:
554 _parse_positive_bounds(value)
555 except ValueError as exc:
556 return str(exc)
557 return None
558
559
560 def _local_pptx_structure_errors(
561 root: ET.Element,
562 svg_path: Path,
563 *,
564 require_structure: bool,
565 ) -> List[str]:
566 """Validate the authoring shape of the structured SVG contract."""
567 errors: List[str] = []
568 root_values = {
569 attr: (root.get(attr) or '').strip()
570 for attr in _PPTX_ROOT_STRUCTURE_ATTRS
571 }
572 has_root_structure = any(root_values.values())
573 if require_structure or has_root_structure:
574 missing = [attr for attr, value in root_values.items() if not value]
575 if missing:
576 errors.append(
577 f"{svg_path.name}: structured SVG root is missing "
578 + ', '.join(missing)
579 )
580 for attr in _PPTX_ROOT_VISIBILITY_ATTRS:
581 raw = root.get(attr)
582 if raw is not None and raw not in {'true', 'false'}:
583 errors.append(
584 f"{svg_path.name}: root {attr} must be exactly 'true' or 'false'"
585 )
586
587 parent_by_id = {
588 id(child): parent
589 for parent in root.iter()
590 for child in list(parent)
591 }
592 for elem in root.iter():
593 tag = elem.tag.rsplit('}', 1)[-1]
594 element_id = elem.get('id') or f"<{tag}>"
595 parent = parent_by_id.get(id(elem))
596
597 if elem is not root:
598 nested_root_attrs = [
599 attr for attr in (
600 *_PPTX_ROOT_STRUCTURE_ATTRS,
601 *_PPTX_ROOT_VISIBILITY_ATTRS,
602 )
603 if elem.get(attr) is not None
604 ]
605 if nested_root_attrs:
606 errors.append(
607 f"{svg_path.name}: {element_id} carries root-only metadata "
608 + ', '.join(nested_root_attrs)
609 )
610
611 if elem.get('data-pptx-layout-kind') is not None:
612 errors.append(
613 f"{svg_path.name}: data-pptx-layout-kind is a legacy distillation "
614 "attribute; restore the page to the structured contract"
615 )
616
617 layer = (elem.get('data-pptx-layer') or '').strip().lower()
618 placeholder = (elem.get('data-pptx-placeholder') or '').strip().lower()
619 if layer in {'master', 'layout'}:
620 if parent is not root:
621 errors.append(
622 f"{svg_path.name}: {element_id} data-pptx-layer={layer!r} "
623 "must be a direct child of the root <svg>"
624 )
625 if tag == 'g' and not (
626 _is_authored_preset_atom is not None
627 and _is_authored_preset_atom(elem)
628 ):
629 errors.append(
630 f"{svg_path.name}: {element_id} is a <g> marked as {layer}; "
631 "Master/Layout fixed visuals must be root-level atomic elements"
632 )
633 if placeholder:
634 errors.append(
635 f"{svg_path.name}: {element_id} cannot be both a fixed "
636 f"{layer} element and a placeholder slot"
637 )
638
639 detail_attrs = [
640 attr for attr in _PPTX_PLACEHOLDER_DETAIL_ATTRS
641 if elem.get(attr) is not None
642 ]
643 if detail_attrs and not placeholder:
644 errors.append(
645 f"{svg_path.name}: {element_id} uses placeholder detail metadata "
646 "without data-pptx-placeholder"
647 )
648
649 if placeholder:
650 if parent is not root:
651 errors.append(
652 f"{svg_path.name}: placeholder slot {element_id} must be a "
653 "direct child of the root <svg>"
654 )
655 if tag != 'g':
656 errors.append(
657 f"{svg_path.name}: placeholder slot {element_id} must be a "
658 "root-level <g>"
659 )
660 if not (elem.get('id') or '').strip():
661 errors.append(
662 f"{svg_path.name}: every placeholder slot <g> requires a stable id"
663 )
664 wrapper_attrs = sorted(
665 attr.rsplit('}', 1)[-1]
666 for attr in elem.attrib
667 if attr != 'id'
668 and not attr.rsplit('}', 1)[-1].startswith('data-pptx-')
669 )
670 if wrapper_attrs:
671 errors.append(
672 f"{svg_path.name}: placeholder slot {element_id} is an "
673 "authoring boundary and may carry only id/data-pptx-*; remove "
674 + ', '.join(wrapper_attrs)
675 )
676 bounds = (elem.get('data-pptx-bounds') or '').strip()
677 if not bounds:
678 errors.append(
679 f"{svg_path.name}: placeholder slot {element_id} requires "
680 "data-pptx-bounds"
681 )
682 else:
683 bounds_error = _placeholder_bounds_error(bounds)
684 if bounds_error:
685 errors.append(
686 f"{svg_path.name}: placeholder slot {element_id} bounds "
687 + bounds_error
688 )
689
690 binding = (
691 elem.get('data-pptx-binding') or 'carrier'
692 ).strip().lower()
693 if binding not in {'carrier', 'proxy'}:
694 errors.append(
695 f"{svg_path.name}: placeholder slot {element_id} has unknown "
696 f"binding {binding!r}; use carrier or proxy"
697 )
698 carrier_descendants = [
699 child for child in elem.iter()
700 if child is not elem
701 and child.get('data-pptx-carrier') is not None
702 ]
703 visual_children = [
704 child for child in list(elem)
705 if child.tag.rsplit('}', 1)[-1] not in _NON_VISUAL_SVG_TAGS
706 ]
707 direct_carriers = [
708 child for child in visual_children
709 if (child.get('data-pptx-carrier') or '').strip().lower()
710 == 'true'
711 ]
712 nested_carriers = [
713 child for child in carrier_descendants
714 if parent_by_id.get(id(child)) is not elem
715 ]
716 if nested_carriers:
717 names = ', '.join(
718 child.get('id') or f"<{child.tag.rsplit('}', 1)[-1]}>"
719 for child in nested_carriers
720 )
721 errors.append(
722 f"{svg_path.name}: placeholder slot {element_id} has nested "
723 f"carrier marker(s): {names}; the carrier must be a direct child"
724 )
725 if binding == 'carrier':
726 if len(visual_children) != 1 or len(direct_carriers) != 1:
727 errors.append(
728 f"{svg_path.name}: placeholder slot {element_id} requires "
729 "exactly one visual direct child, marked "
730 "data-pptx-carrier=\"true\""
731 )
732 if binding == 'proxy':
733 if placeholder != 'object':
734 errors.append(
735 f"{svg_path.name}: proxy binding is allowed only for an "
736 f"object placeholder, not {placeholder!r}"
737 )
738 if carrier_descendants:
739 errors.append(
740 f"{svg_path.name}: proxy placeholder slot {element_id} must "
741 "not declare a visible placeholder carrier"
742 )
743 if not visual_children:
744 errors.append(
745 f"{svg_path.name}: proxy placeholder slot {element_id} must "
746 "contain visible Slide-local content"
747 )
748
749 carrier_value = elem.get('data-pptx-carrier')
750 if carrier_value is not None:
751 if carrier_value.strip().lower() != 'true':
752 errors.append(
753 f"{svg_path.name}: {element_id} "
754 "data-pptx-carrier must equal true"
755 )
756 if parent is None or not (
757 parent.get('data-pptx-placeholder') or ''
758 ).strip():
759 errors.append(
760 f"{svg_path.name}: placeholder carrier {element_id} must be a "
761 "direct child of a root placeholder slot"
762 )
763
764 if tag in _NON_VISUAL_SVG_TAGS and (layer or placeholder):
765 errors.append(
766 f"{svg_path.name}: non-visual {element_id} cannot carry "
767 "Master/Layout/placeholder ownership"
768 )
769
770 return list(dict.fromkeys(errors))
771
772
773 def _normalize_hex_rgb(value: str) -> str | None:
774 """Normalize 3/4/6/8-digit HEX to alpha-free ``RRGGBB``."""
775 if not HEX_VALUE_RE.fullmatch(value):
776 return None
777 color = value[1:]
778 if len(color) in {3, 4}:
779 color = ''.join(channel * 2 for channel in color)
780 return color[:6].upper()
781
782
783 # Cheap numeric envelope for font-size role enforcement. Semantic role assignment
784 # is prompt-owned; Checker only verifies that a used value is close to at least
785 # one declared size anchor.
786 FONT_SIZE_ANCHOR_TOLERANCE_PX = 2.0
787 SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES = 2
788
789 # Oversampling alone does not imply distortion and is often harmless for small
790 # logos. Warn about downscaling only when the source also has material on-disk
791 # weight, because PPTX embeds the compressed source asset rather than raw pixels.
792 IMAGE_DOWNSIZE_WARN_RATIO = 4.0
793 IMAGE_DOWNSIZE_WARN_MIN_BYTES = 1024 * 1024
794
795 _TEMPLATE_SPEC_NAME_RE = re.compile(
796 r'design_spec\.(?P<kind>brand|style|layout|deck)\.(?P<id>[^/\\]+)\.md'
797 )
798
799
800 def _template_spec_paths(directory: Path) -> list[Path]:
801 """Return every template Design Spec directly inside one directory.
802
803 A library workspace keeps the exact ``design_spec.md`` because its parent
804 directory already names the kind and id. A project workspace root shares one
805 ``templates/`` across kinds, so it keeps ``design_spec.<kind>.<id>.md`` and
806 may hold one spec per kind side by side.
807 """
808 if not directory.is_dir():
809 return []
810 exact = directory / 'design_spec.md'
811 qualified = sorted(
812 path
813 for path in directory.glob('design_spec.*.md')
814 if _TEMPLATE_SPEC_NAME_RE.fullmatch(path.name)
815 )
816 if exact.is_file():
817 # Mixing both shapes hides one of them from every reader that stops at
818 # the first match, so it is reported rather than silently resolved.
819 return [exact] + qualified
820 return qualified
821
822
823 def _spec_declared_kind(spec_path: Path) -> str | None:
824 """Return one spec's kind, from its filename when it carries one."""
825 match = _TEMPLATE_SPEC_NAME_RE.fullmatch(spec_path.name)
826 if match is not None:
827 return match.group('kind')
828 return _design_spec_kind(spec_path)
829
830
831 def _roster_spec_paths(directory: Path) -> list[Path]:
832 """Return every spec in one directory that owns an SVG roster."""
833 roster = []
834 for spec in _template_spec_paths(directory):
835 match = _TEMPLATE_SPEC_NAME_RE.fullmatch(spec.name)
836 if match is not None:
837 if match.group('kind') in {'layout', 'deck'}:
838 roster.append(spec)
839 elif _design_spec_kind(spec) not in {'brand', 'style'}:
840 roster.append(spec)
841 return roster
842
843
844 def _roster_spec_path(directory: Path) -> Path | None:
845 """Return the effective spec that owns this directory's SVG roster.
846
847 A project root may carry both Layout and Deck. Layout owns reusable
848 structure when present; Deck owns it only when no Layout is installed.
849 """
850 roster = _roster_spec_paths(directory)
851 for spec in roster:
852 if spec.name == 'design_spec.md':
853 return spec
854 for kind in ('layout', 'deck'):
855 for spec in roster:
856 if _spec_declared_kind(spec) == kind:
857 return spec
858 return None
859
860
861 def _design_spec_kind(spec_path: Path) -> str | None:
862 """Return ``kind`` declared in Design Spec frontmatter.
863
864 Lightweight detector that does not require PyYAML — scans only the
865 frontmatter block (``---`` delimited).
866 """
867 try:
868 text = spec_path.read_text(encoding='utf-8')
869 except OSError:
870 return None
871 if not text.startswith('---\n'):
872 return None
873 end = text.find('\n---\n', 4)
874 if end == -1:
875 return None
876 fm_block = text[4:end]
877 for line in fm_block.splitlines():
878 stripped = line.strip()
879 match = re.fullmatch(
880 r'''kind\s*:\s*(?:(['"])(brand|style|layout|deck)\1|'''
881 r'''(brand|style|layout|deck))'''
882 r'''(?:\s+#.*)?\s*''',
883 stripped,
884 )
885 if match:
886 return match.group(2) or match.group(3)
887 return None
888
889
890 def _declared_template_structure_mode(target_path: Path) -> str | None:
891 """Return a template directory's explicit native structure mode."""
892 directory = target_path.parent if target_path.is_file() else target_path
893 spec_path = _roster_spec_path(directory)
894 if spec_path is None:
895 return None
896 try:
897 text = spec_path.read_text(encoding='utf-8')
898 except OSError:
899 return None
900 if not text.startswith('---\n'):
901 return None
902 end = text.find('\n---\n', 4)
903 if end == -1:
904 return None
905 match = re.search(
906 r'^native_structure_mode:\s*([A-Za-z0-9_-]+)\s*$',
907 text[4:end],
908 re.MULTILINE,
909 )
910 return match.group(1).lower() if match else None
911
912
913 def _declared_template_canvas_viewbox(target_path: Path) -> str | None:
914 """Return a template design spec's locked root-canvas value."""
915 directory = target_path.parent if target_path.is_file() else target_path
916 spec_path = _roster_spec_path(directory)
917 if spec_path is None:
918 return None
919 try:
920 text = spec_path.read_text(encoding='utf-8')
921 except OSError:
922 return None
923 if not text.startswith('---\n'):
924 return None
925 end = text.find('\n---\n', 4)
926 if end == -1:
927 return None
928 match = re.search(
929 r'^canvas_viewbox:\s*["\']?([^"\'\r\n]+?)["\']?\s*$',
930 text[4:end],
931 re.MULTILINE,
932 )
933 return match.group(1).strip() if match else None
934
935
936 def _template_structure_checks_enabled(target_path: Path) -> bool:
937 """Return whether positive structure checks apply to this template."""
938 return _declared_template_structure_mode(target_path) == 'structured'
939
940
941 def _direct_defs_index(
942 root: ET.Element,
943 ) -> tuple[Dict[str, ET.Element], set[str]]:
944 """Return direct ``<defs>`` children by id plus duplicate ids."""
945 definitions: Dict[str, ET.Element] = {}
946 duplicates: set[str] = set()
947 for defs_elem in root.iter():
948 if _local_name(defs_elem) != 'defs':
949 continue
950 for child in defs_elem:
951 definition_id = (child.get('id') or '').strip()
952 if not definition_id:
953 continue
954 if definition_id in definitions:
955 duplicates.add(definition_id)
956 definitions[definition_id] = child
957 return definitions, duplicates
958
959
960 def _effective_presentation_value(
961 elem: ET.Element,
962 name: str,
963 parent_by_id: Dict[int, ET.Element],
964 ) -> str | None:
965 """Resolve one inherited presentation property for validation."""
966 current: ET.Element | None = elem
967 while current is not None:
968 style_values = (
969 _parse_inline_style(current.get('style'))
970 if _parse_inline_style is not None else {}
971 )
972 if name in style_values:
973 return style_values[name]
974 direct = current.get(name)
975 if direct is not None:
976 return direct
977 current = parent_by_id.get(id(current))
978 return None
979
980
981 def _parse_viewbox_values(viewbox: str) -> Tuple[float, float, float, float] | None:
982 """Parse a root viewBox into four numeric values."""
983 try:
984 parsed = parse_project_viewbox(viewbox)
985 except CanvasContractError:
986 return None
987 return 0.0, 0.0, float(parsed.width), float(parsed.height)
988
989
990 def _parse_placeholders_fallback(block: str) -> Dict[str, Tuple[str, ...]]:
991 """Tiny YAML-free reader for the documented ``placeholders:`` shape.
992
993 Used only when PyYAML is unavailable. Recognized lines (indentation-aware,
994 two-space indent assumed):
995
996 .. code-block:: yaml
997
998 placeholders:
999 01_cover: ["{{TITLE}}", "{{LOGO}}"]
1000 03_content: []
1001 03a_content_two_col:
1002 - "{{LEFT_TITLE}}"
1003 - "{{RIGHT_TITLE}}"
1004
1005 Anything outside this minimal grammar is silently skipped — designers who
1006 rely on advanced YAML should install pyyaml.
1007 """
1008 out: Dict[str, Tuple[str, ...]] = {}
1009 inline_re = re.compile(
1010 r"^\s{2}([A-Za-z0-9_]+)\s*:\s*\[(.*)\]\s*$"
1011 )
1012 empty_re = re.compile(r"^\s{2}([A-Za-z0-9_]+)\s*:\s*\[\s*\]\s*$")
1013 block_header_re = re.compile(r"^\s{2}([A-Za-z0-9_]+)\s*:\s*$")
1014 item_re = re.compile(r'^\s{4}-\s*"?([^"]+)"?\s*$')
1015
1016 in_section = False
1017 current_block_key: str | None = None
1018 current_items: List[str] = []
1019
1020 def _flush_block() -> None:
1021 nonlocal current_block_key, current_items
1022 if current_block_key is not None:
1023 out[current_block_key] = tuple(current_items)
1024 current_block_key = None
1025 current_items = []
1026
1027 for line in block.splitlines():
1028 if line.startswith("placeholders:"):
1029 in_section = True
1030 continue
1031 if not in_section:
1032 continue
1033
1034 # End of section: dedent to a non-key line.
1035 if line and not line.startswith(" "):
1036 _flush_block()
1037 in_section = False
1038 continue
1039
1040 if current_block_key is not None:
1041 m = item_re.match(line)
1042 if m:
1043 value = m.group(1).strip().strip('"').strip("'")
1044 if value:
1045 current_items.append(value)
1046 continue
1047 # Block ended.
1048 _flush_block()
1049
1050 if empty_re.match(line):
1051 key = empty_re.match(line).group(1)
1052 out[key] = ()
1053 continue
1054
1055 m = inline_re.match(line)
1056 if m:
1057 key, raw = m.group(1), m.group(2)
1058 items = [p.strip().strip('"').strip("'") for p in raw.split(",")]
1059 out[key] = tuple(item for item in items if item)
1060 continue
1061
1062 m = block_header_re.match(line)
1063 if m:
1064 current_block_key = m.group(1)
1065 current_items = []
1066 continue
1067
1068 _flush_block()
1069 return out
1070
1071
1072 class SVGQualityChecker:
1073 """SVG quality checker"""
1074
1075 # Default placeholder convention per page-type prefix. This is a *hint*,
1076 # not a hard contract: templates may define their own placeholder vocabulary
1077 # via `placeholders:` in design_spec.md frontmatter (see
1078 # references/template-designer.md §4). Missing default placeholders surface
1079 # as warnings, never errors — designers may legitimately swap
1080 # `{{THANK_YOU}}` for `{{CLOSING_MESSAGE}}`, omit `{{DATE}}` when irrelevant,
1081 # or build content variants with bespoke slot vocabularies.
1082 #
1083 # Variants reuse the parent type's expectation (`03a_content_two_col.svg`
1084 # is matched by the same `content` rules as `03_content.svg`).
1085 #
1086 # Keys are page-type tokens, not numbered stems: template numbering is
1087 # presentation order within one template and shifts when the optional
1088 # TOC page is present (`02_chapter` in a four-page roster, `03_chapter`
1089 # in a five-page roster with `02_toc`), so the defaults must apply to
1090 # both spellings.
1091 DEFAULT_PLACEHOLDER_CONVENTION = {
1092 "cover": ("{{TITLE}}",), # only the title is universally expected
1093 "chapter": ("{{CHAPTER_TITLE}}",),
1094 "toc": (), # TOC layouts vary too widely to assert anything
1095 "content": ("{{PAGE_TITLE}}",),
1096 "ending": (), # ending pages legitimately use varied vocabularies
1097 }
1098
1099 def __init__(
1100 self,
1101 *,
1102 template_mode: bool = False,
1103 quick_generate: bool = False,
1104 ):
1105 self.template_mode = template_mode
1106 self.quick_generate = quick_generate
1107 self.results = []
1108 self.summary = {
1109 'total': 0,
1110 'passed': 0,
1111 'warnings': 0,
1112 'errors': 0
1113 }
1114 self.issue_types = defaultdict(int)
1115 # spec_lock anchor comparison state (populated only when
1116 # _parse_spec_lock is available and a spec_lock.md is found near the SVG)
1117 self._lock_cache: Dict[Path, Dict] = {}
1118 self._anchor_value_summary: Dict[str, Dict[str, set]] = {
1119 'colors': defaultdict(set),
1120 'fonts': defaultdict(set),
1121 'sizes': defaultdict(set),
1122 }
1123 self._undeclared_size_occurrences: Counter[str] = Counter()
1124 self._undeclared_size_counts_ready = False
1125 self._lock_seen = False # True once we locate at least one spec_lock.md
1126 self._source_manifest_cache: Dict[
1127 Path,
1128 Tuple[Dict, str | None],
1129 ] = {}
1130 self._source_manifest_errors_reported: set[Path] = set()
1131 # Template-mode aggregation (populated by check_directory when
1132 # template_mode=True). Each entry is (severity, kind, message) where
1133 # severity is 'error' or 'warning'. Printed in print_summary.
1134 self._template_issues: List[Tuple[str, str, str]] = []
1135 self._spec_only_template_kind: str | None = None
1136 self._animation_issues: List[Tuple[str, str]] = []
1137 self._illustration_issues: List[Tuple[str, str, str]] = []
1138 self._communication_trace_issues: List[Tuple[str, str]] = []
1139 self._pptx_structure_issues: List[Tuple[str, str]] = []
1140 self._has_incomplete_page_roster = False
1141 self._active_slide_count: int | None = None
1142 self._prototype_by_output: Dict[Path, Path] = {}
1143 self._active_prototype_path: Path | None = None
1144 self._active_template_reuse_scope: str | None = None
1145 self._prototype_root_cache: Dict[Path, ET.Element | None] = {}
1146 self._source_import_summary: Dict[str, object] = {
1147 'warning_count': 0,
1148 'by_code': {},
1149 }
1150 self._aggregate_counts_applied = False
1151
1152 @staticmethod
1153 def _append_inherited_info(
1154 result: Dict,
1155 kind: str,
1156 message: str,
1157 ) -> None:
1158 """Record prototype-owned diagnostics outside the warning channel."""
1159 result['info'].setdefault('inherited', []).append({
1160 'kind': kind,
1161 'message': message,
1162 })
1163
1164 def _active_prototype_root(self) -> ET.Element | None:
1165 """Parse the selected mirror prototype once for inherited checks."""
1166 if (
1167 self._active_template_reuse_scope != 'mirror'
1168 or self._active_prototype_path is None
1169 ):
1170 return None
1171 path = self._active_prototype_path.resolve()
1172 if path in self._prototype_root_cache:
1173 return self._prototype_root_cache[path]
1174 try:
1175 root = ET.parse(path).getroot()
1176 hydrate_native_payload_refs(root, path)
1177 except (OSError, ET.ParseError, NativePayloadError):
1178 root = None
1179 self._prototype_root_cache[path] = root
1180 return root
1181
1182 def check_file(
1183 self,
1184 svg_file: str,
1185 expected_format: str = None,
1186 *,
1187 expected_viewbox: str | None = None,
1188 expected_viewbox_label: str = "expected canvas",
1189 ) -> Dict:
1190 """
1191 Check a single SVG file
1192
1193 Args:
1194 svg_file: SVG file path
1195 expected_format: Expected canvas format (e.g., 'ppt169')
1196
1197 Returns:
1198 Check result dictionary
1199 """
1200 svg_path = Path(svg_file)
1201
1202 if not svg_path.exists():
1203 return {
1204 'file': str(svg_file),
1205 'exists': False,
1206 'errors': ['File does not exist'],
1207 'warnings': [],
1208 'passed': False
1209 }
1210
1211 result = {
1212 'file': svg_path.name,
1213 'path': str(svg_path),
1214 'exists': True,
1215 'errors': [],
1216 'warnings': [],
1217 'info': {},
1218 'passed': True
1219 }
1220
1221 try:
1222 source_bytes = svg_path.read_bytes()
1223 result['source_sha256'] = hashlib.sha256(source_bytes).hexdigest()
1224 content = source_bytes.decode('utf-8')
1225
1226 # 0. Parse XML once — every other check assumes the file is valid
1227 # XML. Bail early on failure so the regex-based checks below don't
1228 # produce misleading errors on a broken document.
1229 root = self._parse_xml_root(content, result)
1230 if root is not None:
1231 try:
1232 hydrated_payloads = hydrate_native_payload_refs(root, svg_path)
1233 except NativePayloadError as exc:
1234 result['errors'].append(
1235 f"Invalid native payload reference: {exc}"
1236 )
1237 else:
1238 if hydrated_payloads:
1239 result['info']['native_payload_refs'] = hydrated_payloads
1240
1241 # 1. Check viewBox
1242 self._check_viewbox(
1243 root,
1244 svg_path,
1245 result,
1246 expected_format,
1247 expected_viewbox=expected_viewbox,
1248 expected_viewbox_label=expected_viewbox_label,
1249 )
1250 self._check_legacy_pptx_attributes(root, svg_path, result)
1251 self._record_carrier_receipt(root, result)
1252
1253 # 1a. Validate exact importer transport before compatible
1254 # inline geometry is materialized on the shared tree.
1255 svg_contracts.check_nested_svg_crop_contract(root, result)
1256
1257 # 2. Check forbidden elements
1258 svg_contracts.check_forbidden_elements(content, root, result)
1259 svg_contracts.check_mask_contract(root, result)
1260
1261 # 2a. Validate direct geometry lengths and stroke widths.
1262 svg_contracts.check_geometry_length_values(root, result)
1263
1264 # 2b. Validate line-presentation grammar and mappings.
1265 svg_contracts.check_stroke_style_values(root, result)
1266
1267 # 2c. Validate image fit/crop grammar and mappings.
1268 self._check_image_contract(root, svg_path, result)
1269 svg_contracts.check_image_aspect_ratio_values(root, result)
1270
1271 # 2d. Validate complete path-data and point-list grammar.
1272 svg_contracts.check_freeform_geometry_values(root, result)
1273
1274 # 2e. Validate complete transform grammar and native mappings.
1275 svg_contracts.check_transform_values(root, result)
1276
1277 # 2f. Validate opacity grammar and native alpha mappings.
1278 svg_contracts.check_opacity_values(root, result)
1279
1280 # 2g. Validate the closed authoring-property surface and
1281 # conditional definition interfaces before export.
1282 svg_contracts.check_authoring_property_contract(root, result)
1283 svg_contracts.check_text_property_contract(root, result)
1284 self._check_preserved_txbody_contract(root, result)
1285 svg_contracts.check_paint_compatibility(root, result)
1286 svg_contracts.check_reference_spelling(root, result)
1287 svg_contracts.check_definition_contract(root, result)
1288 svg_contracts.check_paint_reference_contract(root, result)
1289 svg_contracts.check_marker_contract(root, result)
1290 svg_contracts.check_clip_path_contract(root, result)
1291
1292 # 2h. Validate the supported shadow/glow filter interface.
1293 svg_contracts.check_imported_effect_status(root, result)
1294 svg_contracts.check_filter_effects(root, result)
1295
1296 # 2i. Validate gradient definitions, stops, and coordinates.
1297 svg_contracts.check_gradient_interfaces(root, result)
1298
1299 # 3. Check font-size values
1300 svg_contracts.check_font_size_values(content, result)
1301
1302 # 4. Check fonts
1303 self._check_fonts(content, result)
1304
1305 # 5. Check text wrapping methods
1306 self._check_text_elements(content, root, result)
1307
1308 # 5b. Validate native hyperlink targets and carrier structure.
1309 self._check_hyperlinks(root, result)
1310
1311 # 6. Check image references (file existence and resolution)
1312 self._check_image_references(root, svg_path, result)
1313
1314 # 7. Check icon placeholders resolve before post-processing.
1315 self._check_icon_placeholders(root, svg_path, result)
1316
1317 # 7b. Reject visual elements the native converter cannot dispatch.
1318 self._check_unsupported_visual_elements(root, result)
1319
1320 # 7c. Fail closed on invalid PPTX preset/adjustment metadata.
1321 self._check_preset_geometry_metadata(root, result)
1322 self._check_preset_geometry_transforms(root, result)
1323
1324 # 8. Check object-level animation anchor quality.
1325 self._check_animation_group_ids(root, svg_path, result)
1326
1327 # 8b. Check <pattern> elements declare a PPTX preset.
1328 self._check_pattern_fills(root, result)
1329
1330 # 8c. Check explicit native replacement markers before export.
1331 self._check_native_object_markers(root, result)
1332
1333 # 8d. Validate explicit master/layout/placeholder metadata.
1334 if (
1335 _template_structure_checks_enabled(svg_path)
1336 if self.template_mode
1337 else _CHECK_PPTX_STRUCTURED_PROJECT
1338 ):
1339 self._check_pptx_structure_metadata(root, svg_path, result)
1340
1341 # 8e. Validate rendering-neutral page/structure compiler hints.
1342 self._check_semantic_markers(root, svg_path, result)
1343
1344 # 9. Compare values with spec_lock anchors. Additional colors
1345 # and fonts are informational. Generated-page type sizes may
1346 # stay sparse twice; the third occurrence is an error. Other
1347 # spec-backed SVG locations retain advisory review. Templates
1348 # do not ship a spec_lock.md, so skip in template mode.
1349 if not self.template_mode:
1350 self._check_spec_lock_alignment(
1351 content,
1352 svg_path,
1353 result,
1354 root=root,
1355 )
1356
1357 # 10. Check web-sourced image attribution. Templates don't carry
1358 # image_sources.json; skip in template mode.
1359 if not self.template_mode:
1360 self._check_sourced_image_attribution(
1361 root,
1362 svg_path,
1363 result,
1364 )
1365
1366 # Determine pass/fail
1367 result['passed'] = len(result['errors']) == 0
1368
1369 except Exception as e:
1370 result['errors'].append(f"Failed to read file: {e}")
1371 result['passed'] = False
1372
1373 # Update statistics
1374 self.summary['total'] += 1
1375 if result['passed']:
1376 if result['warnings']:
1377 self.summary['warnings'] += 1
1378 else:
1379 self.summary['passed'] += 1
1380 else:
1381 self.summary['errors'] += 1
1382
1383 # Categorize issue types
1384 for error in result['errors']:
1385 self.issue_types[self._categorize_issue(error)] += 1
1386
1387 self.results.append(result)
1388 return result
1389
1390 def _parse_xml_root(self, content: str, result: Dict) -> ET.Element | None:
1391 """Parse the SVG content as well-formed XML.
1392
1393 SVG is strict XML. AI-generated decks frequently produce content that
1394 looks fine in HTML5-tolerant previews but fails strict XML parsing —
1395 common causes are HTML named entities (&nbsp; &mdash; &copy;…) and
1396 bare XML reserved characters in text (R&D, error < 5%). Such pages
1397 cannot be exported to PPTX, so we surface them here as a hard error
1398 before any downstream check looks at them.
1399
1400 Returns the parsed root when the document is well-formed; otherwise
1401 appends an error and returns None.
1402 """
1403 try:
1404 return ET.fromstring(content)
1405 except ET.ParseError as e:
1406 result['errors'].append(
1407 f"Invalid XML: {e} — SVG must be well-formed XML. "
1408 f"Use raw Unicode for typography (—, ©, →, NBSP); "
1409 f"escape XML reserved chars as &amp; &lt; &gt; &quot; &apos; "
1410 f"(see references/shared-standards-core.md §1)."
1411 )
1412 return None
1413
1414 def _check_viewbox(
1415 self,
1416 root: ET.Element,
1417 svg_path: Path,
1418 result: Dict,
1419 expected_format: str = None,
1420 *,
1421 expected_viewbox: str | None = None,
1422 expected_viewbox_label: str = "expected canvas",
1423 ):
1424 """Validate the root page canvas and its project-level locks."""
1425 viewbox = root.get('viewBox')
1426 try:
1427 parsed = parse_project_svg_root(
1428 root,
1429 context=svg_path.name,
1430 )
1431 except CanvasContractError as exc:
1432 result['errors'].append(str(exc))
1433 return
1434 assert viewbox is not None
1435 result['info']['viewbox'] = viewbox
1436 if viewbox != parsed.canonical or not parsed.has_integer_dimensions:
1437 if parsed.has_integer_dimensions:
1438 recommendation = f'write viewBox="{parsed.canonical}"'
1439 else:
1440 recommendation = (
1441 "fractional dimensions are reserved for compatible imported "
1442 "custom slide sizes; new authoring uses integer pixels"
1443 )
1444 result['warnings'].append(
1445 f"Compatible non-canonical root viewBox {viewbox!r}; {recommendation}."
1446 )
1447
1448 contracts: list[tuple[str, str]] = []
1449 if expected_viewbox is not None:
1450 contracts.append((expected_viewbox_label, expected_viewbox))
1451 elif not self.template_mode:
1452 lock = self._get_spec_lock(svg_path)
1453 if lock is not None and 'canvas' in lock:
1454 locked_viewbox = lock.get('canvas', {}).get('viewBox')
1455 if not locked_viewbox:
1456 result['errors'].append(
1457 "spec_lock.md canvas section must declare viewBox"
1458 )
1459 else:
1460 contracts.append(("spec_lock canvas", locked_viewbox))
1461
1462 if expected_format and expected_format in CANVAS_FORMATS:
1463 contracts.append((
1464 f"canvas format {expected_format!r}",
1465 CANVAS_FORMATS[expected_format]['viewbox'],
1466 ))
1467 elif expected_format:
1468 result['errors'].append(f"Unsupported canvas format: {expected_format}")
1469
1470 seen_contracts: set[tuple[str, str]] = set()
1471 for label, raw_expected in contracts:
1472 contract_key = (label, raw_expected)
1473 if contract_key in seen_contracts:
1474 continue
1475 seen_contracts.add(contract_key)
1476 try:
1477 expected = parse_project_viewbox(
1478 raw_expected,
1479 context=f"{label} viewBox",
1480 )
1481 except CanvasContractError as exc:
1482 result['errors'].append(str(exc))
1483 continue
1484 if parsed != expected:
1485 result['errors'].append(
1486 f"viewBox mismatch: {label} requires '{expected.canonical}', "
1487 f"got '{parsed.canonical}'"
1488 )
1489
1490 def _check_image_contract(
1491 self,
1492 root: ET.Element,
1493 svg_path: Path,
1494 result: Dict,
1495 ) -> None:
1496 """Validate picture frames, references, and bytes before export."""
1497 if _project_image_errors is None:
1498 result['errors'].append(
1499 'Unable to import the image validator; cannot verify picture '
1500 'frames or media'
1501 )
1502 return
1503 _working_root, _parent_by_id, images = self._visible_image_elements(root)
1504 for image in images:
1505 result['errors'].extend(
1506 _project_image_errors(
1507 image,
1508 svg_path.parent,
1509 allow_template_placeholders=self.template_mode,
1510 )
1511 )
1512
1513 def _record_carrier_receipt(
1514 self,
1515 root: ET.Element,
1516 result: Dict,
1517 ) -> None:
1518 """Record factual visible-carrier use without grading the design."""
1519 parent_by_id = {
1520 id(child): parent
1521 for parent in root.iter()
1522 for child in list(parent)
1523 }
1524 geometry_tags = (
1525 'rect',
1526 'circle',
1527 'ellipse',
1528 'line',
1529 'polyline',
1530 'polygon',
1531 'path',
1532 )
1533 geometry_counts = Counter({tag: 0 for tag in geometry_tags})
1534 preset_names: Counter[str] = Counter()
1535 native_objects = Counter({
1536 'chart': 0,
1537 'table': 0,
1538 'formula_block': 0,
1539 'formula_inline': 0,
1540 'other': 0,
1541 })
1542 marker_counts = Counter({'start': 0, 'mid': 0, 'end': 0})
1543 text_count = 0
1544 icon_count = 0
1545 page_frame_geometry = 0
1546
1547 for element in root.iter():
1548 if (
1549 element is root
1550 or self._is_hidden_element(element, parent_by_id)
1551 or self._has_non_visual_ancestor(element, root, parent_by_id)
1552 or self._has_zero_opacity(element, parent_by_id)
1553 ):
1554 continue
1555
1556 tag = _local_name(element)
1557 if tag == 'text':
1558 text_count += 1
1559 elif tag == 'use' and element.get('data-icon') is not None:
1560 icon_count += 1
1561
1562 if element.get(_INLINE_FORMULA_ATTR) is not None:
1563 native_objects['formula_inline'] += 1
1564 replacement_kind = self._carrier_native_replacement_kind(element)
1565 if replacement_kind:
1566 key = (
1567 'formula_block'
1568 if replacement_kind == 'formula'
1569 else replacement_kind
1570 )
1571 native_objects[key if key in native_objects else 'other'] += 1
1572
1573 preset = (element.get('data-pptx-prst') or '').strip()
1574 if preset:
1575 preset_names[preset] += 1
1576 if self._carrier_page_frame_role(element, root, parent_by_id):
1577 page_frame_geometry += 1
1578 continue
1579 if tag not in geometry_counts or self._has_preset_ancestor(
1580 element,
1581 root,
1582 parent_by_id,
1583 ):
1584 continue
1585
1586 geometry_counts[tag] += 1
1587 if self._carrier_page_frame_role(element, root, parent_by_id):
1588 page_frame_geometry += 1
1589 style_values = (
1590 _parse_inline_style(element.get('style'))
1591 if _parse_inline_style is not None
1592 else {}
1593 )
1594 for position in ('start', 'mid', 'end'):
1595 raw_marker = (
1596 style_values.get(f'marker-{position}')
1597 or element.get(f'marker-{position}')
1598 or ''
1599 ).strip().lower()
1600 if raw_marker and raw_marker != 'none':
1601 marker_counts[position] += 1
1602
1603 image_receipt = self._carrier_image_receipt(root)
1604 result['info']['carrier_receipt'] = {
1605 'text_elements': text_count,
1606 'images': image_receipt,
1607 'icons': icon_count,
1608 'geometry': {
1609 'svg_elements': dict(geometry_counts),
1610 'preset_shapes': sum(preset_names.values()),
1611 'preset_names': dict(sorted(preset_names.items())),
1612 'page_frame_elements': page_frame_geometry,
1613 'marker_uses': dict(marker_counts),
1614 },
1615 'native_objects': dict(native_objects),
1616 }
1617
1618 @staticmethod
1619 def _carrier_native_replacement_kind(element: ET.Element) -> str:
1620 """Return one native replacement kind without turning bad data into a check."""
1621 if _native_replacement_kind is not None:
1622 try:
1623 return (_native_replacement_kind(element) or '').strip().lower()
1624 except ValueError:
1625 pass
1626 return (
1627 element.get('data-pptx-replace-with')
1628 or element.get('data-pptx-native')
1629 or ''
1630 ).strip().lower()
1631
1632 @staticmethod
1633 def _has_preset_ancestor(
1634 element: ET.Element,
1635 root: ET.Element,
1636 parent_by_id: Dict[int, ET.Element],
1637 ) -> bool:
1638 """Return whether geometry is only the visible detail of a preset atom."""
1639 current = parent_by_id.get(id(element))
1640 while current is not None and current is not root:
1641 if (current.get('data-pptx-prst') or '').strip():
1642 return True
1643 current = parent_by_id.get(id(current))
1644 return False
1645
1646 @staticmethod
1647 def _carrier_page_frame_role(
1648 element: ET.Element,
1649 root: ET.Element,
1650 parent_by_id: Dict[int, ET.Element],
1651 ) -> bool:
1652 """Return whether an element belongs to declared page framing."""
1653 current: ET.Element | None = element
1654 while current is not None:
1655 role = (current.get('data-pptx-role') or '').strip().lower()
1656 if role in {'background', 'decoration'}:
1657 return True
1658 if current is root:
1659 break
1660 current = parent_by_id.get(id(current))
1661 return False
1662
1663 def _carrier_image_receipt(self, root: ET.Element) -> Dict:
1664 """Summarize visible image placements and their frame share."""
1665 working_root, parent_by_id, images = self._visible_image_elements(root)
1666 viewbox = _parse_viewbox_values(working_root.get('viewBox') or '')
1667 canvas_area = (
1668 abs(viewbox[2] * viewbox[3])
1669 if viewbox is not None and viewbox[2] and viewbox[3]
1670 else 0.0
1671 )
1672 frame_shares: List[float] = []
1673 filenames = set()
1674
1675 for image in images:
1676 href = image.get('href') or image.get(f'{{{XLINK_NS}}}href') or ''
1677 if href.startswith('data:'):
1678 filenames.add('(embedded)')
1679 elif href:
1680 path_name = Path(unquote(urlsplit(href).path)).name
1681 filenames.add(path_name or href[:80])
1682
1683 display_owner = image
1684 parent = parent_by_id.get(id(image))
1685 if (
1686 parent is not None
1687 and parent is not working_root
1688 and _local_name(parent) == 'svg'
1689 ):
1690 display_owner = parent
1691 try:
1692 x = float(display_owner.get('x') or '0')
1693 y = float(display_owner.get('y') or '0')
1694 width = float(display_owner.get('width') or '0')
1695 height = float(display_owner.get('height') or '0')
1696 except (TypeError, ValueError):
1697 continue
1698 if width <= 0 or height <= 0 or canvas_area <= 0:
1699 continue
1700 transformed = self._transformed_rect_edge_lengths(
1701 display_owner,
1702 (x, y, width, height),
1703 parent_by_id,
1704 )
1705 if transformed is not None:
1706 width, height = transformed
1707 frame_shares.append(abs(width * height) / canvas_area)
1708
1709 return {
1710 'placements': len(images),
1711 'files': sorted(filenames),
1712 'max_frame_share': round(max(frame_shares), 4) if frame_shares else 0.0,
1713 }
1714
1715 def _check_fonts(self, content: str, result: Dict):
1716 """Check font usage.
1717
1718 PPTX stores concrete typefaces per run with no CSS fallback. The
1719 converter resolves each SVG font stack to exported latin / EA typefaces;
1720 validate those exported values rather than the visual-preview tail.
1721 """
1722 font_matches = self._font_family_values(content)
1723
1724 if not font_matches:
1725 return
1726
1727 result['info']['fonts'] = sorted(set(font_matches))
1728 if _unsafe_exported_font_faces is None:
1729 result['warnings'].append(
1730 "Unable to import svg_to_pptx font resolver; skipped exported-font safety check"
1731 )
1732 return
1733
1734 for font_family in font_matches:
1735 unsafe = [
1736 f"{role}={family}"
1737 for role, family in _unsafe_exported_font_faces(font_family).items()
1738 ]
1739 if unsafe:
1740 result['warnings'].append(
1741 "Font stack exports non-PPT-safe typeface(s) to PPTX "
1742 f"({', '.join(unsafe)}): {font_family}"
1743 )
1744 break
1745
1746 @staticmethod
1747 def _font_family_values(content: str) -> List[str]:
1748 """Extract SVG font-family values from attributes and inline styles."""
1749 return SVGQualityChecker._svg_property_values(content, 'font-family')
1750
1751 @staticmethod
1752 def _svg_property_values(content: str, property_name: str) -> List[str]:
1753 """Extract a SVG property from direct attributes and inline styles."""
1754 values: List[str] = []
1755 attr_re = re.compile(
1756 rf'\b{re.escape(property_name)}\s*=\s*(["\'])(.*?)\1',
1757 re.IGNORECASE | re.DOTALL,
1758 )
1759 for match in attr_re.finditer(content):
1760 values.append(html.unescape(match.group(2)).strip())
1761
1762 for match in re.finditer(r'\bstyle\s*=\s*(["\'])(.*?)\1', content, re.IGNORECASE | re.DOTALL):
1763 style_value = html.unescape(match.group(2))
1764 for part in style_value.split(';'):
1765 if ':' not in part:
1766 continue
1767 name, value = part.split(':', 1)
1768 if name.strip().lower() == property_name.lower():
1769 values.append(value.strip())
1770 return [value for value in values if value]
1771
1772 def _check_text_elements(self, content: str, root: ET.Element, result: Dict):
1773 """Check text elements and wrapping methods"""
1774 # Count text and tspan elements
1775 text_count = content.count('<text')
1776 tspan_count = content.count('<tspan')
1777
1778 result['info']['text_elements'] = text_count
1779 result['info']['tspan_elements'] = tspan_count
1780
1781 self._check_module_bounds_contract(root, result)
1782 self._check_text_output_geometry(root, result)
1783 self._check_text_bounds(root, result)
1784 self._check_fragmented_paragraph_text(root, result)
1785 self._check_unmergeable_leading_text(root, result)
1786 self._check_nested_positional_tspans(root, result)
1787
1788 def _check_hyperlinks(self, root: ET.Element, result: Dict) -> None:
1789 """Validate the standard SVG anchor surface shared with export."""
1790 anchors = [
1791 elem for elem in root.iter()
1792 if _local_name(elem) == 'a'
1793 ]
1794 transports = [
1795 elem for elem in root.iter()
1796 if elem.get(_SHAPE_HYPERLINK_ATTR) is not None
1797 ]
1798 if not anchors and not transports:
1799 return
1800 result['info']['hyperlinks'] = len(anchors) + len(transports)
1801 if _project_hyperlink_errors is None:
1802 result['errors'].append(
1803 'Unable to import hyperlink validator; cannot verify SVG links'
1804 )
1805 return
1806 result['errors'].extend(
1807 f'Invalid SVG hyperlink: {error}'
1808 for error in _project_hyperlink_errors(
1809 root,
1810 slide_count=self._active_slide_count,
1811 )
1812 )
1813
1814 def _check_nested_positional_tspans(
1815 self,
1816 root: ET.Element,
1817 result: Dict,
1818 ) -> None:
1819 """Reject nested baseline jumps that DrawingML runs cannot represent."""
1820 if _nested_positional_tspan_errors is None:
1821 return
1822 result['errors'].extend(_nested_positional_tspan_errors(root))
1823
1824 @classmethod
1825 def _single_line_text_runs(
1826 cls,
1827 text_el: ET.Element,
1828 ) -> List[Tuple[ET.Element, str]] | None:
1829 """Return normalized inline runs, or ``None`` for positioned text."""
1830 if (
1831 _normalize_project_text_segments is None
1832 or _resolve_project_xml_space is None
1833 ):
1834 return None
1835 raw_runs: List[Tuple[ET.Element, str, str]] = []
1836
1837 def append_run(owner: ET.Element, raw: str, xml_space: str) -> None:
1838 if raw:
1839 raw_runs.append((owner, xml_space, raw))
1840
1841 def collect(container: ET.Element, inherited_xml_space: str) -> bool:
1842 try:
1843 xml_space = _resolve_project_xml_space(
1844 container,
1845 inherited_xml_space,
1846 )
1847 except ValueError:
1848 return False
1849 if container.text:
1850 append_run(container, container.text, xml_space)
1851 for child in list(container):
1852 if not cls._is_tspan(child):
1853 return False
1854 if any(child.get(name) is not None for name in ('x', 'y', 'dx', 'dy')):
1855 return False
1856 if any(
1857 name.startswith('data-paragraph-')
1858 for name in child.attrib
1859 ):
1860 return False
1861 if not collect(child, xml_space):
1862 return False
1863 if child.tail:
1864 append_run(container, child.tail, xml_space)
1865 return True
1866
1867 if not collect(text_el, 'default'):
1868 return None
1869 normalized = _normalize_project_text_segments([
1870 (xml_space, raw)
1871 for _owner, xml_space, raw in raw_runs
1872 ])
1873 return [
1874 (raw_runs[index][0], text)
1875 for index, text in normalized
1876 ]
1877
1878 @staticmethod
1879 def _unchanged_txbody_group_ids(
1880 root: ET.Element,
1881 ) -> set[int]:
1882 """Return imported shape groups whose original text body will survive."""
1883 if _preserved_native_text_body is None:
1884 return set()
1885 unchanged: set[int] = set()
1886 for group in root.iter(f'{{{SVG_NS}}}g'):
1887 try:
1888 if _preserved_native_text_body(
1889 group,
1890 trust_runtime_snapshot=False,
1891 ) is not None:
1892 unchanged.add(id(group))
1893 except _SvgNativeConversionError:
1894 # The dedicated txBody contract check owns the diagnostic.
1895 continue
1896 return unchanged
1897
1898 @staticmethod
1899 def _check_preserved_txbody_contract(
1900 root: ET.Element,
1901 result: Dict,
1902 ) -> None:
1903 """Validate imported txBody payloads independently of text geometry."""
1904 if _preserved_native_text_body is None:
1905 return
1906 errors: set[str] = set()
1907 for group in root.iter(f'{{{SVG_NS}}}g'):
1908 try:
1909 _preserved_native_text_body(
1910 group,
1911 trust_runtime_snapshot=False,
1912 )
1913 except _SvgNativeConversionError as exc:
1914 errors.add(
1915 f'{_element_label(group)} cannot preserve source '
1916 f'txBody: {exc}'
1917 )
1918 result['errors'].extend(sorted(errors))
1919
1920 @staticmethod
1921 def _has_ancestor_id(
1922 elem: ET.Element,
1923 parent_by_id: Dict[int, ET.Element],
1924 ancestor_ids: set[int],
1925 ) -> bool:
1926 current = parent_by_id.get(id(elem))
1927 while current is not None:
1928 if id(current) in ancestor_ids:
1929 return True
1930 current = parent_by_id.get(id(current))
1931 return False
1932
1933 @classmethod
1934 def _resolved_single_line_text_runs(
1935 cls,
1936 text_el: ET.Element,
1937 parent_by_id: Dict[int, ET.Element],
1938 font_sizes: Dict[int, float],
1939 letter_spacings: Dict[int, float],
1940 ) -> List[Dict] | None:
1941 """Resolve the same run metrics used by generated text-frame sizing."""
1942 source_runs = cls._single_line_text_runs(text_el)
1943 if source_runs is None:
1944 return None
1945 resolved: List[Dict] = []
1946 for owner, text in source_runs:
1947 raw_weight = (
1948 _effective_presentation_value(
1949 owner,
1950 'font-weight',
1951 parent_by_id,
1952 )
1953 or 'normal'
1954 ).strip().lower()
1955 weight = _parse_project_font_weight(raw_weight).canonical
1956 family = (
1957 _effective_presentation_value(
1958 owner,
1959 'font-family',
1960 parent_by_id,
1961 )
1962 or ''
1963 )
1964 opacity_chain: List[str] = []
1965 current: ET.Element | None = owner
1966 while current is not None:
1967 style_values = (
1968 _parse_inline_style(current.get('style'))
1969 if _parse_inline_style is not None else {}
1970 )
1971 raw_opacity = style_values.get('opacity')
1972 if raw_opacity is None:
1973 raw_opacity = current.get('opacity')
1974 if raw_opacity is not None:
1975 opacity_chain.append(raw_opacity.strip())
1976 current = parent_by_id.get(id(current))
1977 resolved.append({
1978 'owner': owner,
1979 'text': text,
1980 'font_size': font_sizes[id(owner)],
1981 'font_weight': weight,
1982 'font_family': family,
1983 'letter_spacing': letter_spacings[id(owner)],
1984 'font_style': _effective_presentation_value(
1985 owner,
1986 'font-style',
1987 parent_by_id,
1988 ) or 'normal',
1989 'text_decoration': _effective_presentation_value(
1990 owner,
1991 'text-decoration',
1992 parent_by_id,
1993 ) or 'none',
1994 'fill_raw': _effective_presentation_value(
1995 owner,
1996 'fill',
1997 parent_by_id,
1998 ) or '#000000',
1999 'fill_opacity': _effective_presentation_value(
2000 owner,
2001 'fill-opacity',
2002 parent_by_id,
2003 ) or '1',
2004 'stroke_raw': _effective_presentation_value(
2005 owner,
2006 'stroke',
2007 parent_by_id,
2008 ) or 'none',
2009 'stroke_width': _effective_presentation_value(
2010 owner,
2011 'stroke-width',
2012 parent_by_id,
2013 ) or '1',
2014 'stroke_opacity': _effective_presentation_value(
2015 owner,
2016 'stroke-opacity',
2017 parent_by_id,
2018 ) or '1',
2019 'opacity_chain': tuple(reversed(opacity_chain)),
2020 'inline_formula': owner.get(_INLINE_FORMULA_ATTR),
2021 })
2022 return cls._coalesce_checker_text_runs(resolved)
2023
2024 @staticmethod
2025 def _coalesce_checker_text_runs(runs: List[Dict]) -> List[Dict]:
2026 """Join only runs whose resolved source styles are provably equal."""
2027 if _detect_text_lang is None:
2028 return runs
2029 style_keys = (
2030 'font_size',
2031 'font_weight',
2032 'font_family',
2033 'letter_spacing',
2034 'font_style',
2035 'text_decoration',
2036 'fill_raw',
2037 'fill_opacity',
2038 'stroke_raw',
2039 'stroke_width',
2040 'stroke_opacity',
2041 'opacity_chain',
2042 )
2043
2044 def signature(run: Dict) -> Tuple:
2045 return (
2046 _detect_text_lang(str(run.get('text', ''))),
2047 *(run.get(key) for key in style_keys),
2048 )
2049
2050 merged: List[Dict] = []
2051 previous_signature: Tuple | None = None
2052 for run in runs:
2053 if run.get('inline_formula') is not None:
2054 merged.append(run)
2055 previous_signature = None
2056 continue
2057 current_signature = signature(run)
2058 if merged and current_signature == previous_signature:
2059 candidate = {
2060 **merged[-1],
2061 'text': (
2062 str(merged[-1].get('text', ''))
2063 + str(run.get('text', ''))
2064 ),
2065 }
2066 candidate_signature = signature(candidate)
2067 if candidate_signature == previous_signature:
2068 merged[-1] = candidate
2069 previous_signature = candidate_signature
2070 continue
2071 merged.append(run)
2072 previous_signature = current_signature
2073 return merged
2074
2075 @staticmethod
2076 def _text_line_vertical_extent(
2077 runs: List[Dict],
2078 font_size: float,
2079 ) -> Tuple[float, float]:
2080 """Return native-math-aware ascent/descent for checker bounds."""
2081 ascent = font_size * 0.85
2082 descent = font_size * 0.35
2083 if _estimate_inline_formula_vertical_extent is None:
2084 return ascent, descent
2085 for run in runs:
2086 latex = run.get('inline_formula')
2087 if latex is None:
2088 continue
2089 try:
2090 run_font_size = float(run.get('font_size', font_size))
2091 extent = _estimate_inline_formula_vertical_extent(str(latex))
2092 except (TypeError, ValueError):
2093 continue
2094 ascent = max(ascent, run_font_size * extent.ascent_em)
2095 descent = max(descent, run_font_size * extent.descent_em)
2096 return ascent, descent
2097
2098 def _check_text_output_geometry(
2099 self,
2100 root: ET.Element,
2101 result: Dict,
2102 ) -> None:
2103 """Reject measurable run advances or frames with non-positive geometry."""
2104 helpers = (
2105 _drawingml_text_frame_width_emu,
2106 _estimate_single_line_text_frame_width,
2107 _parse_project_font_weight,
2108 _resolve_project_font_sizes,
2109 _resolve_project_letter_spacings,
2110 _validate_single_line_text_run_advances,
2111 )
2112 if any(helper is None for helper in helpers):
2113 return
2114 try:
2115 font_sizes = _resolve_project_font_sizes(root)
2116 letter_spacings = _resolve_project_letter_spacings(root, font_sizes)
2117 except ValueError:
2118 return
2119
2120 parent_by_id = {
2121 id(child): parent
2122 for parent in root.iter()
2123 for child in list(parent)
2124 }
2125 unchanged_groups = self._unchanged_txbody_group_ids(root)
2126 errors: List[str] = []
2127 for text_el in root.iter(f'{{{SVG_NS}}}text'):
2128 chain: List[ET.Element] = []
2129 current: ET.Element | None = text_el
2130 while current is not None:
2131 chain.append(current)
2132 current = parent_by_id.get(id(current))
2133 if any(
2134 _local_name(current) in _NON_VISUAL_SVG_TAGS
2135 for current in chain
2136 ):
2137 continue
2138 if self._has_ancestor_id(text_el, parent_by_id, unchanged_groups):
2139 continue
2140 try:
2141 runs = self._resolved_single_line_text_runs(
2142 text_el,
2143 parent_by_id,
2144 font_sizes,
2145 letter_spacings,
2146 )
2147 if not runs:
2148 continue
2149 if not ''.join(str(run['text']) for run in runs).strip():
2150 continue
2151 text_width = _estimate_single_line_text_frame_width(runs)
2152 ext_cx = _drawingml_text_frame_width_emu(
2153 text_width,
2154 font_sizes[id(text_el)],
2155 )
2156 except (KeyError, TypeError, ValueError):
2157 continue
2158 if ext_cx < 1:
2159 errors.append(
2160 f'{_element_label(text_el)} negative letter-spacing '
2161 'produces a non-positive DrawingML text-frame extent '
2162 f'(cx={ext_cx})'
2163 )
2164 continue
2165 try:
2166 _validate_single_line_text_run_advances(runs)
2167 except ValueError as exc:
2168 errors.append(f'{_element_label(text_el)} {exc}')
2169 result['errors'].extend(errors)
2170
2171 @classmethod
2172 def _positioned_text_lines(
2173 cls,
2174 text_el: ET.Element,
2175 parent_by_id: Dict[int, ET.Element],
2176 font_sizes: Dict[int, float],
2177 letter_spacings: Dict[int, float],
2178 ) -> List[Tuple[ET.Element, float, float, List[Dict], float]] | None:
2179 """Resolve direct positioned tspans into estimable visual lines."""
2180 if _parse_project_geometry_length is None:
2181 return None
2182 children = list(text_el)
2183 if not children or (text_el.text or '').strip():
2184 return None
2185 if any(
2186 not cls._is_tspan(child)
2187 or not cls._is_line_tspan(child)
2188 or child.get('x') is None
2189 or (child.tail or '').strip()
2190 for child in children
2191 ):
2192 return None
2193
2194 try:
2195 current_y = _parse_project_geometry_length(
2196 text_el.get('y') or '0',
2197 'y',
2198 )
2199 except ValueError:
2200 return None
2201
2202 lines: List[Tuple[ET.Element, float, float, List[Dict], float]] = []
2203 for child in children:
2204 try:
2205 line_x = _parse_project_geometry_length(child.get('x'), 'x')
2206 line_y = (
2207 _parse_project_geometry_length(child.get('y'), 'y')
2208 if child.get('y') is not None
2209 else current_y
2210 )
2211 if child.get('dx') is not None:
2212 line_x += _parse_project_geometry_length(
2213 child.get('dx'),
2214 'dx',
2215 )
2216 if child.get('dy') is not None:
2217 line_y += _parse_project_geometry_length(
2218 child.get('dy'),
2219 'dy',
2220 )
2221 runs = cls._resolved_single_line_text_runs(
2222 child,
2223 parent_by_id,
2224 font_sizes,
2225 letter_spacings,
2226 )
2227 except (KeyError, TypeError, ValueError):
2228 return None
2229 current_y = line_y
2230 if not runs:
2231 continue
2232 try:
2233 font_size = max(float(run['font_size']) for run in runs)
2234 except (KeyError, TypeError, ValueError):
2235 return None
2236 lines.append((child, line_x, line_y, runs, font_size))
2237 return lines or None
2238
2239 @classmethod
2240 def _estimated_text_line_bounds(
2241 cls,
2242 line_el: ET.Element,
2243 x: float,
2244 y: float,
2245 runs: List[Dict],
2246 font_size: float,
2247 parent_by_id: Dict[int, ET.Element],
2248 *,
2249 include_headroom: bool = True,
2250 ) -> Tuple[float, float, float, float] | None:
2251 """Estimate one line's transformed visible bounds in SVG coordinates."""
2252 if any(helper is None for helper in (
2253 _estimate_single_line_text_frame_width,
2254 _IDENTITY_MATRIX,
2255 _matrix_multiply,
2256 _parse_project_text_anchor,
2257 _parse_transform_matrix,
2258 _transform_point,
2259 )):
2260 return None
2261 try:
2262 width = float(_estimate_single_line_text_frame_width(
2263 runs,
2264 include_headroom=include_headroom,
2265 ))
2266 raw_anchor = (
2267 _effective_presentation_value(
2268 line_el,
2269 'text-anchor',
2270 parent_by_id,
2271 )
2272 or 'start'
2273 ).strip().lower()
2274 anchor = _parse_project_text_anchor(raw_anchor).value
2275 except (TypeError, ValueError):
2276 return None
2277 if not all(math.isfinite(value) for value in (x, y, width, font_size)):
2278 return None
2279 if width <= 0 or font_size <= 0:
2280 return None
2281
2282 if anchor == 'middle':
2283 left = x - width / 2
2284 right = x + width / 2
2285 elif anchor == 'end':
2286 left = x - width
2287 right = x
2288 elif anchor == 'start':
2289 left = x
2290 right = x + width
2291 else:
2292 return None
2293 ascent, descent = cls._text_line_vertical_extent(runs, font_size)
2294 top = y - ascent
2295 bottom = y + descent
2296
2297 return cls._transformed_rect_bounds(
2298 line_el,
2299 (left, top, right - left, bottom - top),
2300 parent_by_id,
2301 )
2302
2303 @classmethod
2304 def _estimated_text_bounds(
2305 cls,
2306 text_el: ET.Element,
2307 parent_by_id: Dict[int, ET.Element],
2308 font_sizes: Dict[int, float],
2309 letter_spacings: Dict[int, float],
2310 *,
2311 include_headroom: bool = True,
2312 ) -> Tuple[float, float, float, float] | None:
2313 """Estimate one single- or multi-line text carrier's visual bounds."""
2314 lines: List[Tuple[ET.Element, float, float, List[Dict], float]] | None
2315 try:
2316 runs = cls._resolved_single_line_text_runs(
2317 text_el,
2318 parent_by_id,
2319 font_sizes,
2320 letter_spacings,
2321 )
2322 except (KeyError, TypeError, ValueError):
2323 return None
2324 if runs:
2325 try:
2326 lines = [(
2327 text_el,
2328 _parse_project_geometry_length(text_el.get('x') or '0', 'x'),
2329 _parse_project_geometry_length(text_el.get('y') or '0', 'y'),
2330 runs,
2331 max(float(run['font_size']) for run in runs),
2332 )]
2333 except (KeyError, TypeError, ValueError):
2334 return None
2335 else:
2336 lines = cls._positioned_text_lines(
2337 text_el,
2338 parent_by_id,
2339 font_sizes,
2340 letter_spacings,
2341 )
2342 if not lines:
2343 return None
2344
2345 bounds = [
2346 cls._estimated_text_line_bounds(
2347 line_el,
2348 x,
2349 y,
2350 line_runs,
2351 font_size,
2352 parent_by_id,
2353 include_headroom=include_headroom,
2354 )
2355 for line_el, x, y, line_runs, font_size in lines
2356 ]
2357 resolved = [item for item in bounds if item is not None]
2358 if not resolved:
2359 return None
2360 return (
2361 min(item[0] for item in resolved),
2362 min(item[1] for item in resolved),
2363 max(item[2] for item in resolved),
2364 max(item[3] for item in resolved),
2365 )
2366
2367 @staticmethod
2368 def _accumulated_transform_matrix(
2369 element: ET.Element,
2370 parent_by_id: Dict[int, ET.Element],
2371 ):
2372 """Return the element-to-root transform matrix when available."""
2373 if any(helper is None for helper in (
2374 _IDENTITY_MATRIX,
2375 _matrix_multiply,
2376 _parse_transform_matrix,
2377 )):
2378 return None
2379 chain: List[ET.Element] = []
2380 current: ET.Element | None = element
2381 while current is not None:
2382 chain.append(current)
2383 current = parent_by_id.get(id(current))
2384 matrix = _IDENTITY_MATRIX
2385 try:
2386 for current in reversed(chain):
2387 raw_transform = current.get('transform')
2388 if raw_transform:
2389 matrix = _matrix_multiply(
2390 matrix,
2391 _parse_transform_matrix(raw_transform),
2392 )
2393 except (TypeError, ValueError):
2394 return None
2395 return matrix
2396
2397 @classmethod
2398 def _transformed_rect_bounds(
2399 cls,
2400 element: ET.Element,
2401 bounds: Tuple[float, float, float, float],
2402 parent_by_id: Dict[int, ET.Element],
2403 ) -> Tuple[float, float, float, float] | None:
2404 """Transform one local rectangle into root SVG coordinates."""
2405 if _transform_point is None:
2406 return None
2407 matrix = cls._accumulated_transform_matrix(element, parent_by_id)
2408 if matrix is None:
2409 return None
2410 x, y, width, height = bounds
2411 try:
2412 corners = [
2413 _transform_point(matrix, corner_x, corner_y)
2414 for corner_x, corner_y in (
2415 (x, y),
2416 (x + width, y),
2417 (x + width, y + height),
2418 (x, y + height),
2419 )
2420 ]
2421 except (TypeError, ValueError):
2422 return None
2423 xs = [point[0] for point in corners]
2424 ys = [point[1] for point in corners]
2425 return min(xs), min(ys), max(xs), max(ys)
2426
2427 @classmethod
2428 def _transformed_rect_edge_lengths(
2429 cls,
2430 element: ET.Element,
2431 bounds: Tuple[float, float, float, float],
2432 parent_by_id: Dict[int, ET.Element],
2433 ) -> Tuple[float, float] | None:
2434 """Return frame-axis lengths after accumulated SVG transforms."""
2435 if _transform_point is None:
2436 return None
2437 matrix = cls._accumulated_transform_matrix(element, parent_by_id)
2438 if matrix is None:
2439 return None
2440 x, y, width, height = bounds
2441 try:
2442 origin = _transform_point(matrix, x, y)
2443 width_end = _transform_point(matrix, x + width, y)
2444 height_end = _transform_point(matrix, x, y + height)
2445 except (TypeError, ValueError):
2446 return None
2447 rendered_w = math.hypot(
2448 width_end[0] - origin[0],
2449 width_end[1] - origin[1],
2450 )
2451 rendered_h = math.hypot(
2452 height_end[0] - origin[0],
2453 height_end[1] - origin[1],
2454 )
2455 if rendered_w <= 0 or rendered_h <= 0:
2456 return None
2457 return rendered_w, rendered_h
2458
2459 @staticmethod
2460 def _resolved_root_module_bounds(
2461 group: ET.Element,
2462 ) -> Tuple[str, Tuple[float, float, float, float]] | None:
2463 """Return one root module's explicit boundary in root coordinates."""
2464 raw = group.get(_BOUNDS_ATTR)
2465 if raw is None:
2466 return None
2467 try:
2468 x, y, width, height = _parse_positive_bounds(raw)
2469 except ValueError:
2470 return None
2471 return _BOUNDS_ATTR, (x, y, x + width, y + height)
2472
2473 @staticmethod
2474 def _bounds_overflow_metrics(
2475 inner: Tuple[float, float, float, float],
2476 outer: Tuple[float, float, float, float],
2477 *,
2478 tolerance: float = _BOUNDS_OVERFLOW_TOLERANCE,
2479 ) -> Tuple[str, float, float] | None:
2480 """Return overflow axes and ratios relative to the outer dimensions."""
2481 left, top, right, bottom = inner
2482 outer_left, outer_top, outer_right, outer_bottom = outer
2483 left_overflow = max(outer_left - left, 0.0)
2484 right_overflow = max(right - outer_right, 0.0)
2485 top_overflow = max(outer_top - top, 0.0)
2486 bottom_overflow = max(bottom - outer_bottom, 0.0)
2487 horizontal = (
2488 left_overflow > tolerance
2489 or right_overflow > tolerance
2490 )
2491 vertical = (
2492 top_overflow > tolerance
2493 or bottom_overflow > tolerance
2494 )
2495 if not horizontal and not vertical:
2496 return None
2497
2498 outer_width = outer_right - outer_left
2499 outer_height = outer_bottom - outer_top
2500 if outer_width <= 0.0 or outer_height <= 0.0:
2501 return None
2502 horizontal_ratio = (
2503 max(left_overflow, right_overflow) / outer_width
2504 if horizontal else 0.0
2505 )
2506 vertical_ratio = (
2507 max(top_overflow, bottom_overflow) / outer_height
2508 if vertical else 0.0
2509 )
2510 if horizontal and vertical:
2511 axes = 'horizontal and vertical'
2512 elif horizontal:
2513 axes = 'horizontal'
2514 else:
2515 axes = 'vertical'
2516 return axes, horizontal_ratio, vertical_ratio
2517
2518 @staticmethod
2519 def _bounds_are_disjoint(
2520 first: Tuple[float, float, float, float],
2521 second: Tuple[float, float, float, float],
2522 ) -> bool:
2523 """Return whether two root-coordinate rectangles do not intersect."""
2524 left, top, right, bottom = first
2525 other_left, other_top, other_right, other_bottom = second
2526 return (
2527 right <= other_left
2528 or left >= other_right
2529 or bottom <= other_top
2530 or top >= other_bottom
2531 )
2532
2533 @classmethod
2534 def _is_off_canvas_morph_group(
2535 cls,
2536 group: ET.Element,
2537 canvas: Tuple[float, float, float, float],
2538 ) -> bool:
2539 """Return whether a group declares one wholly off-canvas Morph state."""
2540 if group.get(_MORPH_STAGING_ATTR) != 'true':
2541 return False
2542 resolved = cls._resolved_root_module_bounds(group)
2543 return (
2544 resolved is not None
2545 and cls._bounds_are_disjoint(resolved[1], canvas)
2546 )
2547
2548 @classmethod
2549 def _record_bounds_overflow(
2550 cls,
2551 result: Dict,
2552 *,
2553 subject: str,
2554 inner: Tuple[float, float, float, float],
2555 container: str,
2556 outer: Tuple[float, float, float, float],
2557 repair: str,
2558 ) -> None:
2559 """Record a warning through 5% overflow and an error above it."""
2560 metrics = cls._bounds_overflow_metrics(inner, outer)
2561 if metrics is None:
2562 return
2563 axes, horizontal_ratio, vertical_ratio = metrics
2564 overflow_ratio = max(horizontal_ratio, vertical_ratio)
2565 exceeds_error_ratio = (
2566 overflow_ratio > _BOUNDS_OVERFLOW_ERROR_RATIO
2567 and not math.isclose(
2568 overflow_ratio,
2569 _BOUNDS_OVERFLOW_ERROR_RATIO,
2570 rel_tol=0.0,
2571 abs_tol=1e-9,
2572 )
2573 )
2574 bucket = (
2575 result['errors']
2576 if exceeds_error_ratio
2577 else result['warnings']
2578 )
2579 left, top, right, bottom = inner
2580 outer_left, outer_top, outer_right, outer_bottom = outer
2581 bucket.append(
2582 f'{subject} exceeds {container} on the {axes} axis: '
2583 f'content ({left:.1f}, {top:.1f})-({right:.1f}, '
2584 f'{bottom:.1f}), container ({outer_left:.1f}, '
2585 f'{outer_top:.1f})-({outer_right:.1f}, '
2586 f'{outer_bottom:.1f}), overflow horizontal '
2587 f'{horizontal_ratio:.1%}, vertical {vertical_ratio:.1%}; '
2588 f'{repair}'
2589 )
2590
2591 @classmethod
2592 def _record_canvas_text_overflow(
2593 cls,
2594 result: Dict,
2595 *,
2596 subject: str,
2597 inner: Tuple[float, float, float, float],
2598 canvas: Tuple[float, float, float, float],
2599 ) -> bool:
2600 """Record one page-boundary error and return whether it overflowed."""
2601 metrics = cls._bounds_overflow_metrics(inner, canvas)
2602 if metrics is None:
2603 return False
2604 axes, horizontal_ratio, vertical_ratio = metrics
2605 left, top, right, bottom = inner
2606 canvas_left, canvas_top, canvas_right, canvas_bottom = canvas
2607 result['errors'].append(
2608 f'{subject} exceeds the root viewBox on the {axes} axis: '
2609 f'content ({left:.1f}, {top:.1f})-({right:.1f}, '
2610 f'{bottom:.1f}), canvas ({canvas_left:.1f}, '
2611 f'{canvas_top:.1f})-({canvas_right:.1f}, '
2612 f'{canvas_bottom:.1f}), overflow horizontal '
2613 f'{horizontal_ratio:.1%}, vertical {vertical_ratio:.1%}; '
2614 'move or reflow the text until its estimated bounds stay on-page'
2615 )
2616 return True
2617
2618 @staticmethod
2619 def _text_diagnostic_label(text_element: ET.Element) -> str:
2620 """Return a locatable label for one SVG text carrier."""
2621 label = _element_label(text_element)
2622 if (text_element.get('id') or '').strip():
2623 return label
2624
2625 details: List[str] = []
2626 raw_x = (text_element.get('x') or '').strip()
2627 raw_y = (text_element.get('y') or '').strip()
2628 if raw_x or raw_y:
2629 details.append(f'x={raw_x or "?"}, y={raw_y or "?"}')
2630 snippet = re.sub(r'\s+', ' ', ''.join(text_element.itertext())).strip()
2631 if snippet:
2632 preview = snippet[:20] + ('…' if len(snippet) > 20 else '')
2633 details.append(f'text={preview!r}')
2634 return f'{label} ({"; ".join(details)})' if details else label
2635
2636 @staticmethod
2637 def _is_hidden_element(
2638 element: ET.Element,
2639 parent_by_id: Dict[int, ET.Element],
2640 ) -> bool:
2641 """Return whether inherited display or visibility hides an element."""
2642 current: ET.Element | None = element
2643 while current is not None:
2644 style_values = (
2645 _parse_inline_style(current.get('style'))
2646 if _parse_inline_style is not None
2647 else {}
2648 )
2649 display = style_values.get('display')
2650 if display is None:
2651 display = current.get('display')
2652 if display and display.strip().lower() == 'none':
2653 return True
2654 current = parent_by_id.get(id(current))
2655 visibility = (
2656 _effective_presentation_value(
2657 element,
2658 'visibility',
2659 parent_by_id,
2660 )
2661 or ''
2662 ).strip().lower()
2663 return visibility in {'hidden', 'collapse'}
2664
2665 @staticmethod
2666 def _has_zero_opacity(
2667 element: ET.Element,
2668 parent_by_id: Dict[int, ET.Element],
2669 ) -> bool:
2670 """Return whether an element or ancestor has zero effective opacity."""
2671 current: ET.Element | None = element
2672 while current is not None:
2673 style_values = (
2674 _parse_inline_style(current.get('style'))
2675 if _parse_inline_style is not None
2676 else {}
2677 )
2678 raw = style_values.get('opacity')
2679 if raw is None:
2680 raw = current.get('opacity')
2681 if raw is not None:
2682 value = raw.strip()
2683 try:
2684 opacity = (
2685 float(value[:-1]) / 100
2686 if value.endswith('%')
2687 else float(value)
2688 )
2689 except ValueError:
2690 pass
2691 else:
2692 if opacity <= 0:
2693 return True
2694 current = parent_by_id.get(id(current))
2695 return False
2696
2697 @classmethod
2698 def _visible_image_elements(
2699 cls,
2700 root: ET.Element,
2701 ) -> Tuple[ET.Element, Dict[int, ET.Element], List[ET.Element]]:
2702 """Return rendered image instances after expanding static local uses."""
2703 working_root = copy.deepcopy(root)
2704 if (
2705 _expand_local_use_references is not None
2706 and _UseExpansionError is not None
2707 ):
2708 try:
2709 _expand_local_use_references(working_root)
2710 except _UseExpansionError:
2711 # The local-reference validator owns the actionable failure.
2712 working_root = copy.deepcopy(root)
2713
2714 parent_by_id = {
2715 id(child): parent
2716 for parent in working_root.iter()
2717 for child in list(parent)
2718 }
2719 images = [
2720 element
2721 for element in working_root.iter(f'{{{SVG_NS}}}image')
2722 if not cls._is_hidden_element(element, parent_by_id)
2723 and not cls._has_non_visual_ancestor(
2724 element,
2725 working_root,
2726 parent_by_id,
2727 )
2728 and not cls._has_zero_opacity(element, parent_by_id)
2729 ]
2730 return working_root, parent_by_id, images
2731
2732 @staticmethod
2733 def _has_non_visual_ancestor(
2734 element: ET.Element,
2735 module: ET.Element,
2736 parent_by_id: Dict[int, ET.Element],
2737 ) -> bool:
2738 """Return whether an element lives in a non-rendered module subtree."""
2739 current: ET.Element | None = element
2740 while current is not None and current is not module:
2741 if _local_name(current) in _NON_VISUAL_SVG_TAGS:
2742 return True
2743 current = parent_by_id.get(id(current))
2744 return False
2745
2746 def _check_module_bounds_contract(
2747 self,
2748 root: ET.Element,
2749 result: Dict,
2750 ) -> None:
2751 """Validate ordinary direct-root module boundaries in the SVG canvas."""
2752 parent_by_id = {
2753 id(child): parent
2754 for parent in root.iter()
2755 for child in list(parent)
2756 }
2757 viewbox = _parse_viewbox_values(root.get('viewBox') or '')
2758 canvas = None
2759 if viewbox is not None:
2760 x, y, width, height = viewbox
2761 canvas = (x, y, x + width, y + height)
2762
2763 for element in root.iter():
2764 if element.get(_BOUNDS_ATTR) is None:
2765 continue
2766 if _local_name(element) != 'g':
2767 result['errors'].append(
2768 f'{_element_label(element)} {_BOUNDS_ATTR} is valid '
2769 'only on <g> layout modules'
2770 )
2771
2772 for element in root.iter():
2773 raw_staging = element.get(_MORPH_STAGING_ATTR)
2774 if raw_staging is None:
2775 continue
2776 label = _element_label(element)
2777 if raw_staging != 'true':
2778 result['errors'].append(
2779 f'{label} {_MORPH_STAGING_ATTR} must equal "true"; '
2780 'set the exact value or remove the marker'
2781 )
2782 continue
2783 if _local_name(element) != 'g':
2784 result['errors'].append(
2785 f'{label} {_MORPH_STAGING_ATTR} is valid only on <g>; '
2786 'move it to the enclosing ordinary direct-root group'
2787 )
2788 continue
2789 if parent_by_id.get(id(element)) is not root:
2790 result['errors'].append(
2791 f'{label} {_MORPH_STAGING_ATTR} requires a direct-root '
2792 '<g>; move the marked group directly under <svg> or remove '
2793 'the marker'
2794 )
2795 continue
2796 if not (element.get('id') or '').strip():
2797 result['errors'].append(
2798 f'{label} {_MORPH_STAGING_ATTR} requires a stable non-empty '
2799 'id; add an id to the marked direct-root group'
2800 )
2801 continue
2802 incompatible = [
2803 attribute
2804 for attribute in (
2805 'data-pptx-layer',
2806 'data-pptx-placeholder',
2807 )
2808 if element.get(attribute) is not None
2809 ]
2810 if incompatible:
2811 result['errors'].append(
2812 f'{label} {_MORPH_STAGING_ATTR} cannot be combined with '
2813 f'{", ".join(incompatible)}; use an ordinary Slide-local '
2814 'group or remove the marker'
2815 )
2816 continue
2817 resolved = self._resolved_root_module_bounds(element)
2818 if resolved is None:
2819 result['errors'].append(
2820 f'{label} {_MORPH_STAGING_ATTR} requires valid '
2821 f'{_BOUNDS_ATTR}; add or fix positive root-coordinate '
2822 'x y width height bounds'
2823 )
2824 continue
2825 if canvas is None:
2826 result['errors'].append(
2827 f'{label} {_MORPH_STAGING_ATTR} cannot verify an off-canvas '
2828 'endpoint without a valid root viewBox; fix the root viewBox'
2829 )
2830 continue
2831 if not self._bounds_are_disjoint(resolved[1], canvas):
2832 result['errors'].append(
2833 f'{label} {_MORPH_STAGING_ATTR} requires wholly off-canvas '
2834 f'{_BOUNDS_ATTR}; move the full bounds outside the root '
2835 'viewBox or remove the marker from partially visible content'
2836 )
2837
2838 missing: List[str] = []
2839 root_groups = [
2840 child
2841 for child in list(root)
2842 if _local_name(child) == 'g'
2843 ]
2844 require_bounds = (
2845 self.template_mode
2846 or root.get('data-pptx-page-role') is not None
2847 or any(
2848 root.get(attribute) is not None
2849 for attribute in _PPTX_ROOT_STRUCTURE_ATTRS
2850 )
2851 )
2852 for group in root_groups:
2853 if self._is_hidden_element(group, parent_by_id):
2854 continue
2855 if (
2856 _authored_preset_encoding is not None
2857 and _authored_preset_encoding(group) == 'compact'
2858 ):
2859 continue
2860 raw_bounds = group.get(_BOUNDS_ATTR)
2861 if raw_bounds is None:
2862 missing.append(_element_label(group))
2863 continue
2864 try:
2865 _parse_positive_bounds(raw_bounds)
2866 except ValueError as exc:
2867 result['errors'].append(
2868 f'{_element_label(group)} {_BOUNDS_ATTR} {exc}'
2869 )
2870 continue
2871
2872 resolved = self._resolved_root_module_bounds(group)
2873 if resolved is None or canvas is None:
2874 continue
2875 if self._is_off_canvas_morph_group(group, canvas):
2876 continue
2877 attribute, bounds = resolved
2878 self._record_bounds_overflow(
2879 result,
2880 subject=f'{_element_label(group)} {attribute}',
2881 inner=bounds,
2882 container='canvas viewBox',
2883 outer=canvas,
2884 repair=(
2885 'keep the root module subcanvas inside the SVG viewBox'
2886 ),
2887 )
2888
2889 if missing:
2890 sample = '; '.join(missing[:3])
2891 suffix = '' if len(missing) <= 3 else f'; +{len(missing) - 3} more'
2892 bucket = result['errors'] if require_bounds else result['warnings']
2893 prefix = 'Detected' if require_bounds else 'Reference SVG: detected'
2894 bucket.append(
2895 f'{prefix} {len(missing)} visible root-level <g> '
2896 f'module(s) without explicit {_BOUNDS_ATTR} '
2897 f'({sample}{suffix}); every final-page/template root <g> other '
2898 'than a compact authored-preset atom declares its root-coordinate '
2899 'layout subcanvas even when it also carries native coordinates'
2900 )
2901
2902 def _check_text_bounds(
2903 self,
2904 root: ET.Element,
2905 result: Dict,
2906 ) -> None:
2907 """Validate visible text against page and root-module bounds."""
2908 helpers = (
2909 _estimate_single_line_text_frame_width,
2910 _parse_project_font_weight,
2911 _parse_project_geometry_length,
2912 _parse_project_text_anchor,
2913 _resolve_project_font_sizes,
2914 _resolve_project_letter_spacings,
2915 )
2916 if any(helper is None for helper in helpers):
2917 return
2918 try:
2919 font_sizes = _resolve_project_font_sizes(root)
2920 letter_spacings = _resolve_project_letter_spacings(
2921 root,
2922 font_sizes,
2923 )
2924 except ValueError:
2925 return
2926
2927 parent_by_id = {
2928 id(child): parent
2929 for parent in root.iter()
2930 for child in list(parent)
2931 }
2932 unchanged_groups = self._unchanged_txbody_group_ids(root)
2933 viewbox = _parse_viewbox_values(root.get('viewBox') or '')
2934 canvas = None
2935 if viewbox is not None:
2936 x, y, width, height = viewbox
2937 canvas = (x, y, x + width, y + height)
2938
2939 estimated_by_id: Dict[
2940 int,
2941 Tuple[float, float, float, float],
2942 ] = {}
2943 page_overflow_text_ids: set[int] = set()
2944 unverified: List[str] = []
2945 for text_element in root.iter(f'{{{SVG_NS}}}text'):
2946 if self._has_ancestor_id(
2947 text_element,
2948 parent_by_id,
2949 unchanged_groups,
2950 ):
2951 continue
2952 if self._has_non_visual_ancestor(
2953 text_element,
2954 root,
2955 parent_by_id,
2956 ):
2957 continue
2958 if self._is_hidden_element(text_element, parent_by_id):
2959 continue
2960 visible_text = ''.join(text_element.itertext())
2961 if (
2962 not visible_text.strip()
2963 or ('{{' in visible_text and '}}' in visible_text)
2964 ):
2965 continue
2966 estimated = self._estimated_text_bounds(
2967 text_element,
2968 parent_by_id,
2969 font_sizes,
2970 letter_spacings,
2971 include_headroom=False,
2972 )
2973 if estimated is not None:
2974 estimated_by_id[id(text_element)] = estimated
2975
2976 if (
2977 canvas is None
2978 or self._has_zero_opacity(text_element, parent_by_id)
2979 ):
2980 continue
2981
2982 page_estimated = self._estimated_text_bounds(
2983 text_element,
2984 parent_by_id,
2985 font_sizes,
2986 letter_spacings,
2987 include_headroom=False,
2988 )
2989 if page_estimated is None:
2990 unverified.append(self._text_diagnostic_label(text_element))
2991 continue
2992 direct_child = text_element
2993 parent = parent_by_id.get(id(direct_child))
2994 while parent is not None and parent is not root:
2995 direct_child = parent
2996 parent = parent_by_id.get(id(direct_child))
2997 morph_staging = (
2998 parent is root
2999 and _local_name(direct_child) == 'g'
3000 and self._is_off_canvas_morph_group(
3001 direct_child,
3002 canvas,
3003 )
3004 and self._bounds_are_disjoint(page_estimated, canvas)
3005 )
3006 if (
3007 not morph_staging
3008 and self._record_canvas_text_overflow(
3009 result,
3010 subject=self._text_diagnostic_label(text_element),
3011 inner=page_estimated,
3012 canvas=canvas,
3013 )
3014 ):
3015 page_overflow_text_ids.add(id(text_element))
3016
3017 if unverified:
3018 sample = ', '.join(unverified[:3])
3019 suffix = (
3020 ''
3021 if len(unverified) <= 3
3022 else f', +{len(unverified) - 3} more'
3023 )
3024 result['warnings'].append(
3025 'Cannot verify root viewBox bounds for visible text with '
3026 f'unsupported or unresolved geometry: {sample}{suffix}; use '
3027 'supported explicit text positioning when page fit matters'
3028 )
3029
3030 root_groups = [
3031 child
3032 for child in list(root)
3033 if _local_name(child) == 'g'
3034 ]
3035 for module in root_groups:
3036 if self._is_hidden_element(module, parent_by_id):
3037 continue
3038 resolved_module = self._resolved_root_module_bounds(module)
3039 if resolved_module is None:
3040 continue
3041 boundary_attribute, boundary = resolved_module
3042 for text_element in module.iter(f'{{{SVG_NS}}}text'):
3043 if id(text_element) in page_overflow_text_ids:
3044 continue
3045 estimated = estimated_by_id.get(id(text_element))
3046 if estimated is None:
3047 continue
3048 self._record_bounds_overflow(
3049 result,
3050 subject=self._text_diagnostic_label(text_element),
3051 inner=estimated,
3052 container=(
3053 f'{_element_label(module)} {boundary_attribute}'
3054 ),
3055 outer=boundary,
3056 repair=(
3057 'expand the root module bounds into available '
3058 'non-overlapping space; otherwise reflow the text'
3059 ),
3060 )
3061
3062 def _check_unmergeable_leading_text(self, root: ET.Element, result: Dict) -> None:
3063 """Warn when leading text cannot be normalized into one PPT text frame."""
3064 risky = []
3065 for text_el in root.iter(f'{{{SVG_NS}}}text'):
3066 if not (text_el.text or "").strip():
3067 continue
3068 children = list(text_el)
3069 if not any(self._is_line_tspan(child) for child in children):
3070 continue
3071
3072 reason = self._leading_text_normalizer_reject_reason(text_el)
3073 if reason is not None:
3074 risky.append(reason)
3075
3076 if risky:
3077 sample = '; '.join(risky[:3])
3078 suffix = '' if len(risky) <= 3 else f"; +{len(risky) - 3} more"
3079 result['warnings'].append(
3080 "Detected multi-line <text> with leading direct text that cannot "
3081 f"be normalized into one PPT text frame ({sample}{suffix})"
3082 )
3083
3084 def _check_fragmented_paragraph_text(
3085 self,
3086 root: ET.Element,
3087 result: Dict,
3088 ) -> None:
3089 """Warn on high-confidence prose lines split into sibling text frames."""
3090 helpers = (
3091 _parse_project_geometry_length,
3092 _resolve_project_font_sizes,
3093 )
3094 if any(helper is None for helper in helpers):
3095 return
3096 try:
3097 font_sizes = _resolve_project_font_sizes(root)
3098 except ValueError:
3099 return
3100
3101 parent_by_id = {
3102 id(child): parent
3103 for parent in root.iter()
3104 for child in list(parent)
3105 }
3106 unchanged_groups = self._unchanged_txbody_group_ids(root)
3107 style_properties = (
3108 'fill',
3109 'fill-opacity',
3110 'font-family',
3111 'font-style',
3112 'font-weight',
3113 'letter-spacing',
3114 'opacity',
3115 'stroke',
3116 'stroke-opacity',
3117 'stroke-width',
3118 'text-decoration',
3119 )
3120
3121 def line_record(element: ET.Element) -> Dict | None:
3122 if (
3123 _local_name(element) != 'text'
3124 or list(element)
3125 or element.get('x') is None
3126 or element.get('y') is None
3127 or any(element.get(name) is not None for name in ('dx', 'dy'))
3128 or element.get('transform') is not None
3129 or self._is_hidden_element(element, parent_by_id)
3130 or self._has_ancestor_id(
3131 element,
3132 parent_by_id,
3133 unchanged_groups,
3134 )
3135 ):
3136 return None
3137 text = (element.text or '').strip()
3138 compact_text = re.sub(r'\s+', '', text)
3139 if (
3140 not compact_text
3141 or ('{{' in text and '}}' in text)
3142 or _PARAGRAPH_LIST_MARKER_RE.match(text)
3143 ):
3144 return None
3145 anchor = (
3146 _effective_presentation_value(
3147 element,
3148 'text-anchor',
3149 parent_by_id,
3150 )
3151 or 'start'
3152 ).strip().lower()
3153 if anchor != 'start':
3154 return None
3155 try:
3156 x = _parse_project_geometry_length(element.get('x'), 'x')
3157 y = _parse_project_geometry_length(element.get('y'), 'y')
3158 font_size = float(font_sizes[id(element)])
3159 except (KeyError, TypeError, ValueError):
3160 return None
3161 if font_size <= 0:
3162 return None
3163 style = tuple(
3164 (
3165 _effective_presentation_value(
3166 element,
3167 name,
3168 parent_by_id,
3169 )
3170 or ''
3171 ).strip().lower()
3172 for name in style_properties
3173 )
3174 return {
3175 'chars': len(compact_text),
3176 'font_size': font_size,
3177 'style': style,
3178 'text': text,
3179 'x': x,
3180 'y': y,
3181 }
3182
3183 suspects: List[str] = []
3184 for group in list(root):
3185 if (
3186 _local_name(group) != 'g'
3187 or self._is_hidden_element(group, parent_by_id)
3188 ):
3189 continue
3190 current_run: List[Dict] = []
3191
3192 def flush_run() -> None:
3193 if len(current_run) < 2:
3194 return
3195 total_chars = sum(line['chars'] for line in current_run)
3196 longest_line = max(line['chars'] for line in current_run)
3197 if (
3198 total_chars < _PARAGRAPH_LINE_MIN_TOTAL_CHARS
3199 or longest_line < _PARAGRAPH_LINE_MIN_LONGEST_CHARS
3200 ):
3201 return
3202 first = current_run[0]
3203 last = current_run[-1]
3204 suspects.append(
3205 f'{_element_label(group)} x={first["x"]:.1f}, '
3206 f'y={first["y"]:.1f}..{last["y"]:.1f}, '
3207 f'{len(current_run)} lines'
3208 )
3209
3210 for child in list(group):
3211 line = line_record(child)
3212 if line is None:
3213 flush_run()
3214 current_run = []
3215 continue
3216 if current_run:
3217 previous = current_run[-1]
3218 line_gap = line['y'] - previous['y']
3219 same_frame = (
3220 abs(line['x'] - previous['x'])
3221 <= _PARAGRAPH_LINE_X_TOLERANCE
3222 and line['style'] == previous['style']
3223 and math.isclose(
3224 line['font_size'],
3225 previous['font_size'],
3226 rel_tol=0.0,
3227 abs_tol=1e-6,
3228 )
3229 and line_gap
3230 >= line['font_size'] * _PARAGRAPH_LINE_GAP_MIN_RATIO
3231 and line_gap
3232 <= line['font_size'] * _PARAGRAPH_LINE_GAP_MAX_RATIO
3233 and not _PARAGRAPH_LINE_TERMINATOR_RE.search(
3234 previous['text']
3235 )
3236 )
3237 if not same_frame:
3238 flush_run()
3239 current_run = []
3240 current_run.append(line)
3241 flush_run()
3242
3243 if not suspects:
3244 return
3245 sample = '; '.join(suspects[:3])
3246 suffix = '' if len(suspects) <= 3 else f'; +{len(suspects) - 3} more'
3247 result['warnings'].append(
3248 f'Detected {len(suspects)} paragraph-like line run(s) split '
3249 f'across sibling <text> elements ({sample}{suffix}). If each run '
3250 'is one prose paragraph, combine it into one <text>: keep its '
3251 'first line as direct text and use direct <tspan> children with '
3252 'the parent x and positive relative dy values for later lines. '
3253 'An all-<tspan> form may start with dy="0". Keep semantically '
3254 'independent text frames separate.'
3255 )
3256
3257 @staticmethod
3258 def _is_tspan(elem: ET.Element) -> bool:
3259 return elem.tag == f'{{{SVG_NS}}}tspan'
3260
3261 @classmethod
3262 def _is_line_tspan(cls, elem: ET.Element) -> bool:
3263 if not cls._is_tspan(elem):
3264 return False
3265 if elem.get('x') is not None or elem.get('y') is not None:
3266 return True
3267 dy = elem.get('dy')
3268 if dy is None:
3269 return False
3270 try:
3271 return float(re.match(r'^[\s,]*([+-]?(?:\d+\.?\d*|\d*\.\d+))', dy).group(1)) != 0
3272 except (AttributeError, ValueError):
3273 return True
3274
3275 @classmethod
3276 def _leading_text_normalizer_reject_reason(cls, text_el: ET.Element) -> str | None:
3277 if text_el.get('x') is None:
3278 return '<text> has no x anchor'
3279
3280 for child in list(text_el):
3281 if not cls._is_tspan(child):
3282 return '<text> has non-tspan child'
3283 if (child.tail or "").strip():
3284 return '<tspan> has non-empty tail text'
3285
3286 return None
3287
3288 def _check_image_references(self, root: ET.Element, svg_path: Path, result: Dict):
3289 """Check image file existence and effective rendered resolution."""
3290 svg_dir = svg_path.parent
3291 working_root, parent_by_id, images = self._visible_image_elements(root)
3292
3293 for image in images:
3294 href = image.get('href') or image.get(f'{{{XLINK_NS}}}href')
3295 if not href or href.startswith('data:'):
3296 continue
3297 if self.template_mode and '{{' in href and '}}' in href:
3298 continue
3299 if _resolve_external_image_reference is None:
3300 result['warnings'].append(
3301 "Detected image references, but shared image resolver could not be imported; "
3302 "export will still validate them."
3303 )
3304 return
3305
3306 img_path = _resolve_external_image_reference(svg_dir, href)
3307 if img_path is None:
3308 # The shared image-source contract already reports the
3309 # blocking resolution failure. This pass adds quality advice
3310 # only for valid, resolved images.
3311 continue
3312
3313 # Check resolution vs display size
3314 display_owner = image
3315 parent = parent_by_id.get(id(image))
3316 if (
3317 parent is not None
3318 and parent is not working_root
3319 and parent.tag == f'{{{SVG_NS}}}svg'
3320 ):
3321 # Imported crops use a unit-frame inner image. Quality advice
3322 # must compare the source against the visible outer frame.
3323 display_owner = parent
3324 display_w_str = display_owner.get('width')
3325 display_h_str = display_owner.get('height')
3326 if not display_w_str or not display_h_str:
3327 continue
3328
3329 try:
3330 display_x = float(display_owner.get('x') or '0')
3331 display_y = float(display_owner.get('y') or '0')
3332 local_display_w = float(display_w_str)
3333 local_display_h = float(display_h_str)
3334 except (ValueError, TypeError):
3335 continue
3336 if local_display_w <= 0 or local_display_h <= 0:
3337 continue
3338 display_w = local_display_w
3339 display_h = local_display_h
3340 transformed_size = self._transformed_rect_edge_lengths(
3341 display_owner,
3342 (display_x, display_y, local_display_w, local_display_h),
3343 parent_by_id,
3344 )
3345 if transformed_size is not None:
3346 display_w, display_h = transformed_size
3347 axis_scale_x = display_w / local_display_w
3348 axis_scale_y = display_h / local_display_h
3349
3350 try:
3351 from PIL import Image as PILImage, ImageOps
3352 with PILImage.open(img_path) as img:
3353 actual_w, actual_h = ImageOps.exif_transpose(img).size
3354 source_bytes = img_path.stat().st_size
3355
3356 visible_w = float(actual_w)
3357 visible_h = float(actual_h)
3358 fit_owner = image
3359 if display_owner is not image:
3360 fit_owner = display_owner
3361 viewbox = (display_owner.get('viewBox') or '').split()
3362 if len(viewbox) == 4:
3363 try:
3364 viewbox_w = float(viewbox[2])
3365 viewbox_h = float(viewbox[3])
3366 except ValueError:
3367 pass
3368 else:
3369 if 0 < viewbox_w <= 1 and 0 < viewbox_h <= 1:
3370 visible_w *= viewbox_w
3371 visible_h *= viewbox_h
3372
3373 raw_aspect = fit_owner.get('preserveAspectRatio')
3374 try:
3375 align, mode = (
3376 _parse_project_image_aspect_ratio(raw_aspect)
3377 if _parse_project_image_aspect_ratio is not None
3378 else ('xMidYMid', 'meet')
3379 )
3380 except ValueError:
3381 continue
3382
3383 local_scale_x = local_display_w / visible_w
3384 local_scale_y = local_display_h / visible_h
3385 if align == 'none':
3386 render_scale = max(
3387 local_scale_x * axis_scale_x,
3388 local_scale_y * axis_scale_y,
3389 )
3390 fit_label = 'none'
3391 elif mode == 'slice':
3392 render_scale = (
3393 max(local_scale_x, local_scale_y)
3394 * max(axis_scale_x, axis_scale_y)
3395 )
3396 fit_label = 'slice'
3397 else:
3398 render_scale = (
3399 min(local_scale_x, local_scale_y)
3400 * max(axis_scale_x, axis_scale_y)
3401 )
3402 fit_label = 'meet'
3403
3404 if render_scale > 1.0:
3405 result['warnings'].append(
3406 f"Image {href} is {actual_w}x{actual_h} and renders at "
3407 f"{render_scale:.2f}x scale in a "
3408 f"{int(display_w)}x{int(display_h)} {fit_label} frame "
3409 "— may appear blurry"
3410 )
3411 elif (
3412 render_scale < 1.0 / IMAGE_DOWNSIZE_WARN_RATIO
3413 and source_bytes >= IMAGE_DOWNSIZE_WARN_MIN_BYTES
3414 ):
3415 source_mib = source_bytes / (1024 * 1024)
3416 result['warnings'].append(
3417 f"Image {href} is {actual_w}x{actual_h} and renders at "
3418 f"{render_scale:.2f}x scale in a "
3419 f"{int(display_w)}x{int(display_h)} {fit_label} frame; "
3420 f"the source is {source_mib:.1f} MiB — file-size "
3421 "advisory only, not an aspect-ratio warning; consider "
3422 "a smaller source asset"
3423 )
3424 except ImportError:
3425 pass # PIL not available, skip resolution check
3426 except Exception:
3427 pass # Image unreadable, skip resolution check
3428
3429 def _check_icon_placeholders(self, root: ET.Element, svg_path: Path, result: Dict) -> None:
3430 """Check that <use data-icon="..."> placeholders resolve."""
3431 placeholders = [
3432 elem for elem in root.iter()
3433 if _local_name(elem).lower() == 'use' and elem.get('data-icon') is not None
3434 ]
3435 if not placeholders:
3436 return
3437
3438 if _resolve_icon_path is None:
3439 result['warnings'].append(
3440 "Detected data-icon placeholders, but icon resolver could not be imported; "
3441 "post-processing/export will still validate them."
3442 )
3443 return
3444 if _icon_search_dirs_for_svg is None:
3445 result['warnings'].append(
3446 "Detected data-icon placeholders, but shared icon search helper could not be imported; "
3447 "post-processing/export will still validate them."
3448 )
3449 return
3450
3451 icons_dir, fallback_dir = _icon_search_dirs_for_svg(svg_path)
3452 require_project_local = self._requires_project_local_icons(svg_path)
3453 project_icons_dir = (
3454 _project_root_for_svg_path(svg_path) / 'icons'
3455 if _project_root_for_svg_path is not None
3456 else None
3457 )
3458 seen = set()
3459 for elem in placeholders:
3460 icon_name = (elem.get('data-icon') or '').strip()
3461 if not icon_name:
3462 result['errors'].append("Icon placeholder has empty data-icon value")
3463 continue
3464 if icon_name in seen:
3465 continue
3466 seen.add(icon_name)
3467
3468 if require_project_local and project_icons_dir is not None:
3469 local_path, _ = _resolve_icon_path(
3470 icon_name,
3471 project_icons_dir,
3472 None,
3473 )
3474 if not local_path.exists():
3475 result['errors'].append(
3476 f"Icon is not prepared in the project: {icon_name} "
3477 f"(expected under {project_icons_dir}); return to "
3478 "Strategist preparation instead of using the global fallback"
3479 )
3480 continue
3481
3482 icon_path, _ = _resolve_icon_path(icon_name, icons_dir, fallback_dir)
3483 if not icon_path.exists():
3484 fallback_msg = f", then {fallback_dir}" if fallback_dir else ""
3485 suggestion = (
3486 _suggest_icon_name(icon_name, icons_dir, fallback_dir)
3487 if _suggest_icon_name is not None else None
3488 )
3489 hint = (
3490 f"; identifiers are case-sensitive; use '{suggestion}'"
3491 if suggestion else ""
3492 )
3493 result['errors'].append(
3494 f"Icon not found: {icon_name} (searched {icons_dir}"
3495 f"{fallback_msg}){hint}"
3496 )
3497 continue
3498 try:
3499 icon_root = ET.parse(icon_path).getroot()
3500 hydrated = hydrate_native_payload_refs(icon_root, icon_path)
3501 except (OSError, ET.ParseError, NativePayloadError) as exc:
3502 result['errors'].append(
3503 f"Icon {icon_name} has invalid native payload metadata: {exc}"
3504 )
3505 continue
3506 if _project_mask_errors is not None:
3507 result['errors'].extend(
3508 f'Icon {icon_name}: {error}'
3509 for error in _project_mask_errors(icon_root)
3510 )
3511 if hydrated:
3512 result['info']['native_icon_payload_refs'] = (
3513 result['info'].get('native_icon_payload_refs', 0) + hydrated
3514 )
3515
3516 @staticmethod
3517 def _requires_project_local_icons(svg_path: Path) -> bool:
3518 """Return whether a generated page belongs to a versioned project."""
3519 if svg_path.parent.name != 'svg_output' or _project_root_for_svg_path is None:
3520 return False
3521 lock_path = _project_root_for_svg_path(svg_path) / 'spec_lock.md'
3522 try:
3523 first_line = next(
3524 (
3525 line.strip()
3526 for line in lock_path.read_text(encoding='utf-8-sig').splitlines()
3527 if line.strip()
3528 ),
3529 '',
3530 )
3531 except OSError:
3532 return False
3533 return bool(
3534 re.fullmatch(
3535 r'<!--[ \t]+ppt-master-schema:[ \t]*spec-lock/v[1-9][0-9]*[ \t]+-->',
3536 first_line,
3537 re.IGNORECASE,
3538 )
3539 )
3540
3541 def _check_unsupported_visual_elements(
3542 self,
3543 root: ET.Element,
3544 result: Dict,
3545 ) -> None:
3546 """Reject authored visual elements with no native converter dispatch."""
3547 if _collect_unsupported_visuals is None:
3548 result['errors'].append(
3549 "Unable to import native visual-element preflight; "
3550 "cannot verify SVG element support"
3551 )
3552 return
3553 if _expand_local_use_references is None or _UseExpansionError is None:
3554 result['errors'].append(
3555 "Unable to import local <use> expansion; "
3556 "cannot verify SVG element support"
3557 )
3558 return
3559
3560 expanded_root = copy.deepcopy(root)
3561 try:
3562 _expand_local_use_references(expanded_root)
3563 except _UseExpansionError:
3564 # _check_forbidden_elements already reports the actionable
3565 # local-reference validation error.
3566 return
3567
3568 unsupported = _collect_unsupported_visuals(
3569 expanded_root,
3570 allow_data_icon_use=True,
3571 )
3572 if not unsupported:
3573 return
3574
3575 preview = '; '.join(unsupported[:8])
3576 suffix = '' if len(unsupported) <= 8 else f'; +{len(unsupported) - 8} more'
3577 result['errors'].append(
3578 f"Unsupported visual SVG element(s) for native PPTX export: "
3579 f"{preview}{suffix}"
3580 )
3581
3582 def _check_preset_geometry_metadata(
3583 self,
3584 root: ET.Element,
3585 result: Dict,
3586 ) -> None:
3587 """Validate round-trip preset metadata with the exporter's parser."""
3588 marked = [
3589 elem
3590 for elem in root.iter()
3591 if (
3592 elem.get('data-pptx-prst') is not None
3593 or elem.get('data-pptx-frame') is not None
3594 or elem.get('data-pptx-geometry-status') is not None
3595 or elem.get('data-pptx-geometry-reason') is not None
3596 or elem.get('data-pptx-geometry-kind') is not None
3597 or elem.get('data-pptx-custgeom') is not None
3598 or elem.get('data-pptx-preview-sha256') is not None
3599 or elem.get('data-pptx-shape-id') is not None
3600 or elem.get('data-pptx-shape-scope') is not None
3601 or elem.get('data-pptx-shape-style') is not None
3602 or elem.get(_AUTHORING_ATTR) is not None
3603 or any(attr.startswith('data-pptx-av-') for attr in elem.attrib)
3604 )
3605 ]
3606 if not marked:
3607 return
3608 if _validate_preset_geometry_metadata is None:
3609 result['errors'].append(
3610 'Unable to import PPTX preset metadata validator; '
3611 'cannot verify native shape restoration'
3612 )
3613 return
3614
3615 issues = set()
3616 for elem in marked:
3617 tag = _local_name(elem)
3618 elem_id = elem.get('id')
3619 label = f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>'
3620 for error in _validate_preset_geometry_metadata(elem):
3621 issues.add(f'{label} has invalid PPTX shape metadata: {error}')
3622 if _validate_authored_preset_tree is None:
3623 if any(
3624 elem.get(_AUTHORING_ATTR) is not None
3625 for elem in root.iter()
3626 ):
3627 issues.add(
3628 'Unable to import authored PPTX preset validator'
3629 )
3630 else:
3631 for error in _validate_authored_preset_tree(root):
3632 issues.add(f'Invalid authored PPTX preset: {error}')
3633 if (
3634 _svg_preset_preview_fingerprint is None
3635 or _resolve_preset_preview_hash is None
3636 ):
3637 issues.add('Unable to import PPTX preset preview fingerprint validator')
3638 else:
3639 for elem in root.iter():
3640 if (
3641 _local_name(elem) != 'g'
3642 or elem.get('data-pptx-object') not in {'shape', 'connector'}
3643 or elem.get('data-pptx-prst') is None
3644 ):
3645 continue
3646 try:
3647 expected = _resolve_preset_preview_hash(elem)
3648 except ValueError as exc:
3649 elem_id = elem.get('id') or '(no id)'
3650 issues.add(
3651 f'<g id="{elem_id}"> has an invalid PPTX preset '
3652 f'preview contract: {exc}'
3653 )
3654 continue
3655 if expected is None:
3656 continue
3657 actual = _svg_preset_preview_fingerprint(elem)
3658 if actual != expected:
3659 elem_id = elem.get('id') or '(no id)'
3660 issues.add(
3661 f'<g id="{elem_id}"> has a stale PPTX preset preview; '
3662 'update the native carrier or restore the generated detail paths'
3663 )
3664 result['errors'].extend(sorted(issues))
3665 if (
3666 _authored_preset_encoding is not None
3667 and _validate_authored_preset_group is not None
3668 ):
3669 expanded = [
3670 elem.get('id') or '(no id)'
3671 for elem in root.iter()
3672 if _authored_preset_encoding(elem) == 'expanded'
3673 and not _validate_authored_preset_group(elem)
3674 ]
3675 if expanded:
3676 examples = ', '.join(expanded[:3])
3677 suffix = '' if len(expanded) <= 3 else f', +{len(expanded) - 3} more'
3678 result['warnings'].append(
3679 'Compatible expanded authored-preset fragment(s) detected '
3680 f'({len(expanded)}: {examples}{suffix}). New project-authored '
3681 'pages and templates use the compact helper form; the '
3682 'expanded carrier/preview form remains readable for compatibility. '
3683 'No change is required while it remains ordinary Slide-local input.'
3684 )
3685 inherited_paint = _compact_preset_ancestor_paint(root)
3686 if inherited_paint:
3687 examples = ', '.join(
3688 f'{element_id} ({"/".join(properties)})'
3689 for element_id, properties in inherited_paint[:3]
3690 )
3691 suffix = (
3692 ''
3693 if len(inherited_paint) <= 3
3694 else f', +{len(inherited_paint) - 3} more'
3695 )
3696 result['warnings'].append(
3697 'Compact authored preset(s) use compatible ancestor paint or '
3698 f'opacity ({examples}{suffix}). Canonical page/template authoring '
3699 'keeps preset paint local and reruns the helper with channel alpha; '
3700 'export remains supported.'
3701 )
3702
3703 def _check_preset_geometry_transforms(
3704 self,
3705 root: ET.Element,
3706 result: Dict,
3707 ) -> None:
3708 """Reject preset transforms that DrawingML cannot represent exactly."""
3709 helpers = (
3710 _IDENTITY_MATRIX,
3711 _matrix_multiply,
3712 _parse_transform_matrix,
3713 _rect_to_dml_xfrm,
3714 _validate_dml_shape_matrix,
3715 )
3716 if any(helper is None for helper in helpers):
3717 return
3718
3719 relevant: set[ET.Element] = set()
3720
3721 def mark_relevant(element: ET.Element) -> bool:
3722 found = element.get('data-pptx-prst') is not None
3723 for child in element:
3724 found = mark_relevant(child) or found
3725 if found:
3726 relevant.add(element)
3727 return found
3728
3729 mark_relevant(root)
3730 issues = set()
3731
3732 def visit(element: ET.Element, parent_matrix) -> None:
3733 if element not in relevant:
3734 return
3735 matrix = parent_matrix
3736 transform = element.get('transform')
3737 if transform:
3738 try:
3739 local_matrix = _parse_transform_matrix(transform)
3740 matrix = _matrix_multiply(parent_matrix, local_matrix)
3741 except ValueError as exc:
3742 issues.add(
3743 f'<{_local_name(element)}> has invalid preset '
3744 f'transform: {exc}'
3745 )
3746 return
3747 if element.get('data-pptx-prst') is not None:
3748 try:
3749 raw_frame = element.get('data-pptx-frame')
3750 if raw_frame:
3751 frame = tuple(
3752 float(part)
3753 for part in re.split(r'[\s,]+', raw_frame.strip())
3754 )
3755 if len(frame) != 4:
3756 raise ValueError(
3757 'data-pptx-frame must contain four numbers'
3758 )
3759 preset = element.get('data-pptx-prst') or ''
3760 _rect_to_dml_xfrm(
3761 frame[0],
3762 frame[1],
3763 frame[2],
3764 frame[3],
3765 matrix,
3766 preserve_degenerate_axes=(
3767 element.get('data-pptx-object') == 'connector'
3768 or preset in _CONNECTOR_PRESET_TYPES
3769 ),
3770 )
3771 else:
3772 _validate_dml_shape_matrix(matrix)
3773 except ValueError as exc:
3774 elem_id = element.get('id') or '(no id)'
3775 issues.add(
3776 f'<{_local_name(element)} id="{elem_id}"> has '
3777 f'unsupported preset transform: {exc}'
3778 )
3779 for child in element:
3780 visit(child, matrix)
3781
3782 visit(root, _IDENTITY_MATRIX)
3783 result['errors'].extend(sorted(issues))
3784
3785 @staticmethod
3786 def _is_full_canvas_root_rect(
3787 root: ET.Element,
3788 element: ET.Element,
3789 ) -> bool:
3790 """Return whether one direct rect is the ordinary full-page backdrop."""
3791 if (
3792 _local_name(element) != 'rect'
3793 or _parse_project_geometry_length is None
3794 or any(
3795 element.get(attribute)
3796 for attribute in ('transform', 'filter', 'clip-path')
3797 )
3798 ):
3799 return False
3800 viewbox = _parse_viewbox_values(root.get('viewBox') or '')
3801 if viewbox is None:
3802 return False
3803
3804 parent_by_id = {id(element): root}
3805
3806 def inherited(name: str, default: str) -> str:
3807 return _effective_presentation_value(
3808 element,
3809 name,
3810 parent_by_id,
3811 ) or default
3812
3813 try:
3814 values = {
3815 name: _parse_project_geometry_length(
3816 element.get(name) or '0',
3817 name,
3818 )
3819 for name in ('x', 'y', 'width', 'height', 'rx', 'ry')
3820 }
3821 stroke_width = _parse_project_geometry_length(
3822 inherited('stroke-width', '1'),
3823 'stroke-width',
3824 )
3825 stroke_opacity = (
3826 _parse_project_opacity(inherited('stroke-opacity', '1'))
3827 if _parse_project_opacity is not None else 1.0
3828 )
3829 except ValueError:
3830 return False
3831 fill = inherited('fill', '#000000').strip().lower()
3832 stroke = inherited('stroke', 'none').strip().lower()
3833 if (
3834 fill == 'none'
3835 or (
3836 stroke != 'none'
3837 and stroke_width > 0
3838 and stroke_opacity > 0
3839 )
3840 ):
3841 return False
3842
3843 view_x, view_y, view_width, view_height = viewbox
3844 tolerance = 0.5
3845 return (
3846 values['rx'] == 0
3847 and values['ry'] == 0
3848 and abs(values['x'] - view_x) <= tolerance
3849 and abs(values['y'] - view_y) <= tolerance
3850 and abs(values['width'] - view_width) <= tolerance
3851 and abs(values['height'] - view_height) <= tolerance
3852 )
3853
3854 def _check_animation_group_ids(
3855 self,
3856 root: ET.Element,
3857 svg_path: Path,
3858 result: Dict,
3859 ):
3860 """Validate top-level animation anchors without policing inner groups."""
3861 non_visual = {'defs', 'title', 'desc', 'metadata', 'style'}
3862 group_indexes: Dict[str, List[int]] = defaultdict(list)
3863 ungrouped: List[str] = []
3864 ungrouped_signatures: List[Tuple[object, ...]] = []
3865 visual_index = 0
3866
3867 for child in root:
3868 tag = _local_name(child)
3869 if tag in non_visual:
3870 continue
3871 visual_index += 1
3872 is_first_visual = visual_index == 1
3873
3874 if tag == 'g':
3875 group_id = _usable_animation_group_id(child.get('id'))
3876 if group_id is None:
3877 result['warnings'].append(
3878 f"Top-level visible <g> #{visual_index} has no id; "
3879 "object-level animation config cannot reference it"
3880 )
3881 continue
3882 group_indexes[group_id].append(visual_index)
3883 continue
3884
3885 if svg_path.parent.name != 'svg_output':
3886 continue
3887 if child.get('data-pptx-layer') is not None:
3888 continue
3889 if (
3890 _is_static_page_frame is not None
3891 and _is_static_page_frame(
3892 child.get('data-pptx-role'),
3893 child.get('data-pptx-placeholder'),
3894 )
3895 ):
3896 continue
3897 if is_first_visual and self._is_full_canvas_root_rect(root, child):
3898 continue
3899 child_id = (child.get('id') or '').strip()
3900 ungrouped.append(
3901 f'<{tag} id="{child_id}">'
3902 if child_id else f'<{tag}> #{visual_index}'
3903 )
3904 ungrouped_signatures.append(
3905 self._prototype_element_signature(child)
3906 )
3907
3908 for group_id, indexes in sorted(group_indexes.items()):
3909 if len(indexes) > 1:
3910 positions = ', '.join(str(item) for item in indexes)
3911 result['errors'].append(
3912 f'Duplicate top-level group id {group_id!r} at visible '
3913 f'positions {positions}; animation target ids must be unique'
3914 )
3915
3916 if ungrouped:
3917 samples = ', '.join(ungrouped[:3])
3918 if len(ungrouped) > 3:
3919 samples += ', ...'
3920 message = (
3921 f'{len(ungrouped)} ungrouped top-level Slide-local element(s) '
3922 f'in svg_output ({samples}); group only logical content units '
3923 'in a top-level <g id="...">. Keep genuine static page framing '
3924 'as a root primitive and declare a supported data-pptx-role such '
3925 'as "background" or "decoration"'
3926 )
3927 prototype_root = self._active_prototype_root()
3928 prototype_ungrouped = (
3929 self._ungrouped_slide_local_facts(prototype_root)
3930 if prototype_root is not None
3931 else ([], [])
3932 )
3933 if (
3934 prototype_root is not None
3935 and ungrouped == prototype_ungrouped[0]
3936 and ungrouped_signatures == prototype_ungrouped[1]
3937 ):
3938 self._append_inherited_info(
3939 result,
3940 'animation_anchor',
3941 message,
3942 )
3943 else:
3944 result['warnings'].append(message)
3945
3946 @staticmethod
3947 def _prototype_element_signature(
3948 element: ET.Element,
3949 ) -> Tuple[object, ...]:
3950 """Compare warning-owned topology/style while ignoring visible text."""
3951 return (
3952 _local_name(element),
3953 tuple(sorted(element.attrib.items())),
3954 tuple(
3955 SVGQualityChecker._prototype_element_signature(child)
3956 for child in element
3957 ),
3958 )
3959
3960 def _ungrouped_slide_local_facts(
3961 self,
3962 root: ET.Element,
3963 ) -> Tuple[List[str], List[Tuple[object, ...]]]:
3964 """Describe and fingerprint top-level non-group Slide-local atoms."""
3965 non_visual = {'defs', 'title', 'desc', 'metadata', 'style'}
3966 descriptors: List[str] = []
3967 signatures: List[Tuple[object, ...]] = []
3968 visual_index = 0
3969 for child in root:
3970 tag = _local_name(child)
3971 if tag in non_visual:
3972 continue
3973 visual_index += 1
3974 if tag == 'g' or child.get('data-pptx-layer') is not None:
3975 continue
3976 if (
3977 _is_static_page_frame is not None
3978 and _is_static_page_frame(
3979 child.get('data-pptx-role'),
3980 child.get('data-pptx-placeholder'),
3981 )
3982 ):
3983 continue
3984 if visual_index == 1 and self._is_full_canvas_root_rect(root, child):
3985 continue
3986 child_id = (child.get('id') or '').strip()
3987 descriptors.append(
3988 f'<{tag} id="{child_id}">'
3989 if child_id else f'<{tag}> #{visual_index}'
3990 )
3991 signatures.append(self._prototype_element_signature(child))
3992 return descriptors, signatures
3993
3994 # OOXML ST_PresetPatternVal enum — anything outside this set produces a
3995 # PPTX schema violation ("PowerPoint found a problem with the content").
3996 _OOXML_PATTERN_PRESETS = frozenset({
3997 'pct5', 'pct10', 'pct20', 'pct25', 'pct30', 'pct40', 'pct50', 'pct60',
3998 'pct70', 'pct75', 'pct80', 'pct90',
3999 'horz', 'vert', 'ltHorz', 'ltVert', 'dkHorz', 'dkVert',
4000 'narHorz', 'narVert', 'dashHorz', 'dashVert',
4001 'cross', 'dnDiag', 'upDiag', 'ltDnDiag', 'ltUpDiag', 'dkDnDiag',
4002 'dkUpDiag', 'wdDnDiag', 'wdUpDiag',
4003 'dashDnDiag', 'dashUpDiag', 'diagCross',
4004 'smCheck', 'lgCheck', 'smGrid', 'lgGrid', 'dotGrid', 'smConfetti',
4005 'lgConfetti', 'horzBrick', 'diagBrick', 'solidDmnd', 'openDmnd',
4006 'dotDmnd', 'plaid', 'sphere', 'weave', 'wave', 'trellis', 'zigZag',
4007 'divot', 'shingle',
4008 })
4009
4010 def _check_pattern_fills(self, root: ET.Element, result: Dict):
4011 """Audit <pattern> defs that drive PPTX <a:pattFill> output.
4012
4013 svg_to_pptx maps <pattern fill> to native <a:pattFill prst="...">. The
4014 preset name comes from `data-pptx-pattern` (e.g. `lgGrid` / `smGrid` /
4015 `dkUpDiag`). Patterns marked with `data-pptx-text-image-fill` instead
4016 map to run-level <a:blipFill> and are validated by converter preflight.
4017 Two preset-pattern failure modes are worth catching pre-export:
4018
4019 1. Missing annotation → the converter compatibility fallback chooses
4020 `ltUpDiag` (diagonal stripes), which is not an authoring contract.
4021 2. Invalid preset name → PPTX schema rejects the file; PowerPoint
4022 opens it with "needs to be repaired". OOXML
4023 `ST_PresetPatternVal` is a closed enum — only the names in
4024 `_OOXML_PATTERN_PRESETS` are legal. Inventing `ltGrid` (no such
4025 value) is the canonical mistake; the only grids are `smGrid` /
4026 `lgGrid` / `dotGrid`.
4027 """
4028 definitions, _duplicates = _direct_defs_index(root)
4029 referenced_patterns: set[str] = set()
4030 for elem in root.iter():
4031 style_values = (
4032 _parse_inline_style(elem.get('style'))
4033 if _parse_inline_style is not None else {}
4034 )
4035 fill = style_values.get('fill') or elem.get('fill')
4036 match = re.fullmatch(r'url\(#([^)]+)\)', (fill or '').strip())
4037 if match is None:
4038 continue
4039 definition = definitions.get(match.group(1))
4040 if definition is not None and _local_name(definition) == 'pattern':
4041 referenced_patterns.add(match.group(1))
4042
4043 for pattern in (
4044 elem for elem in root.iter()
4045 if _local_name(elem) == 'pattern'
4046 ):
4047 pat_id = pattern.get('id', '<unnamed>')
4048 prst = pattern.get('data-pptx-pattern')
4049 if pattern.get(_TEXT_IMAGE_FILL_ATTR) is not None:
4050 continue
4051 if pat_id in referenced_patterns and not prst:
4052 result['warnings'].append(
4053 f"Fidelity warning: <pattern id=\"{pat_id}\"> has no "
4054 "data-pptx-pattern attribute, so the converter will use its "
4055 "compatible `ltUpDiag` fallback. Generated SVG should declare a valid "
4056 "data-pptx-pattern to make the intended preset explicit; "
4057 "set data-pptx-fg/data-pptx-bg or matching child paints "
4058 "when explicit pattern colors are required. No change is "
4059 "required for export."
4060 )
4061 if pat_id in referenced_patterns and pattern.get('patternTransform'):
4062 result['errors'].append(
4063 f"<pattern id=\"{pat_id}\"> cannot use patternTransform; "
4064 "the native preset mapping does not preserve custom tile transforms"
4065 )
4066 if prst not in self._OOXML_PATTERN_PRESETS:
4067 if not prst:
4068 continue
4069 result['errors'].append(
4070 f"<pattern id=\"{pat_id}\"> uses data-pptx-pattern=\"{prst}\" "
4071 "which is not in OOXML ST_PresetPatternVal — exported PPTX "
4072 "will fail schema validation ('needs to be repaired'). "
4073 "Use one of: smGrid / lgGrid / dotGrid (grids), "
4074 "ltUpDiag / dkUpDiag / cross / diagCross / weave / plaid / "
4075 "horzBrick (others); see references/native-data-interface.md §1 "
4076 "for the full authoring enum."
4077 )
4078
4079 def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None:
4080 """Validate explicit native replacement markers before PPTX export."""
4081 inline_formula_markers = [
4082 elem for elem in root.iter()
4083 if elem.get(_INLINE_FORMULA_ATTR) is not None
4084 ]
4085 if inline_formula_markers and _inline_formula_marker_errors is None:
4086 result['errors'].append(
4087 "Unable to import inline-formula validator; cannot verify "
4088 f"{_INLINE_FORMULA_ATTR} markers"
4089 )
4090 elif _inline_formula_marker_errors is not None:
4091 for error in _inline_formula_marker_errors(root):
4092 result['errors'].append(f"Invalid inline formula marker: {error}")
4093
4094 invalid_status_elements: set[ET.Element] = set()
4095 for elem in root.iter():
4096 marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
4097 if elem.tag.rsplit('}', 1)[-1] == 'metadata':
4098 continue
4099 has_status = any(
4100 elem.get(name) is not None
4101 for name in (
4102 'data-pptx-replace-with',
4103 'data-pptx-native',
4104 'data-pptx-fallback-kind',
4105 'data-pptx-visual-status',
4106 'data-pptx-route-status',
4107 'data-pptx-replacement-status',
4108 'data-pptx-native-status',
4109 'data-pptx-import-source',
4110 'data-pptx-native-source',
4111 )
4112 )
4113 if not has_status:
4114 continue
4115 if (
4116 _native_marker_status_errors is None
4117 or _native_marker_release_block_reason is None
4118 ):
4119 result['errors'].append(
4120 "Unable to import native-object status validator; "
4121 f"cannot verify PPTX graphic {marker_id}"
4122 )
4123 continue
4124 status_errors = _native_marker_status_errors(elem)
4125 for error in status_errors:
4126 result['errors'].append(
4127 f"PPTX graphic {marker_id} has invalid status metadata: {error}"
4128 )
4129 if status_errors:
4130 invalid_status_elements.add(elem)
4131 continue
4132 if _native_marker_legacy_warnings is not None:
4133 for warning in _native_marker_legacy_warnings(elem):
4134 result['warnings'].append(
4135 f"PPTX replacement marker {marker_id}: {warning}"
4136 )
4137 try:
4138 fallback_kind = (
4139 _native_fallback_kind(elem)
4140 if _native_fallback_kind is not None else None
4141 )
4142 replacement_kind = (
4143 _native_replacement_kind(elem)
4144 if _native_replacement_kind is not None else ''
4145 )
4146 except ValueError:
4147 # The shared status validator reported the alias conflict.
4148 continue
4149 if fallback_kind == 'placeholder':
4150 route = (
4151 "the native Chart/Table route may reconstruct its active marker"
4152 if replacement_kind
4153 else "default export keeps the visible placeholder"
4154 )
4155 result['warnings'].append(
4156 f"PPTX graphic {marker_id} is a reconstruction-only placeholder; "
4157 f"it has no baked preview and {route}"
4158 )
4159
4160 for elem in root.iter():
4161 if elem.tag.rsplit('}', 1)[-1] == 'metadata':
4162 continue
4163 if _native_replacement_status is None or _native_replacement_kind is None:
4164 continue
4165 try:
4166 status = _native_replacement_status(elem)
4167 replacement_kind = _native_replacement_kind(elem)
4168 except ValueError:
4169 continue
4170 if not status or replacement_kind:
4171 continue
4172 marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
4173 result['warnings'].append(
4174 f"Native PPTX object {marker_id} is fallback-only: {status}"
4175 )
4176
4177 markers = [
4178 elem for elem in root.iter()
4179 if (
4180 _native_replacement_kind is not None
4181 and elem.tag.rsplit('}', 1)[-1] != 'metadata'
4182 and elem not in invalid_status_elements
4183 and _native_replacement_kind(elem)
4184 )
4185 ]
4186 if not markers:
4187 return
4188 if _validate_native_object_marker is None:
4189 result['warnings'].append(
4190 "Detected data-pptx-replace-with markers, but replacement validator "
4191 "could not be imported; export-time validation will still run."
4192 )
4193 return
4194
4195 parent_map = {
4196 child: parent
4197 for parent in root.iter()
4198 for child in parent
4199 }
4200
4201 def append_metadata_legacy_warnings(marker: ET.Element) -> None:
4202 if _native_marker_legacy_warnings is None:
4203 return
4204 marker_id = marker.get('id') or '<unnamed>'
4205 for child in marker:
4206 if child.tag.rsplit('}', 1)[-1] != 'metadata':
4207 continue
4208 for warning in _native_marker_legacy_warnings(child):
4209 result['warnings'].append(
4210 f"PPTX replacement marker {marker_id}: {warning}"
4211 )
4212
4213 for marker in markers:
4214 marker_id = marker.get('id') or '<unnamed>'
4215 ancestors = []
4216 parent = parent_map.get(marker)
4217 while parent is not None and parent is not root:
4218 if parent.tag.rsplit('}', 1)[-1] == 'g':
4219 ancestors.append(parent)
4220 parent = parent_map.get(parent)
4221 ancestors_tuple = tuple(reversed(ancestors))
4222 if _validate_native_object_marker_with_warnings is not None:
4223 try:
4224 warnings = _validate_native_object_marker_with_warnings(
4225 marker,
4226 ancestors=ancestors_tuple,
4227 document_root=root,
4228 )
4229 except RuntimeError as exc:
4230 result['errors'].append(
4231 f"Invalid data-pptx-replace-with marker {marker_id}: {exc}"
4232 )
4233 continue
4234 for warning in warnings:
4235 result['warnings'].append(
4236 f"data-pptx-replace-with marker {marker_id}: {warning}"
4237 )
4238 append_metadata_legacy_warnings(marker)
4239 continue
4240
4241 try:
4242 _validate_native_object_marker(marker, ancestors=ancestors_tuple)
4243 except RuntimeError as exc:
4244 result['errors'].append(
4245 f"Invalid data-pptx-replace-with marker {marker_id}: {exc}"
4246 )
4247 continue
4248 append_metadata_legacy_warnings(marker)
4249 if _native_object_marker_warnings is None:
4250 continue
4251 for warning in _native_object_marker_warnings(
4252 marker,
4253 ancestors=ancestors_tuple,
4254 document_root=root,
4255 ):
4256 result['warnings'].append(
4257 f"data-pptx-replace-with marker {marker_id}: {warning}"
4258 )
4259
4260 def _check_pptx_structure_metadata(
4261 self,
4262 root: ET.Element,
4263 svg_path: Path,
4264 result: Dict,
4265 ) -> None:
4266 """Validate the intrinsic structured Master/Layout SVG contract."""
4267 if self.quick_generate:
4268 forbidden_attrs = sorted({
4269 attr
4270 for elem in root.iter()
4271 for attr in _PPTX_STRUCTURE_ATTRS
4272 if elem.get(attr) is not None
4273 })
4274 if forbidden_attrs:
4275 result['errors'].append(
4276 f"{svg_path.name}: Quick Generate uses flat export and "
4277 "forbids Master/Layout/layer/placeholder metadata; remove "
4278 + ', '.join(forbidden_attrs)
4279 )
4280 return
4281 if not self.template_mode and svg_path.parent.name == 'svg_output':
4282 declared_mode = _declared_pptx_structure_mode(
4283 self._resolve_project_path(svg_path)
4284 )
4285 if declared_mode == 'flat':
4286 forbidden_attrs = sorted({
4287 attr
4288 for elem in root.iter()
4289 for attr in _PPTX_STRUCTURE_ATTRS
4290 if elem.get(attr) is not None
4291 })
4292 if forbidden_attrs:
4293 result['errors'].append(
4294 f"{svg_path.name}: pptx_structure.mode: flat forbids "
4295 "Master/Layout/layer/placeholder metadata; remove "
4296 + ', '.join(forbidden_attrs)
4297 )
4298 return
4299 if declared_mode != 'structured':
4300 # The project-level gate emits one actionable migration error.
4301 # Avoid burying it under repeated per-page structure failures.
4302 return
4303 has_structure_metadata = any(
4304 elem.get(attr) is not None
4305 for elem in root.iter()
4306 for attr in _PPTX_STRUCTURE_ATTRS
4307 )
4308 require_structure = bool(
4309 self.template_mode
4310 or svg_path.parent.name == 'svg_output'
4311 )
4312 if not has_structure_metadata and not require_structure:
4313 return
4314 result['errors'].extend(_local_pptx_structure_errors(
4315 root,
4316 svg_path,
4317 require_structure=require_structure,
4318 ))
4319 self._check_placeholder_carrier_flattening(root, svg_path, result)
4320 if svg_path.parent.name == 'svg_output':
4321 self._append_structure_coverage_warnings(root, result)
4322 if _validate_template_structure_svg is None:
4323 result['errors'].append(
4324 "Structured PPTX metadata validator could not be imported; "
4325 "the quality gate cannot verify this SVG"
4326 )
4327 return
4328 result['errors'].extend(_validate_template_structure_svg(svg_path))
4329 result['errors'] = list(dict.fromkeys(result['errors']))
4330
4331 @staticmethod
4332 def _check_placeholder_carrier_flattening(
4333 root: ET.Element,
4334 svg_path: Path,
4335 result: Dict,
4336 ) -> None:
4337 """Reject slot carriers that export as multiple native children.
4338
4339 Default export flattens non-mergeable positional ``<tspan>`` lines
4340 before converting the surrounding slot group to DrawingML. Reuse that
4341 exact transform here so the quality gate fails before the later
4342 placeholder-unwrapping step does.
4343 """
4344 if _flatten_positional_tspans is None:
4345 return
4346
4347 candidate_ids: List[str] = []
4348 for slot in root.iter(f'{{{SVG_NS}}}g'):
4349 if not (slot.get('data-pptx-placeholder') or '').strip():
4350 continue
4351 binding = (
4352 slot.get('data-pptx-binding') or 'carrier'
4353 ).strip().lower()
4354 if binding != 'carrier':
4355 continue
4356 visual_children = [
4357 child for child in list(slot)
4358 if _local_name(child) not in _NON_VISUAL_SVG_TAGS
4359 ]
4360 carriers = [
4361 child for child in visual_children
4362 if (child.get('data-pptx-carrier') or '')
4363 .strip()
4364 .lower()
4365 == 'true'
4366 ]
4367 slot_id = (slot.get('id') or '').strip()
4368 if not slot_id or len(visual_children) != 1 or len(carriers) != 1:
4369 continue
4370 if not any(
4371 _local_name(descendant) == 'tspan'
4372 and any(
4373 descendant.get(name) is not None
4374 for name in ('x', 'y', 'dy')
4375 )
4376 for descendant in carriers[0].iter()
4377 ):
4378 continue
4379 candidate_ids.append(slot_id)
4380
4381 if not candidate_ids:
4382 return
4383
4384 flattened_root = copy.deepcopy(root)
4385 try:
4386 _flatten_positional_tspans(
4387 ET.ElementTree(flattened_root),
4388 merge_paragraphs=True,
4389 preserve_line_breaks=True,
4390 )
4391 except ValueError:
4392 # The shared text check reports the unsupported nested-position
4393 # contract; avoid turning a quality result into a checker crash.
4394 return
4395 slots_by_id = {
4396 (slot.get('id') or '').strip(): slot
4397 for slot in flattened_root.iter(f'{{{SVG_NS}}}g')
4398 if (slot.get('id') or '').strip()
4399 }
4400 for slot_id in candidate_ids:
4401 slot = slots_by_id.get(slot_id)
4402 if slot is None:
4403 continue
4404 native_children = [
4405 child for child in list(slot)
4406 if _local_name(child) not in _NON_VISUAL_SVG_TAGS
4407 ]
4408 if len(native_children) == 1:
4409 continue
4410 result['errors'].append(
4411 f"{svg_path.name}: placeholder slot {slot_id} becomes "
4412 f"{len(native_children)} native children after positional "
4413 "<tspan> flattening; a carrier-bound slot must export as one "
4414 "text or picture carrier. Use one single-frame dy-stacked text "
4415 "frame, or move independently positioned lines outside the slot"
4416 )
4417
4418 def _append_structure_coverage_warnings(
4419 self,
4420 root: ET.Element,
4421 result: Dict,
4422 ) -> None:
4423 """Warn on mapped pages that compile to bare Masters / empty Layouts.
4424
4425 Zero-slot and framing-only Layouts are legal contracts, so these stay
4426 advisory warnings. They neither fail the workflow gate nor require a
4427 per-warning disposition.
4428 """
4429 messages = self._structure_coverage_messages(root)
4430 if not messages:
4431 return
4432 prototype_root = self._active_prototype_root()
4433 if (
4434 prototype_root is not None
4435 and messages == self._structure_coverage_messages(prototype_root)
4436 ):
4437 for message in messages:
4438 self._append_inherited_info(
4439 result,
4440 'structure_coverage',
4441 message,
4442 )
4443 return
4444 result['warnings'].extend(messages)
4445
4446 @staticmethod
4447 def _structure_coverage_messages(root: ET.Element) -> List[str]:
4448 """Return advisory coverage messages for one structured page."""
4449 if not (root.get('data-pptx-layout') or '').strip():
4450 return []
4451 messages: List[str] = []
4452 has_layer_mark = any(
4453 elem.get('data-pptx-layer') is not None
4454 for elem in root.iter()
4455 )
4456 has_layout_atom = any(
4457 child.get('data-pptx-layer') == 'layout'
4458 for child in list(root)
4459 )
4460 has_placeholder = any(
4461 elem.get('data-pptx-placeholder') is not None
4462 for elem in root.iter()
4463 )
4464 if not has_layer_mark:
4465 messages.append(
4466 'Mapped page declares data-pptx-layout but no data-pptx-layer '
4467 'mark; the exported Master gets no shared background/chrome '
4468 'and the Layout gets no static framing. Generated templates '
4469 'should mark the deck-wide '
4470 'background data-pptx-layer="master" and this layout key\'s '
4471 'framing data-pptx-layer="layout". No change or disposition '
4472 'is required.'
4473 )
4474 if not has_placeholder and not has_layout_atom:
4475 messages.append(
4476 'Mapped page has no placeholder slot and no '
4477 'data-pptx-layer="layout" atom; its Layout exports empty. '
4478 'Generated templates should declare the slots the page actually '
4479 'has (title / subtitle / '
4480 'body / picture / slide-number / footer) and mark the layout '
4481 'key\'s static framing unless this is intentionally a fixed '
4482 'zero-slot composition. No change or disposition is required.'
4483 )
4484 elif not has_placeholder:
4485 messages.append(
4486 'Mapped Layout has static framing but no insertable '
4487 'placeholder slot. Generated templates should declare the '
4488 'slots the page actually has (title / subtitle / body / '
4489 'picture / slide-number / footer) unless zero-slot is the '
4490 'intended reusable contract. No change or disposition is required.'
4491 )
4492 return messages
4493
4494 @staticmethod
4495 def _check_legacy_pptx_attributes(
4496 root: ET.Element,
4497 svg_path: Path,
4498 result: Dict,
4499 ) -> None:
4500 """Reject superseded long-form authoring attributes."""
4501 for element in root.iter():
4502 for legacy, canonical in _LEGACY_PPTX_ATTRIBUTE_RENAMES.items():
4503 if element.get(legacy) is None:
4504 continue
4505 result['errors'].append(
4506 f'{svg_path.name}: {_element_label(element)} uses legacy '
4507 f'{legacy}; rename it to {canonical}'
4508 )
4509
4510 def _check_semantic_markers(
4511 self,
4512 root: ET.Element,
4513 svg_path: Path,
4514 result: Dict,
4515 ) -> None:
4516 """Validate minimal compiler hints without changing SVG rendering."""
4517 has_semantics = any(
4518 elem.get(attr) is not None
4519 for elem in root.iter()
4520 for attr in _SEMANTIC_ATTRS
4521 )
4522 require_page_role = (
4523 svg_path.parent.name in {'svg_output', 'svg_final'}
4524 and root.get('data-pptx-layout') is None
4525 )
4526 if _validate_semantic_markers is None:
4527 if has_semantics:
4528 result['warnings'].append(
4529 "Detected Semantic SVG markers, but their validator could "
4530 "not be imported."
4531 )
4532 return
4533 for issue in _validate_semantic_markers(
4534 root,
4535 require_page_role=require_page_role,
4536 ):
4537 if issue.severity == 'error':
4538 result['errors'].append(issue.message)
4539 else:
4540 result['warnings'].append(issue.message)
4541
4542 def _get_spec_lock(self, svg_path: Path):
4543 """Locate and parse spec_lock.md near the SVG. Returns dict or None.
4544
4545 Looks in svg_path.parent and svg_path.parent.parent (covers the two
4546 common layouts: SVG directly under <project>/ or under
4547 <project>/svg_output/). Results are cached per lock path.
4548 """
4549 if self.quick_generate:
4550 return None
4551 if _parse_spec_lock is None:
4552 return None
4553 for candidate in (svg_path.parent / 'spec_lock.md',
4554 svg_path.parent.parent / 'spec_lock.md'):
4555 if candidate in self._lock_cache:
4556 return self._lock_cache[candidate]
4557 if candidate.exists():
4558 try:
4559 data = _parse_spec_lock(candidate)
4560 except Exception:
4561 data = None
4562 self._lock_cache[candidate] = data
4563 if data is not None:
4564 self._lock_seen = True
4565 return data
4566 return None
4567
4568 def _prototype_drift_allowances(
4569 self,
4570 ) -> Tuple[set[str], set[str], set[str]]:
4571 """Return color/font/size values owned by the selected mirror page."""
4572 prototype_root = self._active_prototype_root()
4573 if prototype_root is None:
4574 return set(), set(), set()
4575 try:
4576 content = self._active_prototype_path.read_text(encoding='utf-8')
4577 except (AttributeError, OSError):
4578 return set(), set(), set()
4579
4580 colors: set[str] = set()
4581 for attribute in _PAINT_PROPERTIES or ():
4582 for raw_value in self._svg_property_values(content, attribute):
4583 normalized = raw_value.strip()
4584 if normalized.lower() in {'none', 'transparent'} or re.fullmatch(
4585 r'url\(#[^)]+\)', normalized
4586 ):
4587 continue
4588 if _parse_export_color is not None:
4589 color, _alpha = _parse_export_color(normalized)
4590 else:
4591 color = _normalize_hex_rgb(normalized)
4592 if color:
4593 colors.add(color)
4594 fonts = {
4595 self._normalize_font_stack(value)
4596 for value in self._font_family_values(content)
4597 if self._normalize_font_stack(value)
4598 }
4599 sizes = set(self._effective_text_size_counts(prototype_root))
4600 return colors, fonts, sizes
4601
4602 def _declared_typography_size_anchors(
4603 self,
4604 lock: Dict,
4605 ) -> Tuple[Dict, set[str], List[float], List[str]]:
4606 """Return valid declared size anchors and malformed lock rows."""
4607 typography = lock.get('typography', {})
4608 positive_numeric_re = re.compile(
4609 r'^(?=.*[1-9])(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)$'
4610 )
4611 locked_sizes: set[str] = set()
4612 anchor_sizes: List[float] = []
4613 invalid_sizes: List[str] = []
4614 for key, raw_value in typography.items():
4615 if key == 'font_family' or key.endswith('_family'):
4616 continue
4617 value = raw_value.strip()
4618 if positive_numeric_re.fullmatch(value) is None:
4619 invalid_sizes.append(f"{key}: {raw_value}")
4620 continue
4621 try:
4622 anchor = float(value)
4623 except (TypeError, ValueError):
4624 invalid_sizes.append(f"{key}: {raw_value}")
4625 continue
4626 if not math.isfinite(anchor) or anchor <= 0:
4627 invalid_sizes.append(f"{key}: {raw_value}")
4628 continue
4629 locked_sizes.add(self._canonical_font_size_key(anchor))
4630 anchor_sizes.append(anchor)
4631 return typography, locked_sizes, anchor_sizes, invalid_sizes
4632
4633 def _count_undeclared_size_occurrences(
4634 self,
4635 root: ET.Element,
4636 *,
4637 locked_sizes: set[str],
4638 anchor_sizes: List[float],
4639 prototype_sizes: set[str],
4640 ) -> Counter[str]:
4641 """Count text objects using valid sizes outside all declared bands."""
4642 counts: Counter[str] = Counter()
4643 if not locked_sizes:
4644 return counts
4645 for value, occurrence_count in self._effective_text_size_counts(root).items():
4646 if value in prototype_sizes and value not in locked_sizes:
4647 continue
4648 if value in locked_sizes:
4649 continue
4650 try:
4651 used_px = float(value)
4652 except (TypeError, ValueError):
4653 continue
4654 if not math.isfinite(used_px) or used_px < 0:
4655 continue
4656 if any(
4657 abs(used_px - anchor_px) <= FONT_SIZE_ANCHOR_TOLERANCE_PX
4658 for anchor_px in anchor_sizes
4659 ):
4660 continue
4661 counts[value] += occurrence_count
4662 return counts
4663
4664 def _effective_text_size_counts(self, root: ET.Element) -> Counter[str]:
4665 """Count each effective size once per non-empty SVG text object."""
4666 counts: Counter[str] = Counter()
4667 if _resolve_project_font_sizes is None:
4668 return counts
4669 working_root = root
4670 if (
4671 _expand_local_use_references is not None
4672 and _UseExpansionError is not None
4673 ):
4674 expanded_root = copy.deepcopy(root)
4675 try:
4676 _expand_local_use_references(expanded_root)
4677 except _UseExpansionError:
4678 pass
4679 else:
4680 working_root = expanded_root
4681 try:
4682 effective_sizes = _resolve_project_font_sizes(working_root)
4683 except ValueError:
4684 return counts
4685
4686 def collect_text_object_sizes(element: ET.Element) -> set[str]:
4687 values: set[str] = set()
4688
4689 def visit(node: ET.Element) -> None:
4690 if (node.text or '').strip():
4691 values.add(
4692 self._canonical_font_size_key(effective_sizes[id(node)])
4693 )
4694 for child in node:
4695 visit(child)
4696 if (child.tail or '').strip():
4697 values.add(
4698 self._canonical_font_size_key(
4699 effective_sizes[id(node)]
4700 )
4701 )
4702
4703 visit(element)
4704 return values
4705
4706 definition_containers = {
4707 'clippath',
4708 'defs',
4709 'marker',
4710 'mask',
4711 'pattern',
4712 'symbol',
4713 }
4714
4715 def visit_visible(element: ET.Element) -> None:
4716 local_name = _local_name(element).casefold()
4717 if local_name in definition_containers:
4718 return
4719 if local_name == 'text':
4720 counts.update(collect_text_object_sizes(element))
4721 return
4722 for child in element:
4723 visit_visible(child)
4724
4725 visit_visible(working_root)
4726 return counts
4727
4728 @staticmethod
4729 def _canonical_font_size_key(value: float) -> str:
4730 """Canonicalize equivalent numeric spellings for deck-wide counting."""
4731 return format(value, '.12g')
4732
4733 def _prepare_undeclared_size_occurrences(
4734 self,
4735 svg_files: List[Path],
4736 ) -> None:
4737 """Pre-count sparse undeclared sizes before per-file diagnostics."""
4738 previous_prototype = self._active_prototype_path
4739 try:
4740 for svg_path in svg_files:
4741 lock = self._get_spec_lock(svg_path)
4742 if lock is None:
4743 continue
4744 _typography, locked_sizes, anchor_sizes, _invalid = (
4745 self._declared_typography_size_anchors(lock)
4746 )
4747 self._active_prototype_path = self._prototype_by_output.get(
4748 svg_path.resolve()
4749 )
4750 _colors, _fonts, prototype_sizes = (
4751 self._prototype_drift_allowances()
4752 )
4753 try:
4754 content = svg_path.read_text(encoding='utf-8')
4755 except OSError:
4756 continue
4757 try:
4758 root = ET.fromstring(content)
4759 except ET.ParseError:
4760 continue
4761 self._undeclared_size_occurrences.update(
4762 self._count_undeclared_size_occurrences(
4763 root,
4764 locked_sizes=locked_sizes,
4765 anchor_sizes=anchor_sizes,
4766 prototype_sizes=prototype_sizes,
4767 )
4768 )
4769 finally:
4770 self._active_prototype_path = previous_prototype
4771 self._undeclared_size_counts_ready = True
4772
4773 def _check_spec_lock_alignment(
4774 self,
4775 content: str,
4776 svg_path: Path,
4777 result: Dict,
4778 *,
4779 root: ET.Element,
4780 ):
4781 """Compare SVG values with reusable anchors in spec_lock.md.
4782
4783 Covers colors (fill / stroke / stop-color / flood-color / pattern
4784 metadata), font-family, and font-size.
4785 Additional colors and font families are valid contextual authoring and
4786 are recorded as information. A valid undeclared display size may occur
4787 at most twice across generated pages; its third occurrence makes it a
4788 recurring role and blocks ``svg_output`` until the role is declared.
4789 Structural text still maps to declared role bands. Exact mirror-
4790 prototype values remain inherited information. Exact values are
4791 accumulated in self._anchor_value_summary for end-of-run aggregation.
4792 When spec_lock.md is missing, silently skip this local comparison; the
4793 Generate route's required-artifact gate owns whether execution may begin.
4794 """
4795 lock = self._get_spec_lock(svg_path)
4796 if lock is None:
4797 return
4798 prototype_colors, prototype_fonts, prototype_sizes = (
4799 self._prototype_drift_allowances()
4800 )
4801
4802 # Build allow-sets from the lock
4803 allowed_colors = set()
4804 for v in lock.get('colors', {}).values():
4805 if _parse_export_color is not None:
4806 color, _alpha = _parse_export_color(v)
4807 if color:
4808 allowed_colors.add(color)
4809 else:
4810 color = _normalize_hex_rgb(v)
4811 if color:
4812 allowed_colors.add(color)
4813
4814 # A validated compact preset may contain registry-derived darken/lighten
4815 # layer colors. Their base paint still comes from spec_lock; the exact
4816 # child HEX values are deterministic compiler evidence, not color drift.
4817 if (
4818 _authored_preset_encoding is not None
4819 and _validate_authored_preset_group is not None
4820 ):
4821 for group in root.iter():
4822 if (
4823 _authored_preset_encoding(group) != 'compact'
4824 or _validate_authored_preset_group(group)
4825 ):
4826 continue
4827 for child in group:
4828 for attribute in ('fill', 'stroke'):
4829 raw_value = child.get(attribute)
4830 if raw_value is None:
4831 continue
4832 if _parse_export_color is not None:
4833 color, _alpha = _parse_export_color(raw_value)
4834 else:
4835 color = _normalize_hex_rgb(raw_value)
4836 if color:
4837 allowed_colors.add(color)
4838 locked_colors = set(allowed_colors)
4839 allowed_colors.update(prototype_colors)
4840
4841 typo, locked_sizes, anchor_sizes, invalid_lock_sizes = (
4842 self._declared_typography_size_anchors(lock)
4843 )
4844 if invalid_lock_sizes:
4845 shown = ', '.join(invalid_lock_sizes[:5])
4846 more = len(invalid_lock_sizes) - 5
4847 suffix = f" (+{more} more)" if more > 0 else ""
4848 result['errors'].append(
4849 f"spec_lock typography sizes must be positive finite unitless px values; "
4850 f"found {shown}{suffix}."
4851 )
4852
4853 # Font families: default `font_family` plus any per-role `*_family`
4854 # override (title_family / body_family / emphasis_family / code_family,
4855 # per templates/schemas/spec_lock.schema.json). Any of these is a legitimate declared
4856 # value; an SVG that uses any one of them is not drifting.
4857 allowed_fonts = set()
4858 if typo:
4859 default_font = typo.get('font_family', '').strip()
4860 if default_font:
4861 allowed_fonts.add(self._normalize_font_stack(default_font))
4862 for k, v in typo.items():
4863 if k == 'font_family' or not k.endswith('_family'):
4864 continue
4865 v_clean = v.strip()
4866 # Skip placeholder text like "same as body (omit if identical)"
4867 if not v_clean or v_clean.lower().startswith('same as'):
4868 continue
4869 allowed_fonts.add(self._normalize_font_stack(v_clean))
4870 locked_fonts = set(allowed_fonts)
4871 allowed_fonts.update(prototype_fonts)
4872
4873 # Sizes: declared slots are anchors. Checker cannot infer which role a
4874 # text node carries, so it uses the union of their ±2px bands as a cheap
4875 # numeric safety net; prompt rules own semantic role mapping.
4876 # Scan SVG for used values
4877 color_drifts = set()
4878 inherited_colors = set()
4879 for attr in _PAINT_PROPERTIES or ():
4880 for raw_value in self._svg_property_values(content, attr):
4881 normalized = raw_value.strip()
4882 if normalized.lower() in {'none', 'transparent'} or re.fullmatch(
4883 r'url\(#[^)]+\)', normalized
4884 ):
4885 continue
4886 if _BARE_HEX_VALUE_RE.fullmatch(normalized):
4887 continue
4888 if _parse_export_color is not None:
4889 val, _alpha = _parse_export_color(normalized)
4890 if val is None:
4891 continue
4892 else:
4893 val = _normalize_hex_rgb(normalized)
4894 if val is None:
4895 continue
4896 if val not in allowed_colors:
4897 color_drifts.add(f'#{val}')
4898 elif val in prototype_colors and val not in locked_colors:
4899 inherited_colors.add(f'#{val}')
4900
4901 font_drifts = set()
4902 inherited_fonts = set()
4903 for val in self._font_family_values(content):
4904 normalized_font = self._normalize_font_stack(val)
4905 if allowed_fonts and normalized_font not in allowed_fonts:
4906 font_drifts.add(val)
4907 elif (
4908 normalized_font in prototype_fonts
4909 and normalized_font not in locked_fonts
4910 ):
4911 inherited_fonts.add(val)
4912
4913 size_drift_counts = self._count_undeclared_size_occurrences(
4914 root,
4915 locked_sizes=locked_sizes,
4916 anchor_sizes=anchor_sizes,
4917 prototype_sizes=prototype_sizes,
4918 )
4919 size_drifts = set(size_drift_counts)
4920 inherited_sizes = set()
4921 for val in self._effective_text_size_counts(root):
4922 if val in prototype_sizes and val not in locked_sizes:
4923 inherited_sizes.add(val)
4924
4925 # Record in run-wide aggregation. Colors/fonts beyond the anchor set are
4926 # contextual values, not release issues. Generated-page sizes enforce
4927 # role-anchor ownership; other spec-backed locations retain review.
4928 fname = svg_path.name
4929 for v in color_drifts:
4930 self._anchor_value_summary['colors'][v].add(fname)
4931 for v in font_drifts:
4932 self._anchor_value_summary['fonts'][v].add(fname)
4933 for v in size_drifts:
4934 self._anchor_value_summary['sizes'][v].add(fname)
4935
4936 contextual_values = {}
4937 if color_drifts:
4938 contextual_values['colors'] = sorted(color_drifts)
4939 if font_drifts:
4940 contextual_values['font_families'] = sorted(font_drifts)
4941 if contextual_values:
4942 result['info']['contextual_values'] = contextual_values
4943
4944 sparse_sizes = {}
4945 recurring_sizes = {}
4946 for value, local_count in size_drift_counts.items():
4947 total_count = (
4948 self._undeclared_size_occurrences.get(value, local_count)
4949 if self._undeclared_size_counts_ready
4950 else local_count
4951 )
4952 target = (
4953 sparse_sizes
4954 if total_count <= SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES
4955 else recurring_sizes
4956 )
4957 target[value] = total_count
4958
4959 if sparse_sizes:
4960 result['info']['sparse_typography_sizes'] = {
4961 value: count for value, count in sorted(sparse_sizes.items())
4962 }
4963
4964 if recurring_sizes:
4965 shown = ', '.join(
4966 f"{value} ({count} occurrences)"
4967 for value, count in sorted(recurring_sizes.items())
4968 )
4969 size_issue = (
4970 f"undeclared font-size {shown} exceeds the sparse-display limit "
4971 f"of {SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES} occurrences"
4972 )
4973 if svg_path.parent.name == 'svg_output':
4974 result['errors'].append(
4975 "spec_lock typography-size recurrence: "
4976 f"{size_issue}. Structural text must return to its declared "
4977 "role band; a genuinely recurring display treatment needs a "
4978 "justified named role in the Design Spec and spec_lock."
4979 )
4980 else:
4981 result['warnings'].append(
4982 f"spec_lock typography-size recurrence review: {size_issue}"
4983 )
4984 inherited_parts = []
4985 if inherited_colors:
4986 inherited_parts.append(f"{len(inherited_colors)} color(s)")
4987 if inherited_fonts:
4988 inherited_parts.append(f"{len(inherited_fonts)} font-family value(s)")
4989 if inherited_sizes:
4990 inherited_parts.append(f"{len(inherited_sizes)} font-size value(s)")
4991 if inherited_parts:
4992 self._append_inherited_info(
4993 result,
4994 'spec_lock_alignment',
4995 f"{', '.join(inherited_parts)} come unchanged from mirror "
4996 "prototype and are accepted without expanding spec_lock.md",
4997 )
4998
4999 def _find_image_sources_manifest(self, svg_path: Path) -> Path | None:
5000 """Locate image_sources.json for a project SVG.
5001
5002 Quality checks run primarily on <project>/svg_output/*.svg, but this
5003 also supports SVGs checked from project root or svg_final.
5004 """
5005 bases = (svg_path.parent, svg_path.parent.parent, svg_path.parent.parent.parent)
5006 for base in bases:
5007 candidate = base / 'images' / 'image_sources.json'
5008 if candidate.exists():
5009 return candidate
5010 return None
5011
5012 def _load_image_sources_manifest(
5013 self,
5014 svg_path: Path,
5015 ) -> Tuple[Dict, str | None, Path | None]:
5016 manifest_path = self._find_image_sources_manifest(svg_path)
5017 if manifest_path is None:
5018 return {}, None, None
5019 payload, error = self._read_image_sources_manifest(manifest_path)
5020 return payload, error, manifest_path
5021
5022 def _read_image_sources_manifest(
5023 self,
5024 manifest_path: Path,
5025 ) -> Tuple[Dict, str | None]:
5026 """Read one provenance manifest without accepting damaged state."""
5027 if manifest_path in self._source_manifest_cache:
5028 return self._source_manifest_cache[manifest_path]
5029 try:
5030 payload = json.loads(manifest_path.read_text(encoding='utf-8'))
5031 except (OSError, json.JSONDecodeError) as exc:
5032 payload = {}
5033 error = f"cannot read {manifest_path}: {exc}"
5034 else:
5035 if not isinstance(payload, dict):
5036 error = f"{manifest_path} must contain a JSON object"
5037 payload = {}
5038 elif not isinstance(payload.get('items'), list):
5039 error = f"{manifest_path} must contain an items array"
5040 payload = {}
5041 elif any(not isinstance(item, dict) for item in payload['items']):
5042 error = f"{manifest_path} items must contain JSON objects"
5043 payload = {}
5044 else:
5045 seen_filenames: set[str] = set()
5046 error = None
5047 for index, item in enumerate(payload['items']):
5048 filename = item.get('filename')
5049 if (
5050 not isinstance(filename, str)
5051 or not filename.strip()
5052 or filename in {'.', '..'}
5053 or '/' in filename
5054 or '\\' in filename
5055 or ':' in filename
5056 or Path(filename).is_absolute()
5057 ):
5058 error = (
5059 f"{manifest_path} items[{index}].filename must be "
5060 "a non-empty bare filename"
5061 )
5062 break
5063 if filename in seen_filenames:
5064 error = (
5065 f"{manifest_path} contains duplicate filename "
5066 f"{filename!r}"
5067 )
5068 break
5069 seen_filenames.add(filename)
5070 if error:
5071 payload = {}
5072 self._source_manifest_cache[manifest_path] = (payload, error)
5073 return payload, error
5074
5075 @staticmethod
5076 def _external_image_reference_basename(href: str) -> str | None:
5077 """Return a decoded basename for one local external image href."""
5078 if not href or href.startswith('data:'):
5079 return None
5080 decoded_href = html.unescape(href)
5081 parsed = urlsplit(decoded_href)
5082 if parsed.scheme and parsed.scheme != 'file':
5083 return None
5084 path_part = (
5085 parsed.path
5086 if parsed.scheme
5087 else decoded_href.split('?', 1)[0].split('#', 1)[0]
5088 )
5089 return Path(unquote(path_part)).name or None
5090
5091 @classmethod
5092 def _referenced_image_basenames(cls, root: ET.Element) -> set[str]:
5093 """Return external image basenames rendered by one parsed SVG."""
5094 filenames = set()
5095 _working_root, _parent_by_id, images = cls._visible_image_elements(root)
5096 for elem in images:
5097 href = elem.get('href') or elem.get(f'{{{XLINK_NS}}}href')
5098 filename = cls._external_image_reference_basename(href or '')
5099 if filename:
5100 filenames.add(filename)
5101 return filenames
5102
5103 def _check_sourced_image_attribution(
5104 self,
5105 root: ET.Element,
5106 svg_path: Path,
5107 result: Dict,
5108 ):
5109 """Require visible credit text for attribution-required web images.
5110
5111 image_search.py records the legal tier in images/image_sources.json;
5112 Executor must render compact credit text into the SVG. This check
5113 binds each credit to the referenced image's author and license instead
5114 of accepting one generic deck-level CC token.
5115 """
5116 manifest, error, manifest_path = self._load_image_sources_manifest(svg_path)
5117 if error:
5118 if (
5119 manifest_path is not None
5120 and manifest_path not in self._source_manifest_errors_reported
5121 ):
5122 result['errors'].append(
5123 f"Invalid image source manifest: {error}"
5124 )
5125 self._source_manifest_errors_reported.add(manifest_path)
5126 return
5127
5128 items = manifest.get('items') or []
5129 if not items:
5130 return
5131
5132 credit_blocks = self._visible_svg_text_blocks(root)
5133 referenced_filenames = self._referenced_image_basenames(root)
5134
5135 for item in items:
5136 if not item.get('attribution_required') and item.get('license_tier') != 'attribution-required':
5137 continue
5138
5139 filename = str(item.get('filename') or '')
5140 if not filename or filename not in referenced_filenames:
5141 continue
5142
5143 license_name = str(item.get('license_name') or '').upper()
5144 license_token = 'CC BY-SA' if 'BY-SA' in license_name else 'CC BY'
5145 author = str(item.get('author') or '').strip()
5146 has_credit = bool(author) and any(
5147 author.casefold() in block.casefold()
5148 and license_token in block.upper()
5149 for block in credit_blocks
5150 )
5151 if not has_credit:
5152 result['errors'].append(
5153 f"Missing image-specific inline attribution for sourced "
5154 f"image {filename} ({author or 'unknown author'}; "
5155 f"{license_token}). Add compact author + license credit per "
5156 f"references/image-searcher.md §7."
5157 )
5158
5159 @classmethod
5160 def _visible_svg_text_blocks(cls, root: ET.Element) -> List[str]:
5161 """Return rendered text blocks, excluding hidden/non-visual content."""
5162 working_root = copy.deepcopy(root)
5163 if (
5164 _expand_local_use_references is not None
5165 and _UseExpansionError is not None
5166 ):
5167 try:
5168 _expand_local_use_references(working_root)
5169 except _UseExpansionError:
5170 working_root = copy.deepcopy(root)
5171 parent_by_id = {
5172 id(child): parent
5173 for parent in working_root.iter()
5174 for child in list(parent)
5175 }
5176
5177 blocks: List[str] = []
5178 for element in working_root.iter(f'{{{SVG_NS}}}text'):
5179 if (
5180 cls._is_hidden_element(element, parent_by_id)
5181 or cls._has_non_visual_ancestor(
5182 element,
5183 working_root,
5184 parent_by_id,
5185 )
5186 or cls._has_zero_opacity(element, parent_by_id)
5187 ):
5188 continue
5189 text = re.sub(r'\s+', ' ', ' '.join(element.itertext())).strip()
5190 if text:
5191 blocks.append(text)
5192 return blocks
5193
5194 @staticmethod
5195 def _normalize_size(value: str) -> str:
5196 """Normalize a font-size value for drift comparison.
5197
5198 Unit-bearing SVG values are reported as errors before drift checking.
5199 The legacy `px` strip remains to avoid a duplicate drift warning after
5200 the hard error has already identified the unit problem.
5201 """
5202 v = value.strip().lower()
5203 if v.endswith('px'):
5204 v = v[:-2].strip()
5205 return v
5206
5207 @staticmethod
5208 def _normalize_font_stack(stack: str) -> str:
5209 """Normalize a font-family stack for comparison: split on commas, strip
5210 quotes / whitespace, lowercase, rejoin. Collapses cosmetic differences
5211 (comma spacing, single vs double quotes, case) so that
5212 `Consolas,'Courier New',monospace` matches `Consolas, "Courier New", monospace`."""
5213 parts = [p.strip().strip('"\'').lower() for p in stack.split(',')]
5214 return ','.join(p for p in parts if p)
5215
5216 def _categorize_issue(self, error_msg: str) -> str:
5217 """Categorize issue type"""
5218 if 'Invalid XML' in error_msg:
5219 return 'XML well-formedness'
5220 elif 'viewBox' in error_msg:
5221 return 'viewBox issues'
5222 elif 'foreignObject' in error_msg:
5223 return 'foreignObject'
5224 elif 'paint' in error_msg.lower() or 'color value' in error_msg.lower():
5225 return 'Paint issues'
5226 elif 'font' in error_msg.lower():
5227 return 'Font issues'
5228 else:
5229 return 'Other'
5230
5231 def _configure_prototype_context(
5232 self,
5233 target_path: Path,
5234 svg_files: List[Path],
5235 ) -> None:
5236 """Map generated pages to selected prototypes for inherited diagnostics."""
5237 self._prototype_by_output = {}
5238 self._active_prototype_path = None
5239 self._active_template_reuse_scope = None
5240 self._source_import_summary = {
5241 'warning_count': 0,
5242 'by_code': {},
5243 }
5244 if (
5245 self.template_mode
5246 or self.quick_generate
5247 or _load_pptx_structure_lock is None
5248 ):
5249 return
5250 project_path = self._resolve_project_path(target_path)
5251 try:
5252 structure_lock = _load_pptx_structure_lock(project_path)
5253 except (_TemplateStructureError, OSError):
5254 # The project-level structure gate reports the actionable parser
5255 # error. Inherited classification is optional and stays silent.
5256 return
5257 if structure_lock is None:
5258 return
5259 self._active_template_reuse_scope = getattr(
5260 structure_lock,
5261 'template_reuse_scope',
5262 None,
5263 )
5264 references = {
5265 reference.slide_num: reference.svg_path
5266 for reference in structure_lock.prototypes
5267 }
5268 if target_path.is_file():
5269 sibling_files = discover_slide_svgs(target_path.parent)
5270 resolved_target = target_path.resolve()
5271 slide_num = next(
5272 (
5273 index
5274 for index, sibling in enumerate(sibling_files, start=1)
5275 if sibling.resolve() == resolved_target
5276 ),
5277 1,
5278 )
5279 prototype = references.get(slide_num)
5280 if prototype is not None:
5281 self._prototype_by_output[resolved_target] = prototype.resolve()
5282 else:
5283 for slide_num, svg_path in enumerate(svg_files, start=1):
5284 prototype = references.get(slide_num)
5285 if prototype is not None:
5286 self._prototype_by_output[svg_path.resolve()] = prototype.resolve()
5287
5288 if self._active_template_reuse_scope not in {'mirror', 'layout'}:
5289 return
5290 manifest_path = (
5291 project_path / 'templates' / 'template_execution_manifest.json'
5292 )
5293 try:
5294 manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
5295 except (FileNotFoundError, OSError, json.JSONDecodeError):
5296 return
5297 if manifest.get('schema') != 'ppt-master.template-execution-manifest.v1':
5298 return
5299 source_import = manifest.get('source_import')
5300 if isinstance(source_import, dict):
5301 self._source_import_summary = source_import
5302
5303 def check_directory(self, directory: str, expected_format: str = None) -> List[Dict]:
5304 """
5305 Check all SVG files in a directory
5306
5307 Args:
5308 directory: Directory path
5309 expected_format: Expected canvas format
5310
5311 Returns:
5312 List of check results
5313 """
5314 dir_path = Path(directory)
5315 self._has_incomplete_page_roster = False
5316 self._undeclared_size_occurrences = Counter()
5317 self._undeclared_size_counts_ready = False
5318
5319 if not dir_path.exists():
5320 print(f"[ERROR] Directory does not exist: {directory}")
5321 self.summary['errors'] += 1
5322 self.issue_types['Input issues'] += 1
5323 return []
5324
5325 # Brand and Style workspaces have no SVG roster. Validate their
5326 # portable contracts through the same authority used by library
5327 # registration, while keeping project scope independent of global
5328 # indexes and directory names.
5329 if self.template_mode and dir_path.is_dir():
5330 nested = dir_path / 'templates'
5331 spec_dir = nested if _template_spec_paths(nested) else dir_path
5332 specs = _template_spec_paths(spec_dir)
5333 bare = [spec for spec in specs if spec.name == 'design_spec.md']
5334 qualified = [spec for spec in specs if spec.name != 'design_spec.md']
5335 if bare and qualified:
5336 self._template_issues.append((
5337 'error',
5338 'spec_naming',
5339 'design_spec.md and design_spec.<kind>.<id>.md cannot share '
5340 f'{spec_dir}; rename the bare spec to its kind-qualified name',
5341 ))
5342 return self.results
5343 try:
5344 from register_template import (
5345 SpecParseError,
5346 validate_qualified_spec_identity,
5347 )
5348 for spec in qualified:
5349 validate_qualified_spec_identity(spec)
5350 except ImportError as exc:
5351 self._template_issues.append((
5352 'error',
5353 'spec_naming',
5354 f'Qualified Design Spec validator could not be imported: {exc}',
5355 ))
5356 return self.results
5357 except (OSError, SpecParseError) as exc:
5358 self._template_issues.append((
5359 'error',
5360 'spec_naming',
5361 str(exc),
5362 ))
5363 return self.results
5364 declared_kinds = [
5365 kind
5366 for spec in qualified
5367 for kind in [_spec_declared_kind(spec)]
5368 if kind is not None
5369 ]
5370 duplicate_kinds = sorted({
5371 kind for kind in declared_kinds
5372 if declared_kinds.count(kind) > 1
5373 })
5374 if duplicate_kinds:
5375 self._template_issues.append((
5376 'error',
5377 'spec_naming',
5378 f'{spec_dir} declares the same kind more than once: '
5379 + ', '.join(duplicate_kinds),
5380 ))
5381 return self.results
5382 active_roster_spec = _roster_spec_path(spec_dir)
5383 shadowed_deck_specs = [
5384 spec
5385 for spec in _roster_spec_paths(spec_dir)
5386 if spec != active_roster_spec
5387 and _spec_declared_kind(spec) == 'deck'
5388 ]
5389 for spec in shadowed_deck_specs:
5390 try:
5391 from register_template import (
5392 SpecParseError,
5393 validate_shadowed_deck_spec,
5394 )
5395 declared_pages = self._extract_spec_roster(
5396 spec.read_text(encoding='utf-8')
5397 )
5398 validate_shadowed_deck_spec(spec, declared_pages)
5399 except ImportError as exc:
5400 self._template_issues.append((
5401 'error',
5402 'deck_contract',
5403 f'Shadowed Deck validator could not be imported: {exc}',
5404 ))
5405 return self.results
5406 except (OSError, SpecParseError) as exc:
5407 self._template_issues.append((
5408 'error',
5409 'deck_contract',
5410 str(exc),
5411 ))
5412 return self.results
5413 roster_free = [
5414 (spec, kind)
5415 for spec in _template_spec_paths(spec_dir)
5416 for kind in [_spec_declared_kind(spec)]
5417 if kind in {'brand', 'style'}
5418 ]
5419 for spec, spec_kind in roster_free:
5420 self._spec_only_template_kind = spec_kind
5421 self.summary['total'] += 1
5422 spec_valid = True
5423 pretty_kind = spec_kind.title()
5424 print(
5425 f"[INFO] {pretty_kind} spec detected "
5426 f"({spec.name}) — "
5427 f"validating its portable workspace contract."
5428 )
5429 workspace_root = (
5430 spec.parent.parent
5431 if spec.parent.name == 'templates'
5432 else spec.parent
5433 )
5434 try:
5435 from register_template import (
5436 SpecParseError,
5437 validate_brand_workspace,
5438 validate_style_workspace,
5439 )
5440 validator = {
5441 'brand': validate_brand_workspace,
5442 'style': validate_style_workspace,
5443 }[spec_kind]
5444 validator(workspace_root)
5445 except ImportError as exc:
5446 spec_valid = False
5447 self._template_issues.append((
5448 'error',
5449 f'{spec_kind}_contract',
5450 f"{pretty_kind} schema validator could not be imported: {exc}",
5451 ))
5452 except (OSError, SpecParseError) as exc:
5453 spec_valid = False
5454 self._template_issues.append((
5455 'error',
5456 f'{spec_kind}_contract',
5457 str(exc),
5458 ))
5459 if spec_valid:
5460 self.summary['passed'] += 1
5461 # A roster-bearing Layout/Deck spec may sit beside those in one
5462 # project workspace; only then does SVG validation still apply.
5463 if roster_free and _roster_spec_path(spec_dir) is None:
5464 return self.results
5465
5466 # Find all SVG files
5467 if dir_path.is_file():
5468 svg_files = [dir_path]
5469 else:
5470 if self.template_mode:
5471 # Template directories live at templates/{layouts,decks}/<id>/.
5472 svg_files = discover_slide_svgs(dir_path)
5473 else:
5474 svg_output = dir_path / \
5475 'svg_output' if (
5476 dir_path / 'svg_output').exists() else dir_path
5477 svg_files = discover_slide_svgs(svg_output)
5478
5479 if not svg_files:
5480 print(f"[ERROR] No SVG files found in: {directory}")
5481 self.summary['errors'] += 1
5482 self.issue_types['Input issues'] += 1
5483 return []
5484
5485 self._active_slide_count = len(svg_files)
5486
5487 self._configure_prototype_context(dir_path, svg_files)
5488 if not self.template_mode:
5489 self._prepare_undeclared_size_occurrences(svg_files)
5490
5491 directory_expected_viewbox: str | None = None
5492 directory_expected_label = "the first SVG canvas"
5493 directory_lock_has_canvas = False
5494 if self.template_mode:
5495 template_viewbox = _declared_template_canvas_viewbox(dir_path)
5496 if template_viewbox:
5497 directory_expected_viewbox = template_viewbox
5498 directory_expected_label = "design_spec canvas_viewbox"
5499 else:
5500 directory_expected_viewbox = ""
5501 directory_expected_label = "design_spec canvas_viewbox"
5502 if expected_format is None and directory_expected_viewbox is None:
5503 lock = (
5504 None
5505 if self.template_mode
5506 else self._get_spec_lock(svg_files[0])
5507 )
5508 if lock is not None:
5509 if 'canvas' in lock:
5510 directory_lock_has_canvas = True
5511 locked_viewbox = lock.get('canvas', {}).get('viewBox')
5512 if locked_viewbox:
5513 directory_expected_viewbox = locked_viewbox
5514 directory_expected_label = "spec_lock canvas"
5515 else:
5516 directory_expected_viewbox = ""
5517 directory_expected_label = "spec_lock canvas"
5518 if (
5519 directory_expected_viewbox is None
5520 and not directory_lock_has_canvas
5521 ):
5522 for svg_file in svg_files:
5523 try:
5524 root = ET.parse(svg_file).getroot()
5525 first_canvas = parse_project_viewbox(
5526 root.get('viewBox'),
5527 context=f"{svg_file.name} root viewBox",
5528 )
5529 except (OSError, ET.ParseError, CanvasContractError):
5530 continue
5531 directory_expected_viewbox = first_canvas.canonical
5532 directory_expected_label = f"first SVG {svg_file.name}"
5533 break
5534
5535 print(f"\n[SCAN] Checking {len(svg_files)} SVG file(s)...\n")
5536
5537 for svg_file in svg_files:
5538 self._active_prototype_path = self._prototype_by_output.get(
5539 svg_file.resolve()
5540 )
5541 result = self.check_file(
5542 str(svg_file),
5543 expected_format,
5544 expected_viewbox=directory_expected_viewbox,
5545 expected_viewbox_label=directory_expected_label,
5546 )
5547 self._print_result(result)
5548
5549 if self.template_mode:
5550 check_structure = _template_structure_checks_enabled(dir_path)
5551 if check_structure:
5552 self._check_pptx_structure_contract(dir_path, svg_files)
5553 if dir_path.is_dir():
5554 self._check_template_contract(
5555 dir_path,
5556 svg_files,
5557 check_structure=check_structure,
5558 )
5559 elif _CHECK_PPTX_STRUCTURED_PROJECT:
5560 self._check_pptx_structure_contract(dir_path, svg_files)
5561 if (
5562 not self.template_mode
5563 and not self.quick_generate
5564 and dir_path.is_dir()
5565 ):
5566 self._check_animation_config_contract(dir_path)
5567 self._check_illustration_resource_contract(dir_path)
5568 if (
5569 not self.template_mode
5570 and not self.quick_generate
5571 and validate_communication_trace is not None
5572 ):
5573 project_path = self._resolve_project_path(dir_path)
5574 self._communication_trace_issues.extend(
5575 ('error', message)
5576 for message in validate_communication_trace(project_path)
5577 )
5578 return self.results
5579
5580 def _check_pptx_structure_contract(
5581 self,
5582 target_path: Path,
5583 svg_files: List[Path],
5584 ) -> None:
5585 """Validate the all-page structured lock and reusable contracts."""
5586 if self.quick_generate:
5587 return
5588 project_path = self._resolve_project_path(target_path)
5589 standard_project = bool(
5590 not self.template_mode
5591 and (project_path / 'svg_output').is_dir()
5592 )
5593 declared_mode = (
5594 _declared_pptx_structure_mode(project_path)
5595 if standard_project
5596 else None
5597 )
5598 if standard_project and declared_mode in {'flat', 'structured'}:
5599 self._pptx_structure_issues.extend(
5600 ('error', message)
5601 for message in _generated_theme_contract_errors(project_path)
5602 )
5603 if standard_project and declared_mode == 'flat':
5604 if (
5605 _load_pptx_structure_lock is None
5606 or _TemplateStructureError is None
5607 ):
5608 self._pptx_structure_issues.append((
5609 'error',
5610 'Flat PPTX project validation is unavailable because the '
5611 'template_structure module could not be imported.',
5612 ))
5613 return
5614 try:
5615 structure_lock = _load_pptx_structure_lock(project_path)
5616 except _TemplateStructureError as exc:
5617 self._pptx_structure_issues.append(('error', str(exc)))
5618 return
5619 if structure_lock is None or structure_lock.mode != 'flat':
5620 self._pptx_structure_issues.append((
5621 'error',
5622 'spec_lock.md must contain one complete '
5623 'pptx_structure.mode: flat contract.',
5624 ))
5625 return
5626 has_metadata = False
5627 for svg_path in svg_files:
5628 try:
5629 root = ET.parse(svg_path).getroot()
5630 except (OSError, ET.ParseError):
5631 continue
5632 if any(
5633 elem.get(attr) is not None
5634 for elem in root.iter()
5635 for attr in _PPTX_STRUCTURE_ATTRS
5636 ):
5637 has_metadata = True
5638 break
5639
5640 if not standard_project and not self.template_mode and not has_metadata:
5641 return
5642 if (
5643 _load_pptx_structure_lock is None
5644 or _parse_template_structure_slide is None
5645 or _parse_template_structure_slides is None
5646 or _structure_subtree_signature is None
5647 or _template_lock_errors is None
5648 or _TemplateStructureError is None
5649 ):
5650 self._pptx_structure_issues.append((
5651 'error',
5652 'Structured PPTX project validation is unavailable because the '
5653 'template_structure module could not be imported.',
5654 ))
5655 return
5656
5657 if self.template_mode:
5658 try:
5659 specs = _parse_template_structure_slides(svg_files)
5660 except _TemplateStructureError as exc:
5661 self._pptx_structure_issues.append(('error', str(exc)))
5662 return
5663 self._pptx_structure_issues.extend(
5664 ('error', message)
5665 for message in self._shared_fixed_layer_errors(specs)
5666 )
5667 self._pptx_structure_issues.extend(
5668 ('warning', message)
5669 for message in self._duplicate_layout_key_warnings(specs)
5670 )
5671 return
5672
5673 if standard_project and declared_mode != 'structured':
5674 label = repr(declared_mode) if declared_mode else (
5675 'missing (legacy implicit baseline)'
5676 )
5677 self._pptx_structure_issues.append((
5678 'error',
5679 'release SVG projects require an explicit spec_lock.md '
5680 'pptx_structure.mode: flat (free design / brand-only) or '
5681 f'structured (deck/layout template); found {label}. New '
5682 'free-design projects use mode: flat; create a new template '
5683 'workspace through skills/ppt-master/workflows/create-template.md, '
5684 'then generate new structured SVG pages before export. Existing '
5685 'PPTX/SVG files are not upgraded in place.',
5686 ))
5687 return
5688
5689 try:
5690 structure_lock = _load_pptx_structure_lock(project_path)
5691 except _TemplateStructureError as exc:
5692 self._pptx_structure_issues.append(('error', str(exc)))
5693 return
5694 if structure_lock is None or structure_lock.mode != 'structured':
5695 self._pptx_structure_issues.append((
5696 'error',
5697 'spec_lock.md must contain one complete '
5698 'pptx_structure.mode: structured contract.',
5699 ))
5700 return
5701 complete_roster = target_path.is_dir()
5702 try:
5703 if not complete_roster and target_path.is_file():
5704 sibling_files = discover_slide_svgs(target_path.parent)
5705 resolved_target = target_path.resolve()
5706 slide_num = next(
5707 (
5708 index
5709 for index, sibling in enumerate(sibling_files, start=1)
5710 if sibling.resolve() == resolved_target
5711 ),
5712 1,
5713 )
5714 specs = [
5715 _parse_template_structure_slide(target_path, slide_num)
5716 ]
5717 else:
5718 specs = _parse_template_structure_slides(svg_files)
5719 except _TemplateStructureError as exc:
5720 self._pptx_structure_issues.append(('error', str(exc)))
5721 return
5722
5723 if complete_roster:
5724 actual_slides = {spec.slide_num for spec in specs}
5725 expected_slides = {
5726 reference.slide_num
5727 for reference in structure_lock.layouts
5728 }
5729 expected_slides.update(
5730 reference.slide_num
5731 for reference in structure_lock.prototypes
5732 )
5733 self._has_incomplete_page_roster = bool(
5734 expected_slides - actual_slides
5735 )
5736 self._pptx_structure_issues.extend(
5737 ('error', message)
5738 for message in _template_lock_errors(specs, structure_lock)
5739 )
5740 else:
5741 self._pptx_structure_issues.extend(
5742 ('error', message)
5743 for message in self._partial_structure_lock_errors(
5744 specs,
5745 structure_lock,
5746 )
5747 )
5748 if _template_prototype_errors is not None:
5749 self._pptx_structure_issues.extend(
5750 ('error', message)
5751 for message in _template_prototype_errors(
5752 specs,
5753 structure_lock,
5754 require_complete_roster=complete_roster,
5755 )
5756 )
5757 self._pptx_structure_issues.extend(
5758 ('error', message)
5759 for message in self._shared_fixed_layer_errors(specs)
5760 )
5761 self._pptx_structure_issues.extend(
5762 ('warning', message)
5763 for message in self._duplicate_layout_key_warnings(specs)
5764 )
5765
5766 @staticmethod
5767 def _partial_structure_lock_errors(specs, structure_lock) -> List[str]:
5768 """Compare explicitly checked pages without requiring the full roster."""
5769 references = {
5770 reference.slide_num: reference
5771 for reference in structure_lock.layouts
5772 }
5773 master_names = {
5774 master.master_key: master.master_name
5775 for master in structure_lock.masters
5776 }
5777 definitions = {
5778 definition.layout_key: definition
5779 for definition in structure_lock.layout_definitions
5780 }
5781 errors: List[str] = []
5782 for spec in specs:
5783 page = f"P{spec.slide_num:02d}"
5784 reference = references.get(spec.slide_num)
5785 if reference is None:
5786 errors.append(
5787 f"spec_lock.md page_pptx_layouts is missing {page}"
5788 )
5789 continue
5790 definition = definitions.get(reference.layout_key)
5791 if definition is None:
5792 errors.append(
5793 f"spec_lock.md pptx_layouts is missing Layout "
5794 f"{reference.layout_key!r}"
5795 )
5796 continue
5797 if spec.master_key != definition.master_key:
5798 errors.append(
5799 f"{spec.svg_path.name}: data-pptx-master={spec.master_key!r} "
5800 f"does not match spec_lock Layout {reference.layout_key!r} "
5801 f"Master key {definition.master_key!r}"
5802 )
5803 if spec.layout_key != reference.layout_key:
5804 errors.append(
5805 f"{spec.svg_path.name}: data-pptx-layout={spec.layout_key!r} "
5806 f"does not match spec_lock {page} layout key "
5807 f"{reference.layout_key!r}"
5808 )
5809 if spec.layout_name != definition.layout_name:
5810 errors.append(
5811 f"{spec.svg_path.name}: data-pptx-layout-name="
5812 f"{spec.layout_name!r} does not match spec_lock Layout "
5813 f"{reference.layout_key!r} name {definition.layout_name!r}"
5814 )
5815 expected_master_name = master_names.get(spec.master_key)
5816 if expected_master_name != spec.master_name:
5817 errors.append(
5818 f"{spec.svg_path.name}: data-pptx-master-name="
5819 f"{spec.master_name!r} does not match spec_lock Master "
5820 f"{spec.master_key!r} name {expected_master_name!r}"
5821 )
5822 return errors
5823
5824 def _duplicate_layout_key_warnings(self, specs) -> List[str]:
5825 """Flag distinct layout keys whose static contracts are identical.
5826
5827 Keys split by page topic over one shared skeleton compile into
5828 duplicate PowerPoint Layouts; the fingerprint compares the
5829 id-insensitive layout-layer drawing plus the placeholder contract.
5830 """
5831 prototypes: Dict[Tuple[str, str], Path] = {}
5832 for spec in specs:
5833 prototypes.setdefault(
5834 (getattr(spec, 'master_key', ''), spec.layout_key),
5835 spec.svg_path,
5836 )
5837 if len(prototypes) < 2:
5838 return []
5839 fingerprint_keys: Dict[tuple, List[str]] = {}
5840 for (master_key, layout_key), svg_path in prototypes.items():
5841 fingerprint = self._layout_contract_fingerprint(svg_path)
5842 if fingerprint is None:
5843 continue
5844 fingerprint_keys.setdefault(
5845 (master_key, fingerprint),
5846 [],
5847 ).append(layout_key)
5848 messages = []
5849 for keys in fingerprint_keys.values():
5850 if len(keys) < 2:
5851 continue
5852 joined = ', '.join(sorted(keys))
5853 messages.append(
5854 f"layout keys {joined} declare identical static Layout framing "
5855 "and placeholder contracts; they compile to duplicate Layouts. "
5856 "Either merge them into one reusable key (spec_lock.md "
5857 "pptx_layouts + each SVG root), or — when their reusable "
5858 "contracts genuinely differ — assign distinct explicit default "
5859 "placeholder bounds and/or mark only truly stable framing as "
5860 'data-pptx-layer="layout". Slide-local content geometry does not '
5861 "define a Layout. This recommendation is advisory; no change or "
5862 "disposition is required."
5863 )
5864 return messages
5865
5866 @classmethod
5867 def _shared_fixed_layer_errors(cls, specs) -> List[str]:
5868 """Reject fixed atoms whose payload varies inside one reuse scope."""
5869 master_groups = defaultdict(list)
5870 layout_groups = defaultdict(list)
5871 for spec in specs:
5872 master_groups[spec.master_key].append(spec)
5873 layout_groups[(spec.master_key, spec.layout_key)].append(spec)
5874
5875 try:
5876 errors = cls._fixed_layer_group_errors(master_groups, 'master')
5877 errors.extend(cls._fixed_layer_group_errors(layout_groups, 'layout'))
5878 except _TemplateStructureError as exc:
5879 return [str(exc)]
5880 return errors
5881
5882 @classmethod
5883 def _fixed_layer_group_errors(cls, groups, layer: str) -> List[str]:
5884 """Compare fixed atom payloads across grouped slide specifications."""
5885 errors = []
5886 for scope_key, group_specs in groups.items():
5887 if len(group_specs) < 2:
5888 continue
5889 variants = defaultdict(lambda: defaultdict(list))
5890 for spec in group_specs:
5891 payloads = cls._fixed_layer_payloads(spec, layer)
5892 for element_id, payload in payloads.items():
5893 variants[element_id][payload].append(spec)
5894 for element_id, payload_specs in variants.items():
5895 if len(payload_specs) < 2:
5896 continue
5897 slide_names = ', '.join(
5898 spec.svg_path.name
5899 for spec in sorted(group_specs, key=lambda item: item.slide_num)
5900 )
5901 if layer == 'master':
5902 scope = f"Master {scope_key!r}"
5903 else:
5904 master_key, layout_key = scope_key
5905 scope = (
5906 f"Layout {layout_key!r} under Master {master_key!r}"
5907 )
5908 if element_id is None:
5909 subject = "fixed visual resources"
5910 verb = "differ"
5911 else:
5912 subject = f"fixed element {element_id!r}"
5913 verb = "differs"
5914 errors.append(
5915 f"{scope} {subject} {verb} across slides: "
5916 f"{slide_names}. Values marked data-pptx-layer={layer!r} must "
5917 "remain identical throughout their reuse scope; move variable "
5918 "text or images into a placeholder slot or keep them Slide-local."
5919 )
5920 return errors
5921
5922 @staticmethod
5923 def _fixed_layer_payloads(spec, layer: str) -> Dict[object, tuple]:
5924 """Return resolved fixed-layer visual payloads keyed by SVG id."""
5925 elements = (
5926 spec.master_elements if layer == 'master' else spec.layout_elements
5927 )
5928 if not elements:
5929 return {}
5930 signature = _structure_subtree_signature(
5931 spec.svg_path,
5932 elements,
5933 include_skin=True,
5934 include_text=True,
5935 asset_identity=True,
5936 )
5937 return {
5938 None if element_id == '__visual_resources__' else element_id: payload
5939 for element_id, payload in signature
5940 }
5941
5942 @staticmethod
5943 def _layout_contract_fingerprint(svg_path: Path):
5944 """Id-insensitive static contract: layout-layer XML + placeholder slots."""
5945 try:
5946 root = ET.parse(str(svg_path)).getroot()
5947 except (OSError, ET.ParseError):
5948 return None
5949 layout_parts = []
5950 placeholder_parts = []
5951 for child in list(root):
5952 if child.get('data-pptx-layer') == 'layout':
5953 clone = copy.deepcopy(child)
5954 for elem in clone.iter():
5955 elem.attrib.pop('id', None)
5956 xml = ET.tostring(clone, encoding='unicode')
5957 layout_parts.append(re.sub(r'\s+', ' ', xml).strip())
5958 placeholder = child.get('data-pptx-placeholder')
5959 if placeholder is not None:
5960 carrier_tags = tuple(
5961 grandchild.tag.rsplit('}', 1)[-1]
5962 for grandchild in list(child)
5963 if (
5964 grandchild.get('data-pptx-carrier') or ''
5965 ).strip().lower() == 'true'
5966 )
5967 placeholder_parts.append((
5968 placeholder,
5969 child.tag.rsplit('}', 1)[-1],
5970 child.get('data-pptx-bounds') or '',
5971 child.get('data-pptx-idx') or '',
5972 (
5973 child.get('data-pptx-binding') or 'carrier'
5974 ).strip().lower(),
5975 carrier_tags,
5976 ))
5977 return (
5978 tuple(layout_parts),
5979 tuple(sorted(placeholder_parts)),
5980 )
5981
5982 def _check_illustration_resource_contract(self, dir_path: Path) -> None:
5983 """Project-level planned-image and illustration resource checks."""
5984 project_path = self._resolve_project_path(dir_path)
5985 spec_path = project_path / 'design_spec.md'
5986 if not spec_path.exists():
5987 return
5988
5989 try:
5990 spec_text = spec_path.read_text(encoding='utf-8')
5991 except OSError as exc:
5992 self._illustration_issues.append((
5993 'warning',
5994 'spec_unreadable',
5995 f"could not read {spec_path}: {exc}",
5996 ))
5997 return
5998
5999 current_contract = (
6000 '<!-- ppt-master-schema: design-spec/v1 -->' in spec_text
6001 )
6002 rows = self._extract_image_resource_rows(spec_text)
6003 if not rows and not current_contract:
6004 return
6005
6006 lock_entries, lock_error = self._load_project_lock_image_entries(
6007 project_path
6008 )
6009 lock_images = set(lock_entries)
6010 svg_references, inline_image_counts, image_placements = (
6011 self._load_project_svg_image_references(project_path)
6012 )
6013 all_svg_references = (
6014 set().union(*(
6015 set(references)
6016 for references in svg_references.values()
6017 ))
6018 if svg_references
6019 else set()
6020 )
6021
6022 sheet_rows = [
6023 row
6024 for row in rows
6025 if self._row_type(row).lower() == 'illustration sheet'
6026 ]
6027 slice_rows = [row for row in rows if self._row_acquire(row) == 'slice']
6028 for row in sheet_rows:
6029 filename = self._row_filename(row)
6030 if not filename:
6031 continue
6032 if filename in lock_images:
6033 self._illustration_issues.append((
6034 'error',
6035 'sheet_in_lock',
6036 f"{filename} is an Illustration Sheet but is listed in spec_lock.md images; "
6037 "only sliced element rows may be listed.",
6038 ))
6039 if filename in all_svg_references:
6040 self._illustration_issues.append((
6041 'error',
6042 'sheet_referenced',
6043 f"{filename} is an Illustration Sheet but is referenced by an SVG; "
6044 "generate it only as a slice source, never place it.",
6045 ))
6046 if (
6047 self._row_status(row) == 'generated'
6048 and not (project_path / 'images' / filename).is_file()
6049 ):
6050 self._illustration_issues.append((
6051 'error',
6052 'sheet_file_missing',
6053 f"{filename} is a Generated Illustration Sheet but "
6054 f"images/{filename} does not exist.",
6055 ))
6056
6057 if current_contract:
6058 self._check_planned_image_closure(
6059 rows,
6060 project_path,
6061 lock_entries,
6062 lock_error,
6063 svg_references,
6064 inline_image_counts,
6065 image_placements,
6066 )
6067 else:
6068 for row in slice_rows:
6069 filename = self._row_filename(row)
6070 if not filename:
6071 continue
6072 if filename not in lock_images:
6073 self._illustration_issues.append((
6074 'error',
6075 'slice_missing_lock',
6076 f"{filename} is a slice row but is absent from spec_lock.md images.",
6077 ))
6078 if (
6079 self._row_status(row) == 'generated'
6080 and not (project_path / 'images' / filename).exists()
6081 ):
6082 self._illustration_issues.append((
6083 'error',
6084 'slice_file_missing',
6085 f"{filename} is a Generated slice row but "
6086 f"images/{filename} does not exist.",
6087 ))
6088
6089 @staticmethod
6090 def _resolve_project_path(dir_path: Path) -> Path:
6091 """Resolve a checker target directory to its project root."""
6092 candidate = dir_path.parent if dir_path.is_file() else dir_path
6093 if (
6094 _project_root_for_svg_path is not None
6095 and candidate.name in _SVG_WORK_DIR_NAMES
6096 ):
6097 return _project_root_for_svg_path(candidate)
6098 if (
6099 (candidate / 'svg_output').exists()
6100 or (candidate / 'design_spec.md').exists()
6101 ):
6102 return candidate
6103 return candidate.parent
6104
6105 @staticmethod
6106 def _split_md_table_row(line: str) -> List[str]:
6107 """Split a simple Markdown table row into stripped cells."""
6108 return [cell.strip().strip('`') for cell in line.strip().strip('|').split('|')]
6109
6110 @classmethod
6111 def _extract_image_resource_rows(cls, spec_text: str) -> List[Dict[str, str]]:
6112 """Extract rows from design_spec.md §VIII Image Resource List."""
6113 section_match = re.search(
6114 r"^##\s+VIII\.\s+Image Resource List\b.*?(?=^##\s+|\Z)",
6115 spec_text,
6116 re.MULTILINE | re.DOTALL,
6117 )
6118 if not section_match:
6119 return []
6120
6121 lines = section_match.group(0).splitlines()
6122 header = None
6123 rows: List[Dict[str, str]] = []
6124 in_resource_table = False
6125 for line in lines:
6126 if not line.strip().startswith('|'):
6127 if in_resource_table and rows:
6128 break
6129 continue
6130
6131 cells = cls._split_md_table_row(line)
6132 if not cells:
6133 continue
6134 if header is None:
6135 if any(cell.lower() == 'filename' for cell in cells):
6136 header = cells
6137 in_resource_table = True
6138 continue
6139 if set(cell.replace('-', '').strip() for cell in cells) == {''}:
6140 continue
6141 if not in_resource_table:
6142 continue
6143 row = {header[i]: cells[i] if i < len(cells) else '' for i in range(len(header))}
6144 filename = row.get('Filename', '').strip()
6145 if (
6146 filename.lower() != 'filename'
6147 and any(value.strip() for value in row.values())
6148 ):
6149 rows.append(row)
6150
6151 return rows
6152
6153 @staticmethod
6154 def _row_filename(row: Dict[str, str]) -> str:
6155 return Path(row.get('Filename', '').strip()).name
6156
6157 @staticmethod
6158 def _row_raw_filename(row: Dict[str, str]) -> str:
6159 return row.get('Filename', '').strip()
6160
6161 @staticmethod
6162 def _row_type(row: Dict[str, str]) -> str:
6163 return row.get('Type', '').strip()
6164
6165 @staticmethod
6166 def _row_acquire(row: Dict[str, str]) -> str:
6167 return row.get('Acquire Via', '').strip().lower()
6168
6169 @staticmethod
6170 def _row_status(row: Dict[str, str]) -> str:
6171 return row.get('Status', '').strip().lower()
6172
6173 @staticmethod
6174 def _row_layout(row: Dict[str, str]) -> str:
6175 return row.get('Layout pattern', '').strip()
6176
6177 @staticmethod
6178 def _row_crop(row: Dict[str, str]) -> str:
6179 return row.get('Crop Policy', '').strip().lower()
6180
6181 @staticmethod
6182 def _layout_projection_matches(left: str, right: str) -> bool:
6183 """Compare one Strategist recommendation without locking its wording."""
6184 left_ids = re.findall(r'#([0-9]+)(?![0-9])', left)
6185 right_ids = re.findall(r'#([0-9]+)(?![0-9])', right)
6186 if left_ids or right_ids:
6187 return left_ids == right_ids
6188
6189 def normalize(value: str) -> str:
6190 return re.sub(r'\s+', ' ', value.replace('`', '')).strip()
6191
6192 return normalize(left) == normalize(right)
6193
6194 def _load_project_lock_image_entries(
6195 self,
6196 project_path: Path,
6197 ) -> Tuple[Dict[str, List[Dict[str, str]]], str | None]:
6198 """Return parsed image-lock rows keyed by basename."""
6199 lock_path = project_path / 'spec_lock.md'
6200 if not lock_path.exists():
6201 return {}, f"{lock_path} does not exist"
6202 if _parse_spec_lock is None:
6203 return {}, "spec_lock parser is unavailable"
6204 if _parse_spec_lock_image_value is None:
6205 return {}, "spec_lock image parser is unavailable"
6206 try:
6207 lock = _parse_spec_lock(lock_path)
6208 except Exception as exc:
6209 return {}, f"cannot parse {lock_path}: {exc}"
6210
6211 entries: Dict[str, List[Dict[str, str]]] = defaultdict(list)
6212 legacy_metadata_keys = {
6213 'image_rendering',
6214 'image_rendering_references',
6215 'image_rendering_behavior',
6216 }
6217 errors: List[str] = []
6218 for key, value in lock.get('images', {}).items():
6219 if str(key).strip().lower() in legacy_metadata_keys:
6220 continue
6221 try:
6222 parsed = _parse_spec_lock_image_value(str(key), str(value))
6223 except ValueError as exc:
6224 errors.append(f"images row {key!r} {exc}")
6225 continue
6226 path_part = parsed['path']
6227 filename = Path(path_part).name
6228 if not filename:
6229 continue
6230 entries[filename].append({
6231 'key': str(key),
6232 'path': path_part,
6233 'source': parsed['source'],
6234 'pattern': parsed['pattern'],
6235 'crop': parsed['crop'],
6236 'legacy': parsed['legacy'],
6237 })
6238 error = (
6239 f"{lock_path}: " + "; ".join(errors)
6240 if errors
6241 else None
6242 )
6243 return dict(entries), error
6244
6245 def _load_project_lock_images(self, project_path: Path) -> set[str]:
6246 """Return filenames listed under spec_lock.md images."""
6247 entries, _error = self._load_project_lock_image_entries(project_path)
6248 return set(entries)
6249
6250 @classmethod
6251 def _load_project_svg_image_references(
6252 cls,
6253 project_path: Path,
6254 ) -> Tuple[
6255 Dict[Path, Dict[str, set[Path]]],
6256 Dict[Path, int],
6257 Dict[str, List[Tuple[Path, str, Tuple[str, ...]]]],
6258 ]:
6259 """Parse rendered image instances, paths, and crop mechanisms."""
6260 svg_dir = project_path / 'svg_output'
6261 if not svg_dir.exists():
6262 return {}, {}, {}
6263 out: Dict[Path, Dict[str, set[Path]]] = {}
6264 inline_counts: Dict[Path, int] = {}
6265 placements: Dict[
6266 str,
6267 List[Tuple[Path, str, Tuple[str, ...]]],
6268 ] = defaultdict(list)
6269 for svg_path in discover_slide_svgs(svg_dir):
6270 try:
6271 root = ET.parse(svg_path).getroot()
6272 except (OSError, ET.ParseError):
6273 continue
6274 working_root, parent_by_id, images = cls._visible_image_elements(root)
6275 references: Dict[str, set[Path]] = defaultdict(set)
6276 inline_count = 0
6277 for element in images:
6278 href = (
6279 element.get('href')
6280 or element.get(f'{{{XLINK_NS}}}href')
6281 or ''
6282 )
6283 if href.lstrip().lower().startswith('data:'):
6284 inline_count += 1
6285 continue
6286 filename = cls._external_image_reference_basename(href)
6287 if not filename:
6288 continue
6289 references.setdefault(filename, set())
6290 placements[filename].append((
6291 svg_path,
6292 element.get('preserveAspectRatio') or '',
6293 cls._image_crop_mechanisms(
6294 element,
6295 working_root,
6296 parent_by_id,
6297 ),
6298 ))
6299 if _resolve_external_image_reference is not None:
6300 resolved = _resolve_external_image_reference(
6301 svg_path.parent,
6302 href,
6303 )
6304 if resolved is not None:
6305 references[filename].add(resolved.resolve())
6306 out[svg_path] = dict(references)
6307 if inline_count:
6308 inline_counts[svg_path] = inline_count
6309 return out, inline_counts, dict(placements)
6310
6311 @staticmethod
6312 def _image_crop_mechanisms(
6313 image: ET.Element,
6314 root: ET.Element,
6315 parent_by_id: Dict[int, ET.Element],
6316 ) -> Tuple[str, ...]:
6317 """Return objective clipping mechanisms affecting one image instance."""
6318 mechanisms: List[str] = []
6319 current: ET.Element | None = image
6320 while current is not None:
6321 tag = _local_name(current)
6322 style_values = (
6323 _parse_inline_style(current.get('style'))
6324 if _parse_inline_style is not None
6325 else {}
6326 )
6327 for property_name in ('clip-path', 'mask'):
6328 value = style_values.get(property_name)
6329 if value is None:
6330 value = current.get(property_name)
6331 if value and value.strip().lower() != 'none':
6332 mechanisms.append(f"<{tag}> {property_name}")
6333 overflow = style_values.get('overflow')
6334 if overflow is None:
6335 overflow = current.get('overflow')
6336 if overflow and overflow.strip().lower() in {'hidden', 'clip'}:
6337 mechanisms.append(f"<{tag}> overflow={overflow.strip()!r}")
6338 if current is not root and tag == 'svg':
6339 mechanisms.append('nested <svg> viewport')
6340 current = parent_by_id.get(id(current))
6341 return tuple(dict.fromkeys(mechanisms))
6342
6343 def _check_planned_image_closure(
6344 self,
6345 rows: List[Dict[str, str]],
6346 project_path: Path,
6347 lock_entries: Dict[str, List[Dict[str, str]]],
6348 lock_error: str | None,
6349 svg_references: Dict[Path, Dict[str, set[Path]]],
6350 inline_image_counts: Dict[Path, int],
6351 image_placements: Dict[
6352 str,
6353 List[Tuple[Path, str, Tuple[str, ...]]],
6354 ],
6355 ) -> None:
6356 """Close Design Spec, execution lock, files, SVGs, and provenance."""
6357 project_root = project_path.resolve()
6358 if inline_image_counts:
6359 total = sum(inline_image_counts.values())
6360 shown = ', '.join(
6361 f"{path.name} ({count})"
6362 for path, count in sorted(inline_image_counts.items())
6363 )
6364 self._illustration_issues.append((
6365 'error',
6366 'svg_inline_image_untracked',
6367 f"svg_output contains {total} inline data-URI image(s): "
6368 f"{shown}. Current projects must keep external project-local "
6369 "image hrefs so every placement closes through Design Spec "
6370 "§VIII and spec_lock.md.",
6371 ))
6372 valid_acquisitions = {
6373 'ai',
6374 'web',
6375 'user',
6376 'formula',
6377 'placeholder',
6378 'slice',
6379 }
6380 valid_statuses = {
6381 'pending',
6382 'failed',
6383 'generated',
6384 'sourced',
6385 'rendered',
6386 'needs-manual',
6387 'existing',
6388 'placeholder',
6389 }
6390 terminal_by_acquisition = {
6391 'ai': {'generated', 'needs-manual'},
6392 'web': {'sourced', 'needs-manual'},
6393 'user': {'existing', 'needs-manual'},
6394 'formula': {'rendered', 'needs-manual'},
6395 'placeholder': {'placeholder'},
6396 'slice': {'generated', 'needs-manual'},
6397 }
6398 current_image_contract = (
6399 any('Crop Policy' in row for row in rows)
6400 or any(
6401 entry.get('legacy') == 'false'
6402 for entries in lock_entries.values()
6403 for entry in entries
6404 )
6405 )
6406 seen_filenames: set[str] = set()
6407 for row in rows:
6408 raw_filename = self._row_raw_filename(row)
6409 filename = self._row_filename(row)
6410 acquire = self._row_acquire(row)
6411 status = self._row_status(row)
6412 layout = self._row_layout(row)
6413 crop = self._row_crop(row)
6414 filename_is_bare = bool(filename) and (
6415 filename not in {'.', '..'}
6416 and '/' not in filename
6417 and '\\' not in filename
6418 and ':' not in filename
6419 )
6420 filename_is_canonical = raw_filename in {
6421 filename,
6422 f"images/{filename}",
6423 }
6424 if not filename_is_bare or not filename_is_canonical:
6425 self._illustration_issues.append((
6426 'error',
6427 'planned_image_invalid_filename',
6428 f"Design Spec §VIII Filename {raw_filename!r} must be "
6429 "a non-empty bare filename or canonical "
6430 "images/<filename> path.",
6431 ))
6432 elif filename in seen_filenames:
6433 self._illustration_issues.append((
6434 'error',
6435 'planned_image_duplicate_filename',
6436 f"Design Spec §VIII repeats Filename {filename!r}; "
6437 "one resource must have one authoritative row.",
6438 ))
6439 else:
6440 seen_filenames.add(filename)
6441
6442 if current_image_contract and not layout:
6443 self._illustration_issues.append((
6444 'error',
6445 'planned_image_missing_pattern',
6446 f"{filename or '(missing filename)'} has an empty Design "
6447 "Spec §VIII Layout pattern; preserve one non-empty "
6448 "Strategist recommendation without locking SVG geometry.",
6449 ))
6450 if current_image_contract and crop not in {'adaptive', 'no-crop'}:
6451 self._illustration_issues.append((
6452 'error',
6453 'planned_image_invalid_crop_policy',
6454 f"{filename or '(missing filename)'} has invalid Design "
6455 f"Spec §VIII Crop Policy "
6456 f"{row.get('Crop Policy', '').strip()!r}; use adaptive "
6457 "or no-crop.",
6458 ))
6459
6460 if acquire not in valid_acquisitions:
6461 self._illustration_issues.append((
6462 'error',
6463 'planned_image_invalid_acquisition',
6464 f"{filename or '(missing filename)'} has invalid "
6465 f"Acquire Via {row.get('Acquire Via', '').strip()!r}.",
6466 ))
6467 continue
6468 if status not in valid_statuses:
6469 self._illustration_issues.append((
6470 'error',
6471 'planned_image_invalid_status',
6472 f"{filename or '(missing filename)'} has invalid "
6473 f"Status {row.get('Status', '').strip()!r}.",
6474 ))
6475 continue
6476 if status in {'pending', 'failed'}:
6477 self._illustration_issues.append((
6478 'error',
6479 'planned_image_not_terminal',
6480 f"{filename or '(missing filename)'} has non-terminal "
6481 f"Status {row.get('Status', '').strip()!r}; finish the "
6482 "owning acquisition or mark it Needs-Manual before export.",
6483 ))
6484 elif status not in terminal_by_acquisition[acquire]:
6485 expected = ', '.join(sorted(terminal_by_acquisition[acquire]))
6486 self._illustration_issues.append((
6487 'error',
6488 'planned_image_status_mismatch',
6489 f"{filename or '(missing filename)'} uses Acquire Via "
6490 f"{acquire!r} but Status {status!r}; terminal status must "
6491 f"be one of: {expected}.",
6492 ))
6493
6494 if lock_error:
6495 self._illustration_issues.append((
6496 'error',
6497 'image_lock_unreadable',
6498 lock_error,
6499 ))
6500 return
6501
6502 placed_rows = [
6503 row for row in rows
6504 if self._row_type(row).lower() != 'illustration sheet'
6505 and self._row_acquire(row)
6506 in {'ai', 'web', 'user', 'formula', 'placeholder', 'slice'}
6507 ]
6508 rows_by_filename = {
6509 self._row_filename(row): row
6510 for row in placed_rows
6511 if self._row_filename(row)
6512 }
6513 referenced_paths: Dict[str, set[Path]] = defaultdict(set)
6514 for references in svg_references.values():
6515 for filename, paths in references.items():
6516 referenced_paths[filename].update(paths)
6517 referenced = set(referenced_paths)
6518
6519 for filename, row in rows_by_filename.items():
6520 if filename not in lock_entries:
6521 self._illustration_issues.append((
6522 'error',
6523 'planned_image_missing_lock',
6524 f"{filename} is a placed Design Spec image row but is "
6525 "absent from spec_lock.md images.",
6526 ))
6527 continue
6528 if current_image_contract and any(
6529 entry.get('legacy') != 'false'
6530 for entry in lock_entries[filename]
6531 ):
6532 self._illustration_issues.append((
6533 'error',
6534 'planned_image_legacy_lock_projection',
6535 f"{filename} uses the current Design Spec image contract "
6536 "but its spec_lock.md row does not provide complete "
6537 "source=..., pattern=..., and crop=... metadata.",
6538 ))
6539
6540 for filename in sorted(referenced - set(rows_by_filename)):
6541 lock_note = (
6542 ""
6543 if filename in lock_entries
6544 else " and is absent from spec_lock.md images"
6545 )
6546 self._illustration_issues.append((
6547 'error',
6548 'svg_image_missing_spec',
6549 f"svg_output references {filename}, but it has no placed "
6550 f"Design Spec §VIII row{lock_note}.",
6551 ))
6552
6553 for filename, entries in lock_entries.items():
6554 row = rows_by_filename.get(filename)
6555 if row is None:
6556 self._illustration_issues.append((
6557 'error',
6558 'locked_image_missing_spec',
6559 f"{filename} is listed in spec_lock.md images but has no "
6560 "placed row in Design Spec §VIII.",
6561 ))
6562 continue
6563
6564 acquire = self._row_acquire(row)
6565 status = self._row_status(row)
6566 layout_pattern = self._row_layout(row)
6567 crop_policy = self._row_crop(row)
6568 if len(entries) > 1:
6569 keys = ', '.join(repr(entry.get('key', '')) for entry in entries)
6570 self._illustration_issues.append((
6571 'error',
6572 'locked_image_duplicate_entries',
6573 f"{filename} appears in multiple spec_lock.md image rows "
6574 f"({keys}); one resource must have one authoritative row.",
6575 ))
6576 for entry in entries:
6577 if entry.get('legacy') != 'false':
6578 continue
6579 if entry.get('source') != acquire:
6580 self._illustration_issues.append((
6581 'error',
6582 'locked_image_source_mismatch',
6583 f"{filename} spec_lock source={entry.get('source')!r} "
6584 f"does not match Design Spec §VIII Acquire Via "
6585 f"{acquire!r}.",
6586 ))
6587 if entry.get('crop') != crop_policy:
6588 self._illustration_issues.append((
6589 'error',
6590 'locked_image_crop_mismatch',
6591 f"{filename} spec_lock crop={entry.get('crop')!r} "
6592 f"does not match Design Spec §VIII Crop Policy "
6593 f"{crop_policy!r}.",
6594 ))
6595 if not self._layout_projection_matches(
6596 entry.get('pattern', ''),
6597 layout_pattern,
6598 ):
6599 self._illustration_issues.append((
6600 'error',
6601 'locked_image_pattern_mismatch',
6602 f"{filename} spec_lock pattern="
6603 f"{entry.get('pattern')!r} does not preserve the "
6604 "Design Spec §VIII Layout pattern recommendation "
6605 f"{layout_pattern!r}. This Design Spec-to-spec_lock "
6606 "projection check compares ordered catalog ids when "
6607 "present, otherwise normalized text; it does not "
6608 "compare SVG geometry or restrict the Executor's "
6609 "realization.",
6610 ))
6611 candidate_paths: List[Path] = []
6612 for entry in entries:
6613 raw_path = entry.get('path', '')
6614 if not raw_path:
6615 continue
6616 lock_path = Path(raw_path)
6617 legacy_bare_filename = (
6618 not lock_path.is_absolute()
6619 and raw_path not in {'.', '..'}
6620 and '/' not in raw_path
6621 and '\\' not in raw_path
6622 and ':' not in raw_path
6623 )
6624 if lock_path.is_absolute():
6625 path = lock_path
6626 elif legacy_bare_filename:
6627 path = project_path / 'images' / raw_path
6628 else:
6629 path = project_path / raw_path
6630 resolved_path = path.resolve()
6631 try:
6632 resolved_path.relative_to(project_root)
6633 except ValueError:
6634 self._illustration_issues.append((
6635 'error',
6636 'locked_image_path_outside_project',
6637 f"{filename} lock path {entry['path']!r} resolves "
6638 "outside the project workspace.",
6639 ))
6640 continue
6641 candidate_paths.append(resolved_path)
6642
6643 distinct_candidate_paths = set(candidate_paths)
6644 if len(distinct_candidate_paths) > 1:
6645 shown = ', '.join(
6646 str(path.relative_to(project_root))
6647 for path in sorted(distinct_candidate_paths)
6648 )
6649 self._illustration_issues.append((
6650 'error',
6651 'locked_image_ambiguous_paths',
6652 f"{filename} resolves to multiple locked project paths: "
6653 f"{shown}. One resource must have one authoritative asset.",
6654 ))
6655
6656 expected_paths = {
6657 path
6658 for path in distinct_candidate_paths
6659 if path.is_file()
6660 }
6661 asset_exists = bool(expected_paths)
6662 file_required = status in {
6663 'existing',
6664 'generated',
6665 'sourced',
6666 'rendered',
6667 }
6668 if not asset_exists and file_required:
6669 expected = entries[0].get('path') or f"images/{filename}"
6670 self._illustration_issues.append((
6671 'error',
6672 'locked_image_file_missing',
6673 f"{filename} is locked and has terminal Status "
6674 f"{row.get('Status', '').strip()!r}, but {expected} "
6675 "does not exist.",
6676 ))
6677
6678 actual_paths = referenced_paths.get(filename, set())
6679 unexpected_paths = actual_paths - expected_paths
6680 if unexpected_paths:
6681 shown = ', '.join(
6682 str(path.relative_to(project_root))
6683 if path.is_relative_to(project_root)
6684 else str(path)
6685 for path in sorted(unexpected_paths)
6686 )
6687 self._illustration_issues.append((
6688 'error',
6689 'locked_image_reference_mismatch',
6690 f"{filename} is referenced from {shown}, not exclusively "
6691 "from its locked project path.",
6692 ))
6693
6694 should_be_referenced = (
6695 acquire != 'placeholder'
6696 and asset_exists
6697 and status
6698 in {
6699 'existing',
6700 'generated',
6701 'sourced',
6702 'rendered',
6703 'needs-manual',
6704 }
6705 )
6706 if should_be_referenced and not (actual_paths & expected_paths):
6707 self._illustration_issues.append((
6708 'error',
6709 'locked_image_unreferenced',
6710 f"{filename} has usable terminal content but its locked "
6711 "file is not referenced by any svg_output <image> element.",
6712 ))
6713
6714 effective_no_crop = (
6715 crop_policy == 'no-crop'
6716 or acquire == 'formula'
6717 or any(entry.get('crop') == 'no-crop' for entry in entries)
6718 )
6719 if effective_no_crop:
6720 placements_by_svg: Dict[
6721 Path,
6722 List[Tuple[str, Tuple[str, ...]]],
6723 ] = defaultdict(list)
6724 for svg_path, raw_aspect, mechanisms in image_placements.get(
6725 filename,
6726 [],
6727 ):
6728 placements_by_svg[svg_path].append((
6729 raw_aspect,
6730 mechanisms,
6731 ))
6732
6733 for svg_path, placements in placements_by_svg.items():
6734 parsed_placements = []
6735 for raw_aspect, mechanisms in placements:
6736 try:
6737 align, mode = (
6738 _parse_project_image_aspect_ratio(raw_aspect or None)
6739 if _parse_project_image_aspect_ratio is not None
6740 else ('', '')
6741 )
6742 except ValueError:
6743 # The per-SVG aspect-ratio validator owns malformed syntax.
6744 continue
6745 parsed_placements.append((
6746 raw_aspect,
6747 mechanisms,
6748 align,
6749 mode,
6750 ))
6751
6752 has_complete_placement = any(
6753 align != 'none'
6754 and mode == 'meet'
6755 and not mechanisms
6756 for _raw_aspect, mechanisms, align, mode
6757 in parsed_placements
6758 )
6759
6760 for raw_aspect, _mechanisms, align, _mode in parsed_placements:
6761 if align != 'none':
6762 continue
6763 actual = raw_aspect or '(implicit xMidYMid meet)'
6764 self._illustration_issues.append((
6765 'error',
6766 'no_crop_image_fit_mismatch',
6767 f"{svg_path.name}: {filename} is no-crop but its "
6768 f"rendered placement uses "
6769 f"preserveAspectRatio={actual!r}; stretching is not "
6770 "a detail crop and remains forbidden.",
6771 ))
6772
6773 if has_complete_placement:
6774 continue
6775
6776 for raw_aspect, mechanisms, align, mode in parsed_placements:
6777 if align != 'none' and mode != 'meet':
6778 actual = raw_aspect or '(implicit xMidYMid meet)'
6779 self._illustration_issues.append((
6780 'error',
6781 'no_crop_image_fit_mismatch',
6782 f"{svg_path.name}: {filename} is no-crop but "
6783 "this page has no complete placement and uses "
6784 f"preserveAspectRatio={actual!r}; keep at least "
6785 "one unclipped placement with a legal alignment "
6786 "anchor and meet.",
6787 ))
6788 if mechanisms:
6789 self._illustration_issues.append((
6790 'error',
6791 'no_crop_image_clipped',
6792 f"{svg_path.name}: {filename} is no-crop but "
6793 "this page has no complete placement; its "
6794 "rendered placement is affected by "
6795 f"{', '.join(mechanisms)}. Keep at least one "
6796 "unclipped meet placement so every source pixel "
6797 "remains visible.",
6798 ))
6799
6800 self._check_sourced_image_provenance(
6801 rows_by_filename,
6802 project_path,
6803 )
6804
6805 def _check_sourced_image_provenance(
6806 self,
6807 rows_by_filename: Dict[str, Dict[str, str]],
6808 project_path: Path,
6809 ) -> None:
6810 """Require one valid provenance item for every Sourced web row."""
6811 sourced = {
6812 filename: row
6813 for filename, row in rows_by_filename.items()
6814 if self._row_acquire(row) == 'web'
6815 and self._row_status(row) == 'sourced'
6816 }
6817 if not sourced:
6818 return
6819
6820 manifest_path = project_path / 'images' / 'image_sources.json'
6821 if not manifest_path.exists():
6822 self._illustration_issues.append((
6823 'error',
6824 'image_sources_missing',
6825 "Sourced web images are used, but "
6826 "images/image_sources.json does not exist.",
6827 ))
6828 return
6829
6830 payload, error = self._read_image_sources_manifest(manifest_path)
6831 if error:
6832 if manifest_path not in self._source_manifest_errors_reported:
6833 self._illustration_issues.append((
6834 'error',
6835 'image_sources_invalid',
6836 error,
6837 ))
6838 self._source_manifest_errors_reported.add(manifest_path)
6839 return
6840
6841 manifest_items = {
6842 str(item.get('filename') or ''): item
6843 for item in payload['items']
6844 if item.get('filename')
6845 }
6846 valid_tiers = {
6847 'no-attribution',
6848 'attribution-required',
6849 'manual',
6850 }
6851 for filename in sourced:
6852 item = manifest_items.get(filename)
6853 if item is None:
6854 self._illustration_issues.append((
6855 'error',
6856 'sourced_image_missing_provenance',
6857 f"{filename} is Sourced but has no matching entry in "
6858 "images/image_sources.json.",
6859 ))
6860 continue
6861
6862 tier = str(item.get('license_tier') or '').strip()
6863 if tier not in valid_tiers:
6864 self._illustration_issues.append((
6865 'error',
6866 'sourced_image_invalid_license_tier',
6867 f"{filename} has invalid license_tier {tier!r} in "
6868 "images/image_sources.json.",
6869 ))
6870 if tier != 'manual' and not str(
6871 item.get('attribution_text') or ''
6872 ).strip():
6873 self._illustration_issues.append((
6874 'error',
6875 'sourced_image_missing_attribution_text',
6876 f"{filename} has license_tier {tier!r} but no "
6877 "attribution_text in images/image_sources.json.",
6878 ))
6879 if tier == 'attribution-required' and not str(
6880 item.get('author') or ''
6881 ).strip():
6882 self._illustration_issues.append((
6883 'error',
6884 'sourced_image_missing_author',
6885 f"{filename} requires attribution but has no author in "
6886 "images/image_sources.json.",
6887 ))
6888
6889 def _check_animation_config_contract(self, dir_path: Path) -> None:
6890 """Project-level animations.json reference checks."""
6891 project_path = self._resolve_project_path(dir_path)
6892 config_path = project_path / 'animations.json'
6893 if (
6894 _load_animation_config is None
6895 or _validate_animation_config is None
6896 or _validate_animation_config_errors is None
6897 or _validate_transition_config is None
6898 ):
6899 if config_path.is_file():
6900 detail = _animation_config_import_error or 'unknown import error'
6901 self._animation_issues.append((
6902 'error',
6903 f'animations.json validation is unavailable: {detail}',
6904 ))
6905 return
6906 try:
6907 config = _load_animation_config(project_path)
6908 except Exception as exc:
6909 self._animation_issues.append(('error', f"animations.json is invalid: {exc}"))
6910 return
6911 if not config:
6912 return
6913 fatal_errors = list(dict.fromkeys(
6914 _validate_transition_config(config)
6915 + _validate_animation_config_errors(config)
6916 ))
6917 for error in fatal_errors:
6918 self._animation_issues.append(('error', error))
6919 for message in _validate_animation_config(project_path, config):
6920 severity = (
6921 'warning'
6922 if ' has no id and cannot be customized in animations.json' in message
6923 else 'error'
6924 )
6925 self._animation_issues.append((severity, message))
6926
6927 def _check_template_contract(
6928 self,
6929 dir_path: Path,
6930 svg_files: List[Path],
6931 *,
6932 check_structure: bool,
6933 ) -> None:
6934 """Check reusable-template structure, roster, and placeholder hints.
6935
6936 - **Roster mismatch (orphan / missing)** is reported as an *error*: a
6937 stale roster will produce a wrong ``layouts_index.json`` entry.
6938 - **Explicit structure gaps** are errors when positive structure checks
6939 are enabled: every current reusable SVG declares its Master and Layout
6940 identity. Zero-placeholder Layouts are valid. Legacy template-mode
6941 packages fail and must be replaced by a new create-template workspace.
6942 - **Placeholder gaps** are reported as *warnings*. Templates may
6943 legitimately omit conventional placeholders or swap them out (e.g.
6944 ``{{CLOSING_MESSAGE}}`` instead of ``{{THANK_YOU}}``), and a content
6945 variant may use a bespoke slot vocabulary. Designers can declare
6946 their own per-stem expectations via ``placeholders:`` frontmatter
6947 in ``design_spec.md`` to suppress these warnings explicitly.
6948
6949 Issues are aggregated and printed in :py:meth:`print_summary` so the
6950 per-file report stays focused on intrinsic SVG validity.
6951 """
6952 spec_path = _roster_spec_path(dir_path)
6953 spec_text = (
6954 spec_path.read_text(encoding='utf-8')
6955 if spec_path is not None and spec_path.exists()
6956 else ""
6957 )
6958 declared_structure_mode = _declared_template_structure_mode(dir_path)
6959 mode_error_recorded = False
6960 if declared_structure_mode != 'structured':
6961 mode_error_recorded = True
6962 self._template_issues.append((
6963 'error',
6964 'explicit_structure_mode',
6965 "design_spec.md frontmatter must declare "
6966 "native_structure_mode: structured; legacy template-mode "
6967 "workspaces must be re-created through create-template",
6968 ))
6969 if check_structure:
6970 native_contract_path = dir_path / 'native_structure.json'
6971 source_template_path = dir_path / 'source_template.pptx'
6972 legacy_structure_detected = False
6973 for svg_file in svg_files:
6974 try:
6975 root = ET.parse(svg_file).getroot()
6976 except (OSError, ET.ParseError):
6977 continue
6978 if not root.get('data-pptx-master'):
6979 legacy_structure_detected = True
6980 self._template_issues.append((
6981 'error',
6982 'explicit_master_missing',
6983 f"{svg_file.name}: reusable templates require root "
6984 "data-pptx-master metadata",
6985 ))
6986 if not root.get('data-pptx-master-name'):
6987 legacy_structure_detected = True
6988 self._template_issues.append((
6989 'error',
6990 'explicit_master_name_missing',
6991 f"{svg_file.name}: reusable templates require root "
6992 "data-pptx-master-name metadata",
6993 ))
6994 if not root.get('data-pptx-layout'):
6995 self._template_issues.append((
6996 'error',
6997 'explicit_structure_missing',
6998 f"{svg_file.name}: reusable templates require root "
6999 "data-pptx-layout metadata",
7000 ))
7001 if not root.get('data-pptx-layout-name'):
7002 self._template_issues.append((
7003 'error',
7004 'explicit_structure_name_missing',
7005 f"{svg_file.name}: reusable templates require root "
7006 "data-pptx-layout-name metadata",
7007 ))
7008 if root.get('data-pptx-layout-kind') is not None:
7009 legacy_structure_detected = True
7010 self._template_issues.append((
7011 'error',
7012 'deck_instance_layout_kind',
7013 f"{svg_file.name}: reusable template prototypes must omit "
7014 "legacy data-pptx-layout-kind metadata",
7015 ))
7016 if any(
7017 child.get('data-pptx-placeholder') is not None
7018 and child.tag.rsplit('}', 1)[-1] != 'g'
7019 for child in list(root)
7020 ):
7021 legacy_structure_detected = True
7022 missing_bounds = [
7023 child.get('id') or child.tag.rsplit('}', 1)[-1]
7024 for child in list(root)
7025 if child.get('data-pptx-placeholder') is not None
7026 and child.get('data-pptx-bounds') is None
7027 ]
7028 if missing_bounds:
7029 legacy_structure_detected = True
7030 self._template_issues.append((
7031 'error',
7032 'placeholder_bounds_missing',
7033 f"{svg_file.name}: reusable templates require "
7034 "explicit design-zone data-pptx-bounds; missing: "
7035 + ', '.join(missing_bounds),
7036 ))
7037 if native_contract_path.exists() or source_template_path.exists():
7038 legacy_structure_detected = True
7039 self._template_issues.append((
7040 'error',
7041 'legacy_native_structure_pair',
7042 "legacy native_structure.json/source_template.pptx template "
7043 "contracts must be replaced through "
7044 "skills/ppt-master/workflows/create-template.md",
7045 ))
7046
7047 if declared_structure_mode != 'structured':
7048 legacy_structure_detected = True
7049 if not mode_error_recorded:
7050 self._template_issues.append((
7051 'error',
7052 'explicit_structure_mode',
7053 "design_spec.md frontmatter must declare "
7054 "native_structure_mode: structured",
7055 ))
7056 if legacy_structure_detected:
7057 self._template_issues.append((
7058 'error',
7059 'legacy_structure_contract',
7060 "legacy template structure detected; create a new current "
7061 "workspace through skills/ppt-master/workflows/"
7062 "create-template.md before Step 3 consumption",
7063 ))
7064 spec_pages = self._extract_spec_roster(spec_text) if spec_text else []
7065 custom_contract = self._extract_frontmatter_placeholders(spec_text) if spec_text else {}
7066
7067 on_disk = {p.stem for p in svg_files}
7068
7069 if spec_pages:
7070 spec_set = set(spec_pages)
7071 orphan = sorted(on_disk - spec_set)
7072 missing = sorted(spec_set - on_disk)
7073 for page in orphan:
7074 self._template_issues.append((
7075 'error',
7076 'roster_orphan',
7077 f"{page}.svg exists on disk but is not listed in design_spec.md Page Roster",
7078 ))
7079 for page in missing:
7080 self._template_issues.append((
7081 'error',
7082 'roster_missing',
7083 f"design_spec.md Page Roster lists {page} but {page}.svg is missing on disk",
7084 ))
7085 elif spec_path is not None and spec_path.exists():
7086 # design_spec.md is present but the roster parser found nothing —
7087 # reusable template workspaces always fail closed.
7088 self._template_issues.append((
7089 'error',
7090 'roster_unknown',
7091 f"could not extract page roster from {spec_path.name}; "
7092 "skipping orphan/missing checks",
7093 ))
7094 else:
7095 self._template_issues.append((
7096 'error',
7097 'spec_missing',
7098 "one Layout or Deck Design Spec is required for every SVG roster",
7099 ))
7100
7101 # Per-file placeholder coverage. Variants reuse the parent type's set
7102 # (e.g. 03a_content_two_col.svg ↔ 03_content rules) unless the spec
7103 # frontmatter overrides that page (custom_contract takes precedence).
7104 for svg_file in svg_files:
7105 expected = self._lookup_template_contract(
7106 svg_file.stem, overrides=custom_contract,
7107 )
7108 if expected is None:
7109 continue # extension pages or stems with no convention
7110 try:
7111 content = svg_file.read_text(encoding='utf-8')
7112 except OSError:
7113 continue
7114 for placeholder in expected:
7115 if placeholder not in content:
7116 self._template_issues.append((
7117 'warning',
7118 'placeholder_hint',
7119 f"{svg_file.name}: missing conventional placeholder {placeholder} "
7120 "(declare 'placeholders:' frontmatter in design_spec.md to silence)",
7121 ))
7122
7123 @staticmethod
7124 def _extract_frontmatter_placeholders(spec_text: str) -> Dict[str, Tuple[str, ...]]:
7125 """Read the optional ``placeholders:`` map from design_spec.md frontmatter.
7126
7127 Shape:
7128
7129 .. code-block:: yaml
7130
7131 placeholders:
7132 01_cover: ["{{TITLE}}", "{{BRAND_LOGO}}"]
7133 03_content: [] # explicitly assert "no expectation"
7134 03a_content_two_col: # variant-specific override
7135 - "{{LEFT_TITLE}}"
7136 - "{{RIGHT_TITLE}}"
7137
7138 Each key is a stem (full filename without ``.svg``) or page-type prefix
7139 (``01_cover``). An empty list silences the default convention for that
7140 stem; a populated list replaces the default. Stems / prefixes not
7141 listed fall back to ``DEFAULT_PLACEHOLDER_CONVENTION``.
7142
7143 We parse with PyYAML when available; otherwise we fall back to a
7144 minimal regex that handles the documented shape.
7145 """
7146 if not spec_text.startswith("---\n"):
7147 return {}
7148 end = spec_text.find("\n---\n", 4)
7149 if end == -1:
7150 return {}
7151 block = spec_text[4:end]
7152
7153 try:
7154 import yaml # type: ignore
7155 except ImportError:
7156 return _parse_placeholders_fallback(block)
7157
7158 try:
7159 data = yaml.safe_load(block) or {}
7160 except yaml.YAMLError:
7161 return {}
7162 if not isinstance(data, dict):
7163 return {}
7164 raw = data.get("placeholders")
7165 if not isinstance(raw, dict):
7166 return {}
7167
7168 out: Dict[str, Tuple[str, ...]] = {}
7169 for stem, value in raw.items():
7170 if not isinstance(stem, str):
7171 continue
7172 if isinstance(value, list):
7173 out[stem] = tuple(str(v) for v in value)
7174 elif value is None:
7175 out[stem] = ()
7176 return out
7177
7178 @staticmethod
7179 def _extract_spec_roster(spec_text: str) -> List[str]:
7180 """Best-effort: extract the page roster from design_spec.md.
7181
7182 Templates do not share a uniform section index for the roster — the
7183 personality-only skeleton puts it at §V "Page Roster"; legacy specs use
7184 §VI "Page Roster" or bury filenames under §VII "Page Types" as
7185 ``### N. Cover Page (01_cover.svg)``. We match by title (any roman
7186 index), then fall back to scanning the whole document for any
7187 backtick-wrapped ``<stem>.svg`` reference.
7188
7189 Returns the deduplicated stem list in document order. Empty result
7190 means we can't determine the roster confidently — caller should treat
7191 that as "skip orphan/missing checks", not as "no pages declared".
7192 """
7193 # Pass 1: explicit roster section, any roman numeral.
7194 sections = list(re.finditer(
7195 r"^##\s+[IVX]+\.\s+(?:(?:SVG\s+)?Page Roster|Page Structure|Pages|Page Types)\b.*?(?=^##\s+|\Z)",
7196 spec_text,
7197 re.MULTILINE | re.DOTALL | re.IGNORECASE,
7198 ))
7199 roster_scope = next(
7200 (
7201 section.group(0)
7202 for section in sections
7203 if re.match(
7204 r"^##\s+[IVX]+\.\s+(?:SVG\s+)?Page Roster\b",
7205 section.group(0),
7206 re.IGNORECASE,
7207 )
7208 ),
7209 None,
7210 )
7211 scope = roster_scope or next(
7212 (
7213 section.group(0)
7214 for section in sections
7215 if re.search(r"[`\(][0-9A-Za-z_]+\.svg[`\)]", section.group(0))
7216 ),
7217 sections[0].group(0) if sections else None,
7218 )
7219
7220 # Pass 2: full document. We *only* trust this scan when the explicit
7221 # roster scan came up empty (no `<stem>.svg` references inside it) —
7222 # otherwise the explicit section's deliberate roster wins over loose
7223 # mentions elsewhere.
7224 explicit_scope = bool(
7225 scope and re.search(r"[`\(][0-9A-Za-z_]+\.svg[`\)]", scope)
7226 )
7227 if explicit_scope:
7228 text = scope
7229 else:
7230 text = spec_text
7231
7232 stems: List[str] = []
7233 seen: set = set()
7234 # Accept backtick-quoted (`01_cover.svg`) and parenthesized
7235 # (01_cover.svg) forms — existing specs use either.
7236 svg_ref_re = re.compile(r"[`\(]([0-9A-Za-z_]+\.svg)[`\)]")
7237 for match in svg_ref_re.finditer(text):
7238 stem = match.group(1)[:-4]
7239 if stem in seen or (not explicit_scope and not re.match(r"^\d", stem)):
7240 continue
7241 seen.add(stem)
7242 stems.append(stem)
7243
7244 # If the explicit §VI scan listed bare stems (without .svg), accept
7245 # those as fallback — but only when they were inside that section.
7246 if not stems and scope:
7247 for match in re.finditer(r"`([0-9]{2}[a-z]?_[A-Za-z0-9_]+)`", scope):
7248 stem = match.group(1)
7249 if stem in seen:
7250 continue
7251 seen.add(stem)
7252 stems.append(stem)
7253
7254 return stems
7255
7256 @classmethod
7257 def _lookup_template_contract(
7258 cls, stem: str, *,
7259 overrides: Dict[str, Tuple[str, ...]] | None = None,
7260 ) -> Tuple[str, ...] | None:
7261 """Resolve a SVG stem to its expected placeholder set.
7262
7263 Resolution order, first hit wins:
7264 1. ``overrides[stem]`` — frontmatter entry for the exact filename
7265 2. ``overrides[<page_type_prefix>]`` — frontmatter entry for the
7266 variant's parent type (e.g. ``03_content`` for
7267 ``03a_content_two_col``)
7268 3. ``DEFAULT_PLACEHOLDER_CONVENTION[<page_type>]`` — keyed by the
7269 type token alone, so it applies regardless of where the type
7270 lands in the template's presentation-order numbering
7271
7272 Returns ``None`` for stems with no matching convention or override —
7273 e.g. extension pages like ``05_section_break``. ``()`` (empty tuple)
7274 is a valid value meaning "no expected placeholders" — used to
7275 explicitly silence the default convention.
7276 """
7277 overrides = overrides or {}
7278 if stem in overrides:
7279 return overrides[stem]
7280
7281 # Variant convention: <NN><letter>?_<rest>; strip the letter to find
7282 # the parent type prefix, e.g. "03a_content_two_col" -> "03_content".
7283 match = re.match(r"^(\d{2})([a-z])?_([a-z]+)", stem)
7284 if not match:
7285 return None
7286 num, _letter, kind = match.groups()
7287 key = f"{num}_{kind}"
7288 if key in overrides:
7289 return overrides[key]
7290 return cls.DEFAULT_PLACEHOLDER_CONVENTION.get(kind)
7291
7292 def _print_result(self, result: Dict):
7293 """Print check result for a single file"""
7294 if result['passed']:
7295 if result['warnings']:
7296 icon = "[WARN]"
7297 status = "Passed (with warnings)"
7298 else:
7299 icon = "[OK]"
7300 status = "Passed"
7301 else:
7302 icon = "[ERROR]"
7303 status = "Failed"
7304
7305 print(f"{icon} {result['file']} - {status}")
7306
7307 # Display basic info
7308 if result['info']:
7309 info_items = []
7310 if 'viewbox' in result['info']:
7311 info_items.append(f"viewBox: {result['info']['viewbox']}")
7312 if info_items:
7313 print(f" {' | '.join(info_items)}")
7314
7315 # Display errors
7316 if result['errors']:
7317 for error in result['errors']:
7318 print(f" [ERROR] {error}")
7319
7320 # Display the complete warning set from this run. The generation
7321 # workflow reviews all findings before one consolidated repair pass.
7322 if result['warnings']:
7323 for warning in result['warnings']:
7324 print(f" [WARN] {warning}")
7325
7326 print()
7327
7328 def print_summary(self):
7329 """Print check summary"""
7330 self._apply_aggregated_issue_counts()
7331
7332 print("=" * 80)
7333 print("[SUMMARY] Check Summary")
7334 print("=" * 80)
7335
7336 print(f"\nTotal files: {self.summary['total']}")
7337 print(
7338 f" [OK] Fully passed: {self.summary['passed']} ({self._percentage(self.summary['passed'])}%)")
7339 print(
7340 f" [WARN] With warnings: {self.summary['warnings']} ({self._percentage(self.summary['warnings'])}%)")
7341 print(
7342 f" [ERROR] With errors: {self.summary['errors']} ({self._percentage(self.summary['errors'])}%)")
7343
7344 self._print_provenance_category_summary()
7345 self._print_carrier_receipt_summary()
7346
7347 if self.issue_types:
7348 print(f"\nIssue categories:")
7349 for issue_type, count in sorted(self.issue_types.items(), key=lambda x: x[1], reverse=True):
7350 print(f" {issue_type}: {count}")
7351
7352 # spec_lock anchor comparison (only printed when a lock was found)
7353 self._print_anchor_value_summary()
7354
7355 # Template-mode aggregation (orphan/missing roster + placeholder hints)
7356 self._print_template_summary()
7357
7358 # Animation config aggregation.
7359 self._print_animation_summary()
7360
7361 # Illustration strategy aggregation.
7362 self._print_illustration_summary()
7363
7364 # Communication contract and per-page audience movement.
7365 self._print_communication_trace_summary()
7366
7367 # Explicit PowerPoint master/layout structure aggregation.
7368 self._print_pptx_structure_summary()
7369
7370 # Source-owned import recovery belongs to the template, not this run.
7371 self._print_source_import_summary()
7372
7373 # Fix suggestions
7374 if self.summary['errors'] > 0 or self.summary['warnings'] > 0:
7375 print(f"\n[TIP] Common fixes:")
7376 print(f" 1. XML well-formedness: write typography as raw Unicode (—, ©, →, NBSP); escape XML reserved chars as &amp; &lt; &gt; &quot; &apos; — never use HTML named entities like &nbsp; &mdash; &copy;")
7377 print(f" 2. viewBox issues: root viewBox is the canvas authority (see references/canvas-formats.md)")
7378 print(
7379 " 3. Paint recommendation: generated SVG prefers uppercase "
7380 "#RRGGBB plus channel-specific opacity; compatible alternatives "
7381 "remain non-blocking"
7382 )
7383 print(f" 4. foreignObject: Use <text> + <tspan> for manual line breaks")
7384 print(f" 5. Font issues: use PPT-safe exported typefaces (e.g. Microsoft YaHei / Arial / Consolas)")
7385
7386 def _carrier_receipt_summary(self) -> Dict:
7387 """Aggregate factual per-page carrier receipts for compact review."""
7388 receipts = [
7389 result.get('info', {}).get('carrier_receipt')
7390 for result in self.results
7391 if result.get('info', {}).get('carrier_receipt')
7392 ]
7393 totals = Counter({
7394 'text_elements': 0,
7395 'image_placements': 0,
7396 'icons': 0,
7397 'svg_geometry_elements': 0,
7398 'preset_shapes': 0,
7399 'page_frame_elements': 0,
7400 'marker_uses': 0,
7401 })
7402 pages_with = Counter({
7403 'images': 0,
7404 'icons': 0,
7405 'presets': 0,
7406 'charts': 0,
7407 'tables': 0,
7408 'formulas': 0,
7409 })
7410 geometry_counts: Counter[str] = Counter()
7411 preset_names: Counter[str] = Counter()
7412 native_objects: Counter[str] = Counter()
7413 image_frame_shares: List[float] = []
7414
7415 for receipt in receipts:
7416 images = receipt['images']
7417 geometry = receipt['geometry']
7418 native = receipt['native_objects']
7419 totals['text_elements'] += receipt['text_elements']
7420 totals['image_placements'] += images['placements']
7421 totals['icons'] += receipt['icons']
7422 totals['preset_shapes'] += geometry['preset_shapes']
7423 totals['page_frame_elements'] += geometry['page_frame_elements']
7424 totals['marker_uses'] += sum(geometry['marker_uses'].values())
7425 geometry_counts.update(geometry['svg_elements'])
7426 preset_names.update(geometry['preset_names'])
7427 native_objects.update(native)
7428
7429 if images['placements']:
7430 pages_with['images'] += 1
7431 image_frame_shares.append(images['max_frame_share'])
7432 if receipt['icons']:
7433 pages_with['icons'] += 1
7434 if geometry['preset_shapes']:
7435 pages_with['presets'] += 1
7436 if native.get('chart'):
7437 pages_with['charts'] += 1
7438 if native.get('table'):
7439 pages_with['tables'] += 1
7440 if native.get('formula_block') or native.get('formula_inline'):
7441 pages_with['formulas'] += 1
7442
7443 totals['svg_geometry_elements'] = sum(geometry_counts.values())
7444 frame_share_range = (
7445 [round(min(image_frame_shares), 4), round(max(image_frame_shares), 4)]
7446 if image_frame_shares
7447 else []
7448 )
7449 return {
7450 'scope': 'informational-not-a-quota',
7451 'pages': len(receipts),
7452 'totals': dict(totals),
7453 'pages_with': dict(pages_with),
7454 'geometry_elements': dict(sorted(geometry_counts.items())),
7455 'preset_names': dict(sorted(preset_names.items())),
7456 'native_objects': dict(sorted(native_objects.items())),
7457 'image_page_max_frame_share_range': frame_share_range,
7458 }
7459
7460 def _print_carrier_receipt_summary(self) -> None:
7461 """Print a compact actual-use receipt without a score or threshold."""
7462 if self.template_mode:
7463 return
7464 receipt = self._carrier_receipt_summary()
7465 if not receipt['pages']:
7466 return
7467 totals = receipt['totals']
7468 native = receipt['native_objects']
7469 print("\n[CARRIERS] Actual-use receipt (informational; not a quota)")
7470 print(
7471 f" Pages: {receipt['pages']} | text: {totals['text_elements']} | "
7472 f"images: {totals['image_placements']} | icons: {totals['icons']}"
7473 )
7474 print(
7475 f" Geometry: SVG elements {totals['svg_geometry_elements']} | "
7476 f"native presets {totals['preset_shapes']} | "
7477 f"page-frame elements {totals['page_frame_elements']} | "
7478 f"marker uses {totals['marker_uses']}"
7479 )
7480 print(
7481 f" Native objects: charts {native.get('chart', 0)} | "
7482 f"tables {native.get('table', 0)} | formulas "
7483 f"{native.get('formula_block', 0) + native.get('formula_inline', 0)}"
7484 )
7485 presets = receipt['preset_names']
7486 preset_text = (
7487 ', '.join(f'{name} x{count}' for name, count in presets.items())
7488 if presets
7489 else '(none)'
7490 )
7491 print(f" Presets: {preset_text}")
7492 image_range = receipt['image_page_max_frame_share_range']
7493 if image_range:
7494 print(
7495 " Largest image-frame share on image pages: "
7496 f"{image_range[0] * 100:.1f}%–{image_range[1] * 100:.1f}%"
7497 )
7498
7499 def _print_provenance_category_summary(self):
7500 """Print compact JSON-equivalent counts for token-safe gate handling."""
7501 categories = self._provenance_categories()
7502 rows = (
7503 (
7504 'blocking',
7505 len(categories['blocking']),
7506 'hard findings; gate also requires exit 0',
7507 ),
7508 (
7509 'introduced',
7510 len(categories['introduced']),
7511 'advisory; new or changed',
7512 ),
7513 (
7514 'inherited',
7515 len(categories['inherited']),
7516 'informational; prototype-identical',
7517 ),
7518 (
7519 'source-import',
7520 _source_import_warning_count(categories['source_import']),
7521 'informational; source-conversion loss',
7522 ),
7523 )
7524
7525 print("\nProvenance categories:")
7526 for name, count, note in rows:
7527 print(f" {f'{name}: {count}':<20} {note}")
7528
7529 def _print_animation_summary(self):
7530 """Print animations.json validation issues if present."""
7531 if not self._animation_issues:
7532 return
7533
7534 errors = [item for item in self._animation_issues if item[0] == 'error']
7535 warnings = [item for item in self._animation_issues if item[0] == 'warning']
7536
7537 print("\n[ANIMATION] animations.json checks")
7538 for _severity, msg in errors:
7539 print(f" [ERROR] {msg}")
7540 for _severity, msg in warnings:
7541 print(f" [WARN] {msg}")
7542
7543 def _print_illustration_summary(self):
7544 """Print project-level illustration strategy issues if present."""
7545 if not self._illustration_issues:
7546 return
7547
7548 errors = [item for item in self._illustration_issues if item[0] == 'error']
7549 warnings = [item for item in self._illustration_issues if item[0] == 'warning']
7550
7551 print("\n[IMAGES] Image resource checks")
7552 if errors:
7553 print(f" Errors ({len(errors)}):")
7554 for _severity, kind, msg in errors:
7555 print(f" [{kind}] {msg}")
7556 if warnings:
7557 print(f" Warnings ({len(warnings)}):")
7558 for _severity, kind, msg in warnings:
7559 print(f" [{kind}] {msg}")
7560
7561 def _print_pptx_structure_summary(self):
7562 """Print project-level PowerPoint structure contract issues."""
7563 if not self._pptx_structure_issues:
7564 return
7565 print("\n[PPTX STRUCTURE] Master/layout contract checks")
7566 for severity, message in self._pptx_structure_issues:
7567 print(f" [{severity.upper()}] {message}")
7568
7569 def _print_communication_trace_summary(self):
7570 """Print project-level communication trace issues."""
7571 if not self._communication_trace_issues:
7572 return
7573 print("\n[COMMUNICATION TRACE] Contract and Audience move checks")
7574 for severity, message in self._communication_trace_issues:
7575 print(f" [{severity.upper()}] {message}")
7576
7577 def _print_source_import_summary(self):
7578 """Print source-owned tolerant-import diagnostics as information."""
7579 warning_count = _source_import_warning_count(
7580 self._source_import_summary
7581 )
7582 if warning_count <= 0:
7583 return
7584 print("\n[SOURCE IMPORT] Template-owned compatibility diagnostics")
7585 print(
7586 f" [INFO] {warning_count} source-import warning(s); unchanged "
7587 "template recovery is not attributed to generated content."
7588 )
7589 by_code = self._source_import_summary.get('by_code')
7590 if isinstance(by_code, dict):
7591 for code, count in sorted(by_code.items()):
7592 print(f" {code}: {count}")
7593
7594 def _print_template_summary(self):
7595 """Aggregate template-mode roster / placeholder issues at the bottom.
7596
7597 Errors land under the ``errors`` summary count (so the exit signal
7598 from ``main`` agrees), warnings under ``warnings``. Both are listed
7599 per file so the user can act on them directly.
7600 """
7601 if not self._template_issues and self._spec_only_template_kind is None:
7602 return
7603
7604 errors = [item for item in self._template_issues if item[0] == 'error']
7605 warnings = [item for item in self._template_issues if item[0] == 'warning']
7606
7607 print("\n[TEMPLATE] Template mode checks")
7608 if errors:
7609 print(f" Errors ({len(errors)}):")
7610 for _sev, kind, msg in errors:
7611 print(f" [{kind}] {msg}")
7612 if warnings:
7613 print(f" Warnings ({len(warnings)}):")
7614 for _sev, kind, msg in warnings:
7615 print(f" [{kind}] {msg}")
7616 if self._spec_only_template_kind is not None and not errors:
7617 pretty_kind = self._spec_only_template_kind.title()
7618 print(f" {pretty_kind} design_spec.md contract passed.")
7619 if not errors:
7620 if self._spec_only_template_kind is None:
7621 print(" No structural roster issues.")
7622 print(" Conventional placeholder-name hints may be declared through "
7623 "'placeholders:' frontmatter. Placeholder bounds are mandatory "
7624 "design-zone metadata.")
7625
7626 def _apply_aggregated_issue_counts(self):
7627 """Mirror project-level aggregate issues into summary counters once."""
7628 if self._aggregate_counts_applied:
7629 return
7630 self._aggregate_counts_applied = True
7631
7632 animation_errors = [item for item in self._animation_issues if item[0] == 'error']
7633 animation_warnings = [item for item in self._animation_issues if item[0] == 'warning']
7634 self.summary['errors'] += len(animation_errors)
7635 self.summary['warnings'] += len(animation_warnings)
7636 for severity, _msg in self._animation_issues:
7637 self.issue_types[f'animation_config_{severity}'] += 1
7638
7639 template_errors = [item for item in self._template_issues if item[0] == 'error']
7640 template_warnings = [item for item in self._template_issues if item[0] == 'warning']
7641 self.summary['errors'] += len(template_errors)
7642 self.summary['warnings'] += len(template_warnings)
7643 for severity, kind, _msg in self._template_issues:
7644 self.issue_types[f'template_{kind}_{severity}'] += 1
7645
7646 illustration_errors = [item for item in self._illustration_issues if item[0] == 'error']
7647 illustration_warnings = [item for item in self._illustration_issues if item[0] == 'warning']
7648 self.summary['errors'] += len(illustration_errors)
7649 self.summary['warnings'] += len(illustration_warnings)
7650 for severity, kind, _msg in self._illustration_issues:
7651 self.issue_types[f'illustration_{kind}_{severity}'] += 1
7652
7653 communication_errors = [
7654 item for item in self._communication_trace_issues
7655 if item[0] == 'error'
7656 ]
7657 communication_warnings = [
7658 item for item in self._communication_trace_issues
7659 if item[0] == 'warning'
7660 ]
7661 self.summary['errors'] += len(communication_errors)
7662 self.summary['warnings'] += len(communication_warnings)
7663 for severity, _msg in self._communication_trace_issues:
7664 self.issue_types[f'communication_trace_{severity}'] += 1
7665
7666 structure_errors = [item for item in self._pptx_structure_issues if item[0] == 'error']
7667 structure_warnings = [item for item in self._pptx_structure_issues if item[0] == 'warning']
7668 self.summary['errors'] += len(structure_errors)
7669 self.summary['warnings'] += len(structure_warnings)
7670 for severity, _msg in self._pptx_structure_issues:
7671 self.issue_types[f'pptx_structure_{severity}'] += 1
7672
7673 def _print_anchor_value_summary(self):
7674 """Print anchor comparisons without treating contextual paint/type as drift."""
7675 if not self._lock_seen:
7676 return
7677 has_contextual = any(
7678 self._anchor_value_summary[category]
7679 for category in ('colors', 'fonts')
7680 )
7681 has_undeclared_sizes = bool(self._anchor_value_summary['sizes'])
7682 if not has_contextual and not has_undeclared_sizes:
7683 print(
7684 "\n[OK] spec_lock anchor comparison: no additional contextual "
7685 "colors/fonts or out-of-band font sizes"
7686 )
7687 return
7688
7689 if has_contextual:
7690 print("\nContextual values beyond spec_lock anchors (informational):")
7691 for category, label in (
7692 ('colors', 'Colors'),
7693 ('fonts', 'Font families'),
7694 ):
7695 items = self._anchor_value_summary.get(category, {})
7696 if not items:
7697 continue
7698 entries = sorted(
7699 items.items(), key=lambda item: (-len(item[1]), item[0])
7700 )
7701 print(f" {label}:")
7702 for val, files in entries:
7703 count = len(files)
7704 suffix = "file" if count == 1 else "files"
7705 print(f" {val} ({count} {suffix})")
7706 print(
7707 "Note: contextual page paint, gradient/effect colors, and "
7708 "export-safe typefaces are allowed.\n"
7709 " Add a spec_lock row only when a value becomes a "
7710 "recurring named semantic role."
7711 )
7712
7713 if has_undeclared_sizes:
7714 print(
7715 "\nTypography sizes outside every declared role anchor ±2px "
7716 "(up to 2 occurrences are sparse; the 3rd is recurring):"
7717 )
7718 entries = sorted(
7719 self._anchor_value_summary['sizes'].items(),
7720 key=lambda item: (-len(item[1]), item[0]),
7721 )
7722 for val, files in entries:
7723 occurrences = self._undeclared_size_occurrences.get(
7724 val,
7725 len(files),
7726 )
7727 file_count = len(files)
7728 file_suffix = "file" if file_count == 1 else "files"
7729 policy = (
7730 "sparse"
7731 if occurrences <= SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES
7732 else "recurring — declare a role"
7733 )
7734 print(
7735 f" {val} ({occurrences} occurrences in {file_count} "
7736 f"{file_suffix}; {policy})"
7737 )
7738
7739 def _percentage(self, count: int) -> int:
7740 """Calculate percentage"""
7741 if self.summary['total'] == 0:
7742 return 0
7743 return min(100, int(count / self.summary['total'] * 100))
7744
7745 def export_report(self, output_file: str = 'svg_quality_report.txt'):
7746 """Export check report"""
7747 with open(output_file, 'w', encoding='utf-8') as f:
7748 f.write("PPT Master SVG Quality Check Report\n")
7749 f.write("=" * 80 + "\n\n")
7750
7751 for result in self.results:
7752 status = "[OK] Passed" if result['passed'] else "[ERROR] Failed"
7753 f.write(f"{status} - {result['file']}\n")
7754 f.write(f"Path: {result.get('path', 'N/A')}\n")
7755
7756 if result['info']:
7757 f.write(f"Info: {result['info']}\n")
7758
7759 if result['errors']:
7760 f.write(f"\nErrors:\n")
7761 for error in result['errors']:
7762 f.write(f" - {error}\n")
7763
7764 if result['warnings']:
7765 f.write(f"\nWarnings:\n")
7766 for warning in result['warnings']:
7767 f.write(f" - {warning}\n")
7768
7769 f.write("\n" + "-" * 80 + "\n\n")
7770
7771 # Write summary
7772 f.write("\n" + "=" * 80 + "\n")
7773 f.write("Check Summary\n")
7774 f.write("=" * 80 + "\n\n")
7775 f.write(f"Total files: {self.summary['total']}\n")
7776 f.write(f"Fully passed: {self.summary['passed']}\n")
7777 f.write(f"With warnings: {self.summary['warnings']}\n")
7778 f.write(f"With errors: {self.summary['errors']}\n")
7779
7780 print(f"\n[REPORT] Check report exported: {output_file}")
7781
7782 def _provenance_categories(self) -> Dict[str, object]:
7783 """Classify every issue by provenance.
7784
7785 Single source for the JSON report's ``categories`` block and the
7786 terminal summary, so the console and the report never disagree about
7787 what blocks a release export.
7788 """
7789 self._apply_aggregated_issue_counts()
7790 introduced: List[Dict[str, str]] = []
7791 blocking: List[Dict[str, str]] = []
7792 inherited: List[Dict[str, str]] = []
7793 for result in self.results:
7794 filename = str(result.get('file') or '')
7795 introduced.extend({
7796 'file': filename,
7797 'message': warning,
7798 } for warning in result.get('warnings', []))
7799 blocking.extend({
7800 'file': filename,
7801 'message': error,
7802 } for error in result.get('errors', []))
7803 info = result.get('info') or {}
7804 for item in info.get('inherited', []):
7805 if isinstance(item, dict):
7806 inherited.append({
7807 'file': filename,
7808 'kind': str(item.get('kind') or 'prototype'),
7809 'message': str(item.get('message') or ''),
7810 })
7811
7812 project_issues = {
7813 'template': [
7814 {'severity': severity, 'kind': kind, 'message': message}
7815 for severity, kind, message in self._template_issues
7816 ],
7817 'animation': [
7818 {'severity': severity, 'message': message}
7819 for severity, message in self._animation_issues
7820 ],
7821 'illustration': [
7822 {'severity': severity, 'kind': kind, 'message': message}
7823 for severity, kind, message in self._illustration_issues
7824 ],
7825 'communication_trace': [
7826 {'severity': severity, 'message': message}
7827 for severity, message in self._communication_trace_issues
7828 ],
7829 'pptx_structure': [
7830 {'severity': severity, 'message': message}
7831 for severity, message in self._pptx_structure_issues
7832 ],
7833 }
7834 for group, issues in project_issues.items():
7835 for issue in issues:
7836 item = {
7837 'scope': group,
7838 'message': issue['message'],
7839 }
7840 if issue['severity'] == 'error':
7841 blocking.append(item)
7842 else:
7843 introduced.append(item)
7844
7845 return {
7846 'blocking': blocking,
7847 'introduced': introduced,
7848 'inherited': inherited,
7849 'project_issues': project_issues,
7850 'source_import': dict(self._source_import_summary),
7851 }
7852
7853 def export_json_report(
7854 self,
7855 output_file: str,
7856 *,
7857 target: str,
7858 stage: str,
7859 ) -> None:
7860 """Write a machine-readable quality report with provenance classes."""
7861 categories = self._provenance_categories()
7862 blocking = categories['blocking']
7863 introduced = categories['introduced']
7864 inherited = categories['inherited']
7865 project_issues = categories['project_issues']
7866
7867 # Keep the legacy `drift` JSON field for report compatibility. Its
7868 # colors/fonts entries are informational anchor comparisons; sparse
7869 # size entries are informational until their third occurrence.
7870 drift = {
7871 category: {
7872 value: sorted(files)
7873 for value, files in sorted(values.items())
7874 }
7875 for category, values in self._anchor_value_summary.items()
7876 }
7877 source_import = categories['source_import']
7878 payload = {
7879 'schema': 'ppt-master.svg-quality-report.v1',
7880 'stage': stage,
7881 'target': str(Path(target).resolve()),
7882 'source_fingerprint': _quality_source_fingerprint(self.results),
7883 'summary': dict(self.summary),
7884 'issue_types': dict(sorted(self.issue_types.items())),
7885 'categories': {
7886 'blocking': {
7887 'count': len(blocking),
7888 'issues': blocking,
7889 },
7890 'introduced': {
7891 'count': len(introduced),
7892 'issues': introduced,
7893 },
7894 'inherited': {
7895 'count': len(inherited),
7896 'issues': inherited,
7897 },
7898 'source-import': {
7899 'count': _source_import_warning_count(source_import),
7900 'summary': source_import,
7901 },
7902 },
7903 'drift': drift,
7904 'carrier_receipt': self._carrier_receipt_summary(),
7905 'project_issues': project_issues,
7906 'files': self.results,
7907 }
7908 report_path = Path(output_file)
7909 report_path.parent.mkdir(parents=True, exist_ok=True)
7910 report_path.write_text(
7911 json.dumps(payload, ensure_ascii=False, indent=2) + '\n',
7912 encoding='utf-8',
7913 )
7914 print(f"\n[REPORT] JSON quality report exported: {report_path}")
7915
7916
7917 def _source_import_warning_count(summary: Dict[str, object]) -> int:
7918 """Return only a schema-compatible non-negative warning count."""
7919 value = summary.get('warning_count')
7920 if isinstance(value, bool) or not isinstance(value, int) or value < 0:
7921 return 0
7922 return value
7923
7924
7925 def _quality_source_fingerprint(results: List[Dict]) -> Dict[str, object]:
7926 """Bind a quality report to the exact SVG bytes that were checked."""
7927 files: List[Dict[str, object]] = []
7928 aggregate = hashlib.sha256()
7929 candidates = sorted(
7930 (
7931 result
7932 for result in results
7933 if result.get('exists') and result.get('path')
7934 ),
7935 key=lambda result: Path(str(result['path'])).name,
7936 )
7937 for result in candidates:
7938 path = Path(str(result['path']))
7939 file_sha256 = result.get('source_sha256')
7940 if not isinstance(file_sha256, str):
7941 files.append({
7942 'file': path.name,
7943 'sha256': None,
7944 'error': 'source bytes were not available during validation',
7945 })
7946 file_sha256 = 'unreadable'
7947 else:
7948 files.append({'file': path.name, 'sha256': file_sha256})
7949 aggregate.update(path.name.encode('utf-8'))
7950 aggregate.update(b'\0')
7951 aggregate.update(file_sha256.encode('ascii'))
7952 aggregate.update(b'\n')
7953 return {
7954 'algorithm': 'sha256',
7955 'digest': aggregate.hexdigest(),
7956 'file_count': len(files),
7957 'files': files,
7958 }
7959
7959 lines PYTHON