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