7385 lines
286 KiB
Python
7385 lines
286 KiB
Python
#!/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 ( — ©…) 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 & < > " ' "
|
||
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 & < > " ' — never use HTML named entities like — ©")
|
||
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,
|
||
}
|