Files
Templated-PPT-Master/runtime/ppt-master/scripts/svg_quality/checker.py
T

7385 lines
286 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""PPT Master SVG quality-check implementation.
Owns SVG, project-contract, template, and report validation. The stable CLI and
compatibility import surface remain in ``scripts/svg_quality_checker.py``.
Usage:
Import through ``svg_quality_checker`` or invoke the stable script.
Examples:
from svg_quality_checker import SVGQualityChecker
Dependencies:
Standard library plus local PPT Master validation modules.
"""
import copy
import hashlib
import html
import json
import math
import re
from pathlib import Path
from collections import Counter, defaultdict
from typing import Dict, List, Tuple
from urllib.parse import unquote, urlsplit
from xml.etree import ElementTree as ET
from native_payloads import NativePayloadError, hydrate_native_payload_refs
from slide_roster import discover_slide_svgs
from . import svg_contracts
from .xml_support import (
SVG_NS,
XLINK_NS,
element_label as _element_label,
local_name as _local_name,
)
try:
from project_utils import (
CANVAS_FORMATS,
validate_communication_trace,
)
except ImportError:
print("Warning: Unable to import project_utils")
CANVAS_FORMATS = {}
validate_communication_trace = None
from svg_to_pptx.canvas_contract import (
CanvasContractError,
parse_project_svg_root,
parse_project_viewbox,
)
try:
from project_management.project_specs import (
parse_spec_lock as _parse_spec_lock,
parse_spec_lock_image_value as _parse_spec_lock_image_value,
)
except ImportError:
_parse_spec_lock = None # spec_lock anchor comparison will be skipped
_parse_spec_lock_image_value = None
try:
from svg_to_pptx.animation_config import (
load_animation_config as _load_animation_config,
usable_animation_group_id as _usable_animation_group_id,
validate_animation_config as _validate_animation_config,
validate_animation_config_errors as _validate_animation_config_errors,
validate_transition_config as _validate_transition_config,
)
except ImportError as exc:
_load_animation_config = None
_validate_animation_config = None
_validate_animation_config_errors = None
_validate_transition_config = None
_animation_config_import_error = str(exc)
def _usable_animation_group_id(raw: str | None) -> str | None:
return raw if raw and raw.strip() else None
else:
_animation_config_import_error = None
try:
from svg_to_pptx.drawingml.utils import (
IDENTITY_MATRIX as _IDENTITY_MATRIX,
PROJECT_PAINT_PROPERTIES as _PAINT_PROPERTIES,
PROJECT_TEXT_IMAGE_FILL_ATTR as _TEXT_IMAGE_FILL_ATTR,
detect_text_lang as _detect_text_lang,
matrix_multiply as _matrix_multiply,
parse_inline_style as _parse_inline_style,
parse_project_geometry_length as _parse_project_geometry_length,
parse_project_image_aspect_ratio as _parse_project_image_aspect_ratio,
parse_project_opacity as _parse_project_opacity,
parse_svg_color as _parse_export_color,
parse_transform_matrix as _parse_transform_matrix,
project_mask_errors as _project_mask_errors,
rect_to_dml_xfrm as _rect_to_dml_xfrm,
transform_point as _transform_point,
unsafe_exported_font_faces as _unsafe_exported_font_faces,
validate_dml_shape_matrix as _validate_dml_shape_matrix,
)
except ImportError:
_IDENTITY_MATRIX = None
_PAINT_PROPERTIES = None
_TEXT_IMAGE_FILL_ATTR = 'data-pptx-text-image-fill'
_detect_text_lang = None
_matrix_multiply = None
_parse_inline_style = None
_parse_project_geometry_length = None
_parse_project_image_aspect_ratio = None
_parse_project_opacity = None
_parse_export_color = None
_parse_transform_matrix = None
_project_mask_errors = None
_rect_to_dml_xfrm = None
_transform_point = None
_unsafe_exported_font_faces = None
_validate_dml_shape_matrix = None
try:
from svg_to_pptx.drawingml.converter import (
SvgNativeConversionError as _SvgNativeConversionError,
collect_unsupported_visuals as _collect_unsupported_visuals,
preserved_native_text_body as _preserved_native_text_body,
)
except ImportError:
_SvgNativeConversionError = None
_collect_unsupported_visuals = None
_preserved_native_text_body = None
try:
from svg_to_pptx.drawingml.elements import (
drawingml_text_frame_width_emu as _drawingml_text_frame_width_emu,
estimate_single_line_text_frame_width as _estimate_single_line_text_frame_width,
project_image_errors as _project_image_errors,
validate_single_line_text_run_advances as _validate_single_line_text_run_advances,
validate_preset_geometry_metadata as _validate_preset_geometry_metadata,
)
except ImportError:
_drawingml_text_frame_width_emu = None
_estimate_single_line_text_frame_width = None
_project_image_errors = None
_validate_single_line_text_run_advances = None
_validate_preset_geometry_metadata = None
try:
from svg_to_pptx.drawingml.text_properties import (
normalize_project_text_segments as _normalize_project_text_segments,
parse_project_font_weight as _parse_project_font_weight,
parse_project_text_anchor as _parse_project_text_anchor,
resolve_project_xml_space as _resolve_project_xml_space,
resolve_project_font_sizes as _resolve_project_font_sizes,
resolve_project_letter_spacings as _resolve_project_letter_spacings,
)
except ImportError:
_normalize_project_text_segments = None
_parse_project_font_weight = None
_parse_project_text_anchor = None
_resolve_project_xml_space = None
_resolve_project_font_sizes = None
_resolve_project_letter_spacings = None
try:
from pptx_to_svg.preset_authoring import (
AUTHORING_ATTR as _AUTHORING_ATTR,
authored_preset_encoding as _authored_preset_encoding,
validate_authored_preset_group as _validate_authored_preset_group,
validate_authored_preset_tree as _validate_authored_preset_tree,
)
except ImportError:
_AUTHORING_ATTR = 'data-pptx-authoring'
_authored_preset_encoding = None
_validate_authored_preset_group = None
_validate_authored_preset_tree = None
try:
from pptx_shapes import (
CONNECTOR_PRESET_TYPES as _CONNECTOR_PRESET_TYPES,
resolve_preset_preview_hash as _resolve_preset_preview_hash,
svg_preset_preview_fingerprint as _svg_preset_preview_fingerprint,
)
except ImportError:
_CONNECTOR_PRESET_TYPES = frozenset()
_resolve_preset_preview_hash = None
_svg_preset_preview_fingerprint = None
try:
from svg_to_pptx.native_objects import (
validate_native_object_marker as _validate_native_object_marker,
)
except ImportError:
_validate_native_object_marker = None
try:
from svg_to_pptx.native_objects import (
validate_native_object_marker_with_warnings as _validate_native_object_marker_with_warnings,
)
except ImportError:
_validate_native_object_marker_with_warnings = None
try:
from svg_to_pptx.native_objects import (
native_object_marker_warnings as _native_object_marker_warnings,
)
except ImportError:
_native_object_marker_warnings = None
try:
from svg_to_pptx.native_objects import (
native_fallback_kind as _native_fallback_kind,
native_marker_legacy_warnings as _native_marker_legacy_warnings,
native_replacement_kind as _native_replacement_kind,
native_replacement_status as _native_replacement_status,
)
except ImportError:
_native_fallback_kind = None
_native_marker_legacy_warnings = None
_native_replacement_kind = None
_native_replacement_status = None
try:
from svg_to_pptx.native_objects.marker_status import (
native_marker_release_block_reason as _native_marker_release_block_reason,
native_marker_status_errors as _native_marker_status_errors,
)
except ImportError:
_native_marker_release_block_reason = None
_native_marker_status_errors = None
try:
from svg_to_pptx.semantic_markers import (
SEMANTIC_ATTRS as _SEMANTIC_ATTRS,
is_static_page_frame as _is_static_page_frame,
validate_semantic_markers as _validate_semantic_markers,
)
except ImportError:
_SEMANTIC_ATTRS = frozenset({
'data-pptx-page-role',
'data-pptx-role',
})
_is_static_page_frame = None
_validate_semantic_markers = None
try:
from svg_to_pptx.use_expander import (
UseExpansionError as _UseExpansionError,
expand_local_use_references as _expand_local_use_references,
)
except ImportError:
_UseExpansionError = None
_expand_local_use_references = None
try:
from svg_to_pptx.tspan_flattener import (
flatten_positional_tspans as _flatten_positional_tspans,
nested_positional_tspan_errors as _nested_positional_tspan_errors,
)
except ImportError:
_flatten_positional_tspans = None
_nested_positional_tspan_errors = None
try:
from svg_to_pptx.pptx_package.template_structure import (
TemplateStructureError as _TemplateStructureError,
_is_authored_preset_atom as _is_authored_preset_atom,
load_pptx_structure_lock as _load_pptx_structure_lock,
parse_template_slide as _parse_template_structure_slide,
parse_template_slides as _parse_template_structure_slides,
_structure_subtree_signature as _structure_subtree_signature,
template_lock_errors as _template_lock_errors,
template_prototype_errors as _template_prototype_errors,
validate_template_svg as _validate_template_structure_svg,
)
except ImportError:
_TemplateStructureError = None
_is_authored_preset_atom = None
_load_pptx_structure_lock = None
_parse_template_structure_slide = None
_parse_template_structure_slides = None
_structure_subtree_signature = None
_template_lock_errors = None
_template_prototype_errors = None
_validate_template_structure_svg = None
try:
from svg_to_pptx.drawingml.theme_colors import (
ThemeColorError as _ThemeColorError,
load_theme_color_spec as _load_theme_color_spec,
)
from svg_to_pptx.drawingml.theme_fonts import (
ThemeFontError as _ThemeFontError,
load_master_text_style_spec as _load_master_text_style_spec,
load_theme_font_spec as _load_theme_font_spec,
)
except ImportError:
_ThemeColorError = None
_ThemeFontError = None
_load_theme_color_spec = None
_load_master_text_style_spec = None
_load_theme_font_spec = None
try:
from svg_finalize.embed_icons import (
resolve_icon_path as _resolve_icon_path,
suggest_icon_name as _suggest_icon_name,
)
except ImportError:
_resolve_icon_path = None
_suggest_icon_name = None
try:
from resource_paths import (
SVG_WORK_DIR_NAMES as _SVG_WORK_DIR_NAMES,
icon_search_dirs_for_svg as _icon_search_dirs_for_svg,
project_root_for_svg_path as _project_root_for_svg_path,
resolve_external_image_reference as _resolve_external_image_reference,
)
except ImportError:
_SVG_WORK_DIR_NAMES = frozenset()
_icon_search_dirs_for_svg = None
_project_root_for_svg_path = None
_resolve_external_image_reference = None
HEX_VALUE_RE = re.compile(
r"#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})"
)
# Master/Layout preflight validation. Structured deck/layout-template projects
# are checked at authoring time; the exporter remains the final OOXML/package
# authority. Flat projects only receive the negative guard that rejects authored
# structure metadata. Template roster/placeholder checks always run. Current
# bundled templates opt in to complete structure validation through their
# native_structure_mode: structured declaration. Legacy template-mode packages
# fail closed; Create Template must author a new current-contract workspace.
_CHECK_PPTX_STRUCTURED_PROJECT = True
_BARE_HEX_VALUE_RE = re.compile(
r"(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})"
)
_NON_VISUAL_SVG_TAGS = frozenset({
'defs',
'desc',
'metadata',
'style',
'title',
})
_BOUNDS_ATTR = 'data-pptx-bounds'
_MORPH_STAGING_ATTR = 'data-pptx-morph-staging'
_BOUNDS_OVERFLOW_TOLERANCE = 1.0
_BOUNDS_OVERFLOW_ERROR_RATIO = 0.05
_PARAGRAPH_LINE_GAP_MIN_RATIO = 0.9
_PARAGRAPH_LINE_GAP_MAX_RATIO = 2.05
_PARAGRAPH_LINE_X_TOLERANCE = 0.5
_PARAGRAPH_LINE_MIN_TOTAL_CHARS = 12
_PARAGRAPH_LINE_MIN_LONGEST_CHARS = 8
_PARAGRAPH_LINE_TERMINATOR_RE = re.compile(r'[.!?。!?;;]["\'”’))]*$')
_PARAGRAPH_LIST_MARKER_RE = re.compile(
r'^\s*(?:[•·・▪◦‣]\s*|[-–—*]\s+|\d+[.)、]\s+|[((]\d+[))]\s*)\S+'
)
_LEGACY_PPTX_ATTRIBUTE_RENAMES = {
'data-pptx-module-bounds': _BOUNDS_ATTR,
'data-pptx-placeholder-bounds': _BOUNDS_ATTR,
'data-pptx-placeholder-carrier': 'data-pptx-carrier',
'data-pptx-placeholder-binding': 'data-pptx-binding',
'data-pptx-placeholder-idx': 'data-pptx-idx',
}
_PPTX_ROOT_STRUCTURE_ATTRS = (
'data-pptx-master',
'data-pptx-master-name',
'data-pptx-layout',
'data-pptx-layout-name',
)
_PPTX_ROOT_VISIBILITY_ATTRS = (
'data-pptx-show-master-shapes',
'data-pptx-show-inherited-shapes',
)
_PPTX_STRUCTURE_ATTRS = frozenset({
*_PPTX_ROOT_STRUCTURE_ATTRS,
*_PPTX_ROOT_VISIBILITY_ATTRS,
'data-pptx-layer',
'data-pptx-layout-kind',
'data-pptx-placeholder',
'data-pptx-binding',
'data-pptx-carrier',
'data-pptx-idx',
})
_PPTX_PLACEHOLDER_DETAIL_ATTRS = frozenset({
'data-pptx-binding',
'data-pptx-idx',
})
_PPTX_STRUCTURE_SECTION_RE = re.compile(
r"(?ms)^##[ \t]+pptx_structure[ \t]*\r?\n(.*?)(?=^##[ \t]+|\Z)"
)
_PPTX_STRUCTURE_MODE_RE = re.compile(
r"(?m)^-[ \t]+mode[ \t]*:[ \t]*([^\s#]+)[ \t]*(?:#.*)?$"
)
def _compact_preset_ancestor_paint(
root: ET.Element,
) -> list[tuple[str, tuple[str, ...]]]:
"""Return compact presets affected by compatible ancestor paint."""
if (
_authored_preset_encoding is None
or _validate_authored_preset_group is None
):
return []
parents = {
child: parent
for parent in root.iter()
for child in parent
}
affected: list[tuple[str, tuple[str, ...]]] = []
for group in root.iter():
if (
_authored_preset_encoding(group) != 'compact'
or _validate_authored_preset_group(group)
):
continue
relevant = {'opacity'}
if group.get('fill') != 'none' and group.get('fill-opacity') is None:
relevant.add('fill-opacity')
if group.get('stroke') != 'none':
for name in (
'stroke-opacity',
'stroke-dasharray',
'stroke-linecap',
'stroke-linejoin',
):
if group.get(name) is None:
relevant.add(name)
inherited: set[str] = set()
ancestor = parents.get(group)
while ancestor is not None:
declarations = {
name: ancestor.get(name) or ''
for name in relevant
if ancestor.get(name) is not None
}
for declaration in (ancestor.get('style') or '').split(';'):
name, separator, value = declaration.partition(':')
name = name.strip().lower()
if separator and name in relevant:
declarations[name] = value.strip()
for name, value in declarations.items():
normalized = value.strip().lower()
if name in {'opacity', 'fill-opacity', 'stroke-opacity'}:
try:
if float(normalized) == 1:
continue
except ValueError:
pass
elif name == 'stroke-dasharray' and normalized == 'none':
continue
elif name == 'stroke-linecap' and normalized == 'butt':
continue
elif name == 'stroke-linejoin' and normalized == 'miter':
continue
inherited.add(name)
ancestor = parents.get(ancestor)
if inherited:
affected.append((
group.get('id') or '(no id)',
tuple(sorted(inherited)),
))
return affected
def _declared_pptx_structure_mode(project_path: Path) -> str | None:
"""Return the explicitly locked SVG structure mode without a fallback."""
lock_path = project_path / 'spec_lock.md'
try:
content = lock_path.read_text(encoding='utf-8')
except OSError:
return None
section_match = _PPTX_STRUCTURE_SECTION_RE.search(content)
if section_match is None:
return None
mode_match = _PPTX_STRUCTURE_MODE_RE.search(section_match.group(1))
return mode_match.group(1).strip().lower() if mode_match else None
def _generated_theme_contract_errors(project_path: Path) -> List[str]:
"""Validate the current-project theme contract required by release export."""
if (
_ThemeColorError is None
or _ThemeFontError is None
or _load_theme_color_spec is None
or _load_master_text_style_spec is None
or _load_theme_font_spec is None
):
return [
"PowerPoint theme contract validation is unavailable because the "
"theme loader modules could not be imported."
]
try:
theme_font_spec = _load_theme_font_spec(project_path)
_load_master_text_style_spec(project_path)
theme_color_spec = _load_theme_color_spec(project_path)
except (_ThemeFontError, _ThemeColorError) as exc:
return [str(exc)]
missing: List[str] = []
if theme_font_spec is None:
missing.append("typography font_family/title_family/body_family")
if theme_color_spec is None:
missing.append("colors")
if not missing:
return []
return [
"spec_lock.md generated PowerPoint theme contract is missing: "
+ ", ".join(missing)
]
def _parse_positive_bounds(
value: str,
) -> Tuple[float, float, float, float]:
"""Parse one positive x/y/width/height boundary."""
raw_values = [item for item in re.split(r"[\s,]+", value.strip()) if item]
if len(raw_values) != 4:
raise ValueError("must contain exactly four numbers: x y width height")
try:
values = tuple(float(item) for item in raw_values)
except ValueError as exc:
raise ValueError("must contain only numeric values") from exc
if not all(math.isfinite(item) for item in values):
raise ValueError("must contain only finite values")
if values[2] <= 0 or values[3] <= 0:
raise ValueError("must use positive width and height")
return values
def _placeholder_bounds_error(value: str) -> str | None:
"""Return a concise error for invalid design-zone bounds."""
try:
_parse_positive_bounds(value)
except ValueError as exc:
return str(exc)
return None
def _local_pptx_structure_errors(
root: ET.Element,
svg_path: Path,
*,
require_structure: bool,
) -> List[str]:
"""Validate the authoring shape of the structured SVG contract."""
errors: List[str] = []
root_values = {
attr: (root.get(attr) or '').strip()
for attr in _PPTX_ROOT_STRUCTURE_ATTRS
}
has_root_structure = any(root_values.values())
if require_structure or has_root_structure:
missing = [attr for attr, value in root_values.items() if not value]
if missing:
errors.append(
f"{svg_path.name}: structured SVG root is missing "
+ ', '.join(missing)
)
for attr in _PPTX_ROOT_VISIBILITY_ATTRS:
raw = root.get(attr)
if raw is not None and raw not in {'true', 'false'}:
errors.append(
f"{svg_path.name}: root {attr} must be exactly 'true' or 'false'"
)
parent_by_id = {
id(child): parent
for parent in root.iter()
for child in list(parent)
}
for elem in root.iter():
tag = elem.tag.rsplit('}', 1)[-1]
element_id = elem.get('id') or f"<{tag}>"
parent = parent_by_id.get(id(elem))
if elem is not root:
nested_root_attrs = [
attr for attr in (
*_PPTX_ROOT_STRUCTURE_ATTRS,
*_PPTX_ROOT_VISIBILITY_ATTRS,
)
if elem.get(attr) is not None
]
if nested_root_attrs:
errors.append(
f"{svg_path.name}: {element_id} carries root-only metadata "
+ ', '.join(nested_root_attrs)
)
if elem.get('data-pptx-layout-kind') is not None:
errors.append(
f"{svg_path.name}: data-pptx-layout-kind is a legacy distillation "
"attribute; restore the page to the structured contract"
)
layer = (elem.get('data-pptx-layer') or '').strip().lower()
placeholder = (elem.get('data-pptx-placeholder') or '').strip().lower()
if layer in {'master', 'layout'}:
if parent is not root:
errors.append(
f"{svg_path.name}: {element_id} data-pptx-layer={layer!r} "
"must be a direct child of the root <svg>"
)
if tag == 'g' and not (
_is_authored_preset_atom is not None
and _is_authored_preset_atom(elem)
):
errors.append(
f"{svg_path.name}: {element_id} is a <g> marked as {layer}; "
"Master/Layout fixed visuals must be root-level atomic elements"
)
if placeholder:
errors.append(
f"{svg_path.name}: {element_id} cannot be both a fixed "
f"{layer} element and a placeholder slot"
)
detail_attrs = [
attr for attr in _PPTX_PLACEHOLDER_DETAIL_ATTRS
if elem.get(attr) is not None
]
if detail_attrs and not placeholder:
errors.append(
f"{svg_path.name}: {element_id} uses placeholder detail metadata "
"without data-pptx-placeholder"
)
if placeholder:
if parent is not root:
errors.append(
f"{svg_path.name}: placeholder slot {element_id} must be a "
"direct child of the root <svg>"
)
if tag != 'g':
errors.append(
f"{svg_path.name}: placeholder slot {element_id} must be a "
"root-level <g>"
)
if not (elem.get('id') or '').strip():
errors.append(
f"{svg_path.name}: every placeholder slot <g> requires a stable id"
)
wrapper_attrs = sorted(
attr.rsplit('}', 1)[-1]
for attr in elem.attrib
if attr != 'id'
and not attr.rsplit('}', 1)[-1].startswith('data-pptx-')
)
if wrapper_attrs:
errors.append(
f"{svg_path.name}: placeholder slot {element_id} is an "
"authoring boundary and may carry only id/data-pptx-*; remove "
+ ', '.join(wrapper_attrs)
)
bounds = (elem.get('data-pptx-bounds') or '').strip()
if not bounds:
errors.append(
f"{svg_path.name}: placeholder slot {element_id} requires "
"data-pptx-bounds"
)
else:
bounds_error = _placeholder_bounds_error(bounds)
if bounds_error:
errors.append(
f"{svg_path.name}: placeholder slot {element_id} bounds "
+ bounds_error
)
binding = (
elem.get('data-pptx-binding') or 'carrier'
).strip().lower()
if binding not in {'carrier', 'proxy'}:
errors.append(
f"{svg_path.name}: placeholder slot {element_id} has unknown "
f"binding {binding!r}; use carrier or proxy"
)
carrier_descendants = [
child for child in elem.iter()
if child is not elem
and child.get('data-pptx-carrier') is not None
]
visual_children = [
child for child in list(elem)
if child.tag.rsplit('}', 1)[-1] not in _NON_VISUAL_SVG_TAGS
]
direct_carriers = [
child for child in visual_children
if (child.get('data-pptx-carrier') or '').strip().lower()
== 'true'
]
nested_carriers = [
child for child in carrier_descendants
if parent_by_id.get(id(child)) is not elem
]
if nested_carriers:
names = ', '.join(
child.get('id') or f"<{child.tag.rsplit('}', 1)[-1]}>"
for child in nested_carriers
)
errors.append(
f"{svg_path.name}: placeholder slot {element_id} has nested "
f"carrier marker(s): {names}; the carrier must be a direct child"
)
if binding == 'carrier':
if len(visual_children) != 1 or len(direct_carriers) != 1:
errors.append(
f"{svg_path.name}: placeholder slot {element_id} requires "
"exactly one visual direct child, marked "
"data-pptx-carrier=\"true\""
)
if binding == 'proxy':
if placeholder != 'object':
errors.append(
f"{svg_path.name}: proxy binding is allowed only for an "
f"object placeholder, not {placeholder!r}"
)
if carrier_descendants:
errors.append(
f"{svg_path.name}: proxy placeholder slot {element_id} must "
"not declare a visible placeholder carrier"
)
if not visual_children:
errors.append(
f"{svg_path.name}: proxy placeholder slot {element_id} must "
"contain visible Slide-local content"
)
carrier_value = elem.get('data-pptx-carrier')
if carrier_value is not None:
if carrier_value.strip().lower() != 'true':
errors.append(
f"{svg_path.name}: {element_id} "
"data-pptx-carrier must equal true"
)
if parent is None or not (
parent.get('data-pptx-placeholder') or ''
).strip():
errors.append(
f"{svg_path.name}: placeholder carrier {element_id} must be a "
"direct child of a root placeholder slot"
)
if tag in _NON_VISUAL_SVG_TAGS and (layer or placeholder):
errors.append(
f"{svg_path.name}: non-visual {element_id} cannot carry "
"Master/Layout/placeholder ownership"
)
return list(dict.fromkeys(errors))
def _normalize_hex_rgb(value: str) -> str | None:
"""Normalize 3/4/6/8-digit HEX to alpha-free ``RRGGBB``."""
if not HEX_VALUE_RE.fullmatch(value):
return None
color = value[1:]
if len(color) in {3, 4}:
color = ''.join(channel * 2 for channel in color)
return color[:6].upper()
# Cheap numeric envelope for font-size role enforcement. Semantic role assignment
# is prompt-owned; Checker only verifies that a used value is close to at least
# one declared size anchor.
FONT_SIZE_ANCHOR_TOLERANCE_PX = 2.0
SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES = 2
# Oversampling alone does not imply distortion and is often harmless for small
# logos. Warn about downscaling only when the source also has material on-disk
# weight, because PPTX embeds the compressed source asset rather than raw pixels.
IMAGE_DOWNSIZE_WARN_RATIO = 4.0
IMAGE_DOWNSIZE_WARN_MIN_BYTES = 1024 * 1024
def _design_spec_kind(spec_path: Path) -> str | None:
"""Return a roster-free ``kind`` declared in design_spec.md frontmatter.
Lightweight detector that does not require PyYAML — scans only the
frontmatter block (``---`` delimited). Used by ``check_directory`` to
select schema-only validation for Brand and Style workspaces instead of
SVG-roster validation.
"""
try:
text = spec_path.read_text(encoding='utf-8')
except OSError:
return None
if not text.startswith('---\n'):
return None
end = text.find('\n---\n', 4)
if end == -1:
return None
fm_block = text[4:end]
for line in fm_block.splitlines():
stripped = line.strip()
match = re.fullmatch(
r'''kind\s*:\s*(?:(['"])(brand|style)\1|(brand|style))'''
r'''(?:\s+#.*)?\s*''',
stripped,
)
if match:
return match.group(2) or match.group(3)
return None
def _declared_template_structure_mode(target_path: Path) -> str | None:
"""Return a template directory's explicit native structure mode."""
directory = target_path.parent if target_path.is_file() else target_path
spec_path = directory / 'design_spec.md'
try:
text = spec_path.read_text(encoding='utf-8')
except OSError:
return None
if not text.startswith('---\n'):
return None
end = text.find('\n---\n', 4)
if end == -1:
return None
match = re.search(
r'^native_structure_mode:\s*([A-Za-z0-9_-]+)\s*$',
text[4:end],
re.MULTILINE,
)
return match.group(1).lower() if match else None
def _declared_template_canvas_viewbox(target_path: Path) -> str | None:
"""Return a template design spec's locked root-canvas value."""
directory = target_path.parent if target_path.is_file() else target_path
spec_path = directory / 'design_spec.md'
try:
text = spec_path.read_text(encoding='utf-8')
except OSError:
return None
if not text.startswith('---\n'):
return None
end = text.find('\n---\n', 4)
if end == -1:
return None
match = re.search(
r'^canvas_viewbox:\s*["\']?([^"\'\r\n]+?)["\']?\s*$',
text[4:end],
re.MULTILINE,
)
return match.group(1).strip() if match else None
def _template_structure_checks_enabled(target_path: Path) -> bool:
"""Return whether positive structure checks apply to this template."""
return _declared_template_structure_mode(target_path) == 'structured'
def _direct_defs_index(
root: ET.Element,
) -> tuple[Dict[str, ET.Element], set[str]]:
"""Return direct ``<defs>`` children by id plus duplicate ids."""
definitions: Dict[str, ET.Element] = {}
duplicates: set[str] = set()
for defs_elem in root.iter():
if _local_name(defs_elem) != 'defs':
continue
for child in defs_elem:
definition_id = (child.get('id') or '').strip()
if not definition_id:
continue
if definition_id in definitions:
duplicates.add(definition_id)
definitions[definition_id] = child
return definitions, duplicates
def _effective_presentation_value(
elem: ET.Element,
name: str,
parent_by_id: Dict[int, ET.Element],
) -> str | None:
"""Resolve one inherited presentation property for validation."""
current: ET.Element | None = elem
while current is not None:
style_values = (
_parse_inline_style(current.get('style'))
if _parse_inline_style is not None else {}
)
if name in style_values:
return style_values[name]
direct = current.get(name)
if direct is not None:
return direct
current = parent_by_id.get(id(current))
return None
def _parse_viewbox_values(viewbox: str) -> Tuple[float, float, float, float] | None:
"""Parse a root viewBox into four numeric values."""
try:
parsed = parse_project_viewbox(viewbox)
except CanvasContractError:
return None
return 0.0, 0.0, float(parsed.width), float(parsed.height)
def _parse_placeholders_fallback(block: str) -> Dict[str, Tuple[str, ...]]:
"""Tiny YAML-free reader for the documented ``placeholders:`` shape.
Used only when PyYAML is unavailable. Recognized lines (indentation-aware,
two-space indent assumed):
.. code-block:: yaml
placeholders:
01_cover: ["{{TITLE}}", "{{LOGO}}"]
03_content: []
03a_content_two_col:
- "{{LEFT_TITLE}}"
- "{{RIGHT_TITLE}}"
Anything outside this minimal grammar is silently skipped — designers who
rely on advanced YAML should install pyyaml.
"""
out: Dict[str, Tuple[str, ...]] = {}
inline_re = re.compile(
r"^\s{2}([A-Za-z0-9_]+)\s*:\s*\[(.*)\]\s*$"
)
empty_re = re.compile(r"^\s{2}([A-Za-z0-9_]+)\s*:\s*\[\s*\]\s*$")
block_header_re = re.compile(r"^\s{2}([A-Za-z0-9_]+)\s*:\s*$")
item_re = re.compile(r'^\s{4}-\s*"?([^"]+)"?\s*$')
in_section = False
current_block_key: str | None = None
current_items: List[str] = []
def _flush_block() -> None:
nonlocal current_block_key, current_items
if current_block_key is not None:
out[current_block_key] = tuple(current_items)
current_block_key = None
current_items = []
for line in block.splitlines():
if line.startswith("placeholders:"):
in_section = True
continue
if not in_section:
continue
# End of section: dedent to a non-key line.
if line and not line.startswith(" "):
_flush_block()
in_section = False
continue
if current_block_key is not None:
m = item_re.match(line)
if m:
value = m.group(1).strip().strip('"').strip("'")
if value:
current_items.append(value)
continue
# Block ended.
_flush_block()
if empty_re.match(line):
key = empty_re.match(line).group(1)
out[key] = ()
continue
m = inline_re.match(line)
if m:
key, raw = m.group(1), m.group(2)
items = [p.strip().strip('"').strip("'") for p in raw.split(",")]
out[key] = tuple(item for item in items if item)
continue
m = block_header_re.match(line)
if m:
current_block_key = m.group(1)
current_items = []
continue
_flush_block()
return out
class SVGQualityChecker:
"""SVG quality checker"""
# Default placeholder convention per page-type prefix. This is a *hint*,
# not a hard contract: templates may define their own placeholder vocabulary
# via `placeholders:` in design_spec.md frontmatter (see
# references/template-designer.md §4). Missing default placeholders surface
# as warnings, never errors — designers may legitimately swap
# `{{THANK_YOU}}` for `{{CLOSING_MESSAGE}}`, omit `{{DATE}}` when irrelevant,
# or build content variants with bespoke slot vocabularies.
#
# Variants reuse the parent type's expectation (`03a_content_two_col.svg`
# is matched by the same `content` rules as `03_content.svg`).
#
# Keys are page-type tokens, not numbered stems: template numbering is
# presentation order within one template and shifts when the optional
# TOC page is present (`02_chapter` in a four-page roster, `03_chapter`
# in a five-page roster with `02_toc`), so the defaults must apply to
# both spellings.
DEFAULT_PLACEHOLDER_CONVENTION = {
"cover": ("{{TITLE}}",), # only the title is universally expected
"chapter": ("{{CHAPTER_TITLE}}",),
"toc": (), # TOC layouts vary too widely to assert anything
"content": ("{{PAGE_TITLE}}",),
"ending": (), # ending pages legitimately use varied vocabularies
}
def __init__(
self,
*,
template_mode: bool = False,
quick_generate: bool = False,
):
self.template_mode = template_mode
self.quick_generate = quick_generate
self.results = []
self.summary = {
'total': 0,
'passed': 0,
'warnings': 0,
'errors': 0
}
self.issue_types = defaultdict(int)
# spec_lock anchor comparison state (populated only when
# _parse_spec_lock is available and a spec_lock.md is found near the SVG)
self._lock_cache: Dict[Path, Dict] = {}
self._anchor_value_summary: Dict[str, Dict[str, set]] = {
'colors': defaultdict(set),
'fonts': defaultdict(set),
'sizes': defaultdict(set),
}
self._undeclared_size_occurrences: Counter[str] = Counter()
self._undeclared_size_counts_ready = False
self._lock_seen = False # True once we locate at least one spec_lock.md
self._source_manifest_cache: Dict[
Path,
Tuple[Dict, str | None],
] = {}
self._source_manifest_errors_reported: set[Path] = set()
# Template-mode aggregation (populated by check_directory when
# template_mode=True). Each entry is (severity, kind, message) where
# severity is 'error' or 'warning'. Printed in print_summary.
self._template_issues: List[Tuple[str, str, str]] = []
self._spec_only_template_kind: str | None = None
self._animation_issues: List[Tuple[str, str]] = []
self._illustration_issues: List[Tuple[str, str, str]] = []
self._communication_trace_issues: List[Tuple[str, str]] = []
self._pptx_structure_issues: List[Tuple[str, str]] = []
self._has_incomplete_page_roster = False
self._prototype_by_output: Dict[Path, Path] = {}
self._active_prototype_path: Path | None = None
self._active_template_reuse_scope: str | None = None
self._prototype_root_cache: Dict[Path, ET.Element | None] = {}
self._source_import_summary: Dict[str, object] = {
'warning_count': 0,
'by_code': {},
}
self._aggregate_counts_applied = False
@staticmethod
def _append_inherited_info(
result: Dict,
kind: str,
message: str,
) -> None:
"""Record prototype-owned diagnostics outside the warning channel."""
result['info'].setdefault('inherited', []).append({
'kind': kind,
'message': message,
})
def _active_prototype_root(self) -> ET.Element | None:
"""Parse the selected mirror prototype once for inherited checks."""
if (
self._active_template_reuse_scope != 'mirror'
or self._active_prototype_path is None
):
return None
path = self._active_prototype_path.resolve()
if path in self._prototype_root_cache:
return self._prototype_root_cache[path]
try:
root = ET.parse(path).getroot()
hydrate_native_payload_refs(root, path)
except (OSError, ET.ParseError, NativePayloadError):
root = None
self._prototype_root_cache[path] = root
return root
def check_file(
self,
svg_file: str,
expected_format: str = None,
*,
expected_viewbox: str | None = None,
expected_viewbox_label: str = "expected canvas",
) -> Dict:
"""
Check a single SVG file
Args:
svg_file: SVG file path
expected_format: Expected canvas format (e.g., 'ppt169')
Returns:
Check result dictionary
"""
svg_path = Path(svg_file)
if not svg_path.exists():
return {
'file': str(svg_file),
'exists': False,
'errors': ['File does not exist'],
'warnings': [],
'passed': False
}
result = {
'file': svg_path.name,
'path': str(svg_path),
'exists': True,
'errors': [],
'warnings': [],
'info': {},
'passed': True
}
try:
source_bytes = svg_path.read_bytes()
result['source_sha256'] = hashlib.sha256(source_bytes).hexdigest()
content = source_bytes.decode('utf-8')
# 0. Parse XML once — every other check assumes the file is valid
# XML. Bail early on failure so the regex-based checks below don't
# produce misleading errors on a broken document.
root = self._parse_xml_root(content, result)
if root is not None:
try:
hydrated_payloads = hydrate_native_payload_refs(root, svg_path)
except NativePayloadError as exc:
result['errors'].append(
f"Invalid native payload reference: {exc}"
)
else:
if hydrated_payloads:
result['info']['native_payload_refs'] = hydrated_payloads
# 1. Check viewBox
self._check_viewbox(
root,
svg_path,
result,
expected_format,
expected_viewbox=expected_viewbox,
expected_viewbox_label=expected_viewbox_label,
)
self._check_legacy_pptx_attributes(root, svg_path, result)
# 1a. Validate exact importer transport before compatible
# inline geometry is materialized on the shared tree.
svg_contracts.check_nested_svg_crop_contract(root, result)
# 2. Check forbidden elements
svg_contracts.check_forbidden_elements(content, root, result)
svg_contracts.check_mask_contract(root, result)
# 2a. Validate direct geometry lengths and stroke widths.
svg_contracts.check_geometry_length_values(root, result)
# 2b. Validate line-presentation grammar and mappings.
svg_contracts.check_stroke_style_values(root, result)
# 2c. Validate image fit/crop grammar and mappings.
self._check_image_contract(root, svg_path, result)
svg_contracts.check_image_aspect_ratio_values(root, result)
# 2d. Validate complete path-data and point-list grammar.
svg_contracts.check_freeform_geometry_values(root, result)
# 2e. Validate complete transform grammar and native mappings.
svg_contracts.check_transform_values(root, result)
# 2f. Validate opacity grammar and native alpha mappings.
svg_contracts.check_opacity_values(root, result)
# 2g. Validate the closed authoring-property surface and
# conditional definition interfaces before export.
svg_contracts.check_authoring_property_contract(root, result)
svg_contracts.check_text_property_contract(root, result)
self._check_preserved_txbody_contract(root, result)
svg_contracts.check_paint_compatibility(root, result)
svg_contracts.check_reference_spelling(root, result)
svg_contracts.check_definition_contract(root, result)
svg_contracts.check_paint_reference_contract(root, result)
svg_contracts.check_marker_contract(root, result)
svg_contracts.check_clip_path_contract(root, result)
# 2h. Validate the supported shadow/glow filter interface.
svg_contracts.check_imported_effect_status(root, result)
svg_contracts.check_filter_effects(root, result)
# 2i. Validate gradient definitions, stops, and coordinates.
svg_contracts.check_gradient_interfaces(root, result)
# 3. Check font-size values
svg_contracts.check_font_size_values(content, result)
# 4. Check fonts
self._check_fonts(content, result)
# 5. Check text wrapping methods
self._check_text_elements(content, root, result)
# 6. Check image references (file existence and resolution)
self._check_image_references(root, svg_path, result)
# 7. Check icon placeholders resolve before post-processing.
self._check_icon_placeholders(root, svg_path, result)
# 7b. Reject visual elements the native converter cannot dispatch.
self._check_unsupported_visual_elements(root, result)
# 7c. Fail closed on invalid PPTX preset/adjustment metadata.
self._check_preset_geometry_metadata(root, result)
self._check_preset_geometry_transforms(root, result)
# 8. Check object-level animation anchor quality.
self._check_animation_group_ids(root, svg_path, result)
# 8b. Check <pattern> elements declare a PPTX preset.
self._check_pattern_fills(root, result)
# 8c. Check opt-in native table/chart markers before export.
self._check_native_object_markers(root, result)
# 8d. Validate explicit master/layout/placeholder metadata.
if (
_template_structure_checks_enabled(svg_path)
if self.template_mode
else _CHECK_PPTX_STRUCTURED_PROJECT
):
self._check_pptx_structure_metadata(root, svg_path, result)
# 8e. Validate rendering-neutral page/structure compiler hints.
self._check_semantic_markers(root, svg_path, result)
# 9. Compare values with spec_lock anchors. Additional colors
# and fonts are informational. Generated-page type sizes may
# stay sparse twice; the third occurrence is an error. Other
# spec-backed SVG locations retain advisory review. Templates
# do not ship a spec_lock.md, so skip in template mode.
if not self.template_mode:
self._check_spec_lock_alignment(
content,
svg_path,
result,
root=root,
)
# 10. Check web-sourced image attribution. Templates don't carry
# image_sources.json; skip in template mode.
if not self.template_mode:
self._check_sourced_image_attribution(
root,
svg_path,
result,
)
# Determine pass/fail
result['passed'] = len(result['errors']) == 0
except Exception as e:
result['errors'].append(f"Failed to read file: {e}")
result['passed'] = False
# Update statistics
self.summary['total'] += 1
if result['passed']:
if result['warnings']:
self.summary['warnings'] += 1
else:
self.summary['passed'] += 1
else:
self.summary['errors'] += 1
# Categorize issue types
for error in result['errors']:
self.issue_types[self._categorize_issue(error)] += 1
self.results.append(result)
return result
def _parse_xml_root(self, content: str, result: Dict) -> ET.Element | None:
"""Parse the SVG content as well-formed XML.
SVG is strict XML. AI-generated decks frequently produce content that
looks fine in HTML5-tolerant previews but fails strict XML parsing —
common causes are HTML named entities (&nbsp; &mdash; &copy;…) and
bare XML reserved characters in text (R&D, error < 5%). Such pages
cannot be exported to PPTX, so we surface them here as a hard error
before any downstream check looks at them.
Returns the parsed root when the document is well-formed; otherwise
appends an error and returns None.
"""
try:
return ET.fromstring(content)
except ET.ParseError as e:
result['errors'].append(
f"Invalid XML: {e} — SVG must be well-formed XML. "
f"Use raw Unicode for typography (—, ©, →, NBSP); "
f"escape XML reserved chars as &amp; &lt; &gt; &quot; &apos; "
f"(see references/shared-standards-core.md §1)."
)
return None
def _check_viewbox(
self,
root: ET.Element,
svg_path: Path,
result: Dict,
expected_format: str = None,
*,
expected_viewbox: str | None = None,
expected_viewbox_label: str = "expected canvas",
):
"""Validate the root page canvas and its project-level locks."""
viewbox = root.get('viewBox')
try:
parsed = parse_project_svg_root(
root,
context=svg_path.name,
)
except CanvasContractError as exc:
result['errors'].append(str(exc))
return
assert viewbox is not None
result['info']['viewbox'] = viewbox
if viewbox != parsed.canonical or not parsed.has_integer_dimensions:
if parsed.has_integer_dimensions:
recommendation = f'write viewBox="{parsed.canonical}"'
else:
recommendation = (
"fractional dimensions are reserved for compatible imported "
"custom slide sizes; new authoring uses integer pixels"
)
result['warnings'].append(
f"Compatible non-canonical root viewBox {viewbox!r}; {recommendation}."
)
contracts: list[tuple[str, str]] = []
if expected_viewbox is not None:
contracts.append((expected_viewbox_label, expected_viewbox))
elif not self.template_mode:
lock = self._get_spec_lock(svg_path)
if lock is not None and 'canvas' in lock:
locked_viewbox = lock.get('canvas', {}).get('viewBox')
if not locked_viewbox:
result['errors'].append(
"spec_lock.md canvas section must declare viewBox"
)
else:
contracts.append(("spec_lock canvas", locked_viewbox))
if expected_format and expected_format in CANVAS_FORMATS:
contracts.append((
f"canvas format {expected_format!r}",
CANVAS_FORMATS[expected_format]['viewbox'],
))
elif expected_format:
result['errors'].append(f"Unsupported canvas format: {expected_format}")
seen_contracts: set[tuple[str, str]] = set()
for label, raw_expected in contracts:
contract_key = (label, raw_expected)
if contract_key in seen_contracts:
continue
seen_contracts.add(contract_key)
try:
expected = parse_project_viewbox(
raw_expected,
context=f"{label} viewBox",
)
except CanvasContractError as exc:
result['errors'].append(str(exc))
continue
if parsed != expected:
result['errors'].append(
f"viewBox mismatch: {label} requires '{expected.canonical}', "
f"got '{parsed.canonical}'"
)
def _check_image_contract(
self,
root: ET.Element,
svg_path: Path,
result: Dict,
) -> None:
"""Validate picture frames, references, and bytes before export."""
if _project_image_errors is None:
result['errors'].append(
'Unable to import the image validator; cannot verify picture '
'frames or media'
)
return
_working_root, _parent_by_id, images = self._visible_image_elements(root)
for image in images:
result['errors'].extend(
_project_image_errors(
image,
svg_path.parent,
allow_template_placeholders=self.template_mode,
)
)
def _check_fonts(self, content: str, result: Dict):
"""Check font usage.
PPTX stores concrete typefaces per run with no CSS fallback. The
converter resolves each SVG font stack to exported latin / EA typefaces;
validate those exported values rather than the visual-preview tail.
"""
font_matches = self._font_family_values(content)
if not font_matches:
return
result['info']['fonts'] = sorted(set(font_matches))
if _unsafe_exported_font_faces is None:
result['warnings'].append(
"Unable to import svg_to_pptx font resolver; skipped exported-font safety check"
)
return
for font_family in font_matches:
unsafe = [
f"{role}={family}"
for role, family in _unsafe_exported_font_faces(font_family).items()
]
if unsafe:
result['warnings'].append(
"Font stack exports non-PPT-safe typeface(s) to PPTX "
f"({', '.join(unsafe)}): {font_family}"
)
break
@staticmethod
def _font_family_values(content: str) -> List[str]:
"""Extract SVG font-family values from attributes and inline styles."""
return SVGQualityChecker._svg_property_values(content, 'font-family')
@staticmethod
def _svg_property_values(content: str, property_name: str) -> List[str]:
"""Extract a SVG property from direct attributes and inline styles."""
values: List[str] = []
attr_re = re.compile(
rf'\b{re.escape(property_name)}\s*=\s*(["\'])(.*?)\1',
re.IGNORECASE | re.DOTALL,
)
for match in attr_re.finditer(content):
values.append(html.unescape(match.group(2)).strip())
for match in re.finditer(r'\bstyle\s*=\s*(["\'])(.*?)\1', content, re.IGNORECASE | re.DOTALL):
style_value = html.unescape(match.group(2))
for part in style_value.split(';'):
if ':' not in part:
continue
name, value = part.split(':', 1)
if name.strip().lower() == property_name.lower():
values.append(value.strip())
return [value for value in values if value]
def _check_text_elements(self, content: str, root: ET.Element, result: Dict):
"""Check text elements and wrapping methods"""
# Count text and tspan elements
text_count = content.count('<text')
tspan_count = content.count('<tspan')
result['info']['text_elements'] = text_count
result['info']['tspan_elements'] = tspan_count
self._check_module_bounds_contract(root, result)
self._check_text_output_geometry(root, result)
self._check_text_bounds(root, result)
self._check_fragmented_paragraph_text(root, result)
self._check_unmergeable_leading_text(root, result)
self._check_nested_positional_tspans(root, result)
def _check_nested_positional_tspans(
self,
root: ET.Element,
result: Dict,
) -> None:
"""Reject nested baseline jumps that DrawingML runs cannot represent."""
if _nested_positional_tspan_errors is None:
return
result['errors'].extend(_nested_positional_tspan_errors(root))
@classmethod
def _single_line_text_runs(
cls,
text_el: ET.Element,
) -> List[Tuple[ET.Element, str]] | None:
"""Return normalized inline runs, or ``None`` for positioned text."""
if (
_normalize_project_text_segments is None
or _resolve_project_xml_space is None
):
return None
raw_runs: List[Tuple[ET.Element, str, str]] = []
def append_run(owner: ET.Element, raw: str, xml_space: str) -> None:
if raw:
raw_runs.append((owner, xml_space, raw))
def collect(container: ET.Element, inherited_xml_space: str) -> bool:
try:
xml_space = _resolve_project_xml_space(
container,
inherited_xml_space,
)
except ValueError:
return False
if container.text:
append_run(container, container.text, xml_space)
for child in list(container):
if not cls._is_tspan(child):
return False
if any(child.get(name) is not None for name in ('x', 'y', 'dx', 'dy')):
return False
if any(
name.startswith('data-paragraph-')
for name in child.attrib
):
return False
if not collect(child, xml_space):
return False
if child.tail:
append_run(container, child.tail, xml_space)
return True
if not collect(text_el, 'default'):
return None
normalized = _normalize_project_text_segments([
(xml_space, raw)
for _owner, xml_space, raw in raw_runs
])
return [
(raw_runs[index][0], text)
for index, text in normalized
]
@staticmethod
def _unchanged_txbody_group_ids(
root: ET.Element,
) -> set[int]:
"""Return imported shape groups whose original text body will survive."""
if _preserved_native_text_body is None:
return set()
unchanged: set[int] = set()
for group in root.iter(f'{{{SVG_NS}}}g'):
try:
if _preserved_native_text_body(
group,
trust_runtime_snapshot=False,
) is not None:
unchanged.add(id(group))
except _SvgNativeConversionError:
# The dedicated txBody contract check owns the diagnostic.
continue
return unchanged
@staticmethod
def _check_preserved_txbody_contract(
root: ET.Element,
result: Dict,
) -> None:
"""Validate imported txBody payloads independently of text geometry."""
if _preserved_native_text_body is None:
return
errors: set[str] = set()
for group in root.iter(f'{{{SVG_NS}}}g'):
try:
_preserved_native_text_body(
group,
trust_runtime_snapshot=False,
)
except _SvgNativeConversionError as exc:
errors.add(
f'{_element_label(group)} cannot preserve source '
f'txBody: {exc}'
)
result['errors'].extend(sorted(errors))
@staticmethod
def _has_ancestor_id(
elem: ET.Element,
parent_by_id: Dict[int, ET.Element],
ancestor_ids: set[int],
) -> bool:
current = parent_by_id.get(id(elem))
while current is not None:
if id(current) in ancestor_ids:
return True
current = parent_by_id.get(id(current))
return False
@classmethod
def _resolved_single_line_text_runs(
cls,
text_el: ET.Element,
parent_by_id: Dict[int, ET.Element],
font_sizes: Dict[int, float],
letter_spacings: Dict[int, float],
) -> List[Dict] | None:
"""Resolve the same run metrics used by generated text-frame sizing."""
source_runs = cls._single_line_text_runs(text_el)
if source_runs is None:
return None
resolved: List[Dict] = []
for owner, text in source_runs:
raw_weight = (
_effective_presentation_value(
owner,
'font-weight',
parent_by_id,
)
or 'normal'
).strip().lower()
weight = _parse_project_font_weight(raw_weight).canonical
family = (
_effective_presentation_value(
owner,
'font-family',
parent_by_id,
)
or ''
)
opacity_chain: List[str] = []
current: ET.Element | None = owner
while current is not None:
style_values = (
_parse_inline_style(current.get('style'))
if _parse_inline_style is not None else {}
)
raw_opacity = style_values.get('opacity')
if raw_opacity is None:
raw_opacity = current.get('opacity')
if raw_opacity is not None:
opacity_chain.append(raw_opacity.strip())
current = parent_by_id.get(id(current))
resolved.append({
'owner': owner,
'text': text,
'font_size': font_sizes[id(owner)],
'font_weight': weight,
'font_family': family,
'letter_spacing': letter_spacings[id(owner)],
'font_style': _effective_presentation_value(
owner,
'font-style',
parent_by_id,
) or 'normal',
'text_decoration': _effective_presentation_value(
owner,
'text-decoration',
parent_by_id,
) or 'none',
'fill_raw': _effective_presentation_value(
owner,
'fill',
parent_by_id,
) or '#000000',
'fill_opacity': _effective_presentation_value(
owner,
'fill-opacity',
parent_by_id,
) or '1',
'stroke_raw': _effective_presentation_value(
owner,
'stroke',
parent_by_id,
) or 'none',
'stroke_width': _effective_presentation_value(
owner,
'stroke-width',
parent_by_id,
) or '1',
'stroke_opacity': _effective_presentation_value(
owner,
'stroke-opacity',
parent_by_id,
) or '1',
'opacity_chain': tuple(reversed(opacity_chain)),
})
return cls._coalesce_checker_text_runs(resolved)
@staticmethod
def _coalesce_checker_text_runs(runs: List[Dict]) -> List[Dict]:
"""Join only runs whose resolved source styles are provably equal."""
if _detect_text_lang is None:
return runs
style_keys = (
'font_size',
'font_weight',
'font_family',
'letter_spacing',
'font_style',
'text_decoration',
'fill_raw',
'fill_opacity',
'stroke_raw',
'stroke_width',
'stroke_opacity',
'opacity_chain',
)
def signature(run: Dict) -> Tuple:
return (
_detect_text_lang(str(run.get('text', ''))),
*(run.get(key) for key in style_keys),
)
merged: List[Dict] = []
previous_signature: Tuple | None = None
for run in runs:
current_signature = signature(run)
if merged and current_signature == previous_signature:
candidate = {
**merged[-1],
'text': (
str(merged[-1].get('text', ''))
+ str(run.get('text', ''))
),
}
candidate_signature = signature(candidate)
if candidate_signature == previous_signature:
merged[-1] = candidate
previous_signature = candidate_signature
continue
merged.append(run)
previous_signature = current_signature
return merged
def _check_text_output_geometry(
self,
root: ET.Element,
result: Dict,
) -> None:
"""Reject measurable run advances or frames with non-positive geometry."""
helpers = (
_drawingml_text_frame_width_emu,
_estimate_single_line_text_frame_width,
_parse_project_font_weight,
_resolve_project_font_sizes,
_resolve_project_letter_spacings,
_validate_single_line_text_run_advances,
)
if any(helper is None for helper in helpers):
return
try:
font_sizes = _resolve_project_font_sizes(root)
letter_spacings = _resolve_project_letter_spacings(root, font_sizes)
except ValueError:
return
parent_by_id = {
id(child): parent
for parent in root.iter()
for child in list(parent)
}
unchanged_groups = self._unchanged_txbody_group_ids(root)
errors: List[str] = []
for text_el in root.iter(f'{{{SVG_NS}}}text'):
chain: List[ET.Element] = []
current: ET.Element | None = text_el
while current is not None:
chain.append(current)
current = parent_by_id.get(id(current))
if any(
_local_name(current) in _NON_VISUAL_SVG_TAGS
for current in chain
):
continue
if self._has_ancestor_id(text_el, parent_by_id, unchanged_groups):
continue
try:
runs = self._resolved_single_line_text_runs(
text_el,
parent_by_id,
font_sizes,
letter_spacings,
)
if not runs:
continue
if not ''.join(str(run['text']) for run in runs).strip():
continue
text_width = _estimate_single_line_text_frame_width(runs)
ext_cx = _drawingml_text_frame_width_emu(
text_width,
font_sizes[id(text_el)],
)
except (KeyError, TypeError, ValueError):
continue
if ext_cx < 1:
errors.append(
f'{_element_label(text_el)} negative letter-spacing '
'produces a non-positive DrawingML text-frame extent '
f'(cx={ext_cx})'
)
continue
try:
_validate_single_line_text_run_advances(runs)
except ValueError as exc:
errors.append(f'{_element_label(text_el)} {exc}')
result['errors'].extend(errors)
@classmethod
def _positioned_text_lines(
cls,
text_el: ET.Element,
parent_by_id: Dict[int, ET.Element],
font_sizes: Dict[int, float],
letter_spacings: Dict[int, float],
) -> List[Tuple[ET.Element, float, float, List[Dict], float]] | None:
"""Resolve direct positioned tspans into estimable visual lines."""
if _parse_project_geometry_length is None:
return None
children = list(text_el)
if not children or (text_el.text or '').strip():
return None
if any(
not cls._is_tspan(child)
or not cls._is_line_tspan(child)
or child.get('x') is None
or (child.tail or '').strip()
for child in children
):
return None
try:
current_y = _parse_project_geometry_length(
text_el.get('y') or '0',
'y',
)
except ValueError:
return None
lines: List[Tuple[ET.Element, float, float, List[Dict], float]] = []
for child in children:
try:
line_x = _parse_project_geometry_length(child.get('x'), 'x')
line_y = (
_parse_project_geometry_length(child.get('y'), 'y')
if child.get('y') is not None
else current_y
)
if child.get('dx') is not None:
line_x += _parse_project_geometry_length(
child.get('dx'),
'dx',
)
if child.get('dy') is not None:
line_y += _parse_project_geometry_length(
child.get('dy'),
'dy',
)
runs = cls._resolved_single_line_text_runs(
child,
parent_by_id,
font_sizes,
letter_spacings,
)
except (KeyError, TypeError, ValueError):
return None
current_y = line_y
if not runs:
continue
try:
font_size = max(float(run['font_size']) for run in runs)
except (KeyError, TypeError, ValueError):
return None
lines.append((child, line_x, line_y, runs, font_size))
return lines or None
@classmethod
def _estimated_text_line_bounds(
cls,
line_el: ET.Element,
x: float,
y: float,
runs: List[Dict],
font_size: float,
parent_by_id: Dict[int, ET.Element],
*,
include_headroom: bool = True,
) -> Tuple[float, float, float, float] | None:
"""Estimate one line's transformed visible bounds in SVG coordinates."""
if any(helper is None for helper in (
_estimate_single_line_text_frame_width,
_IDENTITY_MATRIX,
_matrix_multiply,
_parse_project_text_anchor,
_parse_transform_matrix,
_transform_point,
)):
return None
try:
width = float(_estimate_single_line_text_frame_width(
runs,
include_headroom=include_headroom,
))
raw_anchor = (
_effective_presentation_value(
line_el,
'text-anchor',
parent_by_id,
)
or 'start'
).strip().lower()
anchor = _parse_project_text_anchor(raw_anchor).value
except (TypeError, ValueError):
return None
if not all(math.isfinite(value) for value in (x, y, width, font_size)):
return None
if width <= 0 or font_size <= 0:
return None
if anchor == 'middle':
left = x - width / 2
right = x + width / 2
elif anchor == 'end':
left = x - width
right = x
elif anchor == 'start':
left = x
right = x + width
else:
return None
top = y - font_size * 0.85
bottom = y + font_size * 0.35
return cls._transformed_rect_bounds(
line_el,
(left, top, right - left, bottom - top),
parent_by_id,
)
@classmethod
def _estimated_text_bounds(
cls,
text_el: ET.Element,
parent_by_id: Dict[int, ET.Element],
font_sizes: Dict[int, float],
letter_spacings: Dict[int, float],
*,
include_headroom: bool = True,
) -> Tuple[float, float, float, float] | None:
"""Estimate one single- or multi-line text carrier's visual bounds."""
lines: List[Tuple[ET.Element, float, float, List[Dict], float]] | None
try:
runs = cls._resolved_single_line_text_runs(
text_el,
parent_by_id,
font_sizes,
letter_spacings,
)
except (KeyError, TypeError, ValueError):
return None
if runs:
try:
lines = [(
text_el,
_parse_project_geometry_length(text_el.get('x') or '0', 'x'),
_parse_project_geometry_length(text_el.get('y') or '0', 'y'),
runs,
max(float(run['font_size']) for run in runs),
)]
except (KeyError, TypeError, ValueError):
return None
else:
lines = cls._positioned_text_lines(
text_el,
parent_by_id,
font_sizes,
letter_spacings,
)
if not lines:
return None
bounds = [
cls._estimated_text_line_bounds(
line_el,
x,
y,
line_runs,
font_size,
parent_by_id,
include_headroom=include_headroom,
)
for line_el, x, y, line_runs, font_size in lines
]
resolved = [item for item in bounds if item is not None]
if not resolved:
return None
return (
min(item[0] for item in resolved),
min(item[1] for item in resolved),
max(item[2] for item in resolved),
max(item[3] for item in resolved),
)
@staticmethod
def _accumulated_transform_matrix(
element: ET.Element,
parent_by_id: Dict[int, ET.Element],
):
"""Return the element-to-root transform matrix when available."""
if any(helper is None for helper in (
_IDENTITY_MATRIX,
_matrix_multiply,
_parse_transform_matrix,
)):
return None
chain: List[ET.Element] = []
current: ET.Element | None = element
while current is not None:
chain.append(current)
current = parent_by_id.get(id(current))
matrix = _IDENTITY_MATRIX
try:
for current in reversed(chain):
raw_transform = current.get('transform')
if raw_transform:
matrix = _matrix_multiply(
matrix,
_parse_transform_matrix(raw_transform),
)
except (TypeError, ValueError):
return None
return matrix
@classmethod
def _transformed_rect_bounds(
cls,
element: ET.Element,
bounds: Tuple[float, float, float, float],
parent_by_id: Dict[int, ET.Element],
) -> Tuple[float, float, float, float] | None:
"""Transform one local rectangle into root SVG coordinates."""
if _transform_point is None:
return None
matrix = cls._accumulated_transform_matrix(element, parent_by_id)
if matrix is None:
return None
x, y, width, height = bounds
try:
corners = [
_transform_point(matrix, corner_x, corner_y)
for corner_x, corner_y in (
(x, y),
(x + width, y),
(x + width, y + height),
(x, y + height),
)
]
except (TypeError, ValueError):
return None
xs = [point[0] for point in corners]
ys = [point[1] for point in corners]
return min(xs), min(ys), max(xs), max(ys)
@classmethod
def _transformed_rect_edge_lengths(
cls,
element: ET.Element,
bounds: Tuple[float, float, float, float],
parent_by_id: Dict[int, ET.Element],
) -> Tuple[float, float] | None:
"""Return frame-axis lengths after accumulated SVG transforms."""
if _transform_point is None:
return None
matrix = cls._accumulated_transform_matrix(element, parent_by_id)
if matrix is None:
return None
x, y, width, height = bounds
try:
origin = _transform_point(matrix, x, y)
width_end = _transform_point(matrix, x + width, y)
height_end = _transform_point(matrix, x, y + height)
except (TypeError, ValueError):
return None
rendered_w = math.hypot(
width_end[0] - origin[0],
width_end[1] - origin[1],
)
rendered_h = math.hypot(
height_end[0] - origin[0],
height_end[1] - origin[1],
)
if rendered_w <= 0 or rendered_h <= 0:
return None
return rendered_w, rendered_h
@staticmethod
def _resolved_root_module_bounds(
group: ET.Element,
) -> Tuple[str, Tuple[float, float, float, float]] | None:
"""Return one root module's explicit boundary in root coordinates."""
raw = group.get(_BOUNDS_ATTR)
if raw is None:
return None
try:
x, y, width, height = _parse_positive_bounds(raw)
except ValueError:
return None
return _BOUNDS_ATTR, (x, y, x + width, y + height)
@staticmethod
def _bounds_overflow_metrics(
inner: Tuple[float, float, float, float],
outer: Tuple[float, float, float, float],
*,
tolerance: float = _BOUNDS_OVERFLOW_TOLERANCE,
) -> Tuple[str, float, float] | None:
"""Return overflow axes and ratios relative to the outer dimensions."""
left, top, right, bottom = inner
outer_left, outer_top, outer_right, outer_bottom = outer
left_overflow = max(outer_left - left, 0.0)
right_overflow = max(right - outer_right, 0.0)
top_overflow = max(outer_top - top, 0.0)
bottom_overflow = max(bottom - outer_bottom, 0.0)
horizontal = (
left_overflow > tolerance
or right_overflow > tolerance
)
vertical = (
top_overflow > tolerance
or bottom_overflow > tolerance
)
if not horizontal and not vertical:
return None
outer_width = outer_right - outer_left
outer_height = outer_bottom - outer_top
if outer_width <= 0.0 or outer_height <= 0.0:
return None
horizontal_ratio = (
max(left_overflow, right_overflow) / outer_width
if horizontal else 0.0
)
vertical_ratio = (
max(top_overflow, bottom_overflow) / outer_height
if vertical else 0.0
)
if horizontal and vertical:
axes = 'horizontal and vertical'
elif horizontal:
axes = 'horizontal'
else:
axes = 'vertical'
return axes, horizontal_ratio, vertical_ratio
@staticmethod
def _bounds_are_disjoint(
first: Tuple[float, float, float, float],
second: Tuple[float, float, float, float],
) -> bool:
"""Return whether two root-coordinate rectangles do not intersect."""
left, top, right, bottom = first
other_left, other_top, other_right, other_bottom = second
return (
right <= other_left
or left >= other_right
or bottom <= other_top
or top >= other_bottom
)
@classmethod
def _is_off_canvas_morph_group(
cls,
group: ET.Element,
canvas: Tuple[float, float, float, float],
) -> bool:
"""Return whether a group declares one wholly off-canvas Morph state."""
if group.get(_MORPH_STAGING_ATTR) != 'true':
return False
resolved = cls._resolved_root_module_bounds(group)
return (
resolved is not None
and cls._bounds_are_disjoint(resolved[1], canvas)
)
@classmethod
def _record_bounds_overflow(
cls,
result: Dict,
*,
subject: str,
inner: Tuple[float, float, float, float],
container: str,
outer: Tuple[float, float, float, float],
repair: str,
) -> None:
"""Record a warning through 5% overflow and an error above it."""
metrics = cls._bounds_overflow_metrics(inner, outer)
if metrics is None:
return
axes, horizontal_ratio, vertical_ratio = metrics
overflow_ratio = max(horizontal_ratio, vertical_ratio)
exceeds_error_ratio = (
overflow_ratio > _BOUNDS_OVERFLOW_ERROR_RATIO
and not math.isclose(
overflow_ratio,
_BOUNDS_OVERFLOW_ERROR_RATIO,
rel_tol=0.0,
abs_tol=1e-9,
)
)
bucket = (
result['errors']
if exceeds_error_ratio
else result['warnings']
)
left, top, right, bottom = inner
outer_left, outer_top, outer_right, outer_bottom = outer
bucket.append(
f'{subject} exceeds {container} on the {axes} axis: '
f'content ({left:.1f}, {top:.1f})-({right:.1f}, '
f'{bottom:.1f}), container ({outer_left:.1f}, '
f'{outer_top:.1f})-({outer_right:.1f}, '
f'{outer_bottom:.1f}), overflow horizontal '
f'{horizontal_ratio:.1%}, vertical {vertical_ratio:.1%}; '
f'{repair}'
)
@classmethod
def _record_canvas_text_overflow(
cls,
result: Dict,
*,
subject: str,
inner: Tuple[float, float, float, float],
canvas: Tuple[float, float, float, float],
) -> bool:
"""Record one page-boundary error and return whether it overflowed."""
metrics = cls._bounds_overflow_metrics(inner, canvas)
if metrics is None:
return False
axes, horizontal_ratio, vertical_ratio = metrics
left, top, right, bottom = inner
canvas_left, canvas_top, canvas_right, canvas_bottom = canvas
result['errors'].append(
f'{subject} exceeds the root viewBox on the {axes} axis: '
f'content ({left:.1f}, {top:.1f})-({right:.1f}, '
f'{bottom:.1f}), canvas ({canvas_left:.1f}, '
f'{canvas_top:.1f})-({canvas_right:.1f}, '
f'{canvas_bottom:.1f}), overflow horizontal '
f'{horizontal_ratio:.1%}, vertical {vertical_ratio:.1%}; '
'move or reflow the text until its estimated bounds stay on-page'
)
return True
@staticmethod
def _text_diagnostic_label(text_element: ET.Element) -> str:
"""Return a locatable label for one SVG text carrier."""
label = _element_label(text_element)
if (text_element.get('id') or '').strip():
return label
details: List[str] = []
raw_x = (text_element.get('x') or '').strip()
raw_y = (text_element.get('y') or '').strip()
if raw_x or raw_y:
details.append(f'x={raw_x or "?"}, y={raw_y or "?"}')
snippet = re.sub(r'\s+', ' ', ''.join(text_element.itertext())).strip()
if snippet:
preview = snippet[:20] + ('…' if len(snippet) > 20 else '')
details.append(f'text={preview!r}')
return f'{label} ({"; ".join(details)})' if details else label
@staticmethod
def _is_hidden_element(
element: ET.Element,
parent_by_id: Dict[int, ET.Element],
) -> bool:
"""Return whether inherited display or visibility hides an element."""
current: ET.Element | None = element
while current is not None:
style_values = (
_parse_inline_style(current.get('style'))
if _parse_inline_style is not None
else {}
)
display = style_values.get('display')
if display is None:
display = current.get('display')
if display and display.strip().lower() == 'none':
return True
current = parent_by_id.get(id(current))
visibility = (
_effective_presentation_value(
element,
'visibility',
parent_by_id,
)
or ''
).strip().lower()
return visibility in {'hidden', 'collapse'}
@staticmethod
def _has_zero_opacity(
element: ET.Element,
parent_by_id: Dict[int, ET.Element],
) -> bool:
"""Return whether an element or ancestor has zero effective opacity."""
current: ET.Element | None = element
while current is not None:
style_values = (
_parse_inline_style(current.get('style'))
if _parse_inline_style is not None
else {}
)
raw = style_values.get('opacity')
if raw is None:
raw = current.get('opacity')
if raw is not None:
value = raw.strip()
try:
opacity = (
float(value[:-1]) / 100
if value.endswith('%')
else float(value)
)
except ValueError:
pass
else:
if opacity <= 0:
return True
current = parent_by_id.get(id(current))
return False
@classmethod
def _visible_image_elements(
cls,
root: ET.Element,
) -> Tuple[ET.Element, Dict[int, ET.Element], List[ET.Element]]:
"""Return rendered image instances after expanding static local uses."""
working_root = copy.deepcopy(root)
if (
_expand_local_use_references is not None
and _UseExpansionError is not None
):
try:
_expand_local_use_references(working_root)
except _UseExpansionError:
# The local-reference validator owns the actionable failure.
working_root = copy.deepcopy(root)
parent_by_id = {
id(child): parent
for parent in working_root.iter()
for child in list(parent)
}
images = [
element
for element in working_root.iter(f'{{{SVG_NS}}}image')
if not cls._is_hidden_element(element, parent_by_id)
and not cls._has_non_visual_ancestor(
element,
working_root,
parent_by_id,
)
and not cls._has_zero_opacity(element, parent_by_id)
]
return working_root, parent_by_id, images
@staticmethod
def _has_non_visual_ancestor(
element: ET.Element,
module: ET.Element,
parent_by_id: Dict[int, ET.Element],
) -> bool:
"""Return whether an element lives in a non-rendered module subtree."""
current: ET.Element | None = element
while current is not None and current is not module:
if _local_name(current) in _NON_VISUAL_SVG_TAGS:
return True
current = parent_by_id.get(id(current))
return False
def _check_module_bounds_contract(
self,
root: ET.Element,
result: Dict,
) -> None:
"""Validate direct root module boundaries in the SVG canvas."""
parent_by_id = {
id(child): parent
for parent in root.iter()
for child in list(parent)
}
viewbox = _parse_viewbox_values(root.get('viewBox') or '')
canvas = None
if viewbox is not None:
x, y, width, height = viewbox
canvas = (x, y, x + width, y + height)
for element in root.iter():
if element.get(_BOUNDS_ATTR) is None:
continue
if _local_name(element) != 'g':
result['errors'].append(
f'{_element_label(element)} {_BOUNDS_ATTR} is valid '
'only on <g> layout modules'
)
for element in root.iter():
raw_staging = element.get(_MORPH_STAGING_ATTR)
if raw_staging is None:
continue
label = _element_label(element)
if raw_staging != 'true':
result['errors'].append(
f'{label} {_MORPH_STAGING_ATTR} must equal "true"; '
'set the exact value or remove the marker'
)
continue
if _local_name(element) != 'g':
result['errors'].append(
f'{label} {_MORPH_STAGING_ATTR} is valid only on <g>; '
'move it to the enclosing ordinary direct-root group'
)
continue
if parent_by_id.get(id(element)) is not root:
result['errors'].append(
f'{label} {_MORPH_STAGING_ATTR} requires a direct-root '
'<g>; move the marked group directly under <svg> or remove '
'the marker'
)
continue
if not (element.get('id') or '').strip():
result['errors'].append(
f'{label} {_MORPH_STAGING_ATTR} requires a stable non-empty '
'id; add an id to the marked direct-root group'
)
continue
incompatible = [
attribute
for attribute in (
'data-pptx-layer',
'data-pptx-placeholder',
)
if element.get(attribute) is not None
]
if incompatible:
result['errors'].append(
f'{label} {_MORPH_STAGING_ATTR} cannot be combined with '
f'{", ".join(incompatible)}; use an ordinary Slide-local '
'group or remove the marker'
)
continue
resolved = self._resolved_root_module_bounds(element)
if resolved is None:
result['errors'].append(
f'{label} {_MORPH_STAGING_ATTR} requires valid '
f'{_BOUNDS_ATTR}; add or fix positive root-coordinate '
'x y width height bounds'
)
continue
if canvas is None:
result['errors'].append(
f'{label} {_MORPH_STAGING_ATTR} cannot verify an off-canvas '
'endpoint without a valid root viewBox; fix the root viewBox'
)
continue
if not self._bounds_are_disjoint(resolved[1], canvas):
result['errors'].append(
f'{label} {_MORPH_STAGING_ATTR} requires wholly off-canvas '
f'{_BOUNDS_ATTR}; move the full bounds outside the root '
'viewBox or remove the marker from partially visible content'
)
missing: List[str] = []
root_groups = [
child
for child in list(root)
if _local_name(child) == 'g'
]
require_bounds = (
self.template_mode
or root.get('data-pptx-page-role') is not None
or any(
root.get(attribute) is not None
for attribute in _PPTX_ROOT_STRUCTURE_ATTRS
)
)
for group in root_groups:
if self._is_hidden_element(group, parent_by_id):
continue
raw_bounds = group.get(_BOUNDS_ATTR)
if raw_bounds is None:
missing.append(_element_label(group))
continue
try:
_parse_positive_bounds(raw_bounds)
except ValueError as exc:
result['errors'].append(
f'{_element_label(group)} {_BOUNDS_ATTR} {exc}'
)
continue
resolved = self._resolved_root_module_bounds(group)
if resolved is None or canvas is None:
continue
if self._is_off_canvas_morph_group(group, canvas):
continue
attribute, bounds = resolved
self._record_bounds_overflow(
result,
subject=f'{_element_label(group)} {attribute}',
inner=bounds,
container='canvas viewBox',
outer=canvas,
repair=(
'keep the root module subcanvas inside the SVG viewBox'
),
)
if missing:
sample = '; '.join(missing[:3])
suffix = '' if len(missing) <= 3 else f'; +{len(missing) - 3} more'
bucket = result['errors'] if require_bounds else result['warnings']
prefix = 'Detected' if require_bounds else 'Reference SVG: detected'
bucket.append(
f'{prefix} {len(missing)} visible root-level <g> '
f'module(s) without explicit {_BOUNDS_ATTR} '
f'({sample}{suffix}); every final-page/template root <g> declares '
'its root-coordinate layout subcanvas even when it also carries '
'data-pptx-frame or native chart/table coordinates'
)
def _check_text_bounds(
self,
root: ET.Element,
result: Dict,
) -> None:
"""Validate visible text against page and root-module bounds."""
helpers = (
_estimate_single_line_text_frame_width,
_parse_project_font_weight,
_parse_project_geometry_length,
_parse_project_text_anchor,
_resolve_project_font_sizes,
_resolve_project_letter_spacings,
)
if any(helper is None for helper in helpers):
return
try:
font_sizes = _resolve_project_font_sizes(root)
letter_spacings = _resolve_project_letter_spacings(
root,
font_sizes,
)
except ValueError:
return
parent_by_id = {
id(child): parent
for parent in root.iter()
for child in list(parent)
}
unchanged_groups = self._unchanged_txbody_group_ids(root)
viewbox = _parse_viewbox_values(root.get('viewBox') or '')
canvas = None
if viewbox is not None:
x, y, width, height = viewbox
canvas = (x, y, x + width, y + height)
estimated_by_id: Dict[
int,
Tuple[float, float, float, float],
] = {}
page_overflow_text_ids: set[int] = set()
unverified: List[str] = []
for text_element in root.iter(f'{{{SVG_NS}}}text'):
if self._has_ancestor_id(
text_element,
parent_by_id,
unchanged_groups,
):
continue
if self._has_non_visual_ancestor(
text_element,
root,
parent_by_id,
):
continue
if self._is_hidden_element(text_element, parent_by_id):
continue
visible_text = ''.join(text_element.itertext())
if (
not visible_text.strip()
or ('{{' in visible_text and '}}' in visible_text)
):
continue
estimated = self._estimated_text_bounds(
text_element,
parent_by_id,
font_sizes,
letter_spacings,
include_headroom=True,
)
if estimated is not None:
estimated_by_id[id(text_element)] = estimated
if (
canvas is None
or self._has_zero_opacity(text_element, parent_by_id)
):
continue
page_estimated = self._estimated_text_bounds(
text_element,
parent_by_id,
font_sizes,
letter_spacings,
include_headroom=False,
)
if page_estimated is None:
unverified.append(self._text_diagnostic_label(text_element))
continue
direct_child = text_element
parent = parent_by_id.get(id(direct_child))
while parent is not None and parent is not root:
direct_child = parent
parent = parent_by_id.get(id(direct_child))
morph_staging = (
parent is root
and _local_name(direct_child) == 'g'
and self._is_off_canvas_morph_group(
direct_child,
canvas,
)
and self._bounds_are_disjoint(page_estimated, canvas)
)
if (
not morph_staging
and self._record_canvas_text_overflow(
result,
subject=self._text_diagnostic_label(text_element),
inner=page_estimated,
canvas=canvas,
)
):
page_overflow_text_ids.add(id(text_element))
if unverified:
sample = ', '.join(unverified[:3])
suffix = (
''
if len(unverified) <= 3
else f', +{len(unverified) - 3} more'
)
result['warnings'].append(
'Cannot verify root viewBox bounds for visible text with '
f'unsupported or unresolved geometry: {sample}{suffix}; use '
'supported explicit text positioning when page fit matters'
)
root_groups = [
child
for child in list(root)
if _local_name(child) == 'g'
]
for module in root_groups:
if self._is_hidden_element(module, parent_by_id):
continue
resolved_module = self._resolved_root_module_bounds(module)
if resolved_module is None:
continue
boundary_attribute, boundary = resolved_module
for text_element in module.iter(f'{{{SVG_NS}}}text'):
if id(text_element) in page_overflow_text_ids:
continue
estimated = estimated_by_id.get(id(text_element))
if estimated is None:
continue
self._record_bounds_overflow(
result,
subject=self._text_diagnostic_label(text_element),
inner=estimated,
container=(
f'{_element_label(module)} {boundary_attribute}'
),
outer=boundary,
repair=(
'expand the root module bounds into available '
'non-overlapping space; otherwise reflow the text'
),
)
def _check_unmergeable_leading_text(self, root: ET.Element, result: Dict) -> None:
"""Warn when leading text cannot be normalized into one PPT text frame."""
risky = []
for text_el in root.iter(f'{{{SVG_NS}}}text'):
if not (text_el.text or "").strip():
continue
children = list(text_el)
if not any(self._is_line_tspan(child) for child in children):
continue
reason = self._leading_text_normalizer_reject_reason(text_el)
if reason is not None:
risky.append(reason)
if risky:
sample = '; '.join(risky[:3])
suffix = '' if len(risky) <= 3 else f"; +{len(risky) - 3} more"
result['warnings'].append(
"Detected multi-line <text> with leading direct text that cannot "
f"be normalized into one PPT text frame ({sample}{suffix})"
)
def _check_fragmented_paragraph_text(
self,
root: ET.Element,
result: Dict,
) -> None:
"""Warn on high-confidence prose lines split into sibling text frames."""
helpers = (
_parse_project_geometry_length,
_resolve_project_font_sizes,
)
if any(helper is None for helper in helpers):
return
try:
font_sizes = _resolve_project_font_sizes(root)
except ValueError:
return
parent_by_id = {
id(child): parent
for parent in root.iter()
for child in list(parent)
}
unchanged_groups = self._unchanged_txbody_group_ids(root)
style_properties = (
'fill',
'fill-opacity',
'font-family',
'font-style',
'font-weight',
'letter-spacing',
'opacity',
'stroke',
'stroke-opacity',
'stroke-width',
'text-decoration',
)
def line_record(element: ET.Element) -> Dict | None:
if (
_local_name(element) != 'text'
or list(element)
or element.get('x') is None
or element.get('y') is None
or any(element.get(name) is not None for name in ('dx', 'dy'))
or element.get('transform') is not None
or self._is_hidden_element(element, parent_by_id)
or self._has_ancestor_id(
element,
parent_by_id,
unchanged_groups,
)
):
return None
text = (element.text or '').strip()
compact_text = re.sub(r'\s+', '', text)
if (
not compact_text
or ('{{' in text and '}}' in text)
or _PARAGRAPH_LIST_MARKER_RE.match(text)
):
return None
anchor = (
_effective_presentation_value(
element,
'text-anchor',
parent_by_id,
)
or 'start'
).strip().lower()
if anchor != 'start':
return None
try:
x = _parse_project_geometry_length(element.get('x'), 'x')
y = _parse_project_geometry_length(element.get('y'), 'y')
font_size = float(font_sizes[id(element)])
except (KeyError, TypeError, ValueError):
return None
if font_size <= 0:
return None
style = tuple(
(
_effective_presentation_value(
element,
name,
parent_by_id,
)
or ''
).strip().lower()
for name in style_properties
)
return {
'chars': len(compact_text),
'font_size': font_size,
'style': style,
'text': text,
'x': x,
'y': y,
}
suspects: List[str] = []
for group in list(root):
if (
_local_name(group) != 'g'
or self._is_hidden_element(group, parent_by_id)
):
continue
current_run: List[Dict] = []
def flush_run() -> None:
if len(current_run) < 2:
return
total_chars = sum(line['chars'] for line in current_run)
longest_line = max(line['chars'] for line in current_run)
if (
total_chars < _PARAGRAPH_LINE_MIN_TOTAL_CHARS
or longest_line < _PARAGRAPH_LINE_MIN_LONGEST_CHARS
):
return
first = current_run[0]
last = current_run[-1]
suspects.append(
f'{_element_label(group)} x={first["x"]:.1f}, '
f'y={first["y"]:.1f}..{last["y"]:.1f}, '
f'{len(current_run)} lines'
)
for child in list(group):
line = line_record(child)
if line is None:
flush_run()
current_run = []
continue
if current_run:
previous = current_run[-1]
line_gap = line['y'] - previous['y']
same_frame = (
abs(line['x'] - previous['x'])
<= _PARAGRAPH_LINE_X_TOLERANCE
and line['style'] == previous['style']
and math.isclose(
line['font_size'],
previous['font_size'],
rel_tol=0.0,
abs_tol=1e-6,
)
and line_gap
>= line['font_size'] * _PARAGRAPH_LINE_GAP_MIN_RATIO
and line_gap
<= line['font_size'] * _PARAGRAPH_LINE_GAP_MAX_RATIO
and not _PARAGRAPH_LINE_TERMINATOR_RE.search(
previous['text']
)
)
if not same_frame:
flush_run()
current_run = []
current_run.append(line)
flush_run()
if not suspects:
return
sample = '; '.join(suspects[:3])
suffix = '' if len(suspects) <= 3 else f'; +{len(suspects) - 3} more'
result['warnings'].append(
f'Detected {len(suspects)} paragraph-like line run(s) split '
f'across sibling <text> elements ({sample}{suffix}). If each run '
'is one prose paragraph, combine it into one <text>: keep its '
'first line as direct text and use direct <tspan> children with '
'the parent x and positive relative dy values for later lines. '
'An all-<tspan> form may start with dy="0". Keep semantically '
'independent text frames separate.'
)
@staticmethod
def _is_tspan(elem: ET.Element) -> bool:
return elem.tag == f'{{{SVG_NS}}}tspan'
@classmethod
def _is_line_tspan(cls, elem: ET.Element) -> bool:
if not cls._is_tspan(elem):
return False
if elem.get('x') is not None or elem.get('y') is not None:
return True
dy = elem.get('dy')
if dy is None:
return False
try:
return float(re.match(r'^[\s,]*([+-]?(?:\d+\.?\d*|\d*\.\d+))', dy).group(1)) != 0
except (AttributeError, ValueError):
return True
@classmethod
def _leading_text_normalizer_reject_reason(cls, text_el: ET.Element) -> str | None:
if text_el.get('x') is None:
return '<text> has no x anchor'
for child in list(text_el):
if not cls._is_tspan(child):
return '<text> has non-tspan child'
if (child.tail or "").strip():
return '<tspan> has non-empty tail text'
return None
def _check_image_references(self, root: ET.Element, svg_path: Path, result: Dict):
"""Check image file existence and effective rendered resolution."""
svg_dir = svg_path.parent
working_root, parent_by_id, images = self._visible_image_elements(root)
for image in images:
href = image.get('href') or image.get(f'{{{XLINK_NS}}}href')
if not href or href.startswith('data:'):
continue
if self.template_mode and '{{' in href and '}}' in href:
continue
if _resolve_external_image_reference is None:
result['warnings'].append(
"Detected image references, but shared image resolver could not be imported; "
"export will still validate them."
)
return
img_path = _resolve_external_image_reference(svg_dir, href)
if img_path is None:
# The shared image-source contract already reports the
# blocking resolution failure. This pass adds quality advice
# only for valid, resolved images.
continue
# Check resolution vs display size
display_owner = image
parent = parent_by_id.get(id(image))
if (
parent is not None
and parent is not working_root
and parent.tag == f'{{{SVG_NS}}}svg'
):
# Imported crops use a unit-frame inner image. Quality advice
# must compare the source against the visible outer frame.
display_owner = parent
display_w_str = display_owner.get('width')
display_h_str = display_owner.get('height')
if not display_w_str or not display_h_str:
continue
try:
display_x = float(display_owner.get('x') or '0')
display_y = float(display_owner.get('y') or '0')
local_display_w = float(display_w_str)
local_display_h = float(display_h_str)
except (ValueError, TypeError):
continue
if local_display_w <= 0 or local_display_h <= 0:
continue
display_w = local_display_w
display_h = local_display_h
transformed_size = self._transformed_rect_edge_lengths(
display_owner,
(display_x, display_y, local_display_w, local_display_h),
parent_by_id,
)
if transformed_size is not None:
display_w, display_h = transformed_size
axis_scale_x = display_w / local_display_w
axis_scale_y = display_h / local_display_h
try:
from PIL import Image as PILImage, ImageOps
with PILImage.open(img_path) as img:
actual_w, actual_h = ImageOps.exif_transpose(img).size
source_bytes = img_path.stat().st_size
visible_w = float(actual_w)
visible_h = float(actual_h)
fit_owner = image
if display_owner is not image:
fit_owner = display_owner
viewbox = (display_owner.get('viewBox') or '').split()
if len(viewbox) == 4:
try:
viewbox_w = float(viewbox[2])
viewbox_h = float(viewbox[3])
except ValueError:
pass
else:
if 0 < viewbox_w <= 1 and 0 < viewbox_h <= 1:
visible_w *= viewbox_w
visible_h *= viewbox_h
raw_aspect = fit_owner.get('preserveAspectRatio')
try:
align, mode = (
_parse_project_image_aspect_ratio(raw_aspect)
if _parse_project_image_aspect_ratio is not None
else ('xMidYMid', 'meet')
)
except ValueError:
continue
local_scale_x = local_display_w / visible_w
local_scale_y = local_display_h / visible_h
if align == 'none':
render_scale = max(
local_scale_x * axis_scale_x,
local_scale_y * axis_scale_y,
)
fit_label = 'none'
elif mode == 'slice':
render_scale = (
max(local_scale_x, local_scale_y)
* max(axis_scale_x, axis_scale_y)
)
fit_label = 'slice'
else:
render_scale = (
min(local_scale_x, local_scale_y)
* max(axis_scale_x, axis_scale_y)
)
fit_label = 'meet'
if render_scale > 1.0:
result['warnings'].append(
f"Image {href} is {actual_w}x{actual_h} and renders at "
f"{render_scale:.2f}x scale in a "
f"{int(display_w)}x{int(display_h)} {fit_label} frame "
"— may appear blurry"
)
elif (
render_scale < 1.0 / IMAGE_DOWNSIZE_WARN_RATIO
and source_bytes >= IMAGE_DOWNSIZE_WARN_MIN_BYTES
):
source_mib = source_bytes / (1024 * 1024)
result['warnings'].append(
f"Image {href} is {actual_w}x{actual_h} and renders at "
f"{render_scale:.2f}x scale in a "
f"{int(display_w)}x{int(display_h)} {fit_label} frame; "
f"the source is {source_mib:.1f} MiB — file-size "
"advisory only, not an aspect-ratio warning; consider "
"a smaller source asset"
)
except ImportError:
pass # PIL not available, skip resolution check
except Exception:
pass # Image unreadable, skip resolution check
def _check_icon_placeholders(self, root: ET.Element, svg_path: Path, result: Dict) -> None:
"""Check that <use data-icon="..."> placeholders resolve."""
placeholders = [
elem for elem in root.iter()
if _local_name(elem).lower() == 'use' and elem.get('data-icon') is not None
]
if not placeholders:
return
if _resolve_icon_path is None:
result['warnings'].append(
"Detected data-icon placeholders, but icon resolver could not be imported; "
"post-processing/export will still validate them."
)
return
if _icon_search_dirs_for_svg is None:
result['warnings'].append(
"Detected data-icon placeholders, but shared icon search helper could not be imported; "
"post-processing/export will still validate them."
)
return
icons_dir, fallback_dir = _icon_search_dirs_for_svg(svg_path)
require_project_local = self._requires_project_local_icons(svg_path)
project_icons_dir = (
_project_root_for_svg_path(svg_path) / 'icons'
if _project_root_for_svg_path is not None
else None
)
seen = set()
for elem in placeholders:
icon_name = (elem.get('data-icon') or '').strip()
if not icon_name:
result['errors'].append("Icon placeholder has empty data-icon value")
continue
if icon_name in seen:
continue
seen.add(icon_name)
if require_project_local and project_icons_dir is not None:
local_path, _ = _resolve_icon_path(
icon_name,
project_icons_dir,
None,
)
if not local_path.exists():
result['errors'].append(
f"Icon is not prepared in the project: {icon_name} "
f"(expected under {project_icons_dir}); return to "
"Strategist preparation instead of using the global fallback"
)
continue
icon_path, _ = _resolve_icon_path(icon_name, icons_dir, fallback_dir)
if not icon_path.exists():
fallback_msg = f", then {fallback_dir}" if fallback_dir else ""
suggestion = (
_suggest_icon_name(icon_name, icons_dir, fallback_dir)
if _suggest_icon_name is not None else None
)
hint = (
f"; identifiers are case-sensitive; use '{suggestion}'"
if suggestion else ""
)
result['errors'].append(
f"Icon not found: {icon_name} (searched {icons_dir}"
f"{fallback_msg}){hint}"
)
continue
try:
icon_root = ET.parse(icon_path).getroot()
hydrated = hydrate_native_payload_refs(icon_root, icon_path)
except (OSError, ET.ParseError, NativePayloadError) as exc:
result['errors'].append(
f"Icon {icon_name} has invalid native payload metadata: {exc}"
)
continue
if _project_mask_errors is not None:
result['errors'].extend(
f'Icon {icon_name}: {error}'
for error in _project_mask_errors(icon_root)
)
if hydrated:
result['info']['native_icon_payload_refs'] = (
result['info'].get('native_icon_payload_refs', 0) + hydrated
)
@staticmethod
def _requires_project_local_icons(svg_path: Path) -> bool:
"""Return whether a generated page belongs to a versioned project."""
if svg_path.parent.name != 'svg_output' or _project_root_for_svg_path is None:
return False
lock_path = _project_root_for_svg_path(svg_path) / 'spec_lock.md'
try:
first_line = next(
(
line.strip()
for line in lock_path.read_text(encoding='utf-8-sig').splitlines()
if line.strip()
),
'',
)
except OSError:
return False
return bool(
re.fullmatch(
r'<!--[ \t]+ppt-master-schema:[ \t]*spec-lock/v[1-9][0-9]*[ \t]+-->',
first_line,
re.IGNORECASE,
)
)
def _check_unsupported_visual_elements(
self,
root: ET.Element,
result: Dict,
) -> None:
"""Reject authored visual elements with no native converter dispatch."""
if _collect_unsupported_visuals is None:
result['errors'].append(
"Unable to import native visual-element preflight; "
"cannot verify SVG element support"
)
return
if _expand_local_use_references is None or _UseExpansionError is None:
result['errors'].append(
"Unable to import local <use> expansion; "
"cannot verify SVG element support"
)
return
expanded_root = copy.deepcopy(root)
try:
_expand_local_use_references(expanded_root)
except _UseExpansionError:
# _check_forbidden_elements already reports the actionable
# local-reference validation error.
return
unsupported = _collect_unsupported_visuals(
expanded_root,
allow_data_icon_use=True,
)
if not unsupported:
return
preview = '; '.join(unsupported[:8])
suffix = '' if len(unsupported) <= 8 else f'; +{len(unsupported) - 8} more'
result['errors'].append(
f"Unsupported visual SVG element(s) for native PPTX export: "
f"{preview}{suffix}"
)
def _check_preset_geometry_metadata(
self,
root: ET.Element,
result: Dict,
) -> None:
"""Validate round-trip preset metadata with the exporter's parser."""
marked = [
elem
for elem in root.iter()
if (
elem.get('data-pptx-prst') is not None
or elem.get('data-pptx-frame') is not None
or elem.get('data-pptx-geometry-status') is not None
or elem.get('data-pptx-geometry-reason') is not None
or elem.get('data-pptx-geometry-kind') is not None
or elem.get('data-pptx-custgeom') is not None
or elem.get('data-pptx-preview-sha256') is not None
or elem.get('data-pptx-shape-id') is not None
or elem.get('data-pptx-shape-scope') is not None
or elem.get('data-pptx-shape-style') is not None
or elem.get(_AUTHORING_ATTR) is not None
or any(attr.startswith('data-pptx-av-') for attr in elem.attrib)
)
]
if not marked:
return
if _validate_preset_geometry_metadata is None:
result['errors'].append(
'Unable to import PPTX preset metadata validator; '
'cannot verify native shape restoration'
)
return
issues = set()
for elem in marked:
tag = _local_name(elem)
elem_id = elem.get('id')
label = f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>'
for error in _validate_preset_geometry_metadata(elem):
issues.add(f'{label} has invalid PPTX shape metadata: {error}')
if _validate_authored_preset_tree is None:
if any(
elem.get(_AUTHORING_ATTR) is not None
for elem in root.iter()
):
issues.add(
'Unable to import authored PPTX preset validator'
)
else:
for error in _validate_authored_preset_tree(root):
issues.add(f'Invalid authored PPTX preset: {error}')
if (
_svg_preset_preview_fingerprint is None
or _resolve_preset_preview_hash is None
):
issues.add('Unable to import PPTX preset preview fingerprint validator')
else:
for elem in root.iter():
if (
_local_name(elem) != 'g'
or elem.get('data-pptx-object') not in {'shape', 'connector'}
or elem.get('data-pptx-prst') is None
):
continue
try:
expected = _resolve_preset_preview_hash(elem)
except ValueError as exc:
elem_id = elem.get('id') or '(no id)'
issues.add(
f'<g id="{elem_id}"> has an invalid PPTX preset '
f'preview contract: {exc}'
)
continue
if expected is None:
continue
actual = _svg_preset_preview_fingerprint(elem)
if actual != expected:
elem_id = elem.get('id') or '(no id)'
issues.add(
f'<g id="{elem_id}"> has a stale PPTX preset preview; '
'update the native carrier or restore the generated detail paths'
)
result['errors'].extend(sorted(issues))
if (
_authored_preset_encoding is not None
and _validate_authored_preset_group is not None
):
expanded = [
elem.get('id') or '(no id)'
for elem in root.iter()
if _authored_preset_encoding(elem) == 'expanded'
and not _validate_authored_preset_group(elem)
]
if expanded:
examples = ', '.join(expanded[:3])
suffix = '' if len(expanded) <= 3 else f', +{len(expanded) - 3} more'
result['warnings'].append(
'Compatible expanded authored-preset fragment(s) detected '
f'({len(expanded)}: {examples}{suffix}). New project-authored '
'pages and templates use the compact helper form; the '
'expanded carrier/preview form remains readable for compatibility. '
'No change is required while it remains ordinary Slide-local input.'
)
inherited_paint = _compact_preset_ancestor_paint(root)
if inherited_paint:
examples = ', '.join(
f'{element_id} ({"/".join(properties)})'
for element_id, properties in inherited_paint[:3]
)
suffix = (
''
if len(inherited_paint) <= 3
else f', +{len(inherited_paint) - 3} more'
)
result['warnings'].append(
'Compact authored preset(s) use compatible ancestor paint or '
f'opacity ({examples}{suffix}). Canonical page/template authoring '
'keeps preset paint local and reruns the helper with channel alpha; '
'export remains supported.'
)
def _check_preset_geometry_transforms(
self,
root: ET.Element,
result: Dict,
) -> None:
"""Reject preset transforms that DrawingML cannot represent exactly."""
helpers = (
_IDENTITY_MATRIX,
_matrix_multiply,
_parse_transform_matrix,
_rect_to_dml_xfrm,
_validate_dml_shape_matrix,
)
if any(helper is None for helper in helpers):
return
relevant: set[ET.Element] = set()
def mark_relevant(element: ET.Element) -> bool:
found = element.get('data-pptx-prst') is not None
for child in element:
found = mark_relevant(child) or found
if found:
relevant.add(element)
return found
mark_relevant(root)
issues = set()
def visit(element: ET.Element, parent_matrix) -> None:
if element not in relevant:
return
matrix = parent_matrix
transform = element.get('transform')
if transform:
try:
local_matrix = _parse_transform_matrix(transform)
matrix = _matrix_multiply(parent_matrix, local_matrix)
except ValueError as exc:
issues.add(
f'<{_local_name(element)}> has invalid preset '
f'transform: {exc}'
)
return
if element.get('data-pptx-prst') is not None:
try:
raw_frame = element.get('data-pptx-frame')
if raw_frame:
frame = tuple(
float(part)
for part in re.split(r'[\s,]+', raw_frame.strip())
)
if len(frame) != 4:
raise ValueError(
'data-pptx-frame must contain four numbers'
)
preset = element.get('data-pptx-prst') or ''
_rect_to_dml_xfrm(
frame[0],
frame[1],
frame[2],
frame[3],
matrix,
preserve_degenerate_axes=(
element.get('data-pptx-object') == 'connector'
or preset in _CONNECTOR_PRESET_TYPES
),
)
else:
_validate_dml_shape_matrix(matrix)
except ValueError as exc:
elem_id = element.get('id') or '(no id)'
issues.add(
f'<{_local_name(element)} id="{elem_id}"> has '
f'unsupported preset transform: {exc}'
)
for child in element:
visit(child, matrix)
visit(root, _IDENTITY_MATRIX)
result['errors'].extend(sorted(issues))
@staticmethod
def _is_full_canvas_root_rect(
root: ET.Element,
element: ET.Element,
) -> bool:
"""Return whether one direct rect is the ordinary full-page backdrop."""
if (
_local_name(element) != 'rect'
or _parse_project_geometry_length is None
or any(
element.get(attribute)
for attribute in ('transform', 'filter', 'clip-path')
)
):
return False
viewbox = _parse_viewbox_values(root.get('viewBox') or '')
if viewbox is None:
return False
parent_by_id = {id(element): root}
def inherited(name: str, default: str) -> str:
return _effective_presentation_value(
element,
name,
parent_by_id,
) or default
try:
values = {
name: _parse_project_geometry_length(
element.get(name) or '0',
name,
)
for name in ('x', 'y', 'width', 'height', 'rx', 'ry')
}
stroke_width = _parse_project_geometry_length(
inherited('stroke-width', '1'),
'stroke-width',
)
stroke_opacity = (
_parse_project_opacity(inherited('stroke-opacity', '1'))
if _parse_project_opacity is not None else 1.0
)
except ValueError:
return False
fill = inherited('fill', '#000000').strip().lower()
stroke = inherited('stroke', 'none').strip().lower()
if (
fill == 'none'
or (
stroke != 'none'
and stroke_width > 0
and stroke_opacity > 0
)
):
return False
view_x, view_y, view_width, view_height = viewbox
tolerance = 0.5
return (
values['rx'] == 0
and values['ry'] == 0
and abs(values['x'] - view_x) <= tolerance
and abs(values['y'] - view_y) <= tolerance
and abs(values['width'] - view_width) <= tolerance
and abs(values['height'] - view_height) <= tolerance
)
def _check_animation_group_ids(
self,
root: ET.Element,
svg_path: Path,
result: Dict,
):
"""Validate top-level animation anchors without policing inner groups."""
non_visual = {'defs', 'title', 'desc', 'metadata', 'style'}
group_indexes: Dict[str, List[int]] = defaultdict(list)
ungrouped: List[str] = []
ungrouped_signatures: List[Tuple[object, ...]] = []
visual_index = 0
for child in root:
tag = _local_name(child)
if tag in non_visual:
continue
visual_index += 1
is_first_visual = visual_index == 1
if tag == 'g':
group_id = _usable_animation_group_id(child.get('id'))
if group_id is None:
result['warnings'].append(
f"Top-level visible <g> #{visual_index} has no id; "
"object-level animation config cannot reference it"
)
continue
group_indexes[group_id].append(visual_index)
continue
if svg_path.parent.name != 'svg_output':
continue
if child.get('data-pptx-layer') is not None:
continue
if (
_is_static_page_frame is not None
and _is_static_page_frame(
child.get('data-pptx-role'),
child.get('data-pptx-placeholder'),
)
):
continue
if is_first_visual and self._is_full_canvas_root_rect(root, child):
continue
child_id = (child.get('id') or '').strip()
ungrouped.append(
f'<{tag} id="{child_id}">'
if child_id else f'<{tag}> #{visual_index}'
)
ungrouped_signatures.append(
self._prototype_element_signature(child)
)
for group_id, indexes in sorted(group_indexes.items()):
if len(indexes) > 1:
positions = ', '.join(str(item) for item in indexes)
result['errors'].append(
f'Duplicate top-level group id {group_id!r} at visible '
f'positions {positions}; animation target ids must be unique'
)
if ungrouped:
samples = ', '.join(ungrouped[:3])
if len(ungrouped) > 3:
samples += ', ...'
message = (
f'{len(ungrouped)} ungrouped top-level Slide-local element(s) '
f'in svg_output ({samples}); group only logical content units '
'in a top-level <g id="...">. Keep genuine static page framing '
'as a root primitive and declare a supported data-pptx-role such '
'as "background" or "decoration"'
)
prototype_root = self._active_prototype_root()
prototype_ungrouped = (
self._ungrouped_slide_local_facts(prototype_root)
if prototype_root is not None
else ([], [])
)
if (
prototype_root is not None
and ungrouped == prototype_ungrouped[0]
and ungrouped_signatures == prototype_ungrouped[1]
):
self._append_inherited_info(
result,
'animation_anchor',
message,
)
else:
result['warnings'].append(message)
@staticmethod
def _prototype_element_signature(
element: ET.Element,
) -> Tuple[object, ...]:
"""Compare warning-owned topology/style while ignoring visible text."""
return (
_local_name(element),
tuple(sorted(element.attrib.items())),
tuple(
SVGQualityChecker._prototype_element_signature(child)
for child in element
),
)
def _ungrouped_slide_local_facts(
self,
root: ET.Element,
) -> Tuple[List[str], List[Tuple[object, ...]]]:
"""Describe and fingerprint top-level non-group Slide-local atoms."""
non_visual = {'defs', 'title', 'desc', 'metadata', 'style'}
descriptors: List[str] = []
signatures: List[Tuple[object, ...]] = []
visual_index = 0
for child in root:
tag = _local_name(child)
if tag in non_visual:
continue
visual_index += 1
if tag == 'g' or child.get('data-pptx-layer') is not None:
continue
if (
_is_static_page_frame is not None
and _is_static_page_frame(
child.get('data-pptx-role'),
child.get('data-pptx-placeholder'),
)
):
continue
if visual_index == 1 and self._is_full_canvas_root_rect(root, child):
continue
child_id = (child.get('id') or '').strip()
descriptors.append(
f'<{tag} id="{child_id}">'
if child_id else f'<{tag}> #{visual_index}'
)
signatures.append(self._prototype_element_signature(child))
return descriptors, signatures
# OOXML ST_PresetPatternVal enum — anything outside this set produces a
# PPTX schema violation ("PowerPoint found a problem with the content").
_OOXML_PATTERN_PRESETS = frozenset({
'pct5', 'pct10', 'pct20', 'pct25', 'pct30', 'pct40', 'pct50', 'pct60',
'pct70', 'pct75', 'pct80', 'pct90',
'horz', 'vert', 'ltHorz', 'ltVert', 'dkHorz', 'dkVert',
'narHorz', 'narVert', 'dashHorz', 'dashVert',
'cross', 'dnDiag', 'upDiag', 'ltDnDiag', 'ltUpDiag', 'dkDnDiag',
'dkUpDiag', 'wdDnDiag', 'wdUpDiag',
'dashDnDiag', 'dashUpDiag', 'diagCross',
'smCheck', 'lgCheck', 'smGrid', 'lgGrid', 'dotGrid', 'smConfetti',
'lgConfetti', 'horzBrick', 'diagBrick', 'solidDmnd', 'openDmnd',
'dotDmnd', 'plaid', 'sphere', 'weave', 'wave', 'trellis', 'zigZag',
'divot', 'shingle',
})
def _check_pattern_fills(self, root: ET.Element, result: Dict):
"""Audit <pattern> defs that drive PPTX <a:pattFill> output.
svg_to_pptx maps <pattern fill> to native <a:pattFill prst="...">. The
preset name comes from `data-pptx-pattern` (e.g. `lgGrid` / `smGrid` /
`dkUpDiag`). Patterns marked with `data-pptx-text-image-fill` instead
map to run-level <a:blipFill> and are validated by converter preflight.
Two preset-pattern failure modes are worth catching pre-export:
1. Missing annotation → the converter compatibility fallback chooses
`ltUpDiag` (diagonal stripes), which is not an authoring contract.
2. Invalid preset name → PPTX schema rejects the file; PowerPoint
opens it with "needs to be repaired". OOXML
`ST_PresetPatternVal` is a closed enum — only the names in
`_OOXML_PATTERN_PRESETS` are legal. Inventing `ltGrid` (no such
value) is the canonical mistake; the only grids are `smGrid` /
`lgGrid` / `dotGrid`.
"""
definitions, _duplicates = _direct_defs_index(root)
referenced_patterns: set[str] = set()
for elem in root.iter():
style_values = (
_parse_inline_style(elem.get('style'))
if _parse_inline_style is not None else {}
)
fill = style_values.get('fill') or elem.get('fill')
match = re.fullmatch(r'url\(#([^)]+)\)', (fill or '').strip())
if match is None:
continue
definition = definitions.get(match.group(1))
if definition is not None and _local_name(definition) == 'pattern':
referenced_patterns.add(match.group(1))
for pattern in (
elem for elem in root.iter()
if _local_name(elem) == 'pattern'
):
pat_id = pattern.get('id', '<unnamed>')
prst = pattern.get('data-pptx-pattern')
if pattern.get(_TEXT_IMAGE_FILL_ATTR) is not None:
continue
if pat_id in referenced_patterns and not prst:
result['warnings'].append(
f"Fidelity warning: <pattern id=\"{pat_id}\"> has no "
"data-pptx-pattern attribute, so the converter will use its "
"compatible `ltUpDiag` fallback. Generated SVG should declare a valid "
"data-pptx-pattern to make the intended preset explicit; "
"set data-pptx-fg/data-pptx-bg or matching child paints "
"when explicit pattern colors are required. No change is "
"required for export."
)
if pat_id in referenced_patterns and pattern.get('patternTransform'):
result['errors'].append(
f"<pattern id=\"{pat_id}\"> cannot use patternTransform; "
"the native preset mapping does not preserve custom tile transforms"
)
if prst not in self._OOXML_PATTERN_PRESETS:
if not prst:
continue
result['errors'].append(
f"<pattern id=\"{pat_id}\"> uses data-pptx-pattern=\"{prst}\" "
"which is not in OOXML ST_PresetPatternVal — exported PPTX "
"will fail schema validation ('needs to be repaired'). "
"Use one of: smGrid / lgGrid / dotGrid (grids), "
"ltUpDiag / dkUpDiag / cross / diagCross / weave / plaid / "
"horzBrick (others); see references/native-data-interface.md §1 "
"for the full authoring enum."
)
def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None:
"""Validate opt-in native table/chart markers before PPTX export."""
invalid_status_elements: set[ET.Element] = set()
for elem in root.iter():
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
if elem.tag.rsplit('}', 1)[-1] == 'metadata':
continue
has_status = any(
elem.get(name) is not None
for name in (
'data-pptx-replace-with',
'data-pptx-native',
'data-pptx-fallback-kind',
'data-pptx-visual-status',
'data-pptx-route-status',
'data-pptx-replacement-status',
'data-pptx-native-status',
'data-pptx-import-source',
'data-pptx-native-source',
)
)
if not has_status:
continue
if (
_native_marker_status_errors is None
or _native_marker_release_block_reason is None
):
result['errors'].append(
"Unable to import native-object status validator; "
f"cannot verify PPTX graphic {marker_id}"
)
continue
status_errors = _native_marker_status_errors(elem)
for error in status_errors:
result['errors'].append(
f"PPTX graphic {marker_id} has invalid status metadata: {error}"
)
if status_errors:
invalid_status_elements.add(elem)
continue
if _native_marker_legacy_warnings is not None:
for warning in _native_marker_legacy_warnings(elem):
result['warnings'].append(
f"PPTX replacement marker {marker_id}: {warning}"
)
try:
fallback_kind = (
_native_fallback_kind(elem)
if _native_fallback_kind is not None else None
)
replacement_kind = (
_native_replacement_kind(elem)
if _native_replacement_kind is not None else ''
)
except ValueError:
# The shared status validator reported the alias conflict.
continue
if fallback_kind == 'placeholder':
route = (
"the native Chart/Table route may reconstruct its active marker"
if replacement_kind
else "default export keeps the visible placeholder"
)
result['warnings'].append(
f"PPTX graphic {marker_id} is a reconstruction-only placeholder; "
f"it has no baked preview and {route}"
)
for elem in root.iter():
if elem.tag.rsplit('}', 1)[-1] == 'metadata':
continue
if _native_replacement_status is None or _native_replacement_kind is None:
continue
try:
status = _native_replacement_status(elem)
replacement_kind = _native_replacement_kind(elem)
except ValueError:
continue
if not status or replacement_kind:
continue
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
result['warnings'].append(
f"Native PPTX object {marker_id} is fallback-only: {status}"
)
markers = [
elem for elem in root.iter()
if (
_native_replacement_kind is not None
and elem.tag.rsplit('}', 1)[-1] != 'metadata'
and elem not in invalid_status_elements
and _native_replacement_kind(elem)
)
]
if not markers:
return
if _validate_native_object_marker is None:
result['warnings'].append(
"Detected data-pptx-replace-with markers, but replacement validator "
"could not be imported; export-time validation will still run."
)
return
parent_map = {
child: parent
for parent in root.iter()
for child in parent
}
def append_metadata_legacy_warnings(marker: ET.Element) -> None:
if _native_marker_legacy_warnings is None:
return
marker_id = marker.get('id') or '<unnamed>'
for child in marker:
if child.tag.rsplit('}', 1)[-1] != 'metadata':
continue
for warning in _native_marker_legacy_warnings(child):
result['warnings'].append(
f"PPTX replacement marker {marker_id}: {warning}"
)
for marker in markers:
marker_id = marker.get('id') or '<unnamed>'
ancestors = []
parent = parent_map.get(marker)
while parent is not None and parent is not root:
if parent.tag.rsplit('}', 1)[-1] == 'g':
ancestors.append(parent)
parent = parent_map.get(parent)
ancestors_tuple = tuple(reversed(ancestors))
if _validate_native_object_marker_with_warnings is not None:
try:
warnings = _validate_native_object_marker_with_warnings(
marker,
ancestors=ancestors_tuple,
document_root=root,
)
except RuntimeError as exc:
result['errors'].append(
f"Invalid data-pptx-replace-with marker {marker_id}: {exc}"
)
continue
for warning in warnings:
result['warnings'].append(
f"data-pptx-replace-with marker {marker_id}: {warning}"
)
append_metadata_legacy_warnings(marker)
continue
try:
_validate_native_object_marker(marker, ancestors=ancestors_tuple)
except RuntimeError as exc:
result['errors'].append(
f"Invalid data-pptx-replace-with marker {marker_id}: {exc}"
)
continue
append_metadata_legacy_warnings(marker)
if _native_object_marker_warnings is None:
continue
for warning in _native_object_marker_warnings(
marker,
ancestors=ancestors_tuple,
document_root=root,
):
result['warnings'].append(
f"data-pptx-replace-with marker {marker_id}: {warning}"
)
def _check_pptx_structure_metadata(
self,
root: ET.Element,
svg_path: Path,
result: Dict,
) -> None:
"""Validate the intrinsic structured Master/Layout SVG contract."""
if self.quick_generate:
forbidden_attrs = sorted({
attr
for elem in root.iter()
for attr in _PPTX_STRUCTURE_ATTRS
if elem.get(attr) is not None
})
if forbidden_attrs:
result['errors'].append(
f"{svg_path.name}: Quick Generate uses flat export and "
"forbids Master/Layout/layer/placeholder metadata; remove "
+ ', '.join(forbidden_attrs)
)
return
if not self.template_mode and svg_path.parent.name == 'svg_output':
declared_mode = _declared_pptx_structure_mode(
self._resolve_project_path(svg_path)
)
if declared_mode == 'flat':
forbidden_attrs = sorted({
attr
for elem in root.iter()
for attr in _PPTX_STRUCTURE_ATTRS
if elem.get(attr) is not None
})
if forbidden_attrs:
result['errors'].append(
f"{svg_path.name}: pptx_structure.mode: flat forbids "
"Master/Layout/layer/placeholder metadata; remove "
+ ', '.join(forbidden_attrs)
)
return
if declared_mode != 'structured':
# The project-level gate emits one actionable migration error.
# Avoid burying it under repeated per-page structure failures.
return
has_structure_metadata = any(
elem.get(attr) is not None
for elem in root.iter()
for attr in _PPTX_STRUCTURE_ATTRS
)
require_structure = bool(
self.template_mode
or svg_path.parent.name == 'svg_output'
)
if not has_structure_metadata and not require_structure:
return
result['errors'].extend(_local_pptx_structure_errors(
root,
svg_path,
require_structure=require_structure,
))
self._check_placeholder_carrier_flattening(root, svg_path, result)
if svg_path.parent.name == 'svg_output':
self._append_structure_coverage_warnings(root, result)
if _validate_template_structure_svg is None:
result['errors'].append(
"Structured PPTX metadata validator could not be imported; "
"the quality gate cannot verify this SVG"
)
return
result['errors'].extend(_validate_template_structure_svg(svg_path))
result['errors'] = list(dict.fromkeys(result['errors']))
@staticmethod
def _check_placeholder_carrier_flattening(
root: ET.Element,
svg_path: Path,
result: Dict,
) -> None:
"""Reject slot carriers that export as multiple native children.
Default export flattens non-mergeable positional ``<tspan>`` lines
before converting the surrounding slot group to DrawingML. Reuse that
exact transform here so the quality gate fails before the later
placeholder-unwrapping step does.
"""
if _flatten_positional_tspans is None:
return
candidate_ids: List[str] = []
for slot in root.iter(f'{{{SVG_NS}}}g'):
if not (slot.get('data-pptx-placeholder') or '').strip():
continue
binding = (
slot.get('data-pptx-binding') or 'carrier'
).strip().lower()
if binding != 'carrier':
continue
visual_children = [
child for child in list(slot)
if _local_name(child) not in _NON_VISUAL_SVG_TAGS
]
carriers = [
child for child in visual_children
if (child.get('data-pptx-carrier') or '')
.strip()
.lower()
== 'true'
]
slot_id = (slot.get('id') or '').strip()
if not slot_id or len(visual_children) != 1 or len(carriers) != 1:
continue
if not any(
_local_name(descendant) == 'tspan'
and any(
descendant.get(name) is not None
for name in ('x', 'y', 'dy')
)
for descendant in carriers[0].iter()
):
continue
candidate_ids.append(slot_id)
if not candidate_ids:
return
flattened_root = copy.deepcopy(root)
try:
_flatten_positional_tspans(
ET.ElementTree(flattened_root),
merge_paragraphs=True,
preserve_line_breaks=True,
)
except ValueError:
# The shared text check reports the unsupported nested-position
# contract; avoid turning a quality result into a checker crash.
return
slots_by_id = {
(slot.get('id') or '').strip(): slot
for slot in flattened_root.iter(f'{{{SVG_NS}}}g')
if (slot.get('id') or '').strip()
}
for slot_id in candidate_ids:
slot = slots_by_id.get(slot_id)
if slot is None:
continue
native_children = [
child for child in list(slot)
if _local_name(child) not in _NON_VISUAL_SVG_TAGS
]
if len(native_children) == 1:
continue
result['errors'].append(
f"{svg_path.name}: placeholder slot {slot_id} becomes "
f"{len(native_children)} native children after positional "
"<tspan> flattening; a carrier-bound slot must export as one "
"text or picture carrier. Use one single-frame dy-stacked text "
"frame, or move independently positioned lines outside the slot"
)
def _append_structure_coverage_warnings(
self,
root: ET.Element,
result: Dict,
) -> None:
"""Warn on mapped pages that compile to bare Masters / empty Layouts.
Zero-slot and framing-only Layouts are legal contracts, so these stay
advisory warnings. They neither fail the workflow gate nor require a
per-warning disposition.
"""
messages = self._structure_coverage_messages(root)
if not messages:
return
prototype_root = self._active_prototype_root()
if (
prototype_root is not None
and messages == self._structure_coverage_messages(prototype_root)
):
for message in messages:
self._append_inherited_info(
result,
'structure_coverage',
message,
)
return
result['warnings'].extend(messages)
@staticmethod
def _structure_coverage_messages(root: ET.Element) -> List[str]:
"""Return advisory coverage messages for one structured page."""
if not (root.get('data-pptx-layout') or '').strip():
return []
messages: List[str] = []
has_layer_mark = any(
elem.get('data-pptx-layer') is not None
for elem in root.iter()
)
has_layout_atom = any(
child.get('data-pptx-layer') == 'layout'
for child in list(root)
)
has_placeholder = any(
elem.get('data-pptx-placeholder') is not None
for elem in root.iter()
)
if not has_layer_mark:
messages.append(
'Mapped page declares data-pptx-layout but no data-pptx-layer '
'mark; the exported Master gets no shared background/chrome '
'and the Layout gets no static framing. Generated templates '
'should mark the deck-wide '
'background data-pptx-layer="master" and this layout key\'s '
'framing data-pptx-layer="layout". No change or disposition '
'is required.'
)
if not has_placeholder and not has_layout_atom:
messages.append(
'Mapped page has no placeholder slot and no '
'data-pptx-layer="layout" atom; its Layout exports empty. '
'Generated templates should declare the slots the page actually '
'has (title / subtitle / '
'body / picture / slide-number / footer) and mark the layout '
'key\'s static framing unless this is intentionally a fixed '
'zero-slot composition. No change or disposition is required.'
)
elif not has_placeholder:
messages.append(
'Mapped Layout has static framing but no insertable '
'placeholder slot. Generated templates should declare the '
'slots the page actually has (title / subtitle / body / '
'picture / slide-number / footer) unless zero-slot is the '
'intended reusable contract. No change or disposition is required.'
)
return messages
@staticmethod
def _check_legacy_pptx_attributes(
root: ET.Element,
svg_path: Path,
result: Dict,
) -> None:
"""Reject superseded long-form authoring attributes."""
for element in root.iter():
for legacy, canonical in _LEGACY_PPTX_ATTRIBUTE_RENAMES.items():
if element.get(legacy) is None:
continue
result['errors'].append(
f'{svg_path.name}: {_element_label(element)} uses legacy '
f'{legacy}; rename it to {canonical}'
)
def _check_semantic_markers(
self,
root: ET.Element,
svg_path: Path,
result: Dict,
) -> None:
"""Validate minimal compiler hints without changing SVG rendering."""
has_semantics = any(
elem.get(attr) is not None
for elem in root.iter()
for attr in _SEMANTIC_ATTRS
)
require_page_role = (
svg_path.parent.name in {'svg_output', 'svg_final'}
and root.get('data-pptx-layout') is None
)
if _validate_semantic_markers is None:
if has_semantics:
result['warnings'].append(
"Detected Semantic SVG markers, but their validator could "
"not be imported."
)
return
for issue in _validate_semantic_markers(
root,
require_page_role=require_page_role,
):
if issue.severity == 'error':
result['errors'].append(issue.message)
else:
result['warnings'].append(issue.message)
def _get_spec_lock(self, svg_path: Path):
"""Locate and parse spec_lock.md near the SVG. Returns dict or None.
Looks in svg_path.parent and svg_path.parent.parent (covers the two
common layouts: SVG directly under <project>/ or under
<project>/svg_output/). Results are cached per lock path.
"""
if self.quick_generate:
return None
if _parse_spec_lock is None:
return None
for candidate in (svg_path.parent / 'spec_lock.md',
svg_path.parent.parent / 'spec_lock.md'):
if candidate in self._lock_cache:
return self._lock_cache[candidate]
if candidate.exists():
try:
data = _parse_spec_lock(candidate)
except Exception:
data = None
self._lock_cache[candidate] = data
if data is not None:
self._lock_seen = True
return data
return None
def _prototype_drift_allowances(
self,
) -> Tuple[set[str], set[str], set[str]]:
"""Return color/font/size values owned by the selected mirror page."""
prototype_root = self._active_prototype_root()
if prototype_root is None:
return set(), set(), set()
try:
content = self._active_prototype_path.read_text(encoding='utf-8')
except (AttributeError, OSError):
return set(), set(), set()
colors: set[str] = set()
for attribute in _PAINT_PROPERTIES or ():
for raw_value in self._svg_property_values(content, attribute):
normalized = raw_value.strip()
if normalized.lower() in {'none', 'transparent'} or re.fullmatch(
r'url\(#[^)]+\)', normalized
):
continue
if _parse_export_color is not None:
color, _alpha = _parse_export_color(normalized)
else:
color = _normalize_hex_rgb(normalized)
if color:
colors.add(color)
fonts = {
self._normalize_font_stack(value)
for value in self._font_family_values(content)
if self._normalize_font_stack(value)
}
sizes = set(self._effective_text_size_counts(prototype_root))
return colors, fonts, sizes
def _declared_typography_size_anchors(
self,
lock: Dict,
) -> Tuple[Dict, set[str], List[float], List[str]]:
"""Return valid declared size anchors and malformed lock rows."""
typography = lock.get('typography', {})
positive_numeric_re = re.compile(
r'^(?=.*[1-9])(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)$'
)
locked_sizes: set[str] = set()
anchor_sizes: List[float] = []
invalid_sizes: List[str] = []
for key, raw_value in typography.items():
if key == 'font_family' or key.endswith('_family'):
continue
value = raw_value.strip()
if positive_numeric_re.fullmatch(value) is None:
invalid_sizes.append(f"{key}: {raw_value}")
continue
try:
anchor = float(value)
except (TypeError, ValueError):
invalid_sizes.append(f"{key}: {raw_value}")
continue
if not math.isfinite(anchor) or anchor <= 0:
invalid_sizes.append(f"{key}: {raw_value}")
continue
locked_sizes.add(self._canonical_font_size_key(anchor))
anchor_sizes.append(anchor)
return typography, locked_sizes, anchor_sizes, invalid_sizes
def _count_undeclared_size_occurrences(
self,
root: ET.Element,
*,
locked_sizes: set[str],
anchor_sizes: List[float],
prototype_sizes: set[str],
) -> Counter[str]:
"""Count text objects using valid sizes outside all declared bands."""
counts: Counter[str] = Counter()
if not locked_sizes:
return counts
for value, occurrence_count in self._effective_text_size_counts(root).items():
if value in prototype_sizes and value not in locked_sizes:
continue
if value in locked_sizes:
continue
try:
used_px = float(value)
except (TypeError, ValueError):
continue
if not math.isfinite(used_px) or used_px < 0:
continue
if any(
abs(used_px - anchor_px) <= FONT_SIZE_ANCHOR_TOLERANCE_PX
for anchor_px in anchor_sizes
):
continue
counts[value] += occurrence_count
return counts
def _effective_text_size_counts(self, root: ET.Element) -> Counter[str]:
"""Count each effective size once per non-empty SVG text object."""
counts: Counter[str] = Counter()
if _resolve_project_font_sizes is None:
return counts
working_root = root
if (
_expand_local_use_references is not None
and _UseExpansionError is not None
):
expanded_root = copy.deepcopy(root)
try:
_expand_local_use_references(expanded_root)
except _UseExpansionError:
pass
else:
working_root = expanded_root
try:
effective_sizes = _resolve_project_font_sizes(working_root)
except ValueError:
return counts
def collect_text_object_sizes(element: ET.Element) -> set[str]:
values: set[str] = set()
def visit(node: ET.Element) -> None:
if (node.text or '').strip():
values.add(
self._canonical_font_size_key(effective_sizes[id(node)])
)
for child in node:
visit(child)
if (child.tail or '').strip():
values.add(
self._canonical_font_size_key(
effective_sizes[id(node)]
)
)
visit(element)
return values
definition_containers = {
'clippath',
'defs',
'marker',
'mask',
'pattern',
'symbol',
}
def visit_visible(element: ET.Element) -> None:
local_name = _local_name(element).casefold()
if local_name in definition_containers:
return
if local_name == 'text':
counts.update(collect_text_object_sizes(element))
return
for child in element:
visit_visible(child)
visit_visible(working_root)
return counts
@staticmethod
def _canonical_font_size_key(value: float) -> str:
"""Canonicalize equivalent numeric spellings for deck-wide counting."""
return format(value, '.12g')
def _prepare_undeclared_size_occurrences(
self,
svg_files: List[Path],
) -> None:
"""Pre-count sparse undeclared sizes before per-file diagnostics."""
previous_prototype = self._active_prototype_path
try:
for svg_path in svg_files:
lock = self._get_spec_lock(svg_path)
if lock is None:
continue
_typography, locked_sizes, anchor_sizes, _invalid = (
self._declared_typography_size_anchors(lock)
)
self._active_prototype_path = self._prototype_by_output.get(
svg_path.resolve()
)
_colors, _fonts, prototype_sizes = (
self._prototype_drift_allowances()
)
try:
content = svg_path.read_text(encoding='utf-8')
except OSError:
continue
try:
root = ET.fromstring(content)
except ET.ParseError:
continue
self._undeclared_size_occurrences.update(
self._count_undeclared_size_occurrences(
root,
locked_sizes=locked_sizes,
anchor_sizes=anchor_sizes,
prototype_sizes=prototype_sizes,
)
)
finally:
self._active_prototype_path = previous_prototype
self._undeclared_size_counts_ready = True
def _check_spec_lock_alignment(
self,
content: str,
svg_path: Path,
result: Dict,
*,
root: ET.Element,
):
"""Compare SVG values with reusable anchors in spec_lock.md.
Covers colors (fill / stroke / stop-color / flood-color / pattern
metadata), font-family, and font-size.
Additional colors and font families are valid contextual authoring and
are recorded as information. A valid undeclared display size may occur
at most twice across generated pages; its third occurrence makes it a
recurring role and blocks ``svg_output`` until the role is declared.
Structural text still maps to declared role bands. Exact mirror-
prototype values remain inherited information. Exact values are
accumulated in self._anchor_value_summary for end-of-run aggregation.
When spec_lock.md is missing, silently skip this local comparison; the
Generate route's required-artifact gate owns whether execution may begin.
"""
lock = self._get_spec_lock(svg_path)
if lock is None:
return
prototype_colors, prototype_fonts, prototype_sizes = (
self._prototype_drift_allowances()
)
# Build allow-sets from the lock
allowed_colors = set()
for v in lock.get('colors', {}).values():
if _parse_export_color is not None:
color, _alpha = _parse_export_color(v)
if color:
allowed_colors.add(color)
else:
color = _normalize_hex_rgb(v)
if color:
allowed_colors.add(color)
# A validated compact preset may contain registry-derived darken/lighten
# layer colors. Their base paint still comes from spec_lock; the exact
# child HEX values are deterministic compiler evidence, not color drift.
if (
_authored_preset_encoding is not None
and _validate_authored_preset_group is not None
):
for group in root.iter():
if (
_authored_preset_encoding(group) != 'compact'
or _validate_authored_preset_group(group)
):
continue
for child in group:
for attribute in ('fill', 'stroke'):
raw_value = child.get(attribute)
if raw_value is None:
continue
if _parse_export_color is not None:
color, _alpha = _parse_export_color(raw_value)
else:
color = _normalize_hex_rgb(raw_value)
if color:
allowed_colors.add(color)
locked_colors = set(allowed_colors)
allowed_colors.update(prototype_colors)
typo, locked_sizes, anchor_sizes, invalid_lock_sizes = (
self._declared_typography_size_anchors(lock)
)
if invalid_lock_sizes:
shown = ', '.join(invalid_lock_sizes[:5])
more = len(invalid_lock_sizes) - 5
suffix = f" (+{more} more)" if more > 0 else ""
result['errors'].append(
f"spec_lock typography sizes must be positive finite unitless px values; "
f"found {shown}{suffix}."
)
# Font families: default `font_family` plus any per-role `*_family`
# override (title_family / body_family / emphasis_family / code_family,
# per templates/schemas/spec_lock.schema.json). Any of these is a legitimate declared
# value; an SVG that uses any one of them is not drifting.
allowed_fonts = set()
if typo:
default_font = typo.get('font_family', '').strip()
if default_font:
allowed_fonts.add(self._normalize_font_stack(default_font))
for k, v in typo.items():
if k == 'font_family' or not k.endswith('_family'):
continue
v_clean = v.strip()
# Skip placeholder text like "same as body (omit if identical)"
if not v_clean or v_clean.lower().startswith('same as'):
continue
allowed_fonts.add(self._normalize_font_stack(v_clean))
locked_fonts = set(allowed_fonts)
allowed_fonts.update(prototype_fonts)
# Sizes: declared slots are anchors. Checker cannot infer which role a
# text node carries, so it uses the union of their ±2px bands as a cheap
# numeric safety net; prompt rules own semantic role mapping.
# Scan SVG for used values
color_drifts = set()
inherited_colors = set()
for attr in _PAINT_PROPERTIES or ():
for raw_value in self._svg_property_values(content, attr):
normalized = raw_value.strip()
if normalized.lower() in {'none', 'transparent'} or re.fullmatch(
r'url\(#[^)]+\)', normalized
):
continue
if _BARE_HEX_VALUE_RE.fullmatch(normalized):
continue
if _parse_export_color is not None:
val, _alpha = _parse_export_color(normalized)
if val is None:
continue
else:
val = _normalize_hex_rgb(normalized)
if val is None:
continue
if val not in allowed_colors:
color_drifts.add(f'#{val}')
elif val in prototype_colors and val not in locked_colors:
inherited_colors.add(f'#{val}')
font_drifts = set()
inherited_fonts = set()
for val in self._font_family_values(content):
normalized_font = self._normalize_font_stack(val)
if allowed_fonts and normalized_font not in allowed_fonts:
font_drifts.add(val)
elif (
normalized_font in prototype_fonts
and normalized_font not in locked_fonts
):
inherited_fonts.add(val)
size_drift_counts = self._count_undeclared_size_occurrences(
root,
locked_sizes=locked_sizes,
anchor_sizes=anchor_sizes,
prototype_sizes=prototype_sizes,
)
size_drifts = set(size_drift_counts)
inherited_sizes = set()
for val in self._effective_text_size_counts(root):
if val in prototype_sizes and val not in locked_sizes:
inherited_sizes.add(val)
# Record in run-wide aggregation. Colors/fonts beyond the anchor set are
# contextual values, not release issues. Generated-page sizes enforce
# role-anchor ownership; other spec-backed locations retain review.
fname = svg_path.name
for v in color_drifts:
self._anchor_value_summary['colors'][v].add(fname)
for v in font_drifts:
self._anchor_value_summary['fonts'][v].add(fname)
for v in size_drifts:
self._anchor_value_summary['sizes'][v].add(fname)
contextual_values = {}
if color_drifts:
contextual_values['colors'] = sorted(color_drifts)
if font_drifts:
contextual_values['font_families'] = sorted(font_drifts)
if contextual_values:
result['info']['contextual_values'] = contextual_values
sparse_sizes = {}
recurring_sizes = {}
for value, local_count in size_drift_counts.items():
total_count = (
self._undeclared_size_occurrences.get(value, local_count)
if self._undeclared_size_counts_ready
else local_count
)
target = (
sparse_sizes
if total_count <= SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES
else recurring_sizes
)
target[value] = total_count
if sparse_sizes:
result['info']['sparse_typography_sizes'] = {
value: count for value, count in sorted(sparse_sizes.items())
}
if recurring_sizes:
shown = ', '.join(
f"{value} ({count} occurrences)"
for value, count in sorted(recurring_sizes.items())
)
size_issue = (
f"undeclared font-size {shown} exceeds the sparse-display limit "
f"of {SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES} occurrences"
)
if svg_path.parent.name == 'svg_output':
result['errors'].append(
"spec_lock typography-size recurrence: "
f"{size_issue}. Structural text must return to its declared "
"role band; a genuinely recurring display treatment needs a "
"justified named role in the Design Spec and spec_lock."
)
else:
result['warnings'].append(
f"spec_lock typography-size recurrence review: {size_issue}"
)
inherited_parts = []
if inherited_colors:
inherited_parts.append(f"{len(inherited_colors)} color(s)")
if inherited_fonts:
inherited_parts.append(f"{len(inherited_fonts)} font-family value(s)")
if inherited_sizes:
inherited_parts.append(f"{len(inherited_sizes)} font-size value(s)")
if inherited_parts:
self._append_inherited_info(
result,
'spec_lock_alignment',
f"{', '.join(inherited_parts)} come unchanged from mirror "
"prototype and are accepted without expanding spec_lock.md",
)
def _find_image_sources_manifest(self, svg_path: Path) -> Path | None:
"""Locate image_sources.json for a project SVG.
Quality checks run primarily on <project>/svg_output/*.svg, but this
also supports SVGs checked from project root or svg_final.
"""
bases = (svg_path.parent, svg_path.parent.parent, svg_path.parent.parent.parent)
for base in bases:
candidate = base / 'images' / 'image_sources.json'
if candidate.exists():
return candidate
return None
def _load_image_sources_manifest(
self,
svg_path: Path,
) -> Tuple[Dict, str | None, Path | None]:
manifest_path = self._find_image_sources_manifest(svg_path)
if manifest_path is None:
return {}, None, None
payload, error = self._read_image_sources_manifest(manifest_path)
return payload, error, manifest_path
def _read_image_sources_manifest(
self,
manifest_path: Path,
) -> Tuple[Dict, str | None]:
"""Read one provenance manifest without accepting damaged state."""
if manifest_path in self._source_manifest_cache:
return self._source_manifest_cache[manifest_path]
try:
payload = json.loads(manifest_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError) as exc:
payload = {}
error = f"cannot read {manifest_path}: {exc}"
else:
if not isinstance(payload, dict):
error = f"{manifest_path} must contain a JSON object"
payload = {}
elif not isinstance(payload.get('items'), list):
error = f"{manifest_path} must contain an items array"
payload = {}
elif any(not isinstance(item, dict) for item in payload['items']):
error = f"{manifest_path} items must contain JSON objects"
payload = {}
else:
seen_filenames: set[str] = set()
error = None
for index, item in enumerate(payload['items']):
filename = item.get('filename')
if (
not isinstance(filename, str)
or not filename.strip()
or filename in {'.', '..'}
or '/' in filename
or '\\' in filename
or ':' in filename
or Path(filename).is_absolute()
):
error = (
f"{manifest_path} items[{index}].filename must be "
"a non-empty bare filename"
)
break
if filename in seen_filenames:
error = (
f"{manifest_path} contains duplicate filename "
f"{filename!r}"
)
break
seen_filenames.add(filename)
if error:
payload = {}
self._source_manifest_cache[manifest_path] = (payload, error)
return payload, error
@staticmethod
def _external_image_reference_basename(href: str) -> str | None:
"""Return a decoded basename for one local external image href."""
if not href or href.startswith('data:'):
return None
decoded_href = html.unescape(href)
parsed = urlsplit(decoded_href)
if parsed.scheme and parsed.scheme != 'file':
return None
path_part = (
parsed.path
if parsed.scheme
else decoded_href.split('?', 1)[0].split('#', 1)[0]
)
return Path(unquote(path_part)).name or None
@classmethod
def _referenced_image_basenames(cls, root: ET.Element) -> set[str]:
"""Return external image basenames rendered by one parsed SVG."""
filenames = set()
_working_root, _parent_by_id, images = cls._visible_image_elements(root)
for elem in images:
href = elem.get('href') or elem.get(f'{{{XLINK_NS}}}href')
filename = cls._external_image_reference_basename(href or '')
if filename:
filenames.add(filename)
return filenames
def _check_sourced_image_attribution(
self,
root: ET.Element,
svg_path: Path,
result: Dict,
):
"""Require visible credit text for attribution-required web images.
image_search.py records the legal tier in images/image_sources.json;
Executor must render compact credit text into the SVG. This check
binds each credit to the referenced image's author and license instead
of accepting one generic deck-level CC token.
"""
manifest, error, manifest_path = self._load_image_sources_manifest(svg_path)
if error:
if (
manifest_path is not None
and manifest_path not in self._source_manifest_errors_reported
):
result['errors'].append(
f"Invalid image source manifest: {error}"
)
self._source_manifest_errors_reported.add(manifest_path)
return
items = manifest.get('items') or []
if not items:
return
credit_blocks = self._visible_svg_text_blocks(root)
referenced_filenames = self._referenced_image_basenames(root)
for item in items:
if not item.get('attribution_required') and item.get('license_tier') != 'attribution-required':
continue
filename = str(item.get('filename') or '')
if not filename or filename not in referenced_filenames:
continue
license_name = str(item.get('license_name') or '').upper()
license_token = 'CC BY-SA' if 'BY-SA' in license_name else 'CC BY'
author = str(item.get('author') or '').strip()
has_credit = bool(author) and any(
author.casefold() in block.casefold()
and license_token in block.upper()
for block in credit_blocks
)
if not has_credit:
result['errors'].append(
f"Missing image-specific inline attribution for sourced "
f"image {filename} ({author or 'unknown author'}; "
f"{license_token}). Add compact author + license credit per "
f"references/image-searcher.md §7."
)
@classmethod
def _visible_svg_text_blocks(cls, root: ET.Element) -> List[str]:
"""Return rendered text blocks, excluding hidden/non-visual content."""
working_root = copy.deepcopy(root)
if (
_expand_local_use_references is not None
and _UseExpansionError is not None
):
try:
_expand_local_use_references(working_root)
except _UseExpansionError:
working_root = copy.deepcopy(root)
parent_by_id = {
id(child): parent
for parent in working_root.iter()
for child in list(parent)
}
blocks: List[str] = []
for element in working_root.iter(f'{{{SVG_NS}}}text'):
if (
cls._is_hidden_element(element, parent_by_id)
or cls._has_non_visual_ancestor(
element,
working_root,
parent_by_id,
)
or cls._has_zero_opacity(element, parent_by_id)
):
continue
text = re.sub(r'\s+', ' ', ' '.join(element.itertext())).strip()
if text:
blocks.append(text)
return blocks
@staticmethod
def _normalize_size(value: str) -> str:
"""Normalize a font-size value for drift comparison.
Unit-bearing SVG values are reported as errors before drift checking.
The legacy `px` strip remains to avoid a duplicate drift warning after
the hard error has already identified the unit problem.
"""
v = value.strip().lower()
if v.endswith('px'):
v = v[:-2].strip()
return v
@staticmethod
def _normalize_font_stack(stack: str) -> str:
"""Normalize a font-family stack for comparison: split on commas, strip
quotes / whitespace, lowercase, rejoin. Collapses cosmetic differences
(comma spacing, single vs double quotes, case) so that
`Consolas,'Courier New',monospace` matches `Consolas, "Courier New", monospace`."""
parts = [p.strip().strip('"\'').lower() for p in stack.split(',')]
return ','.join(p for p in parts if p)
def _categorize_issue(self, error_msg: str) -> str:
"""Categorize issue type"""
if 'Invalid XML' in error_msg:
return 'XML well-formedness'
elif 'viewBox' in error_msg:
return 'viewBox issues'
elif 'foreignObject' in error_msg:
return 'foreignObject'
elif 'paint' in error_msg.lower() or 'color value' in error_msg.lower():
return 'Paint issues'
elif 'font' in error_msg.lower():
return 'Font issues'
else:
return 'Other'
def _configure_prototype_context(
self,
target_path: Path,
svg_files: List[Path],
) -> None:
"""Map generated pages to selected prototypes for inherited diagnostics."""
self._prototype_by_output = {}
self._active_prototype_path = None
self._active_template_reuse_scope = None
self._source_import_summary = {
'warning_count': 0,
'by_code': {},
}
if (
self.template_mode
or self.quick_generate
or _load_pptx_structure_lock is None
):
return
project_path = self._resolve_project_path(target_path)
try:
structure_lock = _load_pptx_structure_lock(project_path)
except (_TemplateStructureError, OSError):
# The project-level structure gate reports the actionable parser
# error. Inherited classification is optional and stays silent.
return
if structure_lock is None:
return
self._active_template_reuse_scope = getattr(
structure_lock,
'template_reuse_scope',
None,
)
references = {
reference.slide_num: reference.svg_path
for reference in structure_lock.prototypes
}
if target_path.is_file():
sibling_files = discover_slide_svgs(target_path.parent)
resolved_target = target_path.resolve()
slide_num = next(
(
index
for index, sibling in enumerate(sibling_files, start=1)
if sibling.resolve() == resolved_target
),
1,
)
prototype = references.get(slide_num)
if prototype is not None:
self._prototype_by_output[resolved_target] = prototype.resolve()
else:
for slide_num, svg_path in enumerate(svg_files, start=1):
prototype = references.get(slide_num)
if prototype is not None:
self._prototype_by_output[svg_path.resolve()] = prototype.resolve()
if self._active_template_reuse_scope not in {'mirror', 'layout'}:
return
manifest_path = (
project_path / 'templates' / 'template_execution_manifest.json'
)
try:
manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
except (FileNotFoundError, OSError, json.JSONDecodeError):
return
if manifest.get('schema') != 'ppt-master.template-execution-manifest.v1':
return
source_import = manifest.get('source_import')
if isinstance(source_import, dict):
self._source_import_summary = source_import
def check_directory(self, directory: str, expected_format: str = None) -> List[Dict]:
"""
Check all SVG files in a directory
Args:
directory: Directory path
expected_format: Expected canvas format
Returns:
List of check results
"""
dir_path = Path(directory)
self._has_incomplete_page_roster = False
self._undeclared_size_occurrences = Counter()
self._undeclared_size_counts_ready = False
if not dir_path.exists():
print(f"[ERROR] Directory does not exist: {directory}")
self.summary['errors'] += 1
self.issue_types['Input issues'] += 1
return []
# Brand and Style workspaces have no SVG roster. Validate their
# portable contracts through the same authority used by library
# registration, while keeping project scope independent of global
# indexes and directory names.
if self.template_mode and dir_path.is_dir():
nested_spec = dir_path / 'templates' / 'design_spec.md'
spec = nested_spec if nested_spec.is_file() else dir_path / 'design_spec.md'
spec_kind = _design_spec_kind(spec) if spec.exists() else None
if spec_kind in {'brand', 'style'}:
self._spec_only_template_kind = spec_kind
self.summary['total'] += 1
spec_valid = True
pretty_kind = spec_kind.title()
print(
f"[INFO] {pretty_kind} directory detected "
f"(kind: {spec_kind}) — "
f"validating its portable workspace contract."
)
workspace_root = (
spec.parent.parent
if spec.parent.name == 'templates'
else spec.parent
)
try:
from register_template import (
SpecParseError,
validate_brand_workspace,
validate_style_workspace,
)
validator = {
'brand': validate_brand_workspace,
'style': validate_style_workspace,
}[spec_kind]
validator(workspace_root)
except ImportError as exc:
spec_valid = False
self._template_issues.append((
'error',
f'{spec_kind}_contract',
f"{pretty_kind} schema validator could not be imported: {exc}",
))
except (OSError, SpecParseError) as exc:
spec_valid = False
self._template_issues.append((
'error',
f'{spec_kind}_contract',
str(exc),
))
if spec_valid:
self.summary['passed'] += 1
return self.results
# Find all SVG files
if dir_path.is_file():
svg_files = [dir_path]
else:
if self.template_mode:
# Template directories live at templates/{layouts,decks}/<id>/.
svg_files = discover_slide_svgs(dir_path)
else:
svg_output = dir_path / \
'svg_output' if (
dir_path / 'svg_output').exists() else dir_path
svg_files = discover_slide_svgs(svg_output)
if not svg_files:
print(f"[ERROR] No SVG files found in: {directory}")
self.summary['errors'] += 1
self.issue_types['Input issues'] += 1
return []
self._configure_prototype_context(dir_path, svg_files)
if not self.template_mode:
self._prepare_undeclared_size_occurrences(svg_files)
directory_expected_viewbox: str | None = None
directory_expected_label = "the first SVG canvas"
directory_lock_has_canvas = False
if self.template_mode:
template_viewbox = _declared_template_canvas_viewbox(dir_path)
if template_viewbox:
directory_expected_viewbox = template_viewbox
directory_expected_label = "design_spec canvas_viewbox"
else:
directory_expected_viewbox = ""
directory_expected_label = "design_spec canvas_viewbox"
if expected_format is None and directory_expected_viewbox is None:
lock = (
None
if self.template_mode
else self._get_spec_lock(svg_files[0])
)
if lock is not None:
if 'canvas' in lock:
directory_lock_has_canvas = True
locked_viewbox = lock.get('canvas', {}).get('viewBox')
if locked_viewbox:
directory_expected_viewbox = locked_viewbox
directory_expected_label = "spec_lock canvas"
else:
directory_expected_viewbox = ""
directory_expected_label = "spec_lock canvas"
if (
directory_expected_viewbox is None
and not directory_lock_has_canvas
):
for svg_file in svg_files:
try:
root = ET.parse(svg_file).getroot()
first_canvas = parse_project_viewbox(
root.get('viewBox'),
context=f"{svg_file.name} root viewBox",
)
except (OSError, ET.ParseError, CanvasContractError):
continue
directory_expected_viewbox = first_canvas.canonical
directory_expected_label = f"first SVG {svg_file.name}"
break
print(f"\n[SCAN] Checking {len(svg_files)} SVG file(s)...\n")
for svg_file in svg_files:
self._active_prototype_path = self._prototype_by_output.get(
svg_file.resolve()
)
result = self.check_file(
str(svg_file),
expected_format,
expected_viewbox=directory_expected_viewbox,
expected_viewbox_label=directory_expected_label,
)
self._print_result(result)
if self.template_mode:
check_structure = _template_structure_checks_enabled(dir_path)
if check_structure:
self._check_pptx_structure_contract(dir_path, svg_files)
if dir_path.is_dir():
self._check_template_contract(
dir_path,
svg_files,
check_structure=check_structure,
)
elif _CHECK_PPTX_STRUCTURED_PROJECT:
self._check_pptx_structure_contract(dir_path, svg_files)
if (
not self.template_mode
and not self.quick_generate
and dir_path.is_dir()
):
self._check_animation_config_contract(dir_path)
self._check_illustration_resource_contract(dir_path)
if (
not self.template_mode
and not self.quick_generate
and validate_communication_trace is not None
):
project_path = self._resolve_project_path(dir_path)
self._communication_trace_issues.extend(
('error', message)
for message in validate_communication_trace(project_path)
)
return self.results
def _check_pptx_structure_contract(
self,
target_path: Path,
svg_files: List[Path],
) -> None:
"""Validate the all-page structured lock and reusable contracts."""
if self.quick_generate:
return
project_path = self._resolve_project_path(target_path)
standard_project = bool(
not self.template_mode
and (project_path / 'svg_output').is_dir()
)
declared_mode = (
_declared_pptx_structure_mode(project_path)
if standard_project
else None
)
if standard_project and declared_mode in {'flat', 'structured'}:
self._pptx_structure_issues.extend(
('error', message)
for message in _generated_theme_contract_errors(project_path)
)
if standard_project and declared_mode == 'flat':
if (
_load_pptx_structure_lock is None
or _TemplateStructureError is None
):
self._pptx_structure_issues.append((
'error',
'Flat PPTX project validation is unavailable because the '
'template_structure module could not be imported.',
))
return
try:
structure_lock = _load_pptx_structure_lock(project_path)
except _TemplateStructureError as exc:
self._pptx_structure_issues.append(('error', str(exc)))
return
if structure_lock is None or structure_lock.mode != 'flat':
self._pptx_structure_issues.append((
'error',
'spec_lock.md must contain one complete '
'pptx_structure.mode: flat contract.',
))
return
has_metadata = False
for svg_path in svg_files:
try:
root = ET.parse(svg_path).getroot()
except (OSError, ET.ParseError):
continue
if any(
elem.get(attr) is not None
for elem in root.iter()
for attr in _PPTX_STRUCTURE_ATTRS
):
has_metadata = True
break
if not standard_project and not self.template_mode and not has_metadata:
return
if (
_load_pptx_structure_lock is None
or _parse_template_structure_slide is None
or _parse_template_structure_slides is None
or _structure_subtree_signature is None
or _template_lock_errors is None
or _TemplateStructureError is None
):
self._pptx_structure_issues.append((
'error',
'Structured PPTX project validation is unavailable because the '
'template_structure module could not be imported.',
))
return
if self.template_mode:
try:
specs = _parse_template_structure_slides(svg_files)
except _TemplateStructureError as exc:
self._pptx_structure_issues.append(('error', str(exc)))
return
self._pptx_structure_issues.extend(
('error', message)
for message in self._shared_fixed_layer_errors(specs)
)
self._pptx_structure_issues.extend(
('warning', message)
for message in self._duplicate_layout_key_warnings(specs)
)
return
if standard_project and declared_mode != 'structured':
label = repr(declared_mode) if declared_mode else (
'missing (legacy implicit baseline)'
)
self._pptx_structure_issues.append((
'error',
'release SVG projects require an explicit spec_lock.md '
'pptx_structure.mode: flat (free design / brand-only) or '
f'structured (deck/layout template); found {label}. New '
'free-design projects use mode: flat; create a new template '
'workspace through skills/ppt-master/workflows/create-template.md, '
'then generate new structured SVG pages before export. Existing '
'PPTX/SVG files are not upgraded in place.',
))
return
try:
structure_lock = _load_pptx_structure_lock(project_path)
except _TemplateStructureError as exc:
self._pptx_structure_issues.append(('error', str(exc)))
return
if structure_lock is None or structure_lock.mode != 'structured':
self._pptx_structure_issues.append((
'error',
'spec_lock.md must contain one complete '
'pptx_structure.mode: structured contract.',
))
return
complete_roster = target_path.is_dir()
try:
if not complete_roster and target_path.is_file():
sibling_files = discover_slide_svgs(target_path.parent)
resolved_target = target_path.resolve()
slide_num = next(
(
index
for index, sibling in enumerate(sibling_files, start=1)
if sibling.resolve() == resolved_target
),
1,
)
specs = [
_parse_template_structure_slide(target_path, slide_num)
]
else:
specs = _parse_template_structure_slides(svg_files)
except _TemplateStructureError as exc:
self._pptx_structure_issues.append(('error', str(exc)))
return
if complete_roster:
actual_slides = {spec.slide_num for spec in specs}
expected_slides = {
reference.slide_num
for reference in structure_lock.layouts
}
expected_slides.update(
reference.slide_num
for reference in structure_lock.prototypes
)
self._has_incomplete_page_roster = bool(
expected_slides - actual_slides
)
self._pptx_structure_issues.extend(
('error', message)
for message in _template_lock_errors(specs, structure_lock)
)
else:
self._pptx_structure_issues.extend(
('error', message)
for message in self._partial_structure_lock_errors(
specs,
structure_lock,
)
)
if _template_prototype_errors is not None:
self._pptx_structure_issues.extend(
('error', message)
for message in _template_prototype_errors(
specs,
structure_lock,
require_complete_roster=complete_roster,
)
)
self._pptx_structure_issues.extend(
('error', message)
for message in self._shared_fixed_layer_errors(specs)
)
self._pptx_structure_issues.extend(
('warning', message)
for message in self._duplicate_layout_key_warnings(specs)
)
@staticmethod
def _partial_structure_lock_errors(specs, structure_lock) -> List[str]:
"""Compare explicitly checked pages without requiring the full roster."""
references = {
reference.slide_num: reference
for reference in structure_lock.layouts
}
master_names = {
master.master_key: master.master_name
for master in structure_lock.masters
}
definitions = {
definition.layout_key: definition
for definition in structure_lock.layout_definitions
}
errors: List[str] = []
for spec in specs:
page = f"P{spec.slide_num:02d}"
reference = references.get(spec.slide_num)
if reference is None:
errors.append(
f"spec_lock.md page_pptx_layouts is missing {page}"
)
continue
definition = definitions.get(reference.layout_key)
if definition is None:
errors.append(
f"spec_lock.md pptx_layouts is missing Layout "
f"{reference.layout_key!r}"
)
continue
if spec.master_key != definition.master_key:
errors.append(
f"{spec.svg_path.name}: data-pptx-master={spec.master_key!r} "
f"does not match spec_lock Layout {reference.layout_key!r} "
f"Master key {definition.master_key!r}"
)
if spec.layout_key != reference.layout_key:
errors.append(
f"{spec.svg_path.name}: data-pptx-layout={spec.layout_key!r} "
f"does not match spec_lock {page} layout key "
f"{reference.layout_key!r}"
)
if spec.layout_name != definition.layout_name:
errors.append(
f"{spec.svg_path.name}: data-pptx-layout-name="
f"{spec.layout_name!r} does not match spec_lock Layout "
f"{reference.layout_key!r} name {definition.layout_name!r}"
)
expected_master_name = master_names.get(spec.master_key)
if expected_master_name != spec.master_name:
errors.append(
f"{spec.svg_path.name}: data-pptx-master-name="
f"{spec.master_name!r} does not match spec_lock Master "
f"{spec.master_key!r} name {expected_master_name!r}"
)
return errors
def _duplicate_layout_key_warnings(self, specs) -> List[str]:
"""Flag distinct layout keys whose static contracts are identical.
Keys split by page topic over one shared skeleton compile into
duplicate PowerPoint Layouts; the fingerprint compares the
id-insensitive layout-layer drawing plus the placeholder contract.
"""
prototypes: Dict[Tuple[str, str], Path] = {}
for spec in specs:
prototypes.setdefault(
(getattr(spec, 'master_key', ''), spec.layout_key),
spec.svg_path,
)
if len(prototypes) < 2:
return []
fingerprint_keys: Dict[tuple, List[str]] = {}
for (master_key, layout_key), svg_path in prototypes.items():
fingerprint = self._layout_contract_fingerprint(svg_path)
if fingerprint is None:
continue
fingerprint_keys.setdefault(
(master_key, fingerprint),
[],
).append(layout_key)
messages = []
for keys in fingerprint_keys.values():
if len(keys) < 2:
continue
joined = ', '.join(sorted(keys))
messages.append(
f"layout keys {joined} declare identical static Layout framing "
"and placeholder contracts; they compile to duplicate Layouts. "
"Either merge them into one reusable key (spec_lock.md "
"pptx_layouts + each SVG root), or — when their reusable "
"contracts genuinely differ — assign distinct explicit default "
"placeholder bounds and/or mark only truly stable framing as "
'data-pptx-layer="layout". Slide-local content geometry does not '
"define a Layout. This recommendation is advisory; no change or "
"disposition is required."
)
return messages
@classmethod
def _shared_fixed_layer_errors(cls, specs) -> List[str]:
"""Reject fixed atoms whose payload varies inside one reuse scope."""
master_groups = defaultdict(list)
layout_groups = defaultdict(list)
for spec in specs:
master_groups[spec.master_key].append(spec)
layout_groups[(spec.master_key, spec.layout_key)].append(spec)
try:
errors = cls._fixed_layer_group_errors(master_groups, 'master')
errors.extend(cls._fixed_layer_group_errors(layout_groups, 'layout'))
except _TemplateStructureError as exc:
return [str(exc)]
return errors
@classmethod
def _fixed_layer_group_errors(cls, groups, layer: str) -> List[str]:
"""Compare fixed atom payloads across grouped slide specifications."""
errors = []
for scope_key, group_specs in groups.items():
if len(group_specs) < 2:
continue
variants = defaultdict(lambda: defaultdict(list))
for spec in group_specs:
payloads = cls._fixed_layer_payloads(spec, layer)
for element_id, payload in payloads.items():
variants[element_id][payload].append(spec)
for element_id, payload_specs in variants.items():
if len(payload_specs) < 2:
continue
slide_names = ', '.join(
spec.svg_path.name
for spec in sorted(group_specs, key=lambda item: item.slide_num)
)
if layer == 'master':
scope = f"Master {scope_key!r}"
else:
master_key, layout_key = scope_key
scope = (
f"Layout {layout_key!r} under Master {master_key!r}"
)
if element_id is None:
subject = "fixed visual resources"
verb = "differ"
else:
subject = f"fixed element {element_id!r}"
verb = "differs"
errors.append(
f"{scope} {subject} {verb} across slides: "
f"{slide_names}. Values marked data-pptx-layer={layer!r} must "
"remain identical throughout their reuse scope; move variable "
"text or images into a placeholder slot or keep them Slide-local."
)
return errors
@staticmethod
def _fixed_layer_payloads(spec, layer: str) -> Dict[object, tuple]:
"""Return resolved fixed-layer visual payloads keyed by SVG id."""
elements = (
spec.master_elements if layer == 'master' else spec.layout_elements
)
if not elements:
return {}
signature = _structure_subtree_signature(
spec.svg_path,
elements,
include_skin=True,
include_text=True,
asset_identity=True,
)
return {
None if element_id == '__visual_resources__' else element_id: payload
for element_id, payload in signature
}
@staticmethod
def _layout_contract_fingerprint(svg_path: Path):
"""Id-insensitive static contract: layout-layer XML + placeholder slots."""
try:
root = ET.parse(str(svg_path)).getroot()
except (OSError, ET.ParseError):
return None
layout_parts = []
placeholder_parts = []
for child in list(root):
if child.get('data-pptx-layer') == 'layout':
clone = copy.deepcopy(child)
for elem in clone.iter():
elem.attrib.pop('id', None)
xml = ET.tostring(clone, encoding='unicode')
layout_parts.append(re.sub(r'\s+', ' ', xml).strip())
placeholder = child.get('data-pptx-placeholder')
if placeholder is not None:
carrier_tags = tuple(
grandchild.tag.rsplit('}', 1)[-1]
for grandchild in list(child)
if (
grandchild.get('data-pptx-carrier') or ''
).strip().lower() == 'true'
)
placeholder_parts.append((
placeholder,
child.tag.rsplit('}', 1)[-1],
child.get('data-pptx-bounds') or '',
child.get('data-pptx-idx') or '',
(
child.get('data-pptx-binding') or 'carrier'
).strip().lower(),
carrier_tags,
))
return (
tuple(layout_parts),
tuple(sorted(placeholder_parts)),
)
def _check_illustration_resource_contract(self, dir_path: Path) -> None:
"""Project-level planned-image and illustration resource checks."""
project_path = self._resolve_project_path(dir_path)
spec_path = project_path / 'design_spec.md'
if not spec_path.exists():
return
try:
spec_text = spec_path.read_text(encoding='utf-8')
except OSError as exc:
self._illustration_issues.append((
'warning',
'spec_unreadable',
f"could not read {spec_path}: {exc}",
))
return
current_contract = (
'<!-- ppt-master-schema: design-spec/v1 -->' in spec_text
)
rows = self._extract_image_resource_rows(spec_text)
if not rows and not current_contract:
return
lock_entries, lock_error = self._load_project_lock_image_entries(
project_path
)
lock_images = set(lock_entries)
svg_references, inline_image_counts, image_placements = (
self._load_project_svg_image_references(project_path)
)
all_svg_references = (
set().union(*(
set(references)
for references in svg_references.values()
))
if svg_references
else set()
)
sheet_rows = [
row
for row in rows
if self._row_type(row).lower() == 'illustration sheet'
]
slice_rows = [row for row in rows if self._row_acquire(row) == 'slice']
for row in sheet_rows:
filename = self._row_filename(row)
if not filename:
continue
if filename in lock_images:
self._illustration_issues.append((
'error',
'sheet_in_lock',
f"{filename} is an Illustration Sheet but is listed in spec_lock.md images; "
"only sliced element rows may be listed.",
))
if filename in all_svg_references:
self._illustration_issues.append((
'error',
'sheet_referenced',
f"{filename} is an Illustration Sheet but is referenced by an SVG; "
"generate it only as a slice source, never place it.",
))
if (
self._row_status(row) == 'generated'
and not (project_path / 'images' / filename).is_file()
):
self._illustration_issues.append((
'error',
'sheet_file_missing',
f"{filename} is a Generated Illustration Sheet but "
f"images/{filename} does not exist.",
))
if current_contract:
self._check_planned_image_closure(
rows,
project_path,
lock_entries,
lock_error,
svg_references,
inline_image_counts,
image_placements,
)
else:
for row in slice_rows:
filename = self._row_filename(row)
if not filename:
continue
if filename not in lock_images:
self._illustration_issues.append((
'error',
'slice_missing_lock',
f"{filename} is a slice row but is absent from spec_lock.md images.",
))
if (
self._row_status(row) == 'generated'
and not (project_path / 'images' / filename).exists()
):
self._illustration_issues.append((
'error',
'slice_file_missing',
f"{filename} is a Generated slice row but "
f"images/{filename} does not exist.",
))
@staticmethod
def _resolve_project_path(dir_path: Path) -> Path:
"""Resolve a checker target directory to its project root."""
candidate = dir_path.parent if dir_path.is_file() else dir_path
if (
_project_root_for_svg_path is not None
and candidate.name in _SVG_WORK_DIR_NAMES
):
return _project_root_for_svg_path(candidate)
if (
(candidate / 'svg_output').exists()
or (candidate / 'design_spec.md').exists()
):
return candidate
return candidate.parent
@staticmethod
def _split_md_table_row(line: str) -> List[str]:
"""Split a simple Markdown table row into stripped cells."""
return [cell.strip().strip('`') for cell in line.strip().strip('|').split('|')]
@classmethod
def _extract_image_resource_rows(cls, spec_text: str) -> List[Dict[str, str]]:
"""Extract rows from design_spec.md §VIII Image Resource List."""
section_match = re.search(
r"^##\s+VIII\.\s+Image Resource List\b.*?(?=^##\s+|\Z)",
spec_text,
re.MULTILINE | re.DOTALL,
)
if not section_match:
return []
lines = section_match.group(0).splitlines()
header = None
rows: List[Dict[str, str]] = []
in_resource_table = False
for line in lines:
if not line.strip().startswith('|'):
if in_resource_table and rows:
break
continue
cells = cls._split_md_table_row(line)
if not cells:
continue
if header is None:
if any(cell.lower() == 'filename' for cell in cells):
header = cells
in_resource_table = True
continue
if set(cell.replace('-', '').strip() for cell in cells) == {''}:
continue
if not in_resource_table:
continue
row = {header[i]: cells[i] if i < len(cells) else '' for i in range(len(header))}
filename = row.get('Filename', '').strip()
if (
filename.lower() != 'filename'
and any(value.strip() for value in row.values())
):
rows.append(row)
return rows
@staticmethod
def _row_filename(row: Dict[str, str]) -> str:
return Path(row.get('Filename', '').strip()).name
@staticmethod
def _row_raw_filename(row: Dict[str, str]) -> str:
return row.get('Filename', '').strip()
@staticmethod
def _row_type(row: Dict[str, str]) -> str:
return row.get('Type', '').strip()
@staticmethod
def _row_acquire(row: Dict[str, str]) -> str:
return row.get('Acquire Via', '').strip().lower()
@staticmethod
def _row_status(row: Dict[str, str]) -> str:
return row.get('Status', '').strip().lower()
@staticmethod
def _row_layout(row: Dict[str, str]) -> str:
return row.get('Layout pattern', '').strip()
@staticmethod
def _row_crop(row: Dict[str, str]) -> str:
return row.get('Crop Policy', '').strip().lower()
@staticmethod
def _layout_projection_matches(left: str, right: str) -> bool:
"""Compare one Strategist recommendation without locking its wording."""
left_ids = re.findall(r'#([0-9]+)(?![0-9])', left)
right_ids = re.findall(r'#([0-9]+)(?![0-9])', right)
if left_ids or right_ids:
return left_ids == right_ids
def normalize(value: str) -> str:
return re.sub(r'\s+', ' ', value.replace('`', '')).strip()
return normalize(left) == normalize(right)
def _load_project_lock_image_entries(
self,
project_path: Path,
) -> Tuple[Dict[str, List[Dict[str, str]]], str | None]:
"""Return parsed image-lock rows keyed by basename."""
lock_path = project_path / 'spec_lock.md'
if not lock_path.exists():
return {}, f"{lock_path} does not exist"
if _parse_spec_lock is None:
return {}, "spec_lock parser is unavailable"
if _parse_spec_lock_image_value is None:
return {}, "spec_lock image parser is unavailable"
try:
lock = _parse_spec_lock(lock_path)
except Exception as exc:
return {}, f"cannot parse {lock_path}: {exc}"
entries: Dict[str, List[Dict[str, str]]] = defaultdict(list)
legacy_metadata_keys = {
'image_rendering',
'image_rendering_references',
'image_rendering_behavior',
}
errors: List[str] = []
for key, value in lock.get('images', {}).items():
if str(key).strip().lower() in legacy_metadata_keys:
continue
try:
parsed = _parse_spec_lock_image_value(str(key), str(value))
except ValueError as exc:
errors.append(f"images row {key!r} {exc}")
continue
path_part = parsed['path']
filename = Path(path_part).name
if not filename:
continue
entries[filename].append({
'key': str(key),
'path': path_part,
'source': parsed['source'],
'pattern': parsed['pattern'],
'crop': parsed['crop'],
'legacy': parsed['legacy'],
})
error = (
f"{lock_path}: " + "; ".join(errors)
if errors
else None
)
return dict(entries), error
def _load_project_lock_images(self, project_path: Path) -> set[str]:
"""Return filenames listed under spec_lock.md images."""
entries, _error = self._load_project_lock_image_entries(project_path)
return set(entries)
@classmethod
def _load_project_svg_image_references(
cls,
project_path: Path,
) -> Tuple[
Dict[Path, Dict[str, set[Path]]],
Dict[Path, int],
Dict[str, List[Tuple[Path, str, Tuple[str, ...]]]],
]:
"""Parse rendered image instances, paths, and crop mechanisms."""
svg_dir = project_path / 'svg_output'
if not svg_dir.exists():
return {}, {}, {}
out: Dict[Path, Dict[str, set[Path]]] = {}
inline_counts: Dict[Path, int] = {}
placements: Dict[
str,
List[Tuple[Path, str, Tuple[str, ...]]],
] = defaultdict(list)
for svg_path in discover_slide_svgs(svg_dir):
try:
root = ET.parse(svg_path).getroot()
except (OSError, ET.ParseError):
continue
working_root, parent_by_id, images = cls._visible_image_elements(root)
references: Dict[str, set[Path]] = defaultdict(set)
inline_count = 0
for element in images:
href = (
element.get('href')
or element.get(f'{{{XLINK_NS}}}href')
or ''
)
if href.lstrip().lower().startswith('data:'):
inline_count += 1
continue
filename = cls._external_image_reference_basename(href)
if not filename:
continue
references.setdefault(filename, set())
placements[filename].append((
svg_path,
element.get('preserveAspectRatio') or '',
cls._image_crop_mechanisms(
element,
working_root,
parent_by_id,
),
))
if _resolve_external_image_reference is not None:
resolved = _resolve_external_image_reference(
svg_path.parent,
href,
)
if resolved is not None:
references[filename].add(resolved.resolve())
out[svg_path] = dict(references)
if inline_count:
inline_counts[svg_path] = inline_count
return out, inline_counts, dict(placements)
@staticmethod
def _image_crop_mechanisms(
image: ET.Element,
root: ET.Element,
parent_by_id: Dict[int, ET.Element],
) -> Tuple[str, ...]:
"""Return objective clipping mechanisms affecting one image instance."""
mechanisms: List[str] = []
current: ET.Element | None = image
while current is not None:
tag = _local_name(current)
style_values = (
_parse_inline_style(current.get('style'))
if _parse_inline_style is not None
else {}
)
for property_name in ('clip-path', 'mask'):
value = style_values.get(property_name)
if value is None:
value = current.get(property_name)
if value and value.strip().lower() != 'none':
mechanisms.append(f"<{tag}> {property_name}")
overflow = style_values.get('overflow')
if overflow is None:
overflow = current.get('overflow')
if overflow and overflow.strip().lower() in {'hidden', 'clip'}:
mechanisms.append(f"<{tag}> overflow={overflow.strip()!r}")
if current is not root and tag == 'svg':
mechanisms.append('nested <svg> viewport')
current = parent_by_id.get(id(current))
return tuple(dict.fromkeys(mechanisms))
def _check_planned_image_closure(
self,
rows: List[Dict[str, str]],
project_path: Path,
lock_entries: Dict[str, List[Dict[str, str]]],
lock_error: str | None,
svg_references: Dict[Path, Dict[str, set[Path]]],
inline_image_counts: Dict[Path, int],
image_placements: Dict[
str,
List[Tuple[Path, str, Tuple[str, ...]]],
],
) -> None:
"""Close Design Spec, execution lock, files, SVGs, and provenance."""
project_root = project_path.resolve()
if inline_image_counts:
total = sum(inline_image_counts.values())
shown = ', '.join(
f"{path.name} ({count})"
for path, count in sorted(inline_image_counts.items())
)
self._illustration_issues.append((
'error',
'svg_inline_image_untracked',
f"svg_output contains {total} inline data-URI image(s): "
f"{shown}. Current projects must keep external project-local "
"image hrefs so every placement closes through Design Spec "
"§VIII and spec_lock.md.",
))
valid_acquisitions = {
'ai',
'web',
'user',
'formula',
'placeholder',
'slice',
}
valid_statuses = {
'pending',
'failed',
'generated',
'sourced',
'rendered',
'needs-manual',
'existing',
'placeholder',
}
terminal_by_acquisition = {
'ai': {'generated', 'needs-manual'},
'web': {'sourced', 'needs-manual'},
'user': {'existing', 'needs-manual'},
'formula': {'rendered', 'needs-manual'},
'placeholder': {'placeholder'},
'slice': {'generated', 'needs-manual'},
}
current_image_contract = (
any('Crop Policy' in row for row in rows)
or any(
entry.get('legacy') == 'false'
for entries in lock_entries.values()
for entry in entries
)
)
seen_filenames: set[str] = set()
for row in rows:
raw_filename = self._row_raw_filename(row)
filename = self._row_filename(row)
acquire = self._row_acquire(row)
status = self._row_status(row)
layout = self._row_layout(row)
crop = self._row_crop(row)
filename_is_bare = bool(filename) and (
filename not in {'.', '..'}
and '/' not in filename
and '\\' not in filename
and ':' not in filename
)
filename_is_canonical = raw_filename in {
filename,
f"images/{filename}",
}
if not filename_is_bare or not filename_is_canonical:
self._illustration_issues.append((
'error',
'planned_image_invalid_filename',
f"Design Spec §VIII Filename {raw_filename!r} must be "
"a non-empty bare filename or canonical "
"images/<filename> path.",
))
elif filename in seen_filenames:
self._illustration_issues.append((
'error',
'planned_image_duplicate_filename',
f"Design Spec §VIII repeats Filename {filename!r}; "
"one resource must have one authoritative row.",
))
else:
seen_filenames.add(filename)
if current_image_contract and not layout:
self._illustration_issues.append((
'error',
'planned_image_missing_pattern',
f"{filename or '(missing filename)'} has an empty Design "
"Spec §VIII Layout pattern; preserve one non-empty "
"Strategist recommendation without locking SVG geometry.",
))
if current_image_contract and crop not in {'adaptive', 'no-crop'}:
self._illustration_issues.append((
'error',
'planned_image_invalid_crop_policy',
f"{filename or '(missing filename)'} has invalid Design "
f"Spec §VIII Crop Policy "
f"{row.get('Crop Policy', '').strip()!r}; use adaptive "
"or no-crop.",
))
if acquire not in valid_acquisitions:
self._illustration_issues.append((
'error',
'planned_image_invalid_acquisition',
f"{filename or '(missing filename)'} has invalid "
f"Acquire Via {row.get('Acquire Via', '').strip()!r}.",
))
continue
if status not in valid_statuses:
self._illustration_issues.append((
'error',
'planned_image_invalid_status',
f"{filename or '(missing filename)'} has invalid "
f"Status {row.get('Status', '').strip()!r}.",
))
continue
if status in {'pending', 'failed'}:
self._illustration_issues.append((
'error',
'planned_image_not_terminal',
f"{filename or '(missing filename)'} has non-terminal "
f"Status {row.get('Status', '').strip()!r}; finish the "
"owning acquisition or mark it Needs-Manual before export.",
))
elif status not in terminal_by_acquisition[acquire]:
expected = ', '.join(sorted(terminal_by_acquisition[acquire]))
self._illustration_issues.append((
'error',
'planned_image_status_mismatch',
f"{filename or '(missing filename)'} uses Acquire Via "
f"{acquire!r} but Status {status!r}; terminal status must "
f"be one of: {expected}.",
))
if lock_error:
self._illustration_issues.append((
'error',
'image_lock_unreadable',
lock_error,
))
return
placed_rows = [
row for row in rows
if self._row_type(row).lower() != 'illustration sheet'
and self._row_acquire(row)
in {'ai', 'web', 'user', 'formula', 'placeholder', 'slice'}
]
rows_by_filename = {
self._row_filename(row): row
for row in placed_rows
if self._row_filename(row)
}
referenced_paths: Dict[str, set[Path]] = defaultdict(set)
for references in svg_references.values():
for filename, paths in references.items():
referenced_paths[filename].update(paths)
referenced = set(referenced_paths)
for filename, row in rows_by_filename.items():
if filename not in lock_entries:
self._illustration_issues.append((
'error',
'planned_image_missing_lock',
f"{filename} is a placed Design Spec image row but is "
"absent from spec_lock.md images.",
))
continue
if current_image_contract and any(
entry.get('legacy') != 'false'
for entry in lock_entries[filename]
):
self._illustration_issues.append((
'error',
'planned_image_legacy_lock_projection',
f"{filename} uses the current Design Spec image contract "
"but its spec_lock.md row does not provide complete "
"source=..., pattern=..., and crop=... metadata.",
))
for filename in sorted(referenced - set(rows_by_filename)):
lock_note = (
""
if filename in lock_entries
else " and is absent from spec_lock.md images"
)
self._illustration_issues.append((
'error',
'svg_image_missing_spec',
f"svg_output references {filename}, but it has no placed "
f"Design Spec §VIII row{lock_note}.",
))
for filename, entries in lock_entries.items():
row = rows_by_filename.get(filename)
if row is None:
self._illustration_issues.append((
'error',
'locked_image_missing_spec',
f"{filename} is listed in spec_lock.md images but has no "
"placed row in Design Spec §VIII.",
))
continue
acquire = self._row_acquire(row)
status = self._row_status(row)
layout_pattern = self._row_layout(row)
crop_policy = self._row_crop(row)
if len(entries) > 1:
keys = ', '.join(repr(entry.get('key', '')) for entry in entries)
self._illustration_issues.append((
'error',
'locked_image_duplicate_entries',
f"{filename} appears in multiple spec_lock.md image rows "
f"({keys}); one resource must have one authoritative row.",
))
for entry in entries:
if entry.get('legacy') != 'false':
continue
if entry.get('source') != acquire:
self._illustration_issues.append((
'error',
'locked_image_source_mismatch',
f"{filename} spec_lock source={entry.get('source')!r} "
f"does not match Design Spec §VIII Acquire Via "
f"{acquire!r}.",
))
if entry.get('crop') != crop_policy:
self._illustration_issues.append((
'error',
'locked_image_crop_mismatch',
f"{filename} spec_lock crop={entry.get('crop')!r} "
f"does not match Design Spec §VIII Crop Policy "
f"{crop_policy!r}.",
))
if not self._layout_projection_matches(
entry.get('pattern', ''),
layout_pattern,
):
self._illustration_issues.append((
'error',
'locked_image_pattern_mismatch',
f"{filename} spec_lock pattern="
f"{entry.get('pattern')!r} does not preserve the "
"Design Spec §VIII Layout pattern recommendation "
f"{layout_pattern!r}. This Design Spec-to-spec_lock "
"projection check compares ordered catalog ids when "
"present, otherwise normalized text; it does not "
"compare SVG geometry or restrict the Executor's "
"realization.",
))
candidate_paths: List[Path] = []
for entry in entries:
raw_path = entry.get('path', '')
if not raw_path:
continue
lock_path = Path(raw_path)
legacy_bare_filename = (
not lock_path.is_absolute()
and raw_path not in {'.', '..'}
and '/' not in raw_path
and '\\' not in raw_path
and ':' not in raw_path
)
if lock_path.is_absolute():
path = lock_path
elif legacy_bare_filename:
path = project_path / 'images' / raw_path
else:
path = project_path / raw_path
resolved_path = path.resolve()
try:
resolved_path.relative_to(project_root)
except ValueError:
self._illustration_issues.append((
'error',
'locked_image_path_outside_project',
f"{filename} lock path {entry['path']!r} resolves "
"outside the project workspace.",
))
continue
candidate_paths.append(resolved_path)
distinct_candidate_paths = set(candidate_paths)
if len(distinct_candidate_paths) > 1:
shown = ', '.join(
str(path.relative_to(project_root))
for path in sorted(distinct_candidate_paths)
)
self._illustration_issues.append((
'error',
'locked_image_ambiguous_paths',
f"{filename} resolves to multiple locked project paths: "
f"{shown}. One resource must have one authoritative asset.",
))
expected_paths = {
path
for path in distinct_candidate_paths
if path.is_file()
}
asset_exists = bool(expected_paths)
file_required = status in {
'existing',
'generated',
'sourced',
'rendered',
}
if not asset_exists and file_required:
expected = entries[0].get('path') or f"images/{filename}"
self._illustration_issues.append((
'error',
'locked_image_file_missing',
f"{filename} is locked and has terminal Status "
f"{row.get('Status', '').strip()!r}, but {expected} "
"does not exist.",
))
actual_paths = referenced_paths.get(filename, set())
unexpected_paths = actual_paths - expected_paths
if unexpected_paths:
shown = ', '.join(
str(path.relative_to(project_root))
if path.is_relative_to(project_root)
else str(path)
for path in sorted(unexpected_paths)
)
self._illustration_issues.append((
'error',
'locked_image_reference_mismatch',
f"{filename} is referenced from {shown}, not exclusively "
"from its locked project path.",
))
should_be_referenced = (
acquire != 'placeholder'
and asset_exists
and status
in {
'existing',
'generated',
'sourced',
'rendered',
'needs-manual',
}
)
if should_be_referenced and not (actual_paths & expected_paths):
self._illustration_issues.append((
'error',
'locked_image_unreferenced',
f"{filename} has usable terminal content but its locked "
"file is not referenced by any svg_output <image> element.",
))
effective_no_crop = (
crop_policy == 'no-crop'
or acquire == 'formula'
or any(entry.get('crop') == 'no-crop' for entry in entries)
)
if effective_no_crop:
placements_by_svg: Dict[
Path,
List[Tuple[str, Tuple[str, ...]]],
] = defaultdict(list)
for svg_path, raw_aspect, mechanisms in image_placements.get(
filename,
[],
):
placements_by_svg[svg_path].append((
raw_aspect,
mechanisms,
))
for svg_path, placements in placements_by_svg.items():
parsed_placements = []
for raw_aspect, mechanisms in placements:
try:
align, mode = (
_parse_project_image_aspect_ratio(raw_aspect or None)
if _parse_project_image_aspect_ratio is not None
else ('', '')
)
except ValueError:
# The per-SVG aspect-ratio validator owns malformed syntax.
continue
parsed_placements.append((
raw_aspect,
mechanisms,
align,
mode,
))
has_complete_placement = any(
align != 'none'
and mode == 'meet'
and not mechanisms
for _raw_aspect, mechanisms, align, mode
in parsed_placements
)
for raw_aspect, _mechanisms, align, _mode in parsed_placements:
if align != 'none':
continue
actual = raw_aspect or '(implicit xMidYMid meet)'
self._illustration_issues.append((
'error',
'no_crop_image_fit_mismatch',
f"{svg_path.name}: {filename} is no-crop but its "
f"rendered placement uses "
f"preserveAspectRatio={actual!r}; stretching is not "
"a detail crop and remains forbidden.",
))
if has_complete_placement:
continue
for raw_aspect, mechanisms, align, mode in parsed_placements:
if align != 'none' and mode != 'meet':
actual = raw_aspect or '(implicit xMidYMid meet)'
self._illustration_issues.append((
'error',
'no_crop_image_fit_mismatch',
f"{svg_path.name}: {filename} is no-crop but "
"this page has no complete placement and uses "
f"preserveAspectRatio={actual!r}; keep at least "
"one unclipped placement with a legal alignment "
"anchor and meet.",
))
if mechanisms:
self._illustration_issues.append((
'error',
'no_crop_image_clipped',
f"{svg_path.name}: {filename} is no-crop but "
"this page has no complete placement; its "
"rendered placement is affected by "
f"{', '.join(mechanisms)}. Keep at least one "
"unclipped meet placement so every source pixel "
"remains visible.",
))
self._check_sourced_image_provenance(
rows_by_filename,
project_path,
)
def _check_sourced_image_provenance(
self,
rows_by_filename: Dict[str, Dict[str, str]],
project_path: Path,
) -> None:
"""Require one valid provenance item for every Sourced web row."""
sourced = {
filename: row
for filename, row in rows_by_filename.items()
if self._row_acquire(row) == 'web'
and self._row_status(row) == 'sourced'
}
if not sourced:
return
manifest_path = project_path / 'images' / 'image_sources.json'
if not manifest_path.exists():
self._illustration_issues.append((
'error',
'image_sources_missing',
"Sourced web images are used, but "
"images/image_sources.json does not exist.",
))
return
payload, error = self._read_image_sources_manifest(manifest_path)
if error:
if manifest_path not in self._source_manifest_errors_reported:
self._illustration_issues.append((
'error',
'image_sources_invalid',
error,
))
self._source_manifest_errors_reported.add(manifest_path)
return
manifest_items = {
str(item.get('filename') or ''): item
for item in payload['items']
if item.get('filename')
}
valid_tiers = {
'no-attribution',
'attribution-required',
'manual',
}
for filename in sourced:
item = manifest_items.get(filename)
if item is None:
self._illustration_issues.append((
'error',
'sourced_image_missing_provenance',
f"{filename} is Sourced but has no matching entry in "
"images/image_sources.json.",
))
continue
tier = str(item.get('license_tier') or '').strip()
if tier not in valid_tiers:
self._illustration_issues.append((
'error',
'sourced_image_invalid_license_tier',
f"{filename} has invalid license_tier {tier!r} in "
"images/image_sources.json.",
))
if tier != 'manual' and not str(
item.get('attribution_text') or ''
).strip():
self._illustration_issues.append((
'error',
'sourced_image_missing_attribution_text',
f"{filename} has license_tier {tier!r} but no "
"attribution_text in images/image_sources.json.",
))
if tier == 'attribution-required' and not str(
item.get('author') or ''
).strip():
self._illustration_issues.append((
'error',
'sourced_image_missing_author',
f"{filename} requires attribution but has no author in "
"images/image_sources.json.",
))
def _check_animation_config_contract(self, dir_path: Path) -> None:
"""Project-level animations.json reference checks."""
project_path = self._resolve_project_path(dir_path)
config_path = project_path / 'animations.json'
if (
_load_animation_config is None
or _validate_animation_config is None
or _validate_animation_config_errors is None
or _validate_transition_config is None
):
if config_path.is_file():
detail = _animation_config_import_error or 'unknown import error'
self._animation_issues.append((
'error',
f'animations.json validation is unavailable: {detail}',
))
return
try:
config = _load_animation_config(project_path)
except Exception as exc:
self._animation_issues.append(('error', f"animations.json is invalid: {exc}"))
return
if not config:
return
fatal_errors = list(dict.fromkeys(
_validate_transition_config(config)
+ _validate_animation_config_errors(config)
))
for error in fatal_errors:
self._animation_issues.append(('error', error))
for message in _validate_animation_config(project_path, config):
severity = (
'warning'
if ' has no id and cannot be customized in animations.json' in message
else 'error'
)
self._animation_issues.append((severity, message))
def _check_template_contract(
self,
dir_path: Path,
svg_files: List[Path],
*,
check_structure: bool,
) -> None:
"""Check reusable-template structure, roster, and placeholder hints.
- **Roster mismatch (orphan / missing)** is reported as an *error*: a
stale roster will produce a wrong ``layouts_index.json`` entry.
- **Explicit structure gaps** are errors when positive structure checks
are enabled: every current reusable SVG declares its Master and Layout
identity. Zero-placeholder Layouts are valid. Legacy template-mode
packages fail and must be replaced by a new create-template workspace.
- **Placeholder gaps** are reported as *warnings*. Templates may
legitimately omit conventional placeholders or swap them out (e.g.
``{{CLOSING_MESSAGE}}`` instead of ``{{THANK_YOU}}``), and a content
variant may use a bespoke slot vocabulary. Designers can declare
their own per-stem expectations via ``placeholders:`` frontmatter
in ``design_spec.md`` to suppress these warnings explicitly.
Issues are aggregated and printed in :py:meth:`print_summary` so the
per-file report stays focused on intrinsic SVG validity.
"""
spec_path = dir_path / 'design_spec.md'
spec_text = spec_path.read_text(encoding='utf-8') if spec_path.exists() else ""
declared_structure_mode = _declared_template_structure_mode(dir_path)
mode_error_recorded = False
if declared_structure_mode != 'structured':
mode_error_recorded = True
self._template_issues.append((
'error',
'explicit_structure_mode',
"design_spec.md frontmatter must declare "
"native_structure_mode: structured; legacy template-mode "
"workspaces must be re-created through create-template",
))
if check_structure:
native_contract_path = dir_path / 'native_structure.json'
source_template_path = dir_path / 'source_template.pptx'
legacy_structure_detected = False
for svg_file in svg_files:
try:
root = ET.parse(svg_file).getroot()
except (OSError, ET.ParseError):
continue
if not root.get('data-pptx-master'):
legacy_structure_detected = True
self._template_issues.append((
'error',
'explicit_master_missing',
f"{svg_file.name}: reusable templates require root "
"data-pptx-master metadata",
))
if not root.get('data-pptx-master-name'):
legacy_structure_detected = True
self._template_issues.append((
'error',
'explicit_master_name_missing',
f"{svg_file.name}: reusable templates require root "
"data-pptx-master-name metadata",
))
if not root.get('data-pptx-layout'):
self._template_issues.append((
'error',
'explicit_structure_missing',
f"{svg_file.name}: reusable templates require root "
"data-pptx-layout metadata",
))
if not root.get('data-pptx-layout-name'):
self._template_issues.append((
'error',
'explicit_structure_name_missing',
f"{svg_file.name}: reusable templates require root "
"data-pptx-layout-name metadata",
))
if root.get('data-pptx-layout-kind') is not None:
legacy_structure_detected = True
self._template_issues.append((
'error',
'deck_instance_layout_kind',
f"{svg_file.name}: reusable template prototypes must omit "
"legacy data-pptx-layout-kind metadata",
))
if any(
child.get('data-pptx-placeholder') is not None
and child.tag.rsplit('}', 1)[-1] != 'g'
for child in list(root)
):
legacy_structure_detected = True
missing_bounds = [
child.get('id') or child.tag.rsplit('}', 1)[-1]
for child in list(root)
if child.get('data-pptx-placeholder') is not None
and child.get('data-pptx-bounds') is None
]
if missing_bounds:
legacy_structure_detected = True
self._template_issues.append((
'error',
'placeholder_bounds_missing',
f"{svg_file.name}: reusable templates require "
"explicit design-zone data-pptx-bounds; missing: "
+ ', '.join(missing_bounds),
))
if native_contract_path.exists() or source_template_path.exists():
legacy_structure_detected = True
self._template_issues.append((
'error',
'legacy_native_structure_pair',
"legacy native_structure.json/source_template.pptx template "
"contracts must be replaced through "
"skills/ppt-master/workflows/create-template.md",
))
if declared_structure_mode != 'structured':
legacy_structure_detected = True
if not mode_error_recorded:
self._template_issues.append((
'error',
'explicit_structure_mode',
"design_spec.md frontmatter must declare "
"native_structure_mode: structured",
))
if legacy_structure_detected:
self._template_issues.append((
'error',
'legacy_structure_contract',
"legacy template structure detected; create a new current "
"workspace through skills/ppt-master/workflows/"
"create-template.md before Step 3 consumption",
))
spec_pages = self._extract_spec_roster(spec_text) if spec_text else []
custom_contract = self._extract_frontmatter_placeholders(spec_text) if spec_text else {}
on_disk = {p.stem for p in svg_files}
if spec_pages:
spec_set = set(spec_pages)
orphan = sorted(on_disk - spec_set)
missing = sorted(spec_set - on_disk)
for page in orphan:
self._template_issues.append((
'error',
'roster_orphan',
f"{page}.svg exists on disk but is not listed in design_spec.md Page Roster",
))
for page in missing:
self._template_issues.append((
'error',
'roster_missing',
f"design_spec.md Page Roster lists {page} but {page}.svg is missing on disk",
))
elif spec_path.exists():
# design_spec.md is present but the roster parser found nothing —
# reusable template workspaces always fail closed.
self._template_issues.append((
'error',
'roster_unknown',
f"could not extract page roster from {spec_path.name}; "
"skipping orphan/missing checks",
))
else:
self._template_issues.append((
'error',
'spec_missing',
f"{spec_path.name} not found — required for every library template",
))
# Per-file placeholder coverage. Variants reuse the parent type's set
# (e.g. 03a_content_two_col.svg ↔ 03_content rules) unless the spec
# frontmatter overrides that page (custom_contract takes precedence).
for svg_file in svg_files:
expected = self._lookup_template_contract(
svg_file.stem, overrides=custom_contract,
)
if expected is None:
continue # extension pages or stems with no convention
try:
content = svg_file.read_text(encoding='utf-8')
except OSError:
continue
for placeholder in expected:
if placeholder not in content:
self._template_issues.append((
'warning',
'placeholder_hint',
f"{svg_file.name}: missing conventional placeholder {placeholder} "
"(declare 'placeholders:' frontmatter in design_spec.md to silence)",
))
@staticmethod
def _extract_frontmatter_placeholders(spec_text: str) -> Dict[str, Tuple[str, ...]]:
"""Read the optional ``placeholders:`` map from design_spec.md frontmatter.
Shape:
.. code-block:: yaml
placeholders:
01_cover: ["{{TITLE}}", "{{BRAND_LOGO}}"]
03_content: [] # explicitly assert "no expectation"
03a_content_two_col: # variant-specific override
- "{{LEFT_TITLE}}"
- "{{RIGHT_TITLE}}"
Each key is a stem (full filename without ``.svg``) or page-type prefix
(``01_cover``). An empty list silences the default convention for that
stem; a populated list replaces the default. Stems / prefixes not
listed fall back to ``DEFAULT_PLACEHOLDER_CONVENTION``.
We parse with PyYAML when available; otherwise we fall back to a
minimal regex that handles the documented shape.
"""
if not spec_text.startswith("---\n"):
return {}
end = spec_text.find("\n---\n", 4)
if end == -1:
return {}
block = spec_text[4:end]
try:
import yaml # type: ignore
except ImportError:
return _parse_placeholders_fallback(block)
try:
data = yaml.safe_load(block) or {}
except yaml.YAMLError:
return {}
if not isinstance(data, dict):
return {}
raw = data.get("placeholders")
if not isinstance(raw, dict):
return {}
out: Dict[str, Tuple[str, ...]] = {}
for stem, value in raw.items():
if not isinstance(stem, str):
continue
if isinstance(value, list):
out[stem] = tuple(str(v) for v in value)
elif value is None:
out[stem] = ()
return out
@staticmethod
def _extract_spec_roster(spec_text: str) -> List[str]:
"""Best-effort: extract the page roster from design_spec.md.
Templates do not share a uniform section index for the roster — the
personality-only skeleton puts it at §V "Page Roster"; legacy specs use
§VI "Page Roster" or bury filenames under §VII "Page Types" as
``### N. Cover Page (01_cover.svg)``. We match by title (any roman
index), then fall back to scanning the whole document for any
backtick-wrapped ``<stem>.svg`` reference.
Returns the deduplicated stem list in document order. Empty result
means we can't determine the roster confidently — caller should treat
that as "skip orphan/missing checks", not as "no pages declared".
"""
# Pass 1: explicit roster section, any roman numeral.
sections = list(re.finditer(
r"^##\s+[IVX]+\.\s+(?:(?:SVG\s+)?Page Roster|Page Structure|Pages|Page Types)\b.*?(?=^##\s+|\Z)",
spec_text,
re.MULTILINE | re.DOTALL | re.IGNORECASE,
))
roster_scope = next(
(
section.group(0)
for section in sections
if re.match(
r"^##\s+[IVX]+\.\s+(?:SVG\s+)?Page Roster\b",
section.group(0),
re.IGNORECASE,
)
),
None,
)
scope = roster_scope or next(
(
section.group(0)
for section in sections
if re.search(r"[`\(][0-9A-Za-z_]+\.svg[`\)]", section.group(0))
),
sections[0].group(0) if sections else None,
)
# Pass 2: full document. We *only* trust this scan when the explicit
# roster scan came up empty (no `<stem>.svg` references inside it) —
# otherwise the explicit section's deliberate roster wins over loose
# mentions elsewhere.
explicit_scope = bool(
scope and re.search(r"[`\(][0-9A-Za-z_]+\.svg[`\)]", scope)
)
if explicit_scope:
text = scope
else:
text = spec_text
stems: List[str] = []
seen: set = set()
# Accept backtick-quoted (`01_cover.svg`) and parenthesized
# (01_cover.svg) forms — existing specs use either.
svg_ref_re = re.compile(r"[`\(]([0-9A-Za-z_]+\.svg)[`\)]")
for match in svg_ref_re.finditer(text):
stem = match.group(1)[:-4]
if stem in seen or (not explicit_scope and not re.match(r"^\d", stem)):
continue
seen.add(stem)
stems.append(stem)
# If the explicit §VI scan listed bare stems (without .svg), accept
# those as fallback — but only when they were inside that section.
if not stems and scope:
for match in re.finditer(r"`([0-9]{2}[a-z]?_[A-Za-z0-9_]+)`", scope):
stem = match.group(1)
if stem in seen:
continue
seen.add(stem)
stems.append(stem)
return stems
@classmethod
def _lookup_template_contract(
cls, stem: str, *,
overrides: Dict[str, Tuple[str, ...]] | None = None,
) -> Tuple[str, ...] | None:
"""Resolve a SVG stem to its expected placeholder set.
Resolution order, first hit wins:
1. ``overrides[stem]`` — frontmatter entry for the exact filename
2. ``overrides[<page_type_prefix>]`` — frontmatter entry for the
variant's parent type (e.g. ``03_content`` for
``03a_content_two_col``)
3. ``DEFAULT_PLACEHOLDER_CONVENTION[<page_type>]`` — keyed by the
type token alone, so it applies regardless of where the type
lands in the template's presentation-order numbering
Returns ``None`` for stems with no matching convention or override —
e.g. extension pages like ``05_section_break``. ``()`` (empty tuple)
is a valid value meaning "no expected placeholders" — used to
explicitly silence the default convention.
"""
overrides = overrides or {}
if stem in overrides:
return overrides[stem]
# Variant convention: <NN><letter>?_<rest>; strip the letter to find
# the parent type prefix, e.g. "03a_content_two_col" -> "03_content".
match = re.match(r"^(\d{2})([a-z])?_([a-z]+)", stem)
if not match:
return None
num, _letter, kind = match.groups()
key = f"{num}_{kind}"
if key in overrides:
return overrides[key]
return cls.DEFAULT_PLACEHOLDER_CONVENTION.get(kind)
def _print_result(self, result: Dict):
"""Print check result for a single file"""
if result['passed']:
if result['warnings']:
icon = "[WARN]"
status = "Passed (with warnings)"
else:
icon = "[OK]"
status = "Passed"
else:
icon = "[ERROR]"
status = "Failed"
print(f"{icon} {result['file']} - {status}")
# Display basic info
if result['info']:
info_items = []
if 'viewbox' in result['info']:
info_items.append(f"viewBox: {result['info']['viewbox']}")
if info_items:
print(f" {' | '.join(info_items)}")
# Display errors
if result['errors']:
for error in result['errors']:
print(f" [ERROR] {error}")
# Display the complete warning set from this run. The generation
# workflow reviews all findings before one consolidated repair pass.
if result['warnings']:
for warning in result['warnings']:
print(f" [WARN] {warning}")
print()
def print_summary(self):
"""Print check summary"""
self._apply_aggregated_issue_counts()
print("=" * 80)
print("[SUMMARY] Check Summary")
print("=" * 80)
print(f"\nTotal files: {self.summary['total']}")
print(
f" [OK] Fully passed: {self.summary['passed']} ({self._percentage(self.summary['passed'])}%)")
print(
f" [WARN] With warnings: {self.summary['warnings']} ({self._percentage(self.summary['warnings'])}%)")
print(
f" [ERROR] With errors: {self.summary['errors']} ({self._percentage(self.summary['errors'])}%)")
self._print_provenance_category_summary()
if self.issue_types:
print(f"\nIssue categories:")
for issue_type, count in sorted(self.issue_types.items(), key=lambda x: x[1], reverse=True):
print(f" {issue_type}: {count}")
# spec_lock anchor comparison (only printed when a lock was found)
self._print_anchor_value_summary()
# Template-mode aggregation (orphan/missing roster + placeholder hints)
self._print_template_summary()
# Animation config aggregation.
self._print_animation_summary()
# Illustration strategy aggregation.
self._print_illustration_summary()
# Communication contract and per-page audience movement.
self._print_communication_trace_summary()
# Explicit PowerPoint master/layout structure aggregation.
self._print_pptx_structure_summary()
# Source-owned import recovery belongs to the template, not this run.
self._print_source_import_summary()
# Fix suggestions
if self.summary['errors'] > 0 or self.summary['warnings'] > 0:
print(f"\n[TIP] Common fixes:")
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;")
print(f" 2. viewBox issues: root viewBox is the canvas authority (see references/canvas-formats.md)")
print(
" 3. Paint recommendation: generated SVG prefers uppercase "
"#RRGGBB plus channel-specific opacity; compatible alternatives "
"remain non-blocking"
)
print(f" 4. foreignObject: Use <text> + <tspan> for manual line breaks")
print(f" 5. Font issues: use PPT-safe exported typefaces (e.g. Microsoft YaHei / Arial / Consolas)")
def _print_provenance_category_summary(self):
"""Print compact JSON-equivalent counts for token-safe gate handling."""
categories = self._provenance_categories()
rows = (
(
'blocking',
len(categories['blocking']),
'hard findings; gate also requires exit 0',
),
(
'introduced',
len(categories['introduced']),
'advisory; new or changed',
),
(
'inherited',
len(categories['inherited']),
'informational; prototype-identical',
),
(
'source-import',
_source_import_warning_count(categories['source_import']),
'informational; source-conversion loss',
),
)
print("\nProvenance categories:")
for name, count, note in rows:
print(f" {f'{name}: {count}':<20} {note}")
def _print_animation_summary(self):
"""Print animations.json validation issues if present."""
if not self._animation_issues:
return
errors = [item for item in self._animation_issues if item[0] == 'error']
warnings = [item for item in self._animation_issues if item[0] == 'warning']
print("\n[ANIMATION] animations.json checks")
for _severity, msg in errors:
print(f" [ERROR] {msg}")
for _severity, msg in warnings:
print(f" [WARN] {msg}")
def _print_illustration_summary(self):
"""Print project-level illustration strategy issues if present."""
if not self._illustration_issues:
return
errors = [item for item in self._illustration_issues if item[0] == 'error']
warnings = [item for item in self._illustration_issues if item[0] == 'warning']
print("\n[IMAGES] Image resource checks")
if errors:
print(f" Errors ({len(errors)}):")
for _severity, kind, msg in errors:
print(f" [{kind}] {msg}")
if warnings:
print(f" Warnings ({len(warnings)}):")
for _severity, kind, msg in warnings:
print(f" [{kind}] {msg}")
def _print_pptx_structure_summary(self):
"""Print project-level PowerPoint structure contract issues."""
if not self._pptx_structure_issues:
return
print("\n[PPTX STRUCTURE] Master/layout contract checks")
for severity, message in self._pptx_structure_issues:
print(f" [{severity.upper()}] {message}")
def _print_communication_trace_summary(self):
"""Print project-level communication trace issues."""
if not self._communication_trace_issues:
return
print("\n[COMMUNICATION TRACE] Contract and Audience move checks")
for severity, message in self._communication_trace_issues:
print(f" [{severity.upper()}] {message}")
def _print_source_import_summary(self):
"""Print source-owned tolerant-import diagnostics as information."""
warning_count = _source_import_warning_count(
self._source_import_summary
)
if warning_count <= 0:
return
print("\n[SOURCE IMPORT] Template-owned compatibility diagnostics")
print(
f" [INFO] {warning_count} source-import warning(s); unchanged "
"template recovery is not attributed to generated content."
)
by_code = self._source_import_summary.get('by_code')
if isinstance(by_code, dict):
for code, count in sorted(by_code.items()):
print(f" {code}: {count}")
def _print_template_summary(self):
"""Aggregate template-mode roster / placeholder issues at the bottom.
Errors land under the ``errors`` summary count (so the exit signal
from ``main`` agrees), warnings under ``warnings``. Both are listed
per file so the user can act on them directly.
"""
if not self._template_issues and self._spec_only_template_kind is None:
return
errors = [item for item in self._template_issues if item[0] == 'error']
warnings = [item for item in self._template_issues if item[0] == 'warning']
print("\n[TEMPLATE] Template mode checks")
if errors:
print(f" Errors ({len(errors)}):")
for _sev, kind, msg in errors:
print(f" [{kind}] {msg}")
if warnings:
print(f" Warnings ({len(warnings)}):")
for _sev, kind, msg in warnings:
print(f" [{kind}] {msg}")
if self._spec_only_template_kind is not None and not errors:
pretty_kind = self._spec_only_template_kind.title()
print(f" {pretty_kind} design_spec.md contract passed.")
if not errors:
if self._spec_only_template_kind is None:
print(" No structural roster issues.")
print(" Conventional placeholder-name hints may be declared through "
"'placeholders:' frontmatter. Placeholder bounds are mandatory "
"design-zone metadata.")
def _apply_aggregated_issue_counts(self):
"""Mirror project-level aggregate issues into summary counters once."""
if self._aggregate_counts_applied:
return
self._aggregate_counts_applied = True
animation_errors = [item for item in self._animation_issues if item[0] == 'error']
animation_warnings = [item for item in self._animation_issues if item[0] == 'warning']
self.summary['errors'] += len(animation_errors)
self.summary['warnings'] += len(animation_warnings)
for severity, _msg in self._animation_issues:
self.issue_types[f'animation_config_{severity}'] += 1
template_errors = [item for item in self._template_issues if item[0] == 'error']
template_warnings = [item for item in self._template_issues if item[0] == 'warning']
self.summary['errors'] += len(template_errors)
self.summary['warnings'] += len(template_warnings)
for severity, kind, _msg in self._template_issues:
self.issue_types[f'template_{kind}_{severity}'] += 1
illustration_errors = [item for item in self._illustration_issues if item[0] == 'error']
illustration_warnings = [item for item in self._illustration_issues if item[0] == 'warning']
self.summary['errors'] += len(illustration_errors)
self.summary['warnings'] += len(illustration_warnings)
for severity, kind, _msg in self._illustration_issues:
self.issue_types[f'illustration_{kind}_{severity}'] += 1
communication_errors = [
item for item in self._communication_trace_issues
if item[0] == 'error'
]
communication_warnings = [
item for item in self._communication_trace_issues
if item[0] == 'warning'
]
self.summary['errors'] += len(communication_errors)
self.summary['warnings'] += len(communication_warnings)
for severity, _msg in self._communication_trace_issues:
self.issue_types[f'communication_trace_{severity}'] += 1
structure_errors = [item for item in self._pptx_structure_issues if item[0] == 'error']
structure_warnings = [item for item in self._pptx_structure_issues if item[0] == 'warning']
self.summary['errors'] += len(structure_errors)
self.summary['warnings'] += len(structure_warnings)
for severity, _msg in self._pptx_structure_issues:
self.issue_types[f'pptx_structure_{severity}'] += 1
def _print_anchor_value_summary(self):
"""Print anchor comparisons without treating contextual paint/type as drift."""
if not self._lock_seen:
return
has_contextual = any(
self._anchor_value_summary[category]
for category in ('colors', 'fonts')
)
has_undeclared_sizes = bool(self._anchor_value_summary['sizes'])
if not has_contextual and not has_undeclared_sizes:
print(
"\n[OK] spec_lock anchor comparison: no additional contextual "
"colors/fonts or out-of-band font sizes"
)
return
if has_contextual:
print("\nContextual values beyond spec_lock anchors (informational):")
for category, label in (
('colors', 'Colors'),
('fonts', 'Font families'),
):
items = self._anchor_value_summary.get(category, {})
if not items:
continue
entries = sorted(
items.items(), key=lambda item: (-len(item[1]), item[0])
)
print(f" {label}:")
for val, files in entries:
count = len(files)
suffix = "file" if count == 1 else "files"
print(f" {val} ({count} {suffix})")
print(
"Note: contextual page paint, gradient/effect colors, and "
"export-safe typefaces are allowed.\n"
" Add a spec_lock row only when a value becomes a "
"recurring named semantic role."
)
if has_undeclared_sizes:
print(
"\nTypography sizes outside every declared role anchor ±2px "
"(up to 2 occurrences are sparse; the 3rd is recurring):"
)
entries = sorted(
self._anchor_value_summary['sizes'].items(),
key=lambda item: (-len(item[1]), item[0]),
)
for val, files in entries:
occurrences = self._undeclared_size_occurrences.get(
val,
len(files),
)
file_count = len(files)
file_suffix = "file" if file_count == 1 else "files"
policy = (
"sparse"
if occurrences <= SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES
else "recurring — declare a role"
)
print(
f" {val} ({occurrences} occurrences in {file_count} "
f"{file_suffix}; {policy})"
)
def _percentage(self, count: int) -> int:
"""Calculate percentage"""
if self.summary['total'] == 0:
return 0
return min(100, int(count / self.summary['total'] * 100))
def export_report(self, output_file: str = 'svg_quality_report.txt'):
"""Export check report"""
with open(output_file, 'w', encoding='utf-8') as f:
f.write("PPT Master SVG Quality Check Report\n")
f.write("=" * 80 + "\n\n")
for result in self.results:
status = "[OK] Passed" if result['passed'] else "[ERROR] Failed"
f.write(f"{status} - {result['file']}\n")
f.write(f"Path: {result.get('path', 'N/A')}\n")
if result['info']:
f.write(f"Info: {result['info']}\n")
if result['errors']:
f.write(f"\nErrors:\n")
for error in result['errors']:
f.write(f" - {error}\n")
if result['warnings']:
f.write(f"\nWarnings:\n")
for warning in result['warnings']:
f.write(f" - {warning}\n")
f.write("\n" + "-" * 80 + "\n\n")
# Write summary
f.write("\n" + "=" * 80 + "\n")
f.write("Check Summary\n")
f.write("=" * 80 + "\n\n")
f.write(f"Total files: {self.summary['total']}\n")
f.write(f"Fully passed: {self.summary['passed']}\n")
f.write(f"With warnings: {self.summary['warnings']}\n")
f.write(f"With errors: {self.summary['errors']}\n")
print(f"\n[REPORT] Check report exported: {output_file}")
def _provenance_categories(self) -> Dict[str, object]:
"""Classify every issue by provenance.
Single source for the JSON report's ``categories`` block and the
terminal summary, so the console and the report never disagree about
what blocks a release export.
"""
self._apply_aggregated_issue_counts()
introduced: List[Dict[str, str]] = []
blocking: List[Dict[str, str]] = []
inherited: List[Dict[str, str]] = []
for result in self.results:
filename = str(result.get('file') or '')
introduced.extend({
'file': filename,
'message': warning,
} for warning in result.get('warnings', []))
blocking.extend({
'file': filename,
'message': error,
} for error in result.get('errors', []))
info = result.get('info') or {}
for item in info.get('inherited', []):
if isinstance(item, dict):
inherited.append({
'file': filename,
'kind': str(item.get('kind') or 'prototype'),
'message': str(item.get('message') or ''),
})
project_issues = {
'template': [
{'severity': severity, 'kind': kind, 'message': message}
for severity, kind, message in self._template_issues
],
'animation': [
{'severity': severity, 'message': message}
for severity, message in self._animation_issues
],
'illustration': [
{'severity': severity, 'kind': kind, 'message': message}
for severity, kind, message in self._illustration_issues
],
'communication_trace': [
{'severity': severity, 'message': message}
for severity, message in self._communication_trace_issues
],
'pptx_structure': [
{'severity': severity, 'message': message}
for severity, message in self._pptx_structure_issues
],
}
for group, issues in project_issues.items():
for issue in issues:
item = {
'scope': group,
'message': issue['message'],
}
if issue['severity'] == 'error':
blocking.append(item)
else:
introduced.append(item)
return {
'blocking': blocking,
'introduced': introduced,
'inherited': inherited,
'project_issues': project_issues,
'source_import': dict(self._source_import_summary),
}
def export_json_report(
self,
output_file: str,
*,
target: str,
stage: str,
) -> None:
"""Write a machine-readable quality report with provenance classes."""
categories = self._provenance_categories()
blocking = categories['blocking']
introduced = categories['introduced']
inherited = categories['inherited']
project_issues = categories['project_issues']
# Keep the legacy `drift` JSON field for report compatibility. Its
# colors/fonts entries are informational anchor comparisons; sparse
# size entries are informational until their third occurrence.
drift = {
category: {
value: sorted(files)
for value, files in sorted(values.items())
}
for category, values in self._anchor_value_summary.items()
}
source_import = categories['source_import']
payload = {
'schema': 'ppt-master.svg-quality-report.v1',
'stage': stage,
'target': str(Path(target).resolve()),
'source_fingerprint': _quality_source_fingerprint(self.results),
'summary': dict(self.summary),
'issue_types': dict(sorted(self.issue_types.items())),
'categories': {
'blocking': {
'count': len(blocking),
'issues': blocking,
},
'introduced': {
'count': len(introduced),
'issues': introduced,
},
'inherited': {
'count': len(inherited),
'issues': inherited,
},
'source-import': {
'count': _source_import_warning_count(source_import),
'summary': source_import,
},
},
'drift': drift,
'project_issues': project_issues,
'files': self.results,
}
report_path = Path(output_file)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + '\n',
encoding='utf-8',
)
print(f"\n[REPORT] JSON quality report exported: {report_path}")
def _source_import_warning_count(summary: Dict[str, object]) -> int:
"""Return only a schema-compatible non-negative warning count."""
value = summary.get('warning_count')
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return 0
return value
def _quality_source_fingerprint(results: List[Dict]) -> Dict[str, object]:
"""Bind a quality report to the exact SVG bytes that were checked."""
files: List[Dict[str, object]] = []
aggregate = hashlib.sha256()
candidates = sorted(
(
result
for result in results
if result.get('exists') and result.get('path')
),
key=lambda result: Path(str(result['path'])).name,
)
for result in candidates:
path = Path(str(result['path']))
file_sha256 = result.get('source_sha256')
if not isinstance(file_sha256, str):
files.append({
'file': path.name,
'sha256': None,
'error': 'source bytes were not available during validation',
})
file_sha256 = 'unreadable'
else:
files.append({'file': path.name, 'sha256': file_sha256})
aggregate.update(path.name.encode('utf-8'))
aggregate.update(b'\0')
aggregate.update(file_sha256.encode('ascii'))
aggregate.update(b'\n')
return {
'algorithm': 'sha256',
'digest': aggregate.hexdigest(),
'file_count': len(files),
'files': files,
}