Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
"""svg_to_pptx — SVG to PPTX conversion package.
|
||||
|
||||
Public API:
|
||||
- main(): CLI entry point
|
||||
- convert_svg_to_slide_shapes(): SVG -> DrawingML slide XML
|
||||
- create_pptx_with_native_svg(): Build PPTX from SVG files
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
'main',
|
||||
'convert_svg_to_slide_shapes',
|
||||
'create_pptx_with_native_svg',
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Load public entry points lazily to keep low-level imports acyclic."""
|
||||
if name == 'main':
|
||||
from .pptx_package.cli import main
|
||||
|
||||
value = main
|
||||
elif name == 'convert_svg_to_slide_shapes':
|
||||
from .drawingml.converter import convert_svg_to_slide_shapes
|
||||
|
||||
value = convert_svg_to_slide_shapes
|
||||
elif name == 'create_pptx_with_native_svg':
|
||||
from .pptx_package.builder import create_pptx_with_native_svg
|
||||
|
||||
value = create_pptx_with_native_svg
|
||||
else:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
globals()[name] = value
|
||||
return value
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
"""Project root-SVG canvas parsing and cross-file validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, localcontext
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
class CanvasContractError(ValueError):
|
||||
"""Raised when a root SVG canvas cannot map faithfully to one slide."""
|
||||
|
||||
|
||||
PPTX_SLIDE_EMU_MIN = 914400
|
||||
PPTX_SLIDE_EMU_MAX = 51206400
|
||||
|
||||
|
||||
_SVG_NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
|
||||
_VIEWBOX_RE = re.compile(
|
||||
rf"^\s*({_SVG_NUMBER})(?:\s*,\s*|\s+)"
|
||||
rf"({_SVG_NUMBER})(?:\s*,\s*|\s+)"
|
||||
rf"({_SVG_NUMBER})(?:\s*,\s*|\s+)"
|
||||
rf"({_SVG_NUMBER})\s*$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectViewBox:
|
||||
"""One validated zero-origin project canvas."""
|
||||
|
||||
width: Decimal
|
||||
height: Decimal
|
||||
|
||||
@property
|
||||
def values(self) -> tuple[Decimal, Decimal, Decimal, Decimal]:
|
||||
return Decimal(0), Decimal(0), self.width, self.height
|
||||
|
||||
@property
|
||||
def pixel_dimensions(self) -> tuple[float, float]:
|
||||
return float(self.width), float(self.height)
|
||||
|
||||
@property
|
||||
def emu_dimensions(self) -> tuple[int, int]:
|
||||
"""Quantize the SVG canvas to DrawingML's integer EMU dimensions."""
|
||||
width_emu, height_emu = self._scaled_emu_values()
|
||||
width = int(width_emu.to_integral_value(ROUND_HALF_UP))
|
||||
height = int(height_emu.to_integral_value(ROUND_HALF_UP))
|
||||
return width, height
|
||||
|
||||
def _scaled_emu_values(self) -> tuple[Decimal, Decimal]:
|
||||
precision = max(
|
||||
28,
|
||||
len(self.width.as_tuple().digits) + 8,
|
||||
len(self.height.as_tuple().digits) + 8,
|
||||
)
|
||||
with localcontext() as context:
|
||||
context.prec = precision
|
||||
return self.width * Decimal(9525), self.height * Decimal(9525)
|
||||
|
||||
@property
|
||||
def canonical(self) -> str:
|
||||
return f"0 0 {_format_decimal(self.width)} {_format_decimal(self.height)}"
|
||||
|
||||
@property
|
||||
def has_integer_dimensions(self) -> bool:
|
||||
return (
|
||||
self.width == self.width.to_integral_value()
|
||||
and self.height == self.height.to_integral_value()
|
||||
)
|
||||
|
||||
|
||||
def _format_decimal(value: Decimal) -> str:
|
||||
token = format(value, "f")
|
||||
if "." in token:
|
||||
token = token.rstrip("0").rstrip(".")
|
||||
return token or "0"
|
||||
|
||||
|
||||
def parse_project_viewbox(
|
||||
raw: str | None,
|
||||
*,
|
||||
context: str = "root viewBox",
|
||||
) -> ProjectViewBox:
|
||||
"""Parse the registered project root-viewBox subset.
|
||||
|
||||
Equivalent SVG numeric spellings are accepted so callers can distinguish
|
||||
compatible input from canonical authoring. Semantic values stay closed:
|
||||
the origin is exactly zero and dimensions map to positive DrawingML sizes.
|
||||
Fractional dimensions remain read-compatible because imported custom PPTX
|
||||
slide sizes are not necessarily whole CSS pixels.
|
||||
"""
|
||||
if raw is None or not raw.strip():
|
||||
raise CanvasContractError(f"{context} is required")
|
||||
|
||||
match = _VIEWBOX_RE.fullmatch(raw)
|
||||
if match is None:
|
||||
raise CanvasContractError(
|
||||
f"{context} must contain exactly four SVG numbers; got {raw!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
values = tuple(Decimal(token) for token in match.groups())
|
||||
except InvalidOperation as exc:
|
||||
raise CanvasContractError(
|
||||
f"{context} contains an invalid numeric value; got {raw!r}"
|
||||
) from exc
|
||||
if not all(value.is_finite() for value in values):
|
||||
raise CanvasContractError(
|
||||
f"{context} values must be finite; got {raw!r}"
|
||||
)
|
||||
|
||||
x, y, width, height = values
|
||||
if x != 0 or y != 0:
|
||||
raise CanvasContractError(
|
||||
f'{context} origin must be "0 0"; got {raw!r}'
|
||||
)
|
||||
if width <= 0 or height <= 0:
|
||||
raise CanvasContractError(
|
||||
f"{context} width and height must be positive; got {raw!r}"
|
||||
)
|
||||
viewbox = ProjectViewBox(width=width, height=height)
|
||||
require_powerpoint_slide_size(viewbox, context=context)
|
||||
return viewbox
|
||||
|
||||
|
||||
def read_project_viewbox(svg_path: str | Path) -> ProjectViewBox:
|
||||
"""Read and validate one file's root SVG viewBox."""
|
||||
path = Path(svg_path)
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
raise CanvasContractError(f"{path.name}: unable to parse root SVG: {exc}") from exc
|
||||
return parse_project_svg_root(
|
||||
root,
|
||||
context=path.name,
|
||||
)
|
||||
|
||||
|
||||
def parse_project_svg_root(
|
||||
root: ET.Element,
|
||||
*,
|
||||
context: str = "document",
|
||||
) -> ProjectViewBox:
|
||||
"""Validate one page root element and its project viewBox."""
|
||||
if root.tag.rsplit("}", 1)[-1] != "svg":
|
||||
raise CanvasContractError(f"{context}: root element must be <svg>")
|
||||
return parse_project_viewbox(
|
||||
root.get("viewBox"),
|
||||
context=f"{context} root viewBox",
|
||||
)
|
||||
|
||||
|
||||
def require_powerpoint_slide_size(
|
||||
viewbox: ProjectViewBox,
|
||||
*,
|
||||
context: str = "SVG canvas",
|
||||
) -> tuple[int, int]:
|
||||
"""Return EMU dimensions or reject values outside PowerPoint's range."""
|
||||
# Reject before converting to int so an adversarial exponent cannot force
|
||||
# construction of an enormous Python integer or fixed-point string.
|
||||
values = viewbox.width, viewbox.height
|
||||
outside_coarse_bound = any(
|
||||
value > Decimal(PPTX_SLIDE_EMU_MAX) for value in values
|
||||
)
|
||||
scaled = () if outside_coarse_bound else viewbox._scaled_emu_values()
|
||||
outside_emu_bound = outside_coarse_bound or not all(
|
||||
Decimal(PPTX_SLIDE_EMU_MIN) - Decimal("0.5")
|
||||
<= value
|
||||
< Decimal(PPTX_SLIDE_EMU_MAX) + Decimal("0.5")
|
||||
for value in scaled
|
||||
)
|
||||
if outside_emu_bound:
|
||||
dimensions = f"{viewbox.width} x {viewbox.height} px"
|
||||
raise CanvasContractError(
|
||||
f"{context} must map to PowerPoint's supported slide range "
|
||||
f"({PPTX_SLIDE_EMU_MIN}..{PPTX_SLIDE_EMU_MAX} EMU per side); "
|
||||
f"got {dimensions}"
|
||||
)
|
||||
return viewbox.emu_dimensions
|
||||
|
||||
|
||||
def require_consistent_project_viewboxes(
|
||||
svg_paths: Iterable[str | Path],
|
||||
*,
|
||||
expected_viewbox: str | None = None,
|
||||
expected_label: str = "expected canvas",
|
||||
) -> ProjectViewBox:
|
||||
"""Validate every public/internal SVG and return their shared canvas."""
|
||||
paths = [Path(path) for path in svg_paths]
|
||||
if not paths:
|
||||
raise CanvasContractError("at least one SVG is required to resolve the canvas")
|
||||
|
||||
errors: list[str] = []
|
||||
parsed: list[tuple[Path, ProjectViewBox]] = []
|
||||
for path in paths:
|
||||
try:
|
||||
parsed.append((path, read_project_viewbox(path)))
|
||||
except CanvasContractError as exc:
|
||||
errors.append(str(exc))
|
||||
if errors:
|
||||
details = "\n".join(f" - {error}" for error in errors)
|
||||
raise CanvasContractError("SVG canvas validation failed:\n" + details)
|
||||
|
||||
if expected_viewbox is not None:
|
||||
reference = parse_project_viewbox(
|
||||
expected_viewbox,
|
||||
context=f"{expected_label} viewBox",
|
||||
)
|
||||
else:
|
||||
reference = parsed[0][1]
|
||||
|
||||
mismatches = [
|
||||
f"{path.name}: expected {reference.canonical}, got {viewbox.canonical}"
|
||||
for path, viewbox in parsed
|
||||
if viewbox != reference
|
||||
]
|
||||
if mismatches:
|
||||
details = "\n".join(f" - {message}" for message in mismatches)
|
||||
raise CanvasContractError(
|
||||
f"SVG canvases must all match {expected_label} "
|
||||
f"({reference.canonical}):\n{details}"
|
||||
)
|
||||
return reference
|
||||
@@ -0,0 +1 @@
|
||||
"""DrawingML conversion helpers for SVG-to-PPTX export."""
|
||||
@@ -0,0 +1,288 @@
|
||||
"""ConvertContext — shared state passed through the SVG → DrawingML pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree import ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .theme_colors import ThemeColorSpec
|
||||
from .theme_fonts import ThemeFontSpec
|
||||
|
||||
AffineMatrix = tuple[float, float, float, float, float, float]
|
||||
IDENTITY_MATRIX: AffineMatrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
|
||||
TEXT_FLOW_PRESERVE = 'preserve'
|
||||
TEXT_FLOW_REFLOW = 'reflow'
|
||||
TEXT_FLOW_SPLIT = 'split'
|
||||
TEXT_FLOW_MODES = frozenset({
|
||||
TEXT_FLOW_PRESERVE,
|
||||
TEXT_FLOW_REFLOW,
|
||||
TEXT_FLOW_SPLIT,
|
||||
})
|
||||
|
||||
|
||||
def resolve_text_flow(
|
||||
text_flow: str | None = None,
|
||||
merge_paragraphs: bool | None = None,
|
||||
) -> str:
|
||||
"""Resolve the public text-layout options to one internal mode."""
|
||||
if text_flow is not None and merge_paragraphs is not None:
|
||||
raise ValueError(
|
||||
'text_flow and legacy merge_paragraphs cannot be used together'
|
||||
)
|
||||
if merge_paragraphs is not None:
|
||||
return TEXT_FLOW_REFLOW if merge_paragraphs else TEXT_FLOW_SPLIT
|
||||
resolved = TEXT_FLOW_PRESERVE if text_flow is None else text_flow
|
||||
if resolved not in TEXT_FLOW_MODES:
|
||||
choices = ', '.join(sorted(TEXT_FLOW_MODES))
|
||||
raise ValueError(
|
||||
f'unsupported text_flow {resolved!r}; expected one of: {choices}'
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShapeResult:
|
||||
"""Internal conversion result carrying XML plus resolved EMU bounds."""
|
||||
|
||||
xml: str
|
||||
bounds_emu: tuple[int, int, int, int] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertContext:
|
||||
"""Shared context passed through the SVG → DrawingML conversion pipeline.
|
||||
|
||||
Derived via child() during recursive SVG tree traversal to accumulate
|
||||
translate / scale / inherited style information.
|
||||
"""
|
||||
|
||||
defs: dict[str, ET.Element] = field(default_factory=dict)
|
||||
id_counter: int = 2 # 1 is reserved for spTree root
|
||||
# Imported PPTX shape ids are reserved before conversion so newly authored
|
||||
# SVG elements cannot steal an id referenced by a native connector.
|
||||
reserved_shape_ids: frozenset[int] = frozenset()
|
||||
source_shape_id_map: dict[tuple[str, str], int] = field(default_factory=dict)
|
||||
claimed_shape_ids: set[int] = field(default_factory=set)
|
||||
referenced_shape_ids: set[int] = field(default_factory=set)
|
||||
slide_num: int = 1
|
||||
translate_x: float = 0.0
|
||||
translate_y: float = 0.0
|
||||
scale_x: float = 1.0
|
||||
scale_y: float = 1.0
|
||||
viewport_width: float = 1280.0
|
||||
viewport_height: float = 720.0
|
||||
transform_matrix: AffineMatrix = IDENTITY_MATRIX
|
||||
use_transform_matrix: bool = False
|
||||
filter_id: str | None = None
|
||||
media_files: dict[str, bytes] = field(default_factory=dict)
|
||||
rel_entries: list[dict[str, str]] = field(default_factory=list)
|
||||
package_files: dict[str, bytes] = field(default_factory=dict)
|
||||
content_type_overrides: dict[str, str] = field(default_factory=dict)
|
||||
rel_id_counter: int = 2 # rId1 reserved for slideLayout
|
||||
svg_dir: Path | None = None
|
||||
inherited_styles: dict[str, str] = field(default_factory=dict)
|
||||
# Effective SVG font sizes keyed by element identity. Shared resolution
|
||||
# keeps relative sizes and em tracking identical across checker/exporter.
|
||||
text_font_sizes: dict[int, float] = field(default_factory=dict)
|
||||
# Effective source-pixel tracking resolved where each declaration occurs.
|
||||
text_letter_spacings: dict[int, float] = field(default_factory=dict)
|
||||
# SVG group opacity is post-compositing, not an inherited presentation
|
||||
# property. DrawingML has no equivalent group alpha, so native export
|
||||
# approximates it by multiplying this value into each descendant object.
|
||||
opacity_multiplier: float = 1.0
|
||||
# Recursion depth — only the depth==0 (root) context records anim targets.
|
||||
depth: int = 0
|
||||
# Top-level <g id="..."> groups, recorded as (shape_id, svg_id) in z-order.
|
||||
# Used by the PPTX builder to emit per-element object-animation timing.
|
||||
anim_targets: list = field(default_factory=list)
|
||||
# Explicit sidecar group ids may override the legacy chrome-name heuristic.
|
||||
# Explicit structural layer/role/placeholder markers remain non-animatable.
|
||||
animation_group_overrides: frozenset[str] = frozenset()
|
||||
# Text-layout policy for positional tspans: preserve authored line breaks
|
||||
# in one frame, reflow them, or split them into independent frames.
|
||||
text_flow: str = TEXT_FLOW_PRESERVE
|
||||
# Explicit opt-in: replace marked chart/table fallback groups with editable
|
||||
# PowerPoint graphicFrames. Default stays off to preserve SVG output.
|
||||
native_objects_enabled: bool = False
|
||||
# Native PPTX image optimization. Keeps generated decks compact by
|
||||
# downsampling oversized raster assets to their rendered size.
|
||||
image_optimize: bool = True
|
||||
image_max_dimension: int | None = 2560
|
||||
image_sizing: str = 'cap'
|
||||
image_scale: float = 2.0
|
||||
image_quality: int = 85
|
||||
# Optional per-element conversion diagnostics. Shared by child contexts so
|
||||
# callers can inspect native / skipped / unsupported decisions per slide.
|
||||
trace_events: list[dict[str, Any]] | None = None
|
||||
# Optional project theme contract. Matching SVG title/body families emit
|
||||
# DrawingML +mj/+mn tokens instead of fixed typeface names.
|
||||
theme_font_spec: ThemeFontSpec | None = None
|
||||
# Optional project theme-color contract. Exact locked colors are promoted
|
||||
# to context-safe DrawingML scheme slots while local colors stay concrete.
|
||||
theme_color_spec: ThemeColorSpec | None = None
|
||||
# Canonical BCP-47 content language from spec_lock.md. ``None`` preserves
|
||||
# the legacy per-run script heuristic for older projects and lockless quick generation.
|
||||
primary_language: str | None = None
|
||||
# Reuse media relationships when the same image fills multiple text runs.
|
||||
text_image_fill_cache: dict[tuple[str, str], str] = field(default_factory=dict)
|
||||
|
||||
def next_id(self) -> int:
|
||||
"""Allocate the next shape ID."""
|
||||
cid = self.id_counter
|
||||
while cid in self.reserved_shape_ids or cid in self.claimed_shape_ids:
|
||||
cid += 1
|
||||
self.id_counter = cid + 1
|
||||
self.claimed_shape_ids.add(cid)
|
||||
return cid
|
||||
|
||||
def claim_shape_id(
|
||||
self,
|
||||
source_id: str | None,
|
||||
source_scope: str | None = None,
|
||||
) -> int:
|
||||
"""Claim a pre-reserved imported shape id, or allocate a fresh one."""
|
||||
if source_id is None:
|
||||
return self.next_id()
|
||||
scope = source_scope or 'slide'
|
||||
key = (scope, source_id)
|
||||
shape_id = self.source_shape_id_map.get(key)
|
||||
if shape_id is None:
|
||||
raise ValueError(
|
||||
f'Unreserved data-pptx-shape-id {source_id!r} in scope {scope!r}'
|
||||
)
|
||||
if shape_id in self.claimed_shape_ids:
|
||||
raise ValueError(
|
||||
f'Duplicate data-pptx-shape-id {source_id!r} in scope {scope!r}'
|
||||
)
|
||||
self.claimed_shape_ids.add(shape_id)
|
||||
return shape_id
|
||||
|
||||
def reference_shape_id(
|
||||
self,
|
||||
source_id: str,
|
||||
source_scope: str | None = None,
|
||||
) -> int:
|
||||
"""Resolve and record a connector target in the imported id space."""
|
||||
scope = source_scope or 'slide'
|
||||
shape_id = self.source_shape_id_map.get((scope, source_id))
|
||||
if shape_id is None:
|
||||
raise ValueError(
|
||||
f'Unknown connector shape reference {source_id!r} in scope {scope!r}'
|
||||
)
|
||||
self.referenced_shape_ids.add(shape_id)
|
||||
return shape_id
|
||||
|
||||
def next_rel_id(self) -> str:
|
||||
"""Allocate the next relationship ID (rIdN)."""
|
||||
rid = f'rId{self.rel_id_counter}'
|
||||
self.rel_id_counter += 1
|
||||
return rid
|
||||
|
||||
def child(
|
||||
self,
|
||||
dx: float = 0,
|
||||
dy: float = 0,
|
||||
sx: float = 1.0,
|
||||
sy: float = 1.0,
|
||||
transform_matrix: AffineMatrix | None = None,
|
||||
filter_id: str | None = None,
|
||||
style_overrides: dict[str, str] | None = None,
|
||||
opacity_multiplier: float = 1.0,
|
||||
) -> ConvertContext:
|
||||
"""Create a child context with accumulated translate / scale / styles.
|
||||
|
||||
Args:
|
||||
dx: X translation delta.
|
||||
dy: Y translation delta.
|
||||
sx: X scale factor.
|
||||
sy: Y scale factor.
|
||||
transform_matrix: Full affine transform to accumulate for
|
||||
converters that can faithfully map it to DrawingML.
|
||||
filter_id: Override filter ID.
|
||||
style_overrides: Style attribute overrides from child element.
|
||||
opacity_multiplier: Local group opacity to multiply into descendants.
|
||||
"""
|
||||
local_matrix = transform_matrix or IDENTITY_MATRIX
|
||||
# When first crossing from scalar to matrix mode, fold accumulated
|
||||
# translate_x/y and scale_x/y into the matrix base. Otherwise the
|
||||
# ancestor's scalar transform — which matrix-path readers (e.g.
|
||||
# <image>) never look at — is silently lost, and the descendant
|
||||
# lands at raw SVG coordinates (typically near (0,0)).
|
||||
if transform_matrix is not None and not self.use_transform_matrix:
|
||||
base_matrix: AffineMatrix = (
|
||||
self.scale_x, 0.0,
|
||||
0.0, self.scale_y,
|
||||
self.translate_x, self.translate_y,
|
||||
)
|
||||
else:
|
||||
base_matrix = self.transform_matrix
|
||||
a1, b1, c1, d1, e1, f1 = base_matrix
|
||||
a2, b2, c2, d2, e2, f2 = local_matrix
|
||||
combined_matrix: AffineMatrix = (
|
||||
a1 * a2 + c1 * b2,
|
||||
b1 * a2 + d1 * b2,
|
||||
a1 * c2 + c1 * d2,
|
||||
b1 * c2 + d1 * d2,
|
||||
a1 * e2 + c1 * f2 + e1,
|
||||
b1 * e2 + d1 * f2 + f1,
|
||||
)
|
||||
|
||||
merged = dict(self.inherited_styles)
|
||||
if style_overrides:
|
||||
merged.update(style_overrides)
|
||||
|
||||
local_opacity = max(0.0, min(1.0, opacity_multiplier))
|
||||
|
||||
return ConvertContext(
|
||||
defs=self.defs,
|
||||
id_counter=self.id_counter,
|
||||
reserved_shape_ids=self.reserved_shape_ids,
|
||||
source_shape_id_map=self.source_shape_id_map,
|
||||
claimed_shape_ids=self.claimed_shape_ids,
|
||||
referenced_shape_ids=self.referenced_shape_ids,
|
||||
slide_num=self.slide_num,
|
||||
translate_x=self.translate_x + dx,
|
||||
translate_y=self.translate_y + dy,
|
||||
scale_x=self.scale_x * sx,
|
||||
scale_y=self.scale_y * sy,
|
||||
viewport_width=self.viewport_width,
|
||||
viewport_height=self.viewport_height,
|
||||
transform_matrix=combined_matrix,
|
||||
use_transform_matrix=self.use_transform_matrix or transform_matrix is not None,
|
||||
filter_id=filter_id or self.filter_id,
|
||||
media_files=self.media_files,
|
||||
rel_entries=self.rel_entries,
|
||||
package_files=self.package_files,
|
||||
content_type_overrides=self.content_type_overrides,
|
||||
rel_id_counter=self.rel_id_counter,
|
||||
svg_dir=self.svg_dir,
|
||||
inherited_styles=merged,
|
||||
text_font_sizes=self.text_font_sizes,
|
||||
text_letter_spacings=self.text_letter_spacings,
|
||||
opacity_multiplier=self.opacity_multiplier * local_opacity,
|
||||
depth=self.depth + 1,
|
||||
# anim_targets is intentionally a fresh list on the child;
|
||||
# only the root-level context's list is read by the builder.
|
||||
animation_group_overrides=self.animation_group_overrides,
|
||||
text_flow=self.text_flow,
|
||||
native_objects_enabled=self.native_objects_enabled,
|
||||
image_optimize=self.image_optimize,
|
||||
image_max_dimension=self.image_max_dimension,
|
||||
image_sizing=self.image_sizing,
|
||||
image_scale=self.image_scale,
|
||||
image_quality=self.image_quality,
|
||||
trace_events=self.trace_events,
|
||||
theme_font_spec=self.theme_font_spec,
|
||||
theme_color_spec=self.theme_color_spec,
|
||||
primary_language=self.primary_language,
|
||||
text_image_fill_cache=self.text_image_fill_cache,
|
||||
)
|
||||
|
||||
def sync_from_child(self, child_ctx: ConvertContext) -> None:
|
||||
"""Sync counters back from a child context."""
|
||||
self.id_counter = child_ctx.id_counter
|
||||
self.rel_id_counter = child_ctx.rel_id_counter
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,847 @@
|
||||
"""SVG path parsing, normalization, and DrawingML path command generation.
|
||||
|
||||
See references/svg-effects.md §6.9 for the project freeform grammar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections import deque
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .context import AffineMatrix
|
||||
from .utils import (
|
||||
SVG_NS,
|
||||
parse_inline_style,
|
||||
parse_project_geometry_length,
|
||||
project_definition_index,
|
||||
px_to_emu,
|
||||
resolve_url_id,
|
||||
transform_point,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PathCommand:
|
||||
"""A single SVG path command with its arguments."""
|
||||
cmd: str # M, L, C, Z, etc. (uppercase = absolute)
|
||||
args: list[float] = field(default_factory=list)
|
||||
|
||||
|
||||
# Argument counts per SVG path command
|
||||
_ARG_COUNTS = {
|
||||
'M': 2, 'm': 2, 'L': 2, 'l': 2,
|
||||
'H': 1, 'h': 1, 'V': 1, 'v': 1,
|
||||
'C': 6, 'c': 6, 'S': 4, 's': 4,
|
||||
'Q': 4, 'q': 4, 'T': 2, 't': 2,
|
||||
'A': 7, 'a': 7, 'Z': 0, 'z': 0,
|
||||
}
|
||||
|
||||
_PATH_COMMAND_CHARS = 'MmLlHhVvCcSsQqTtAaZz'
|
||||
_PATH_NUMBER_PATTERN = (
|
||||
r'[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?'
|
||||
)
|
||||
_PATH_TOKEN_RE = re.compile(
|
||||
rf'(?P<command>[{_PATH_COMMAND_CHARS}])|(?P<number>{_PATH_NUMBER_PATTERN})'
|
||||
)
|
||||
_POINT_TOKEN_RE = re.compile(_PATH_NUMBER_PATTERN)
|
||||
_TOKEN_SEPARATOR_RE = re.compile(r'[ \t\r\n]*(?:,[ \t\r\n]*)?')
|
||||
_TRAILING_WHITESPACE_RE = re.compile(r'[ \t\r\n]*')
|
||||
_CANONICAL_FREEFORM_NUMBER_RE = re.compile(
|
||||
r'-?(?:\d+(?:\.\d+)?|\.\d+)$'
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _GeometryToken:
|
||||
kind: str
|
||||
raw: str
|
||||
offset: int
|
||||
|
||||
|
||||
def _tokenize_path_data(d: str) -> list[_GeometryToken]:
|
||||
"""Tokenize one complete path-data value without skipping input."""
|
||||
if not d or not d.strip():
|
||||
raise ValueError('path d must not be empty')
|
||||
|
||||
tokens: list[_GeometryToken] = []
|
||||
cursor = 0
|
||||
previous_kind: str | None = None
|
||||
for match in _PATH_TOKEN_RE.finditer(d):
|
||||
kind = 'command' if match.lastgroup == 'command' else 'number'
|
||||
gap = d[cursor:match.start()]
|
||||
if _TOKEN_SEPARATOR_RE.fullmatch(gap) is None:
|
||||
raise ValueError(
|
||||
f'path d contains unsupported syntax at offset {cursor}: {gap!r}'
|
||||
)
|
||||
if ',' in gap and (
|
||||
previous_kind is None
|
||||
or previous_kind == 'command'
|
||||
or kind == 'command'
|
||||
):
|
||||
raise ValueError(
|
||||
f'path d has a misplaced comma at offset {cursor}'
|
||||
)
|
||||
tokens.append(_GeometryToken(kind, match.group(), match.start()))
|
||||
previous_kind = kind
|
||||
cursor = match.end()
|
||||
|
||||
trailing = d[cursor:]
|
||||
if _TRAILING_WHITESPACE_RE.fullmatch(trailing) is None:
|
||||
raise ValueError(
|
||||
f'path d contains unsupported trailing syntax at offset '
|
||||
f'{cursor}: {trailing!r}'
|
||||
)
|
||||
if not tokens:
|
||||
raise ValueError('path d must contain a supported command')
|
||||
return tokens
|
||||
|
||||
|
||||
def _finite_token_value(token: _GeometryToken, context: str) -> float:
|
||||
value = float(token.raw)
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(
|
||||
f'{context} contains a non-finite number {token.raw!r} at '
|
||||
f'offset {token.offset}'
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _expand_arc_argument_tokens(
|
||||
argument_tokens: list[_GeometryToken],
|
||||
command_token: _GeometryToken,
|
||||
) -> list[_GeometryToken]:
|
||||
"""Split compact SVG arc flags without weakening the numeric grammar."""
|
||||
pending = deque(argument_tokens)
|
||||
expanded: list[_GeometryToken] = []
|
||||
argument_index = 0
|
||||
while pending:
|
||||
token = pending.popleft()
|
||||
position = argument_index % _ARG_COUNTS[command_token.raw]
|
||||
if position in {3, 4}:
|
||||
if not token.raw or token.raw[0] not in {'0', '1'}:
|
||||
raise ValueError(
|
||||
f'path arc flag at offset {token.offset} must be exactly '
|
||||
f'0 or 1; got {token.raw!r}'
|
||||
)
|
||||
expanded.append(
|
||||
_GeometryToken('number', token.raw[0], token.offset)
|
||||
)
|
||||
remainder = token.raw[1:]
|
||||
if remainder:
|
||||
next_position = (position + 1) % _ARG_COUNTS[command_token.raw]
|
||||
if (
|
||||
next_position in {3, 4}
|
||||
and remainder[0] not in {'0', '1'}
|
||||
) or (
|
||||
next_position not in {3, 4}
|
||||
and re.fullmatch(_PATH_NUMBER_PATTERN, remainder) is None
|
||||
):
|
||||
raise ValueError(
|
||||
f'path arc flag at offset {token.offset} must be '
|
||||
f'exactly 0 or 1; got {token.raw!r}'
|
||||
)
|
||||
pending.appendleft(
|
||||
_GeometryToken('number', remainder, token.offset + 1)
|
||||
)
|
||||
else:
|
||||
expanded.append(token)
|
||||
argument_index += 1
|
||||
|
||||
return expanded
|
||||
|
||||
|
||||
def _tokenize_points(points: str) -> list[_GeometryToken]:
|
||||
"""Tokenize one complete polygon/polyline points value."""
|
||||
if not points or not points.strip():
|
||||
raise ValueError('points must not be empty')
|
||||
|
||||
tokens: list[_GeometryToken] = []
|
||||
cursor = 0
|
||||
for match in _POINT_TOKEN_RE.finditer(points):
|
||||
gap = points[cursor:match.start()]
|
||||
if _TOKEN_SEPARATOR_RE.fullmatch(gap) is None:
|
||||
raise ValueError(
|
||||
f'points contains unsupported syntax at offset {cursor}: {gap!r}'
|
||||
)
|
||||
if not tokens and ',' in gap:
|
||||
raise ValueError('points cannot start with a comma')
|
||||
tokens.append(_GeometryToken('number', match.group(), match.start()))
|
||||
cursor = match.end()
|
||||
|
||||
trailing = points[cursor:]
|
||||
if _TRAILING_WHITESPACE_RE.fullmatch(trailing) is None:
|
||||
raise ValueError(
|
||||
f'points contains unsupported trailing syntax at offset '
|
||||
f'{cursor}: {trailing!r}'
|
||||
)
|
||||
if not tokens:
|
||||
raise ValueError('points must contain coordinate pairs')
|
||||
return tokens
|
||||
|
||||
|
||||
def _parse_svg_path_tokens(
|
||||
d: str,
|
||||
) -> tuple[list[PathCommand], list[_GeometryToken]]:
|
||||
"""Parse path data and retain its semantic numeric tokens."""
|
||||
tokens = _tokenize_path_data(d)
|
||||
if tokens[0].kind != 'command' or tokens[0].raw not in {'M', 'm'}:
|
||||
raise ValueError('path d must begin with M or m')
|
||||
|
||||
commands: list[PathCommand] = []
|
||||
number_tokens: list[_GeometryToken] = []
|
||||
token_index = 0
|
||||
while token_index < len(tokens):
|
||||
command_token = tokens[token_index]
|
||||
if command_token.kind != 'command':
|
||||
raise ValueError(
|
||||
f'path d requires a command at offset {command_token.offset}'
|
||||
)
|
||||
command = command_token.raw
|
||||
token_index += 1
|
||||
if command in {'Z', 'z'}:
|
||||
commands.append(PathCommand(command, []))
|
||||
continue
|
||||
|
||||
argument_tokens: list[_GeometryToken] = []
|
||||
while token_index < len(tokens) and tokens[token_index].kind == 'number':
|
||||
argument_tokens.append(tokens[token_index])
|
||||
token_index += 1
|
||||
|
||||
argument_count = _ARG_COUNTS[command]
|
||||
if command in {'A', 'a'}:
|
||||
argument_tokens = _expand_arc_argument_tokens(
|
||||
argument_tokens,
|
||||
command_token,
|
||||
)
|
||||
if not argument_tokens:
|
||||
raise ValueError(
|
||||
f'path command {command!r} at offset {command_token.offset} '
|
||||
f'requires {argument_count} argument(s)'
|
||||
)
|
||||
if len(argument_tokens) % argument_count:
|
||||
raise ValueError(
|
||||
f'path command {command!r} at offset {command_token.offset} '
|
||||
f'has {len(argument_tokens)} argument(s); expected a multiple '
|
||||
f'of {argument_count}'
|
||||
)
|
||||
|
||||
for group_index in range(0, len(argument_tokens), argument_count):
|
||||
group = argument_tokens[group_index:group_index + argument_count]
|
||||
values = [_finite_token_value(token, 'path d') for token in group]
|
||||
if command in {'A', 'a'}:
|
||||
if values[0] < 0 or values[1] < 0:
|
||||
raise ValueError('path arc radii must be non-negative')
|
||||
|
||||
emitted_command = command
|
||||
if command == 'M' and group_index > 0:
|
||||
emitted_command = 'L'
|
||||
elif command == 'm' and group_index > 0:
|
||||
emitted_command = 'l'
|
||||
commands.append(PathCommand(emitted_command, values))
|
||||
number_tokens.extend(group)
|
||||
return commands, number_tokens
|
||||
|
||||
|
||||
def parse_svg_path(d: str) -> list[PathCommand]:
|
||||
"""Parse one complete supported SVG path value or fail closed."""
|
||||
commands, _ = _parse_svg_path_tokens(d)
|
||||
return commands
|
||||
|
||||
|
||||
def parse_svg_points(
|
||||
points: str,
|
||||
*,
|
||||
min_points: int = 2,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Parse complete polygon/polyline points into finite coordinate pairs."""
|
||||
tokens = _tokenize_points(points)
|
||||
if len(tokens) % 2:
|
||||
raise ValueError(
|
||||
f'points has {len(tokens)} numeric value(s); expected coordinate pairs'
|
||||
)
|
||||
point_count = len(tokens) // 2
|
||||
if point_count < min_points:
|
||||
raise ValueError(
|
||||
f'points requires at least {min_points} coordinate pair(s); '
|
||||
f'found {point_count}'
|
||||
)
|
||||
values = [_finite_token_value(token, 'points') for token in tokens]
|
||||
return [
|
||||
(values[index], values[index + 1])
|
||||
for index in range(0, len(values), 2)
|
||||
]
|
||||
|
||||
|
||||
def noncanonical_path_numbers(d: str) -> tuple[str, ...]:
|
||||
"""Return compatible path numbers that generated SVG should normalize."""
|
||||
_, number_tokens = _parse_svg_path_tokens(d)
|
||||
return tuple(
|
||||
token.raw
|
||||
for token in number_tokens
|
||||
if _CANONICAL_FREEFORM_NUMBER_RE.fullmatch(token.raw) is None
|
||||
)
|
||||
|
||||
|
||||
def noncanonical_points_numbers(points: str, *, min_points: int) -> tuple[str, ...]:
|
||||
"""Return compatible point numbers that generated SVG should normalize."""
|
||||
parse_svg_points(points, min_points=min_points)
|
||||
return tuple(
|
||||
token.raw
|
||||
for token in _tokenize_points(points)
|
||||
if _CANONICAL_FREEFORM_NUMBER_RE.fullmatch(token.raw) is None
|
||||
)
|
||||
|
||||
|
||||
def iter_project_freeform_geometry(
|
||||
root: ET.Element,
|
||||
) -> Iterator[tuple[ET.Element, str, str | None, int | None]]:
|
||||
"""Yield path/points values and their minimum point-count contract."""
|
||||
for elem in root.iter():
|
||||
raw_tag = str(elem.tag)
|
||||
if raw_tag.startswith('{'):
|
||||
namespace, tag = raw_tag[1:].split('}', 1)
|
||||
if namespace != SVG_NS:
|
||||
continue
|
||||
else:
|
||||
tag = raw_tag
|
||||
if tag == 'path':
|
||||
yield elem, 'd', elem.get('d'), None
|
||||
elif tag == 'polygon':
|
||||
yield elem, 'points', elem.get('points'), 3
|
||||
elif tag == 'polyline':
|
||||
yield elem, 'points', elem.get('points'), 2
|
||||
|
||||
|
||||
def project_freeform_geometry_errors(root: ET.Element) -> list[str]:
|
||||
"""Return blocking path/points grammar errors for converter preflight."""
|
||||
errors: list[str] = []
|
||||
for elem, attribute, raw, min_points in iter_project_freeform_geometry(root):
|
||||
tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag)
|
||||
elem_id = elem.get('id')
|
||||
label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>'
|
||||
try:
|
||||
if raw is None:
|
||||
raise ValueError(f'<{tag}> requires {attribute}')
|
||||
if attribute == 'd':
|
||||
parse_svg_path(raw)
|
||||
else:
|
||||
parse_svg_points(raw, min_points=min_points or 2)
|
||||
except ValueError as exc:
|
||||
errors.append(f'{label} {attribute}: {exc}')
|
||||
return errors
|
||||
|
||||
|
||||
def _project_path_like_bounds(
|
||||
elem: ET.Element,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
"""Return intrinsic bounds for line-like SVG geometry."""
|
||||
tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag)
|
||||
points: list[tuple[float, float]] = []
|
||||
|
||||
if tag == 'line':
|
||||
points = [
|
||||
(
|
||||
parse_project_geometry_length(elem.get('x1', '0'), 'x1'),
|
||||
parse_project_geometry_length(elem.get('y1', '0'), 'y1'),
|
||||
),
|
||||
(
|
||||
parse_project_geometry_length(elem.get('x2', '0'), 'x2'),
|
||||
parse_project_geometry_length(elem.get('y2', '0'), 'y2'),
|
||||
),
|
||||
]
|
||||
elif tag in {'polygon', 'polyline'}:
|
||||
min_points = 3 if tag == 'polygon' else 2
|
||||
points = parse_svg_points(elem.get('points', ''), min_points=min_points)
|
||||
elif tag == 'path':
|
||||
commands = normalize_path_commands(
|
||||
svg_path_to_absolute(parse_svg_path(elem.get('d', '')))
|
||||
)
|
||||
current_point: tuple[float, float] | None = None
|
||||
subpath_start: tuple[float, float] | None = None
|
||||
for command in commands:
|
||||
if command.cmd == 'M':
|
||||
current_point = (command.args[0], command.args[1])
|
||||
subpath_start = current_point
|
||||
elif command.cmd == 'L':
|
||||
end_point = (command.args[0], command.args[1])
|
||||
if current_point is not None:
|
||||
points.extend((current_point, end_point))
|
||||
current_point = end_point
|
||||
elif command.cmd == 'C':
|
||||
if current_point is not None:
|
||||
points.append(current_point)
|
||||
points.extend(
|
||||
(command.args[index], command.args[index + 1])
|
||||
for index in range(0, 6, 2)
|
||||
)
|
||||
current_point = (command.args[4], command.args[5])
|
||||
elif command.cmd == 'Z' and current_point is not None:
|
||||
if subpath_start is not None:
|
||||
points.extend((current_point, subpath_start))
|
||||
current_point = subpath_start
|
||||
|
||||
if not points:
|
||||
return None
|
||||
xs = [point[0] for point in points]
|
||||
ys = [point[1] for point in points]
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def project_gradient_geometry_errors(root: ET.Element) -> list[str]:
|
||||
"""Reject object-bounding-box gradient strokes on degenerate geometry."""
|
||||
definitions, _duplicates = project_definition_index(root)
|
||||
parent_by_id = {
|
||||
id(child): parent
|
||||
for parent in root.iter()
|
||||
for child in list(parent)
|
||||
}
|
||||
errors: set[str] = set()
|
||||
|
||||
for elem in root.iter():
|
||||
tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag)
|
||||
if tag not in {'line', 'path', 'polygon', 'polyline'}:
|
||||
continue
|
||||
|
||||
current: ET.Element | None = elem
|
||||
stroke: str | None = None
|
||||
while current is not None:
|
||||
style_values = parse_inline_style(current.get('style'))
|
||||
if 'stroke' in style_values:
|
||||
stroke = style_values['stroke']
|
||||
break
|
||||
if current.get('stroke') is not None:
|
||||
stroke = current.get('stroke')
|
||||
break
|
||||
current = parent_by_id.get(id(current))
|
||||
|
||||
gradient_id = resolve_url_id(stroke)
|
||||
gradient = definitions.get(gradient_id) if gradient_id else None
|
||||
if gradient is None:
|
||||
continue
|
||||
gradient_tag = (
|
||||
gradient.tag.rsplit('}', 1)[-1]
|
||||
if '}' in str(gradient.tag)
|
||||
else str(gradient.tag)
|
||||
)
|
||||
if gradient_tag not in {'linearGradient', 'radialGradient'}:
|
||||
continue
|
||||
if gradient.get('gradientUnits') not in {None, 'objectBoundingBox'}:
|
||||
continue
|
||||
|
||||
try:
|
||||
bounds = _project_path_like_bounds(elem)
|
||||
except ValueError:
|
||||
# The existing geometry preflight owns malformed geometry errors.
|
||||
continue
|
||||
if bounds is None:
|
||||
continue
|
||||
min_x, min_y, max_x, max_y = bounds
|
||||
zero_width = math.isclose(
|
||||
min_x, max_x, rel_tol=0.0, abs_tol=1e-9
|
||||
)
|
||||
zero_height = math.isclose(
|
||||
min_y, max_y, rel_tol=0.0, abs_tol=1e-9
|
||||
)
|
||||
if not zero_width and not zero_height:
|
||||
continue
|
||||
|
||||
dimension = 'width and height' if zero_width and zero_height else (
|
||||
'width' if zero_width else 'height'
|
||||
)
|
||||
elem_id = elem.get('id')
|
||||
label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>'
|
||||
errors.add(
|
||||
f'{label} stroke=url(#{gradient_id}) has zero intrinsic {dimension}; '
|
||||
'objectBoundingBox gradients do not include stroke width and will '
|
||||
'not render. Use a non-degenerate path or a closed filled shape'
|
||||
)
|
||||
|
||||
return sorted(errors)
|
||||
|
||||
|
||||
def svg_path_to_absolute(commands: list[PathCommand]) -> list[PathCommand]:
|
||||
"""Convert all relative path commands to absolute."""
|
||||
result: list[PathCommand] = []
|
||||
cx, cy = 0.0, 0.0 # current point
|
||||
sx, sy = 0.0, 0.0 # subpath start
|
||||
|
||||
for cmd in commands:
|
||||
a = cmd.args
|
||||
if cmd.cmd == 'M':
|
||||
cx, cy = a[0], a[1]
|
||||
sx, sy = cx, cy
|
||||
result.append(PathCommand('M', [cx, cy]))
|
||||
elif cmd.cmd == 'm':
|
||||
cx += a[0]; cy += a[1]
|
||||
sx, sy = cx, cy
|
||||
result.append(PathCommand('M', [cx, cy]))
|
||||
elif cmd.cmd == 'L':
|
||||
cx, cy = a[0], a[1]
|
||||
result.append(PathCommand('L', [cx, cy]))
|
||||
elif cmd.cmd == 'l':
|
||||
cx += a[0]; cy += a[1]
|
||||
result.append(PathCommand('L', [cx, cy]))
|
||||
elif cmd.cmd == 'H':
|
||||
cx = a[0]
|
||||
result.append(PathCommand('L', [cx, cy]))
|
||||
elif cmd.cmd == 'h':
|
||||
cx += a[0]
|
||||
result.append(PathCommand('L', [cx, cy]))
|
||||
elif cmd.cmd == 'V':
|
||||
cy = a[0]
|
||||
result.append(PathCommand('L', [cx, cy]))
|
||||
elif cmd.cmd == 'v':
|
||||
cy += a[0]
|
||||
result.append(PathCommand('L', [cx, cy]))
|
||||
elif cmd.cmd == 'C':
|
||||
result.append(PathCommand('C', list(a)))
|
||||
cx, cy = a[4], a[5]
|
||||
elif cmd.cmd == 'c':
|
||||
abs_args = [
|
||||
cx + a[0], cy + a[1],
|
||||
cx + a[2], cy + a[3],
|
||||
cx + a[4], cy + a[5],
|
||||
]
|
||||
result.append(PathCommand('C', abs_args))
|
||||
cx, cy = abs_args[4], abs_args[5]
|
||||
elif cmd.cmd == 'S':
|
||||
result.append(PathCommand('S', list(a)))
|
||||
cx, cy = a[2], a[3]
|
||||
elif cmd.cmd == 's':
|
||||
abs_args = [cx + a[0], cy + a[1], cx + a[2], cy + a[3]]
|
||||
result.append(PathCommand('S', abs_args))
|
||||
cx, cy = abs_args[2], abs_args[3]
|
||||
elif cmd.cmd == 'Q':
|
||||
result.append(PathCommand('Q', list(a)))
|
||||
cx, cy = a[2], a[3]
|
||||
elif cmd.cmd == 'q':
|
||||
abs_args = [cx + a[0], cy + a[1], cx + a[2], cy + a[3]]
|
||||
result.append(PathCommand('Q', abs_args))
|
||||
cx, cy = abs_args[2], abs_args[3]
|
||||
elif cmd.cmd == 'T':
|
||||
result.append(PathCommand('T', list(a)))
|
||||
cx, cy = a[0], a[1]
|
||||
elif cmd.cmd == 't':
|
||||
abs_args = [cx + a[0], cy + a[1]]
|
||||
result.append(PathCommand('T', abs_args))
|
||||
cx, cy = abs_args[0], abs_args[1]
|
||||
elif cmd.cmd == 'A':
|
||||
result.append(PathCommand('A', list(a)))
|
||||
cx, cy = a[5], a[6]
|
||||
elif cmd.cmd == 'a':
|
||||
abs_args = [a[0], a[1], a[2], a[3], a[4], cx + a[5], cy + a[6]]
|
||||
result.append(PathCommand('A', abs_args))
|
||||
cx, cy = abs_args[5], abs_args[6]
|
||||
elif cmd.cmd in ('Z', 'z'):
|
||||
result.append(PathCommand('Z', []))
|
||||
cx, cy = sx, sy
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _reflect_control_point(
|
||||
cp_x: float, cp_y: float,
|
||||
cx: float, cy: float,
|
||||
) -> tuple[float, float]:
|
||||
"""Reflect a control point through the current point."""
|
||||
return 2 * cx - cp_x, 2 * cy - cp_y
|
||||
|
||||
|
||||
def _quad_to_cubic(
|
||||
qp_x: float, qp_y: float,
|
||||
p0_x: float, p0_y: float,
|
||||
p3_x: float, p3_y: float,
|
||||
) -> list[float]:
|
||||
"""Convert quadratic bezier control point to cubic bezier control points."""
|
||||
cp1_x = p0_x + 2.0 / 3.0 * (qp_x - p0_x)
|
||||
cp1_y = p0_y + 2.0 / 3.0 * (qp_y - p0_y)
|
||||
cp2_x = p3_x + 2.0 / 3.0 * (qp_x - p3_x)
|
||||
cp2_y = p3_y + 2.0 / 3.0 * (qp_y - p3_y)
|
||||
return [cp1_x, cp1_y, cp2_x, cp2_y, p3_x, p3_y]
|
||||
|
||||
|
||||
def _arc_to_cubic_beziers(
|
||||
cx_: float, cy_: float,
|
||||
rx: float, ry: float,
|
||||
phi: float,
|
||||
large_arc: int, sweep: int,
|
||||
x2: float, y2: float,
|
||||
) -> list[PathCommand]:
|
||||
"""Convert SVG arc (endpoint parameterization) to cubic bezier curves.
|
||||
|
||||
Uses the algorithm from the SVG spec (F.6.5) to convert endpoint to center
|
||||
parameterization, then approximates each arc segment with cubic beziers.
|
||||
"""
|
||||
x1, y1 = cx_, cy_
|
||||
|
||||
if abs(x1 - x2) < 1e-10 and abs(y1 - y2) < 1e-10:
|
||||
return []
|
||||
|
||||
rx = abs(rx)
|
||||
ry = abs(ry)
|
||||
if rx < 1e-10 or ry < 1e-10:
|
||||
return [PathCommand('L', [x2, y2])]
|
||||
|
||||
phi_rad = math.radians(phi)
|
||||
cos_phi = math.cos(phi_rad)
|
||||
sin_phi = math.sin(phi_rad)
|
||||
|
||||
# Step 1: Compute (x1', y1')
|
||||
dx = (x1 - x2) / 2.0
|
||||
dy = (y1 - y2) / 2.0
|
||||
x1p = cos_phi * dx + sin_phi * dy
|
||||
y1p = -sin_phi * dx + cos_phi * dy
|
||||
|
||||
# Step 2: Compute (cx', cy')
|
||||
x1p2 = x1p * x1p
|
||||
y1p2 = y1p * y1p
|
||||
rx2 = rx * rx
|
||||
ry2 = ry * ry
|
||||
|
||||
# Ensure radii are large enough
|
||||
lam = x1p2 / rx2 + y1p2 / ry2
|
||||
if lam > 1:
|
||||
lam_sqrt = math.sqrt(lam)
|
||||
rx *= lam_sqrt
|
||||
ry *= lam_sqrt
|
||||
rx2 = rx * rx
|
||||
ry2 = ry * ry
|
||||
|
||||
num = max(rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2, 0)
|
||||
den = rx2 * y1p2 + ry2 * x1p2
|
||||
sq = math.sqrt(num / den) if den > 1e-10 else 0.0
|
||||
|
||||
if large_arc == sweep:
|
||||
sq = -sq
|
||||
|
||||
cxp = sq * rx * y1p / ry
|
||||
cyp = -sq * ry * x1p / rx
|
||||
|
||||
# Step 3: Compute (cx, cy)
|
||||
arc_cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0
|
||||
arc_cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0
|
||||
|
||||
# Step 4: Compute theta1 and dtheta
|
||||
def angle_between(ux: float, uy: float, vx: float, vy: float) -> float:
|
||||
n = math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
|
||||
if n < 1e-10:
|
||||
return 0
|
||||
c = max(-1, min(1, (ux * vx + uy * vy) / n))
|
||||
a = math.acos(c)
|
||||
if ux * vy - uy * vx < 0:
|
||||
a = -a
|
||||
return a
|
||||
|
||||
theta1 = angle_between(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
|
||||
dtheta = angle_between(
|
||||
(x1p - cxp) / rx, (y1p - cyp) / ry,
|
||||
(-x1p - cxp) / rx, (-y1p - cyp) / ry,
|
||||
)
|
||||
|
||||
if sweep == 0 and dtheta > 0:
|
||||
dtheta -= 2 * math.pi
|
||||
elif sweep == 1 and dtheta < 0:
|
||||
dtheta += 2 * math.pi
|
||||
|
||||
# Split arc into segments of at most 90 degrees
|
||||
n_segs = max(1, int(math.ceil(abs(dtheta) / (math.pi / 2))))
|
||||
d_per_seg = dtheta / n_segs
|
||||
|
||||
result: list[PathCommand] = []
|
||||
alpha = 4.0 / 3.0 * math.tan(d_per_seg / 4.0)
|
||||
|
||||
for i in range(n_segs):
|
||||
t1 = theta1 + i * d_per_seg
|
||||
t2 = theta1 + (i + 1) * d_per_seg
|
||||
|
||||
cos_t1 = math.cos(t1)
|
||||
sin_t1 = math.sin(t1)
|
||||
cos_t2 = math.cos(t2)
|
||||
sin_t2 = math.sin(t2)
|
||||
|
||||
ep1_x = cos_t1 - alpha * sin_t1
|
||||
ep1_y = sin_t1 + alpha * cos_t1
|
||||
ep2_x = cos_t2 + alpha * sin_t2
|
||||
ep2_y = sin_t2 - alpha * cos_t2
|
||||
ep_x = cos_t2
|
||||
ep_y = sin_t2
|
||||
|
||||
def transform_pt(px: float, py: float) -> tuple[float, float]:
|
||||
x = rx * px
|
||||
y = ry * py
|
||||
xr = cos_phi * x - sin_phi * y + arc_cx
|
||||
yr = sin_phi * x + cos_phi * y + arc_cy
|
||||
return xr, yr
|
||||
|
||||
cp1 = transform_pt(ep1_x, ep1_y)
|
||||
cp2 = transform_pt(ep2_x, ep2_y)
|
||||
ep = transform_pt(ep_x, ep_y)
|
||||
|
||||
result.append(PathCommand('C', [cp1[0], cp1[1], cp2[0], cp2[1], ep[0], ep[1]]))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def normalize_path_commands(commands: list[PathCommand]) -> list[PathCommand]:
|
||||
"""Normalize path commands to M/L/C/Z only.
|
||||
|
||||
Converts S -> C, Q -> C, T -> C, A -> C sequences.
|
||||
"""
|
||||
result: list[PathCommand] = []
|
||||
cx, cy = 0.0, 0.0
|
||||
last_cp_x, last_cp_y = 0.0, 0.0
|
||||
last_cmd = ''
|
||||
|
||||
for cmd in commands:
|
||||
a = cmd.args
|
||||
|
||||
if cmd.cmd == 'M':
|
||||
cx, cy = a[0], a[1]
|
||||
last_cp_x, last_cp_y = cx, cy
|
||||
result.append(cmd)
|
||||
elif cmd.cmd == 'L':
|
||||
cx, cy = a[0], a[1]
|
||||
last_cp_x, last_cp_y = cx, cy
|
||||
result.append(cmd)
|
||||
elif cmd.cmd == 'C':
|
||||
last_cp_x, last_cp_y = a[2], a[3]
|
||||
cx, cy = a[4], a[5]
|
||||
result.append(cmd)
|
||||
elif cmd.cmd == 'S':
|
||||
if last_cmd in ('C', 'S'):
|
||||
rcp_x, rcp_y = _reflect_control_point(last_cp_x, last_cp_y, cx, cy)
|
||||
else:
|
||||
rcp_x, rcp_y = cx, cy
|
||||
last_cp_x, last_cp_y = a[0], a[1]
|
||||
new_cx, new_cy = a[2], a[3]
|
||||
result.append(PathCommand('C', [rcp_x, rcp_y, a[0], a[1], new_cx, new_cy]))
|
||||
cx, cy = new_cx, new_cy
|
||||
elif cmd.cmd == 'Q':
|
||||
cubic = _quad_to_cubic(a[0], a[1], cx, cy, a[2], a[3])
|
||||
last_cp_x, last_cp_y = a[0], a[1]
|
||||
result.append(PathCommand('C', cubic))
|
||||
cx, cy = a[2], a[3]
|
||||
elif cmd.cmd == 'T':
|
||||
if last_cmd in ('Q', 'T'):
|
||||
qp_x, qp_y = _reflect_control_point(last_cp_x, last_cp_y, cx, cy)
|
||||
else:
|
||||
qp_x, qp_y = cx, cy
|
||||
last_cp_x, last_cp_y = qp_x, qp_y
|
||||
cubic = _quad_to_cubic(qp_x, qp_y, cx, cy, a[0], a[1])
|
||||
result.append(PathCommand('C', cubic))
|
||||
cx, cy = a[0], a[1]
|
||||
elif cmd.cmd == 'A':
|
||||
arc_beziers = _arc_to_cubic_beziers(
|
||||
cx, cy, a[0], a[1], a[2], int(a[3]), int(a[4]), a[5], a[6],
|
||||
)
|
||||
for bc in arc_beziers:
|
||||
result.append(bc)
|
||||
cx, cy = a[5], a[6]
|
||||
last_cp_x, last_cp_y = cx, cy
|
||||
elif cmd.cmd == 'Z':
|
||||
result.append(cmd)
|
||||
else:
|
||||
result.append(cmd)
|
||||
|
||||
last_cmd = cmd.cmd
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def transform_path_commands(
|
||||
commands: list[PathCommand],
|
||||
matrix: AffineMatrix,
|
||||
) -> list[PathCommand]:
|
||||
"""Apply an affine transform to normalized M/L/C/Z path commands."""
|
||||
transformed: list[PathCommand] = []
|
||||
for command in commands:
|
||||
if command.cmd in {'M', 'L'}:
|
||||
x, y = transform_point(
|
||||
matrix,
|
||||
command.args[0],
|
||||
command.args[1],
|
||||
)
|
||||
transformed.append(PathCommand(command.cmd, [x, y]))
|
||||
elif command.cmd == 'C':
|
||||
args: list[float] = []
|
||||
for index in range(0, 6, 2):
|
||||
x, y = transform_point(
|
||||
matrix,
|
||||
command.args[index],
|
||||
command.args[index + 1],
|
||||
)
|
||||
args.extend([x, y])
|
||||
transformed.append(PathCommand(command.cmd, args))
|
||||
else:
|
||||
transformed.append(command)
|
||||
return transformed
|
||||
|
||||
|
||||
def path_commands_to_drawingml(
|
||||
commands: list[PathCommand],
|
||||
offset_x: float = 0,
|
||||
offset_y: float = 0,
|
||||
scale_x: float = 1.0,
|
||||
scale_y: float = 1.0,
|
||||
) -> tuple[str, float, float, float, float]:
|
||||
"""Convert normalized path commands to DrawingML <a:path> inner XML.
|
||||
|
||||
Returns:
|
||||
(path_xml, min_x, min_y, width, height) in scaled+offset coordinates.
|
||||
"""
|
||||
if not commands:
|
||||
return '', 0, 0, 0, 0
|
||||
|
||||
# First pass: calculate bounding box
|
||||
points: list[tuple[float, float]] = []
|
||||
for cmd in commands:
|
||||
if cmd.cmd in ('M', 'L'):
|
||||
points.append((
|
||||
cmd.args[0] * scale_x + offset_x,
|
||||
cmd.args[1] * scale_y + offset_y,
|
||||
))
|
||||
elif cmd.cmd == 'C':
|
||||
for i in range(0, 6, 2):
|
||||
points.append((
|
||||
cmd.args[i] * scale_x + offset_x,
|
||||
cmd.args[i + 1] * scale_y + offset_y,
|
||||
))
|
||||
|
||||
if not points:
|
||||
return '', 0, 0, 0, 0
|
||||
|
||||
min_x = min(p[0] for p in points)
|
||||
min_y = min(p[1] for p in points)
|
||||
max_x = max(p[0] for p in points)
|
||||
max_y = max(p[1] for p in points)
|
||||
|
||||
width = max(max_x - min_x, 1)
|
||||
height = max(max_y - min_y, 1)
|
||||
|
||||
# Second pass: generate DrawingML path commands (EMU, relative to shape)
|
||||
parts: list[str] = []
|
||||
for cmd in commands:
|
||||
if cmd.cmd == 'M':
|
||||
x_emu = px_to_emu(cmd.args[0] * scale_x + offset_x - min_x)
|
||||
y_emu = px_to_emu(cmd.args[1] * scale_y + offset_y - min_y)
|
||||
parts.append(f'<a:moveTo><a:pt x="{x_emu}" y="{y_emu}"/></a:moveTo>')
|
||||
elif cmd.cmd == 'L':
|
||||
x_emu = px_to_emu(cmd.args[0] * scale_x + offset_x - min_x)
|
||||
y_emu = px_to_emu(cmd.args[1] * scale_y + offset_y - min_y)
|
||||
parts.append(f'<a:lnTo><a:pt x="{x_emu}" y="{y_emu}"/></a:lnTo>')
|
||||
elif cmd.cmd == 'C':
|
||||
pts = []
|
||||
for i in range(0, 6, 2):
|
||||
x_emu = px_to_emu(cmd.args[i] * scale_x + offset_x - min_x)
|
||||
y_emu = px_to_emu(cmd.args[i + 1] * scale_y + offset_y - min_y)
|
||||
pts.append(f'<a:pt x="{x_emu}" y="{y_emu}"/>')
|
||||
parts.append(f'<a:cubicBezTo>{"".join(pts)}</a:cubicBezTo>')
|
||||
elif cmd.cmd == 'Z':
|
||||
parts.append('<a:close/>')
|
||||
|
||||
path_inner = '\n'.join(parts)
|
||||
return path_inner, min_x, min_y, width, height
|
||||
@@ -0,0 +1,752 @@
|
||||
"""Fill, stroke, and shadow XML builders for DrawingML conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx_shapes import validate_ooxml_line_width
|
||||
|
||||
from .context import ConvertContext
|
||||
from .theme_colors import ThemeColorSpec, color_node_xml
|
||||
from .utils import (
|
||||
SVG_NS, ANGLE_UNIT,
|
||||
px_to_emu, _f, _get_attr, parse_svg_length,
|
||||
combine_opacity, parse_inline_style, parse_opacity, parse_stop_style,
|
||||
classify_project_marker_shape,
|
||||
is_project_radial_focus_point,
|
||||
matrix_multiply, parse_svg_color, parse_transform_matrix, resolve_url_id,
|
||||
parse_project_filter_params, project_filter_drawingml_coordinates,
|
||||
parse_project_gradient_ratio, parse_project_linear_gradient_coordinate,
|
||||
parse_project_stroke_dasharray, parse_project_stroke_enum,
|
||||
quantize_ooxml_alpha, quantize_ooxml_unit_ratio,
|
||||
)
|
||||
|
||||
|
||||
def build_solid_fill(
|
||||
color: str,
|
||||
opacity: float | None = None,
|
||||
theme_color_spec: ThemeColorSpec | None = None,
|
||||
usage: str = "fill",
|
||||
) -> str:
|
||||
"""Build <a:solidFill> XML."""
|
||||
alpha = ''
|
||||
if opacity is not None and opacity < 1.0:
|
||||
alpha = f'<a:alpha val="{quantize_ooxml_alpha(opacity)}"/>'
|
||||
return (
|
||||
'<a:solidFill>'
|
||||
f'{color_node_xml(color, theme_color_spec, usage, alpha)}'
|
||||
'</a:solidFill>'
|
||||
)
|
||||
|
||||
|
||||
def build_gradient_fill(
|
||||
grad_elem: ET.Element,
|
||||
opacity: float | None = None,
|
||||
theme_color_spec: ThemeColorSpec | None = None,
|
||||
usage: str = "fill",
|
||||
) -> str:
|
||||
"""Build <a:gradFill> from SVG linearGradient or radialGradient element."""
|
||||
tag = grad_elem.tag.replace(f'{{{SVG_NS}}}', '')
|
||||
|
||||
stops_xml = []
|
||||
for child in grad_elem:
|
||||
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||||
if child_tag != 'stop':
|
||||
continue
|
||||
|
||||
offset_str = child.get('offset')
|
||||
if offset_str is None:
|
||||
raise ValueError('Gradient stop requires an explicit offset')
|
||||
offset = parse_project_gradient_ratio(offset_str)
|
||||
pos = quantize_ooxml_unit_ratio(offset)
|
||||
|
||||
# Parse color from style attribute or direct attributes
|
||||
style = child.get('style', '')
|
||||
style_values = parse_inline_style(style)
|
||||
color, stop_opacity = parse_stop_style(style)
|
||||
if not color:
|
||||
color, color_alpha = parse_svg_color(child.get('stop-color', '#000000'))
|
||||
stop_opacity *= color_alpha
|
||||
if color is None:
|
||||
color = '000000'
|
||||
|
||||
direct_stop_op = child.get('stop-opacity')
|
||||
if direct_stop_op is not None and 'stop-opacity' not in style_values:
|
||||
stop_opacity *= parse_opacity(
|
||||
direct_stop_op,
|
||||
allow_percentage=True,
|
||||
)
|
||||
|
||||
alpha_xml = ''
|
||||
effective_opacity = combine_opacity(stop_opacity, opacity)
|
||||
if effective_opacity is not None:
|
||||
alpha_xml = (
|
||||
f'<a:alpha val="{quantize_ooxml_alpha(effective_opacity)}"/>'
|
||||
)
|
||||
|
||||
stops_xml.append(
|
||||
f'<a:gs pos="{pos}">'
|
||||
f'{color_node_xml(color, theme_color_spec, usage, alpha_xml)}'
|
||||
'</a:gs>'
|
||||
)
|
||||
|
||||
if not stops_xml:
|
||||
return ''
|
||||
|
||||
gs_list = '\n'.join(stops_xml)
|
||||
|
||||
if tag == 'linearGradient':
|
||||
x1 = parse_project_linear_gradient_coordinate(
|
||||
grad_elem.get('x1', '0')
|
||||
)
|
||||
y1 = parse_project_linear_gradient_coordinate(
|
||||
grad_elem.get('y1', '0')
|
||||
)
|
||||
x2 = parse_project_linear_gradient_coordinate(
|
||||
grad_elem.get('x2', '1')
|
||||
)
|
||||
y2 = parse_project_linear_gradient_coordinate(
|
||||
grad_elem.get('y2', '0')
|
||||
)
|
||||
|
||||
angle_rad = math.atan2(y2 - y1, x2 - x1)
|
||||
angle_deg = math.degrees(angle_rad)
|
||||
dml_angle = int((angle_deg % 360) * ANGLE_UNIT)
|
||||
|
||||
return f'''<a:gradFill>
|
||||
<a:gsLst>{gs_list}</a:gsLst>
|
||||
<a:lin ang="{dml_angle}" scaled="1"/>
|
||||
</a:gradFill>'''
|
||||
|
||||
elif tag == 'radialGradient':
|
||||
focus_x = parse_project_gradient_ratio(
|
||||
grad_elem.get('fx', grad_elem.get('cx', '0.5'))
|
||||
)
|
||||
focus_y = parse_project_gradient_ratio(
|
||||
grad_elem.get('fy', grad_elem.get('cy', '0.5'))
|
||||
)
|
||||
if not is_project_radial_focus_point(focus_x, focus_y):
|
||||
raise ValueError(
|
||||
'Radial gradient effective focus must lie within the '
|
||||
'canonical circle centered at 0.5,0.5 with radius 0.5'
|
||||
)
|
||||
focus_l = quantize_ooxml_unit_ratio(focus_x)
|
||||
focus_t = quantize_ooxml_unit_ratio(focus_y)
|
||||
focus_r = 100000 - focus_l
|
||||
focus_b = 100000 - focus_t
|
||||
return f'''<a:gradFill>
|
||||
<a:gsLst>{gs_list}</a:gsLst>
|
||||
<a:path path="circle">
|
||||
<a:fillToRect l="{focus_l}" t="{focus_t}" r="{focus_r}" b="{focus_b}"/>
|
||||
</a:path>
|
||||
</a:gradFill>'''
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def build_fill_xml(
|
||||
elem: ET.Element,
|
||||
ctx: ConvertContext,
|
||||
opacity: float | None = None,
|
||||
usage: str = "fill",
|
||||
) -> str:
|
||||
"""Build fill XML for a shape element, with inherited style support."""
|
||||
fill = _get_attr(elem, 'fill', ctx)
|
||||
if fill is None:
|
||||
fill = '#000000' # SVG default fill is black
|
||||
|
||||
if fill.strip().lower() in ('none', 'transparent'):
|
||||
return '<a:noFill/>'
|
||||
|
||||
ref_id = resolve_url_id(fill)
|
||||
if ref_id and ref_id in ctx.defs:
|
||||
ref_elem = ctx.defs[ref_id]
|
||||
ref_tag = ref_elem.tag.replace(f'{{{SVG_NS}}}', '')
|
||||
if ref_tag == 'pattern':
|
||||
patt_xml = build_pattern_fill(
|
||||
ref_elem,
|
||||
opacity,
|
||||
ctx.theme_color_spec,
|
||||
usage,
|
||||
)
|
||||
if patt_xml:
|
||||
return patt_xml
|
||||
return '<a:noFill/>'
|
||||
return build_gradient_fill(
|
||||
ref_elem,
|
||||
opacity,
|
||||
ctx.theme_color_spec,
|
||||
usage,
|
||||
)
|
||||
|
||||
color, color_alpha = parse_svg_color(fill)
|
||||
if color:
|
||||
return build_solid_fill(
|
||||
color,
|
||||
combine_opacity(opacity, color_alpha),
|
||||
ctx.theme_color_spec,
|
||||
usage,
|
||||
)
|
||||
|
||||
return '<a:noFill/>'
|
||||
|
||||
|
||||
def build_pattern_fill(
|
||||
pattern_elem: ET.Element,
|
||||
opacity: float | None = None,
|
||||
theme_color_spec: ThemeColorSpec | None = None,
|
||||
usage: str = "fill",
|
||||
) -> str:
|
||||
"""Build <a:pattFill> from an SVG <pattern> emitted by pptx_to_svg.
|
||||
|
||||
Reads the round-trip annotations (data-pptx-pattern / data-pptx-fg /
|
||||
data-pptx-bg) when present. Falls back to inspecting the inner stroke /
|
||||
rect colors when annotations are absent (hand-authored SVG).
|
||||
"""
|
||||
prst = pattern_elem.get('data-pptx-pattern') or 'ltUpDiag'
|
||||
|
||||
paint_entries = []
|
||||
for child in pattern_elem:
|
||||
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||||
style_values = parse_inline_style(child.get('style'))
|
||||
object_opacity = parse_opacity(
|
||||
style_values.get('opacity') or child.get('opacity')
|
||||
)
|
||||
for paint_attr in ('fill', 'stroke'):
|
||||
paint = style_values.get(paint_attr) or child.get(paint_attr)
|
||||
paint_hex, paint_alpha = parse_svg_color(paint) if paint else (None, 1.0)
|
||||
if paint_hex is None:
|
||||
continue
|
||||
paint_opacity = parse_opacity(
|
||||
style_values.get(f'{paint_attr}-opacity')
|
||||
or child.get(f'{paint_attr}-opacity')
|
||||
)
|
||||
paint_entries.append({
|
||||
'attr': paint_attr,
|
||||
'alpha': paint_alpha,
|
||||
'color': paint,
|
||||
'hex': paint_hex,
|
||||
'opacity': object_opacity * paint_opacity,
|
||||
'tag': tag,
|
||||
})
|
||||
|
||||
fallback_bg = next((
|
||||
entry
|
||||
for entry in paint_entries
|
||||
if entry['tag'] == 'rect' and entry['attr'] == 'fill'
|
||||
), None)
|
||||
fallback_fg = next((
|
||||
entry for entry in paint_entries if entry['attr'] == 'stroke'
|
||||
), None)
|
||||
if fallback_fg is None:
|
||||
fallback_fg = next((
|
||||
entry
|
||||
for entry in paint_entries
|
||||
if entry['attr'] == 'fill' and entry is not fallback_bg
|
||||
), None)
|
||||
|
||||
fg_color = pattern_elem.get('data-pptx-fg')
|
||||
bg_color = pattern_elem.get('data-pptx-bg')
|
||||
fg_from_metadata = bool(fg_color)
|
||||
bg_from_metadata = bool(bg_color)
|
||||
if not fg_color and fallback_fg is not None:
|
||||
fg_color = fallback_fg['color']
|
||||
if not bg_color and fallback_bg is not None:
|
||||
bg_color = fallback_bg['color']
|
||||
|
||||
fg_hex, fg_alpha = parse_svg_color(fg_color) if fg_color else (None, 1.0)
|
||||
bg_hex, bg_alpha = parse_svg_color(bg_color) if bg_color else (None, 1.0)
|
||||
if not fg_hex:
|
||||
return ''
|
||||
|
||||
bg_source = next((
|
||||
entry
|
||||
for entry in paint_entries
|
||||
if entry['tag'] == 'rect'
|
||||
and entry['attr'] == 'fill'
|
||||
and entry['hex'] == bg_hex
|
||||
), None)
|
||||
fg_source = next((
|
||||
entry
|
||||
for entry in paint_entries
|
||||
if entry['attr'] == 'stroke' and entry['hex'] == fg_hex
|
||||
), None)
|
||||
if fg_source is None:
|
||||
fg_source = next((
|
||||
entry
|
||||
for entry in paint_entries
|
||||
if entry['attr'] == 'fill'
|
||||
and entry['hex'] == fg_hex
|
||||
and entry is not bg_source
|
||||
), None)
|
||||
|
||||
fg_child_opacity = 1.0
|
||||
if fg_source is not None:
|
||||
fg_child_opacity = fg_source['opacity'] * (
|
||||
fg_source['alpha'] if fg_from_metadata else 1.0
|
||||
)
|
||||
bg_child_opacity = 1.0
|
||||
if bg_source is not None:
|
||||
bg_child_opacity = bg_source['opacity'] * (
|
||||
bg_source['alpha'] if bg_from_metadata else 1.0
|
||||
)
|
||||
|
||||
fg_opacity = combine_opacity(opacity, fg_alpha, fg_child_opacity)
|
||||
bg_opacity = combine_opacity(opacity, bg_alpha, bg_child_opacity)
|
||||
fg_alpha_xml = (
|
||||
f'<a:alpha val="{quantize_ooxml_alpha(fg_opacity)}"/>'
|
||||
if fg_opacity is not None else ''
|
||||
)
|
||||
bg_alpha_xml = (
|
||||
f'<a:alpha val="{quantize_ooxml_alpha(bg_opacity)}"/>'
|
||||
if bg_opacity is not None else ''
|
||||
)
|
||||
|
||||
fg_xml = color_node_xml(fg_hex, theme_color_spec, usage, fg_alpha_xml)
|
||||
if bg_hex:
|
||||
bg_xml = color_node_xml(bg_hex, theme_color_spec, usage, bg_alpha_xml)
|
||||
else:
|
||||
bg_xml = color_node_xml('FFFFFF', theme_color_spec, usage, bg_alpha_xml)
|
||||
|
||||
return (
|
||||
f'<a:pattFill prst="{prst}">'
|
||||
f'<a:fgClr>{fg_xml}</a:fgClr>'
|
||||
f'<a:bgClr>{bg_xml}</a:bgClr>'
|
||||
f'</a:pattFill>'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Marker (arrow-head) support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _marker_size_buckets(w_attr: float, h_attr: float) -> tuple[str, str]:
|
||||
"""Map SVG markerWidth / markerHeight to DrawingML (w, len) buckets.
|
||||
|
||||
DrawingML arrow-end sizing is categorical: sm / med / lg.
|
||||
Width (perpendicular to the line) maps from markerHeight;
|
||||
length (along the line) maps from markerWidth.
|
||||
"""
|
||||
|
||||
def bucket(v: float) -> str:
|
||||
if v < 6:
|
||||
return 'sm'
|
||||
if v > 12:
|
||||
return 'lg'
|
||||
return 'med'
|
||||
|
||||
return bucket(h_attr), bucket(w_attr)
|
||||
|
||||
|
||||
def _classify_marker(marker_elem: ET.Element) -> tuple[str, str, str] | None:
|
||||
"""Classify an SVG <marker> into a DrawingML line-end preset.
|
||||
|
||||
Returns (type, w, len) where:
|
||||
type in {'triangle', 'stealth', 'diamond', 'oval', 'arrow'}
|
||||
w, len in {'sm', 'med', 'lg'}
|
||||
or None if the marker cannot be classified.
|
||||
|
||||
Current coverage is the five DrawingML line-end shapes: triangle, stealth,
|
||||
arrow, diamond, and oval. Anything else returns ``None``.
|
||||
"""
|
||||
mw = _f(marker_elem.get('markerWidth'), 3.0)
|
||||
mh = _f(marker_elem.get('markerHeight'), 3.0)
|
||||
w_bucket, len_bucket = _marker_size_buckets(mw, mh)
|
||||
|
||||
marker_type = classify_project_marker_shape(marker_elem)
|
||||
if marker_type is None:
|
||||
return None
|
||||
return marker_type, w_bucket, len_bucket
|
||||
|
||||
|
||||
def _emit_line_end(
|
||||
elem: ET.Element,
|
||||
ctx: ConvertContext,
|
||||
which: str,
|
||||
) -> str:
|
||||
"""Build <a:headEnd> or <a:tailEnd> XML for an element's marker reference.
|
||||
|
||||
Args:
|
||||
which: 'head' (SVG marker-start) or 'tail' (SVG marker-end).
|
||||
|
||||
Returns empty string if no marker, cannot resolve, or cannot classify.
|
||||
"""
|
||||
attr = 'marker-start' if which == 'head' else 'marker-end'
|
||||
ref = _get_attr(elem, attr, ctx)
|
||||
if not ref or ref == 'none':
|
||||
return ''
|
||||
|
||||
marker_id = resolve_url_id(ref)
|
||||
if not marker_id or marker_id not in ctx.defs:
|
||||
return ''
|
||||
|
||||
marker_elem = ctx.defs[marker_id]
|
||||
tag = marker_elem.tag.replace(f'{{{SVG_NS}}}', '')
|
||||
if tag != 'marker':
|
||||
# ID collision with non-marker defs entry; ignore.
|
||||
return ''
|
||||
|
||||
cls = _classify_marker(marker_elem)
|
||||
if cls is None:
|
||||
print(
|
||||
f' Warning: marker "{marker_id}" shape cannot be classified; '
|
||||
'skipping (supported: triangle, stealth, arrow, diamond, oval)'
|
||||
)
|
||||
return ''
|
||||
|
||||
typ, w_bucket, len_bucket = cls
|
||||
|
||||
# Reclassify size buckets based on markerUnits semantics:
|
||||
#
|
||||
# markerUnits="strokeWidth" (SVG default):
|
||||
# markerWidth IS a ratio to stroke-width, and DrawingML headEnd/tailEnd
|
||||
# also scale proportionally with line width. We should compare the ratio
|
||||
# (markerWidth) directly against ratio-based thresholds — do NOT multiply
|
||||
# by stroke-width, because that double-counts the scaling.
|
||||
# Empirical DrawingML arrow ratios:
|
||||
# sm ≈ 1.5× stroke-width → markerWidth ≤ 2.0
|
||||
# med ≈ 2.5× stroke-width → markerWidth 2.0 – 3.5
|
||||
# lg ≈ 3.5× stroke-width → markerWidth ≥ 3.5
|
||||
#
|
||||
# markerUnits="userSpaceOnUse":
|
||||
# markerWidth/Height are absolute pixel values – keep the existing
|
||||
# absolute-pixel thresholds from _marker_size_buckets (6 / 12).
|
||||
marker_units = marker_elem.get('markerUnits', 'strokeWidth')
|
||||
if marker_units != 'userSpaceOnUse':
|
||||
mw = _f(marker_elem.get('markerWidth'), 3.0)
|
||||
mh = _f(marker_elem.get('markerHeight'), 3.0)
|
||||
|
||||
def _ratio_bucket(v: float) -> str:
|
||||
if v <= 2.0:
|
||||
return 'sm'
|
||||
if v >= 3.5:
|
||||
return 'lg'
|
||||
return 'med'
|
||||
|
||||
w_bucket = _ratio_bucket(mh) # h → perpendicular width
|
||||
len_bucket = _ratio_bucket(mw) # w → length along line
|
||||
|
||||
dml_tag = 'headEnd' if which == 'head' else 'tailEnd'
|
||||
return f'<a:{dml_tag} type="{typ}" w="{w_bucket}" len="{len_bucket}"/>'
|
||||
|
||||
|
||||
def _effective_stroke_scale(elem: ET.Element, ctx: ConvertContext) -> float:
|
||||
"""Approximate the effective SVG geometry transform as one line-width scale."""
|
||||
vector_effect = _get_attr(elem, 'vector-effect', ctx)
|
||||
if vector_effect:
|
||||
vector_effect = parse_project_stroke_enum(
|
||||
'vector-effect',
|
||||
vector_effect,
|
||||
)
|
||||
if vector_effect == 'non-scaling-stroke':
|
||||
return 1.0
|
||||
|
||||
if ctx.use_transform_matrix:
|
||||
matrix = ctx.transform_matrix
|
||||
else:
|
||||
matrix = (
|
||||
ctx.scale_x, 0.0,
|
||||
0.0, ctx.scale_y,
|
||||
ctx.translate_x, ctx.translate_y,
|
||||
)
|
||||
|
||||
# The context already contains ancestor transforms. Shape converters apply
|
||||
# the leaf element's transform directly, so compose that local matrix once.
|
||||
transform = elem.get('transform')
|
||||
if transform:
|
||||
matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
|
||||
|
||||
# DrawingML has one line width. sqrt(|det|) equals the uniform scale for a
|
||||
# similarity transform and the principal-scale geometric mean otherwise.
|
||||
a, b, c, d, _e, _f = matrix
|
||||
return math.sqrt(abs(a * d - b * c))
|
||||
|
||||
|
||||
def build_stroke_xml(
|
||||
elem: ET.Element,
|
||||
ctx: ConvertContext,
|
||||
opacity: float | None = None,
|
||||
) -> str:
|
||||
"""Build <a:ln> XML for stroke, with inherited style support."""
|
||||
stroke = _get_attr(elem, 'stroke', ctx)
|
||||
if not stroke or stroke.strip().lower() in ('none', 'transparent'):
|
||||
return '<a:ln><a:noFill/></a:ln>'
|
||||
|
||||
source_width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0)
|
||||
width_emu = px_to_emu(source_width * _effective_stroke_scale(elem, ctx))
|
||||
validate_ooxml_line_width(width_emu)
|
||||
|
||||
# Dash pattern
|
||||
dash_xml = ''
|
||||
dasharray = _get_attr(elem, 'stroke-dasharray', ctx)
|
||||
if dasharray:
|
||||
parsed_dasharray = parse_project_stroke_dasharray(dasharray)
|
||||
if parsed_dasharray is not None:
|
||||
preset, values = parsed_dasharray
|
||||
if preset:
|
||||
dash_xml = f'<a:prstDash val="{preset}"/>'
|
||||
else:
|
||||
# The project contract normalizes compatible longer arrays to
|
||||
# their first dash/gap pair before DrawingML quantization.
|
||||
d_raw, sp_raw = values[:2]
|
||||
sw = max(source_width, 0.001)
|
||||
d_pct = max(1, round(d_raw / sw * 100000))
|
||||
sp_pct = max(1, round(sp_raw / sw * 100000))
|
||||
dash_xml = (
|
||||
'<a:custDash>'
|
||||
f'<a:ds d="{d_pct}" sp="{sp_pct}"/>'
|
||||
'</a:custDash>'
|
||||
)
|
||||
|
||||
# Line cap
|
||||
cap_map = {'round': 'rnd', 'square': 'sq', 'butt': 'flat'}
|
||||
cap_attr = ''
|
||||
linecap = _get_attr(elem, 'stroke-linecap', ctx)
|
||||
if linecap:
|
||||
linecap = parse_project_stroke_enum('stroke-linecap', linecap)
|
||||
cap_attr = f' cap="{cap_map[linecap]}"'
|
||||
|
||||
# Line join
|
||||
join_xml = ''
|
||||
linejoin = _get_attr(elem, 'stroke-linejoin', ctx)
|
||||
if linejoin:
|
||||
linejoin = parse_project_stroke_enum('stroke-linejoin', linejoin)
|
||||
if linejoin == 'round':
|
||||
join_xml = '<a:round/>'
|
||||
elif linejoin == 'bevel':
|
||||
join_xml = '<a:bevel/>'
|
||||
elif linejoin == 'miter':
|
||||
join_xml = '<a:miter lim="800000"/>'
|
||||
|
||||
# Line-end markers (SVG marker-start / marker-end → <a:headEnd>/<a:tailEnd>)
|
||||
# DrawingML schema order is: fill → prstDash → join → headEnd → tailEnd,
|
||||
# so these must be appended after join_xml.
|
||||
head_end = _emit_line_end(elem, ctx, 'head')
|
||||
tail_end = _emit_line_end(elem, ctx, 'tail')
|
||||
line_ends = head_end + tail_end
|
||||
|
||||
# Gradient stroke
|
||||
grad_id = resolve_url_id(stroke)
|
||||
if grad_id and grad_id in ctx.defs:
|
||||
grad_fill = build_gradient_fill(
|
||||
ctx.defs[grad_id],
|
||||
opacity,
|
||||
ctx.theme_color_spec,
|
||||
"stroke",
|
||||
)
|
||||
return f'<a:ln w="{width_emu}"{cap_attr}>{grad_fill}{dash_xml}{join_xml}{line_ends}</a:ln>'
|
||||
|
||||
# Solid color stroke
|
||||
color, color_alpha = parse_svg_color(stroke)
|
||||
if not color:
|
||||
return '<a:ln><a:noFill/></a:ln>'
|
||||
|
||||
opacity = combine_opacity(opacity, color_alpha)
|
||||
alpha_xml = ''
|
||||
if opacity is not None and opacity < 1.0:
|
||||
alpha_xml = f'<a:alpha val="{quantize_ooxml_alpha(opacity)}"/>'
|
||||
|
||||
color_xml = color_node_xml(color, ctx.theme_color_spec, "stroke", alpha_xml)
|
||||
return f'''<a:ln w="{width_emu}"{cap_attr}>
|
||||
<a:solidFill>{color_xml}</a:solidFill>{dash_xml}{join_xml}{line_ends}
|
||||
</a:ln>'''
|
||||
|
||||
|
||||
def _infer_shadow_alignment(dx: float, dy: float, threshold: float = 0.5) -> str:
|
||||
"""Infer outer shadow alignment from the SVG offset vector.
|
||||
|
||||
DrawingML applies alignment before blur/offset transforms, so we anchor the
|
||||
shadow opposite to the dominant offset direction:
|
||||
- diagonal offsets map to the opposite corner
|
||||
- pure vertical offsets stay centered, matching common PPT shadow presets
|
||||
- pure horizontal offsets anchor to the opposite side
|
||||
"""
|
||||
if abs(dx) < threshold and abs(dy) < threshold:
|
||||
return 'ctr'
|
||||
if abs(dx) < threshold:
|
||||
return 'ctr'
|
||||
if abs(dy) < threshold:
|
||||
return 'l' if dx > 0 else 'r'
|
||||
if dx > 0 and dy > 0:
|
||||
return 'tl'
|
||||
if dx < 0 and dy > 0:
|
||||
return 'tr'
|
||||
if dx > 0 and dy < 0:
|
||||
return 'bl'
|
||||
return 'br'
|
||||
|
||||
|
||||
def _shadow_dir_angle(dx: float, dy: float) -> int:
|
||||
"""Convert an SVG offset vector to DrawingML clockwise angle units.
|
||||
|
||||
OOXML angles are expressed in 60,000ths of a degree, with positive angles
|
||||
rotating clockwise toward the positive Y axis. SVG uses the same screen
|
||||
coordinate orientation (positive Y points downward), so the raw screen-space
|
||||
vector angle can be mapped directly with atan2(dy, dx).
|
||||
"""
|
||||
if abs(dx) < 0.001 and abs(dy) < 0.001:
|
||||
return 0
|
||||
angle_deg = math.degrees(math.atan2(dy, dx)) % 360
|
||||
return int(angle_deg * ANGLE_UNIT)
|
||||
|
||||
|
||||
def build_shadow_xml(
|
||||
filter_elem: ET.Element,
|
||||
opacity: float | None = None,
|
||||
) -> str:
|
||||
"""Build <a:effectLst> with <a:outerShdw> from SVG filter element.
|
||||
|
||||
SVG-to-DrawingML shadow mapping notes:
|
||||
- SVG feGaussianBlur stdDeviation (σ) maps to DrawingML blurRad using a
|
||||
2.0× scale. Rationale: σ is a standard deviation whose visual radius
|
||||
is ~3σ, while DrawingML blurRad is an outer-spread pixel distance.
|
||||
A 1.0× scale makes PowerPoint render sharp, concentrated shadows
|
||||
("heavy" visual). 2.0× matches the CSS drop-shadow↔box-shadow
|
||||
convention and produces softer diffusion closer to the SVG preview.
|
||||
- The algn attribute is inferred from the offset direction so that
|
||||
the shadow aligns naturally with the shape edge.
|
||||
"""
|
||||
if filter_elem is None:
|
||||
return ''
|
||||
|
||||
p = parse_project_filter_params(filter_elem)
|
||||
dx = p['dx']
|
||||
dy = p['dy']
|
||||
# For shadow, default dy to 4 if no offset was found
|
||||
if not p['has_offset']:
|
||||
dy = 4.0
|
||||
p = {**p, 'dy': dy}
|
||||
|
||||
coordinates = project_filter_drawingml_coordinates(p, 'shadow')
|
||||
blur_rad = coordinates['blurRad']
|
||||
dist = coordinates['dist']
|
||||
dir_angle = _shadow_dir_angle(dx, dy)
|
||||
# PowerPoint renders outerShdw alpha slightly heavier than SVG's filter
|
||||
# composite (different blending path). Scale by 0.75 to match the SVG
|
||||
# preview after blur has been corrected to 2.0× σ.
|
||||
opacity_multiplier = 1.0 if opacity is None else opacity
|
||||
alpha_val = quantize_ooxml_alpha(
|
||||
p['opacity'] * opacity_multiplier * 0.75
|
||||
)
|
||||
algn = _infer_shadow_alignment(dx, dy)
|
||||
|
||||
return f'''<a:effectLst>
|
||||
<a:outerShdw blurRad="{blur_rad}" dist="{dist}" dir="{dir_angle}" algn="{algn}" rotWithShape="0">
|
||||
<a:srgbClr val="{p['color']}"><a:alpha val="{alpha_val}"/></a:srgbClr>
|
||||
</a:outerShdw>
|
||||
</a:effectLst>'''
|
||||
|
||||
|
||||
def build_glow_xml(
|
||||
filter_elem: ET.Element,
|
||||
opacity: float | None = None,
|
||||
) -> str:
|
||||
"""Build <a:effectLst> with <a:glow> from SVG filter element.
|
||||
|
||||
Used for filters that have feGaussianBlur without meaningful feOffset,
|
||||
typically title glow or highlight effects.
|
||||
"""
|
||||
if filter_elem is None:
|
||||
return ''
|
||||
|
||||
p = parse_project_filter_params(filter_elem)
|
||||
rad = project_filter_drawingml_coordinates(p, 'glow')['rad']
|
||||
opacity_multiplier = 1.0 if opacity is None else opacity
|
||||
alpha_val = quantize_ooxml_alpha(p['opacity'] * opacity_multiplier)
|
||||
|
||||
return f'''<a:effectLst>
|
||||
<a:glow rad="{rad}">
|
||||
<a:srgbClr val="{p['color']}"><a:alpha val="{alpha_val}"/></a:srgbClr>
|
||||
</a:glow>
|
||||
</a:effectLst>'''
|
||||
|
||||
|
||||
def classify_filter_effect(filter_elem: ET.Element) -> str | None:
|
||||
"""Classify an SVG filter into a supported DrawingML effect kind."""
|
||||
if filter_elem is None:
|
||||
return None
|
||||
|
||||
p = parse_project_filter_params(filter_elem)
|
||||
return 'shadow' if p['has_offset'] else 'glow'
|
||||
|
||||
|
||||
def build_effect_xml(
|
||||
filter_elem: ET.Element,
|
||||
opacity: float | None = None,
|
||||
) -> str:
|
||||
"""Build effect XML by classifying the SVG filter as shadow or glow.
|
||||
|
||||
Classification rules:
|
||||
- feOffset with non-zero dx/dy → outer shadow
|
||||
- No feOffset or zero offset → glow effect
|
||||
"""
|
||||
if filter_elem is None:
|
||||
return ''
|
||||
|
||||
effect_kind = classify_filter_effect(filter_elem)
|
||||
if effect_kind == 'shadow':
|
||||
return build_shadow_xml(filter_elem, opacity)
|
||||
if effect_kind == 'glow':
|
||||
return build_glow_xml(filter_elem, opacity)
|
||||
return ''
|
||||
|
||||
|
||||
def get_element_opacity(
|
||||
elem: ET.Element,
|
||||
ctx: ConvertContext | None = None,
|
||||
) -> float | None:
|
||||
"""Get local opacity multiplied by any approximated ancestor group alpha."""
|
||||
base = ctx.opacity_multiplier if ctx is not None else 1.0
|
||||
if ctx is not None:
|
||||
op = _get_attr(elem, 'opacity', ctx)
|
||||
else:
|
||||
op = parse_inline_style(elem.get('style')).get('opacity') or elem.get('opacity')
|
||||
if op is None:
|
||||
return base if base < 1.0 else None
|
||||
val = base * parse_opacity(op)
|
||||
return val if val < 1.0 else None
|
||||
|
||||
|
||||
def get_fill_opacity(
|
||||
elem: ET.Element,
|
||||
ctx: ConvertContext | None = None,
|
||||
) -> float | None:
|
||||
"""Get effective fill opacity combining 'opacity' and 'fill-opacity'.
|
||||
|
||||
Returns:
|
||||
Combined opacity value, or None if fully opaque.
|
||||
"""
|
||||
base = ctx.opacity_multiplier if ctx is not None else 1.0
|
||||
|
||||
op = _get_attr(elem, 'opacity', ctx) if ctx else elem.get('opacity')
|
||||
if op is not None:
|
||||
base *= parse_opacity(op)
|
||||
|
||||
fill_op = _get_attr(elem, 'fill-opacity', ctx) if ctx else elem.get('fill-opacity')
|
||||
if fill_op is not None:
|
||||
base *= parse_opacity(fill_op)
|
||||
|
||||
return base if base < 1.0 else None
|
||||
|
||||
|
||||
def get_stroke_opacity(
|
||||
elem: ET.Element,
|
||||
ctx: ConvertContext | None = None,
|
||||
) -> float | None:
|
||||
"""Get effective stroke opacity combining 'opacity' and 'stroke-opacity'.
|
||||
|
||||
Returns:
|
||||
Combined opacity value, or None if fully opaque.
|
||||
"""
|
||||
base = ctx.opacity_multiplier if ctx is not None else 1.0
|
||||
|
||||
op = _get_attr(elem, 'opacity', ctx) if ctx else elem.get('opacity')
|
||||
if op is not None:
|
||||
base *= parse_opacity(op)
|
||||
|
||||
stroke_op = _get_attr(elem, 'stroke-opacity', ctx) if ctx else elem.get('stroke-opacity')
|
||||
if stroke_op is not None:
|
||||
base *= parse_opacity(stroke_op)
|
||||
|
||||
return base if base < 1.0 else None
|
||||
@@ -0,0 +1,763 @@
|
||||
"""Closed project grammar for SVG text presentation properties.
|
||||
|
||||
The project accepts a deliberately small SVG text surface that maps
|
||||
deterministically to editable DrawingML. This module is shared by the quality
|
||||
checker and the converter so unsupported values cannot be silently normalized
|
||||
by one route and rejected by the other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .utils import font_px_to_hpt, parse_svg_length
|
||||
|
||||
|
||||
_SVG_TEXT_PROPERTIES = frozenset({
|
||||
'font-weight',
|
||||
'font-style',
|
||||
'text-anchor',
|
||||
'letter-spacing',
|
||||
'text-decoration',
|
||||
})
|
||||
|
||||
_TEXT_DECLARATION_PROPERTIES = _SVG_TEXT_PROPERTIES | {
|
||||
'font-family',
|
||||
'font-size',
|
||||
}
|
||||
|
||||
_TEXT_INHERITANCE_TARGETS = frozenset({'svg', 'g', 'text', 'tspan'})
|
||||
|
||||
_UNSUPPORTED_TEXT_PROPERTIES = frozenset({
|
||||
'alignment-baseline',
|
||||
'baseline-shift',
|
||||
'direction',
|
||||
'dominant-baseline',
|
||||
'font-kerning',
|
||||
'font-feature-settings',
|
||||
'font-size-adjust',
|
||||
'font-stretch',
|
||||
'font-synthesis',
|
||||
'font-variant',
|
||||
'font-variation-settings',
|
||||
'font',
|
||||
'hyphens',
|
||||
'kerning',
|
||||
'line-height',
|
||||
'overflow-wrap',
|
||||
'text-align',
|
||||
'text-align-last',
|
||||
'text-indent',
|
||||
'text-rendering',
|
||||
'text-shadow',
|
||||
'text-transform',
|
||||
'unicode-bidi',
|
||||
'vertical-align',
|
||||
'white-space',
|
||||
'word-spacing',
|
||||
'word-break',
|
||||
'writing-mode',
|
||||
})
|
||||
|
||||
_TEXT_DIRECT_ATTRIBUTES = frozenset({
|
||||
'fill',
|
||||
'fill-opacity',
|
||||
'filter',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-style',
|
||||
'font-weight',
|
||||
'id',
|
||||
'letter-spacing',
|
||||
'opacity',
|
||||
'stroke',
|
||||
'stroke-opacity',
|
||||
'stroke-width',
|
||||
'style',
|
||||
'text-anchor',
|
||||
'text-decoration',
|
||||
'transform',
|
||||
'x',
|
||||
'xml:space',
|
||||
'y',
|
||||
})
|
||||
|
||||
_TSPAN_DIRECT_ATTRIBUTES = frozenset({
|
||||
'dx',
|
||||
'dy',
|
||||
'fill',
|
||||
'fill-opacity',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-style',
|
||||
'font-weight',
|
||||
'id',
|
||||
'letter-spacing',
|
||||
'opacity',
|
||||
'stroke',
|
||||
'stroke-opacity',
|
||||
'stroke-width',
|
||||
'style',
|
||||
'text-decoration',
|
||||
'x',
|
||||
'xml:space',
|
||||
'y',
|
||||
})
|
||||
|
||||
_TEXT_INLINE_PROPERTIES = frozenset({
|
||||
'fill',
|
||||
'fill-opacity',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-style',
|
||||
'font-weight',
|
||||
'letter-spacing',
|
||||
'opacity',
|
||||
'shape-rendering',
|
||||
'stroke',
|
||||
'stroke-opacity',
|
||||
'stroke-width',
|
||||
'text-anchor',
|
||||
'text-decoration',
|
||||
})
|
||||
|
||||
_TSPAN_INLINE_PROPERTIES = _TEXT_INLINE_PROPERTIES - {'text-anchor'}
|
||||
|
||||
_CANONICAL_DECIMAL_RE = re.compile(r'^-?(?:\d+(?:\.\d+)?|\.\d+)$')
|
||||
_COMPATIBLE_LETTER_SPACING_RE = re.compile(
|
||||
r'(-?(?:\d+(?:\.\d+)?|\.\d+))(px|pt|em)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace'
|
||||
_XML_SPACE_ATTRIBUTE = f'{{{_XML_NAMESPACE}}}space'
|
||||
_PROJECT_XML_SPACE_VALUES = frozenset({'default', 'preserve'})
|
||||
_DRAWINGML_TEXT_SPACING_MIN = -400_000
|
||||
_DRAWINGML_TEXT_SPACING_MAX = 400_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedTextProperty:
|
||||
"""One validated text-property value and its canonical representation."""
|
||||
|
||||
value: object
|
||||
canonical: str
|
||||
compatible: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextPropertyDiagnostic:
|
||||
"""Stable checker/converter diagnostic for one text declaration."""
|
||||
|
||||
severity: str
|
||||
label: str
|
||||
source: str
|
||||
name: str
|
||||
raw: str
|
||||
message: str
|
||||
canonical: str | None = None
|
||||
|
||||
|
||||
def _local_name(value: object) -> str:
|
||||
text = str(value)
|
||||
return text.rsplit('}', 1)[-1] if '}' in text else text
|
||||
|
||||
|
||||
def _element_label(elem: ET.Element) -> str:
|
||||
tag = _local_name(elem.tag)
|
||||
elem_id = elem.get('id')
|
||||
return f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>'
|
||||
|
||||
|
||||
def _attribute_name(raw_name: str) -> str:
|
||||
if raw_name.startswith(f'{{{_XML_NAMESPACE}}}'):
|
||||
return f'xml:{raw_name.rsplit("}", 1)[-1]}'
|
||||
return _local_name(raw_name)
|
||||
|
||||
|
||||
def resolve_project_xml_space(
|
||||
elem: ET.Element,
|
||||
inherited: str = 'default',
|
||||
) -> str:
|
||||
"""Resolve the exact project ``xml:space`` value for one text element."""
|
||||
if inherited not in _PROJECT_XML_SPACE_VALUES:
|
||||
raise ValueError(f'invalid inherited xml:space value {inherited!r}')
|
||||
raw = elem.get(_XML_SPACE_ATTRIBUTE)
|
||||
if raw is None:
|
||||
raw = elem.get('xml:space')
|
||||
if raw is None:
|
||||
return inherited
|
||||
if raw not in _PROJECT_XML_SPACE_VALUES:
|
||||
raise ValueError("xml:space must be exactly 'default' or 'preserve'")
|
||||
return raw
|
||||
|
||||
|
||||
def normalize_project_text_segments(
|
||||
segments: list[tuple[str, str]],
|
||||
) -> list[tuple[int, str]]:
|
||||
"""Normalize text whitespace while retaining the source segment owner.
|
||||
|
||||
Each input tuple is ``(effective_xml_space, raw_text)``. The returned
|
||||
tuples are ``(input_index, normalized_text)`` so callers can retain run
|
||||
formatting. Project whitespace follows rendered SVG behavior: tabs and
|
||||
line endings become ordinary spaces; ``default`` runs collapse across
|
||||
element boundaries and lose only overall leading/trailing spaces;
|
||||
``preserve`` runs retain every resulting ordinary space. Unicode spacing
|
||||
characters such as NBSP are text, not XML whitespace, and remain intact.
|
||||
"""
|
||||
output: list[tuple[int, str]] = []
|
||||
pending_default_space: int | None = None
|
||||
|
||||
def append(index: int, text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
if output and output[-1][0] == index:
|
||||
owner, existing = output[-1]
|
||||
output[-1] = (owner, existing + text)
|
||||
else:
|
||||
output.append((index, text))
|
||||
|
||||
def flush_pending() -> None:
|
||||
nonlocal pending_default_space
|
||||
if pending_default_space is not None and output:
|
||||
append(pending_default_space, ' ')
|
||||
pending_default_space = None
|
||||
|
||||
for index, (xml_space, raw_text) in enumerate(segments):
|
||||
if xml_space not in _PROJECT_XML_SPACE_VALUES:
|
||||
raise ValueError(
|
||||
f'xml:space must be exactly default or preserve; got '
|
||||
f'{xml_space!r}'
|
||||
)
|
||||
text = re.sub(r'[\t\r\n]', ' ', raw_text)
|
||||
for char in text:
|
||||
if xml_space == 'default' and char == ' ':
|
||||
if pending_default_space is None:
|
||||
pending_default_space = index
|
||||
continue
|
||||
flush_pending()
|
||||
append(index, char)
|
||||
|
||||
# A pending default-mode space is the overall trailing space and is
|
||||
# intentionally discarded. Preserved trailing spaces were emitted inline.
|
||||
return output
|
||||
|
||||
|
||||
def _is_unregistered_prefixed_text_property(name: str) -> bool:
|
||||
lowered = name.lower()
|
||||
return (
|
||||
lowered.startswith(('font-', 'text-'))
|
||||
and lowered not in _TEXT_DECLARATION_PROPERTIES
|
||||
)
|
||||
|
||||
|
||||
def _format_decimal(value: float) -> str:
|
||||
if abs(value) < 1e-15:
|
||||
return '0'
|
||||
text = f'{value:.15f}'.rstrip('0').rstrip('.')
|
||||
return '0' if text in {'', '-0'} else text
|
||||
|
||||
|
||||
def parse_project_font_weight(raw: str) -> ParsedTextProperty:
|
||||
"""Parse the closed project font-weight grammar."""
|
||||
if raw in {'normal', 'bold'}:
|
||||
return ParsedTextProperty(raw == 'bold', raw)
|
||||
if raw in {str(value) for value in range(100, 1000, 100)}:
|
||||
return ParsedTextProperty(int(raw) >= 600, raw)
|
||||
aliases = {'medium': '500', 'semibold': '600'}
|
||||
if raw in aliases:
|
||||
canonical = aliases[raw]
|
||||
return ParsedTextProperty(int(canonical) >= 600, canonical, True)
|
||||
raise ValueError(
|
||||
"expected 'normal', 'bold', or an integer weight from 100 through 900"
|
||||
)
|
||||
|
||||
|
||||
def parse_project_font_style(raw: str) -> ParsedTextProperty:
|
||||
"""Parse the closed project font-style grammar."""
|
||||
if raw not in {'normal', 'italic'}:
|
||||
raise ValueError("expected 'normal' or 'italic'")
|
||||
return ParsedTextProperty(raw == 'italic', raw)
|
||||
|
||||
|
||||
def parse_project_text_anchor(raw: str) -> ParsedTextProperty:
|
||||
"""Parse the closed project text-anchor grammar."""
|
||||
if raw not in {'start', 'middle', 'end'}:
|
||||
raise ValueError("expected 'start', 'middle', or 'end'")
|
||||
return ParsedTextProperty(raw, raw)
|
||||
|
||||
|
||||
def parse_project_text_decoration(raw: str) -> ParsedTextProperty:
|
||||
"""Parse text decoration without substring-based false positives."""
|
||||
canonical = {
|
||||
'none': 'none',
|
||||
'underline': 'underline',
|
||||
'line-through': 'line-through',
|
||||
'underline line-through': 'underline line-through',
|
||||
}
|
||||
if raw in canonical:
|
||||
value = (
|
||||
'underline' in raw.split(),
|
||||
'line-through' in raw.split(),
|
||||
)
|
||||
return ParsedTextProperty(value, canonical[raw])
|
||||
if raw == 'line-through underline':
|
||||
return ParsedTextProperty(
|
||||
(True, True),
|
||||
'underline line-through',
|
||||
True,
|
||||
)
|
||||
raise ValueError(
|
||||
"expected 'none', 'underline', 'line-through', or "
|
||||
"'underline line-through'"
|
||||
)
|
||||
|
||||
|
||||
def parse_project_letter_spacing(
|
||||
raw: str,
|
||||
*,
|
||||
font_size: float = 16.0,
|
||||
scale_x: float = 1.0,
|
||||
) -> ParsedTextProperty:
|
||||
"""Parse project tracking into scaled SVG pixels and validate DML range."""
|
||||
if _CANONICAL_DECIMAL_RE.fullmatch(raw):
|
||||
amount = float(raw)
|
||||
unit = ''
|
||||
compatible = False
|
||||
else:
|
||||
match = _COMPATIBLE_LETTER_SPACING_RE.fullmatch(raw)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
'expected a finite ordinary decimal, optionally followed by '
|
||||
'the registered compatible unit px, pt, or em'
|
||||
)
|
||||
amount = float(match.group(1))
|
||||
unit = match.group(2).lower()
|
||||
compatible = True
|
||||
|
||||
if not math.isfinite(amount):
|
||||
raise ValueError('must be finite')
|
||||
if not math.isfinite(font_size) or font_size <= 0:
|
||||
raise ValueError('requires a finite positive effective font size')
|
||||
if not math.isfinite(scale_x) or scale_x <= 0:
|
||||
raise ValueError('requires a finite positive horizontal scale')
|
||||
|
||||
if unit == 'em':
|
||||
value_px = amount * font_size
|
||||
elif unit == 'pt':
|
||||
value_px = amount * 4.0 / 3.0 * scale_x
|
||||
else:
|
||||
value_px = amount * scale_x
|
||||
|
||||
spacing = round(value_px * 75)
|
||||
if not _DRAWINGML_TEXT_SPACING_MIN <= spacing <= _DRAWINGML_TEXT_SPACING_MAX:
|
||||
raise ValueError(
|
||||
'converts outside the DrawingML character-spacing range '
|
||||
f'{_DRAWINGML_TEXT_SPACING_MIN}..{_DRAWINGML_TEXT_SPACING_MAX}'
|
||||
)
|
||||
return ParsedTextProperty(
|
||||
value_px,
|
||||
_format_decimal(value_px),
|
||||
compatible,
|
||||
)
|
||||
|
||||
|
||||
def drawingml_letter_spacing(value_px: float) -> int:
|
||||
"""Return validated DrawingML ``a:rPr@spc`` hundredths-of-a-point."""
|
||||
if not math.isfinite(value_px):
|
||||
raise ValueError('letter-spacing must be finite')
|
||||
spacing = round(value_px * 75)
|
||||
if not _DRAWINGML_TEXT_SPACING_MIN <= spacing <= _DRAWINGML_TEXT_SPACING_MAX:
|
||||
raise ValueError(
|
||||
'letter-spacing converts outside the DrawingML range '
|
||||
f'{_DRAWINGML_TEXT_SPACING_MIN}..{_DRAWINGML_TEXT_SPACING_MAX}'
|
||||
)
|
||||
return spacing
|
||||
|
||||
|
||||
def parse_project_text_property(
|
||||
name: str,
|
||||
raw: str,
|
||||
*,
|
||||
font_size: float = 16.0,
|
||||
) -> ParsedTextProperty:
|
||||
"""Parse one declaration from the shared text-property value contract."""
|
||||
parsers = {
|
||||
'font-weight': parse_project_font_weight,
|
||||
'font-style': parse_project_font_style,
|
||||
'text-anchor': parse_project_text_anchor,
|
||||
'letter-spacing': parse_project_letter_spacing,
|
||||
'text-decoration': parse_project_text_decoration,
|
||||
}
|
||||
parser = parsers.get(name)
|
||||
if parser is None:
|
||||
raise ValueError(f'unsupported project text property {name!r}')
|
||||
if name == 'letter-spacing':
|
||||
return parser(raw, font_size=font_size)
|
||||
return parser(raw)
|
||||
|
||||
|
||||
def _iter_style_declarations(
|
||||
elem: ET.Element,
|
||||
) -> tuple[list[tuple[str, str]], list[str]]:
|
||||
declarations: list[tuple[str, str]] = []
|
||||
malformed: list[str] = []
|
||||
for raw_fragment in (elem.get('style') or '').split(';'):
|
||||
fragment = raw_fragment.strip()
|
||||
if not fragment:
|
||||
continue
|
||||
if ':' not in fragment:
|
||||
malformed.append(fragment)
|
||||
continue
|
||||
raw_name, raw_value = fragment.split(':', 1)
|
||||
name = raw_name.strip().lower()
|
||||
value = raw_value.strip()
|
||||
if not name or not value:
|
||||
malformed.append(fragment)
|
||||
continue
|
||||
declarations.append((name, value))
|
||||
return declarations, malformed
|
||||
|
||||
|
||||
def _resolve_font_sizes(
|
||||
root: ET.Element,
|
||||
) -> tuple[dict[int, float], list[TextPropertyDiagnostic]]:
|
||||
"""Resolve inherited font sizes and retain declaration-level failures."""
|
||||
resolved: dict[int, float] = {}
|
||||
diagnostics: list[TextPropertyDiagnostic] = []
|
||||
|
||||
def parse_declared_size(
|
||||
elem: ET.Element,
|
||||
raw: str,
|
||||
source: str,
|
||||
parent_size: float,
|
||||
root_size: float,
|
||||
) -> float | None:
|
||||
label = _element_label(elem)
|
||||
relative_base = (
|
||||
root_size
|
||||
if raw.strip().lower().endswith('rem')
|
||||
else parent_size
|
||||
)
|
||||
try:
|
||||
value = parse_svg_length(
|
||||
raw,
|
||||
parent_size,
|
||||
font_size=relative_base,
|
||||
)
|
||||
font_px_to_hpt(value)
|
||||
except ValueError as exc:
|
||||
diagnostics.append(TextPropertyDiagnostic(
|
||||
'error',
|
||||
label,
|
||||
source,
|
||||
'font-size',
|
||||
raw,
|
||||
f'{label} {source} font-size={raw!r}: {exc}',
|
||||
))
|
||||
return None
|
||||
return value
|
||||
|
||||
def walk(
|
||||
elem: ET.Element,
|
||||
parent_size: float,
|
||||
root_size: float,
|
||||
) -> None:
|
||||
declarations, _ = _iter_style_declarations(elem)
|
||||
style_sizes = [
|
||||
raw
|
||||
for name, raw in declarations
|
||||
if name == 'font-size'
|
||||
]
|
||||
direct_raw = elem.get('font-size')
|
||||
direct_size = (
|
||||
parse_declared_size(
|
||||
elem,
|
||||
direct_raw,
|
||||
'attribute',
|
||||
parent_size,
|
||||
root_size,
|
||||
)
|
||||
if direct_raw is not None
|
||||
else None
|
||||
)
|
||||
parsed_style_sizes = [
|
||||
parse_declared_size(
|
||||
elem,
|
||||
raw,
|
||||
'inline style',
|
||||
parent_size,
|
||||
root_size,
|
||||
)
|
||||
for raw in style_sizes
|
||||
]
|
||||
effective_size = parent_size
|
||||
if style_sizes:
|
||||
last_style_size = parsed_style_sizes[-1]
|
||||
effective_size = (
|
||||
last_style_size
|
||||
if last_style_size is not None
|
||||
else parent_size
|
||||
)
|
||||
elif direct_raw is not None:
|
||||
effective_size = direct_size if direct_size is not None else parent_size
|
||||
resolved[id(elem)] = effective_size
|
||||
child_root_size = effective_size if elem is root else root_size
|
||||
for child in elem:
|
||||
walk(child, effective_size, child_root_size)
|
||||
|
||||
walk(root, 16.0, 16.0)
|
||||
return resolved, diagnostics
|
||||
|
||||
|
||||
def resolve_project_font_sizes(root: ET.Element) -> dict[int, float]:
|
||||
"""Return effective SVG font sizes or reject an invalid declaration."""
|
||||
resolved, diagnostics = _resolve_font_sizes(root)
|
||||
if diagnostics:
|
||||
raise ValueError('; '.join(item.message for item in diagnostics[:8]))
|
||||
return resolved
|
||||
|
||||
|
||||
def resolve_project_letter_spacings(
|
||||
root: ET.Element,
|
||||
font_sizes: dict[int, float] | None = None,
|
||||
) -> dict[int, float]:
|
||||
"""Resolve tracking at its declaration site before it is inherited."""
|
||||
effective_font_sizes = font_sizes or resolve_project_font_sizes(root)
|
||||
resolved: dict[int, float] = {}
|
||||
|
||||
def walk(elem: ET.Element, parent_spacing: float) -> None:
|
||||
declarations, _ = _iter_style_declarations(elem)
|
||||
style = dict(declarations)
|
||||
direct_raw = elem.get('letter-spacing')
|
||||
style_raw = style.get('letter-spacing')
|
||||
effective_spacing = parent_spacing
|
||||
if style_raw is not None:
|
||||
effective_spacing = float(parse_project_letter_spacing(
|
||||
style_raw,
|
||||
font_size=effective_font_sizes[id(elem)],
|
||||
).value)
|
||||
elif direct_raw is not None:
|
||||
effective_spacing = float(parse_project_letter_spacing(
|
||||
direct_raw,
|
||||
font_size=effective_font_sizes[id(elem)],
|
||||
).value)
|
||||
resolved[id(elem)] = effective_spacing
|
||||
for child in elem:
|
||||
walk(child, effective_spacing)
|
||||
|
||||
walk(root, 0.0)
|
||||
return resolved
|
||||
|
||||
|
||||
def materialize_project_text_metrics(root: ET.Element) -> int:
|
||||
"""Lower relative text metrics before positional tspan restructuring."""
|
||||
font_sizes = resolve_project_font_sizes(root)
|
||||
letter_spacings = resolve_project_letter_spacings(root, font_sizes)
|
||||
materialized = 0
|
||||
for elem in root.iter():
|
||||
canonical_font_size = _format_decimal(font_sizes[id(elem)])
|
||||
canonical_letter_spacing = _format_decimal(letter_spacings[id(elem)])
|
||||
if elem.get('font-size') is not None:
|
||||
elem.set('font-size', canonical_font_size)
|
||||
materialized += 1
|
||||
if elem.get('letter-spacing') is not None:
|
||||
elem.set('letter-spacing', canonical_letter_spacing)
|
||||
materialized += 1
|
||||
|
||||
style = elem.get('style')
|
||||
if not style:
|
||||
continue
|
||||
retained: list[str] = []
|
||||
changed = False
|
||||
for raw_fragment in style.split(';'):
|
||||
fragment = raw_fragment.strip()
|
||||
if not fragment:
|
||||
continue
|
||||
if ':' not in fragment:
|
||||
retained.append(fragment)
|
||||
continue
|
||||
raw_name, _ = fragment.split(':', 1)
|
||||
name = raw_name.strip().lower()
|
||||
if name == 'font-size':
|
||||
retained.append(f'font-size:{canonical_font_size}')
|
||||
changed = True
|
||||
materialized += 1
|
||||
elif name == 'letter-spacing':
|
||||
retained.append(
|
||||
f'letter-spacing:{canonical_letter_spacing}'
|
||||
)
|
||||
changed = True
|
||||
materialized += 1
|
||||
else:
|
||||
retained.append(fragment)
|
||||
if changed:
|
||||
elem.set('style', '; '.join(retained))
|
||||
return materialized
|
||||
|
||||
|
||||
def _diagnose_text_declaration(
|
||||
elem: ET.Element,
|
||||
*,
|
||||
tag: str,
|
||||
source: str,
|
||||
name: str,
|
||||
raw: str,
|
||||
font_size: float,
|
||||
) -> tuple[bool, TextPropertyDiagnostic | None]:
|
||||
"""Return whether a declaration belongs to the text contract and its issue."""
|
||||
label = _element_label(elem)
|
||||
if name == 'xml:space':
|
||||
if source != 'attribute' or tag not in {'text', 'tspan'}:
|
||||
return True, TextPropertyDiagnostic(
|
||||
'error', label, source, name, raw,
|
||||
f'{label} can use xml:space only as a direct attribute on '
|
||||
'<text> or <tspan>',
|
||||
)
|
||||
if raw not in _PROJECT_XML_SPACE_VALUES:
|
||||
return True, TextPropertyDiagnostic(
|
||||
'error', label, source, name, raw,
|
||||
f'{label} attribute xml:space={raw!r}: expected exactly '
|
||||
"'default' or 'preserve'",
|
||||
)
|
||||
return True, None
|
||||
if _is_unregistered_prefixed_text_property(name):
|
||||
return True, TextPropertyDiagnostic(
|
||||
'error', label, source, name, raw,
|
||||
f'{label} uses unregistered inherited text property {name!r}; '
|
||||
'native PPTX export would ignore it',
|
||||
)
|
||||
if (
|
||||
name in _TEXT_DECLARATION_PROPERTIES
|
||||
and tag not in _TEXT_INHERITANCE_TARGETS
|
||||
):
|
||||
return True, TextPropertyDiagnostic(
|
||||
'error', label, source, name, raw,
|
||||
f'{label} cannot carry text property {name!r}; place it on '
|
||||
'<svg>, <g>, <text>, or <tspan>',
|
||||
)
|
||||
if name in _UNSUPPORTED_TEXT_PROPERTIES:
|
||||
return True, TextPropertyDiagnostic(
|
||||
'error', label, source, name, raw,
|
||||
f'{label} uses unsupported text property {name!r}; '
|
||||
'it has no registered DrawingML mapping',
|
||||
)
|
||||
if name not in _SVG_TEXT_PROPERTIES:
|
||||
return name in _TEXT_DECLARATION_PROPERTIES, None
|
||||
if tag == 'tspan' and name == 'text-anchor':
|
||||
return True, TextPropertyDiagnostic(
|
||||
'error', label, source, name, raw,
|
||||
f'{label} cannot use text-anchor on <tspan>; place it on the '
|
||||
'containing <text> or an ancestor group',
|
||||
)
|
||||
try:
|
||||
parsed = parse_project_text_property(
|
||||
name,
|
||||
raw,
|
||||
font_size=font_size,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return True, TextPropertyDiagnostic(
|
||||
'error', label, source, name, raw,
|
||||
f'{label} {source} {name}={raw!r}: {exc}',
|
||||
)
|
||||
if parsed.compatible:
|
||||
return True, TextPropertyDiagnostic(
|
||||
'warning', label, source, name, raw,
|
||||
f'{label} {source} {name}={raw!r} is compatible; '
|
||||
f'prefer {name}={parsed.canonical!r}',
|
||||
parsed.canonical,
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
def project_text_property_diagnostics(
|
||||
root: ET.Element,
|
||||
) -> list[TextPropertyDiagnostic]:
|
||||
"""Validate the closed text attribute/value surface for one SVG tree."""
|
||||
font_sizes, diagnostics = _resolve_font_sizes(root)
|
||||
|
||||
for elem in root.iter():
|
||||
tag = _local_name(elem.tag)
|
||||
label = _element_label(elem)
|
||||
direct_allowlist = {
|
||||
'text': _TEXT_DIRECT_ATTRIBUTES,
|
||||
'tspan': _TSPAN_DIRECT_ATTRIBUTES,
|
||||
}.get(tag)
|
||||
inline_allowlist = {
|
||||
'text': _TEXT_INLINE_PROPERTIES,
|
||||
'tspan': _TSPAN_INLINE_PROPERTIES,
|
||||
}.get(tag)
|
||||
|
||||
for raw_name, raw in elem.attrib.items():
|
||||
name = _attribute_name(raw_name)
|
||||
if name == 'style' or name.startswith('data-'):
|
||||
continue
|
||||
handled, diagnostic = _diagnose_text_declaration(
|
||||
elem,
|
||||
tag=tag,
|
||||
source='attribute',
|
||||
name=name,
|
||||
raw=raw,
|
||||
font_size=font_sizes[id(elem)],
|
||||
)
|
||||
if diagnostic is not None:
|
||||
diagnostics.append(diagnostic)
|
||||
elif (
|
||||
not handled
|
||||
and direct_allowlist is not None
|
||||
and name not in direct_allowlist
|
||||
):
|
||||
diagnostics.append(TextPropertyDiagnostic(
|
||||
'error', label, 'attribute', name, raw,
|
||||
f'{label} uses unsupported text attribute {name!r}; '
|
||||
'native PPTX export would ignore it',
|
||||
))
|
||||
|
||||
declarations, malformed = _iter_style_declarations(elem)
|
||||
for fragment in malformed:
|
||||
property_hint = fragment.split(None, 1)[0].lower()
|
||||
if (
|
||||
inline_allowlist is not None
|
||||
or property_hint in _TEXT_DECLARATION_PROPERTIES
|
||||
or property_hint in _UNSUPPORTED_TEXT_PROPERTIES
|
||||
or _is_unregistered_prefixed_text_property(property_hint)
|
||||
):
|
||||
diagnostics.append(TextPropertyDiagnostic(
|
||||
'error', label, 'inline style', '<malformed>', fragment,
|
||||
f'{label} has malformed inline style declaration {fragment!r}',
|
||||
))
|
||||
for name, raw in declarations:
|
||||
handled, diagnostic = _diagnose_text_declaration(
|
||||
elem,
|
||||
tag=tag,
|
||||
source='inline style',
|
||||
name=name,
|
||||
raw=raw,
|
||||
font_size=font_sizes[id(elem)],
|
||||
)
|
||||
if diagnostic is not None:
|
||||
diagnostics.append(diagnostic)
|
||||
elif (
|
||||
not handled
|
||||
and inline_allowlist is not None
|
||||
and name not in inline_allowlist
|
||||
):
|
||||
diagnostics.append(TextPropertyDiagnostic(
|
||||
'error', label, 'inline style', name, raw,
|
||||
f'{label} uses unsupported inline text property {name!r}; '
|
||||
'native PPTX export would ignore it',
|
||||
))
|
||||
|
||||
return diagnostics
|
||||
|
||||
|
||||
def project_text_property_errors(root: ET.Element) -> list[str]:
|
||||
"""Return blocking diagnostics for the converter preflight."""
|
||||
return [
|
||||
diagnostic.message
|
||||
for diagnostic in project_text_property_diagnostics(root)
|
||||
if diagnostic.severity == 'error'
|
||||
]
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Theme-color contracts shared by SVG conversion and PPTX package assembly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .utils import parse_hex_color
|
||||
|
||||
|
||||
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
_LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$")
|
||||
_SRGB_PAIR_RE = re.compile(
|
||||
r"<a:srgbClr\b(?P<attrs>[^>]*?)(?<!/)>(?P<body>.*?)</a:srgbClr>",
|
||||
re.DOTALL,
|
||||
)
|
||||
_SRGB_EMPTY_RE = re.compile(r"<a:srgbClr\b(?P<attrs>[^>]*)/>")
|
||||
_VAL_RE = re.compile(r'\bval="(?P<color>[0-9A-Fa-f]{6})"')
|
||||
|
||||
_OFFICE_DEFAULTS = {
|
||||
"dk1": "000000",
|
||||
"lt1": "FFFFFF",
|
||||
"dk2": "1F497D",
|
||||
"lt2": "EEECE1",
|
||||
"accent1": "4F81BD",
|
||||
"accent2": "C0504D",
|
||||
"accent3": "9BBB59",
|
||||
"accent4": "8064A2",
|
||||
"accent5": "4BACC6",
|
||||
"accent6": "F79646",
|
||||
"hlink": "0000FF",
|
||||
"folHlink": "800080",
|
||||
}
|
||||
_ROLE_SLOTS = {
|
||||
"bg": "lt1",
|
||||
"background": "lt1",
|
||||
"master_bg": "lt1",
|
||||
"secondary_bg": "lt2",
|
||||
"bg_secondary": "lt2",
|
||||
"text": "dk1",
|
||||
"body_text": "dk1",
|
||||
"text_secondary": "dk2",
|
||||
"primary": "accent1",
|
||||
"accent": "accent2",
|
||||
"secondary_accent": "accent3",
|
||||
"border": "accent4",
|
||||
}
|
||||
_USAGE_ROLE_ORDER = {
|
||||
"background": (
|
||||
"bg", "background", "master_bg", "secondary_bg", "bg_secondary",
|
||||
"primary", "accent", "secondary_accent",
|
||||
),
|
||||
"fill": (
|
||||
"primary", "accent", "secondary_accent", "bg", "background",
|
||||
"master_bg", "secondary_bg", "bg_secondary", "text", "body_text",
|
||||
"text_secondary", "border",
|
||||
),
|
||||
"text": (
|
||||
"text", "body_text", "text_secondary", "primary", "accent",
|
||||
"secondary_accent",
|
||||
),
|
||||
"stroke": (
|
||||
"border", "primary", "accent", "secondary_accent", "text",
|
||||
"body_text", "text_secondary",
|
||||
),
|
||||
"chart": ("primary", "accent", "secondary_accent"),
|
||||
}
|
||||
_BACKGROUND_ROLE_TOKENS = (
|
||||
"bg", "background", "surface", "paper", "card", "panel", "tint",
|
||||
)
|
||||
_TEXT_ROLE_TOKENS = ("text", "ink", "muted")
|
||||
_STROKE_ROLE_TOKENS = ("border", "grid", "line", "stroke")
|
||||
_BACKGROUND_USAGES = frozenset({"background", "fill"})
|
||||
_TEXT_USAGES = frozenset({"text"})
|
||||
_STROKE_USAGES = frozenset({"stroke", "fill"})
|
||||
_SEMANTIC_USAGES = frozenset({"fill", "text", "stroke"})
|
||||
|
||||
|
||||
class ThemeColorError(RuntimeError):
|
||||
"""Raised when a project theme-color contract cannot be loaded or applied."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeColorSpec:
|
||||
"""PowerPoint color scheme derived from one project's color lock."""
|
||||
|
||||
slots: dict[str, str]
|
||||
roles: dict[str, str]
|
||||
role_slots: dict[str, str]
|
||||
extra_roles: tuple[str, ...] = ()
|
||||
|
||||
def scheme_for(self, color: str, usage: str) -> str | None:
|
||||
"""Resolve one concrete color to a safe scheme slot for its usage."""
|
||||
normalized = parse_hex_color(color)
|
||||
if normalized is None:
|
||||
return None
|
||||
for role in _USAGE_ROLE_ORDER.get(usage, ()):
|
||||
if self.roles.get(role) == normalized:
|
||||
slot = self.role_slots[role]
|
||||
if self.slots.get(slot) == normalized:
|
||||
return slot
|
||||
for role in self.extra_roles:
|
||||
if usage in _extra_role_usages(role) and self.roles.get(role) == normalized:
|
||||
slot = self.role_slots[role]
|
||||
if self.slots.get(slot) == normalized:
|
||||
return slot
|
||||
return None
|
||||
|
||||
|
||||
def _extra_role_usages(role: str) -> frozenset[str]:
|
||||
"""Limit extra theme slots to contexts implied by their lock role name."""
|
||||
normalized = role.lower()
|
||||
if any(token in normalized for token in _BACKGROUND_ROLE_TOKENS):
|
||||
return _BACKGROUND_USAGES
|
||||
if any(token in normalized for token in _TEXT_ROLE_TOKENS):
|
||||
return _TEXT_USAGES
|
||||
if any(token in normalized for token in _STROKE_ROLE_TOKENS):
|
||||
return _STROKE_USAGES
|
||||
return _SEMANTIC_USAGES
|
||||
|
||||
|
||||
def _color_rows(lock_path: Path) -> dict[str, str]:
|
||||
rows: dict[str, str] = {}
|
||||
current_section: str | None = None
|
||||
try:
|
||||
lines = lock_path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError as exc:
|
||||
raise ThemeColorError(f"Cannot read {lock_path}: {exc}") from exc
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if line.startswith("## "):
|
||||
current_section = line[3:].strip()
|
||||
continue
|
||||
if current_section != "colors":
|
||||
continue
|
||||
match = _LOCK_ROW_RE.fullmatch(line)
|
||||
if not match:
|
||||
continue
|
||||
raw_color = match.group(2).strip()
|
||||
if (
|
||||
len(raw_color) >= 2
|
||||
and raw_color[0] == raw_color[-1]
|
||||
and raw_color[0] in {'"', "'"}
|
||||
):
|
||||
raw_color = raw_color[1:-1].strip()
|
||||
color = parse_hex_color(raw_color)
|
||||
if color is not None:
|
||||
rows[match.group(1)] = color
|
||||
return rows
|
||||
|
||||
|
||||
def _first_color(roles: dict[str, str], *keys: str, default: str) -> str:
|
||||
return next((roles[key] for key in keys if key in roles), default)
|
||||
|
||||
|
||||
def load_theme_color_spec(project_path: Path) -> ThemeColorSpec | None:
|
||||
"""Load a PowerPoint color scheme from ``spec_lock.md`` color rows."""
|
||||
lock_path = project_path / "spec_lock.md"
|
||||
if not lock_path.is_file():
|
||||
return None
|
||||
roles = _color_rows(lock_path)
|
||||
if not roles:
|
||||
return None
|
||||
|
||||
extra_roles = tuple(
|
||||
key
|
||||
for key, color in roles.items()
|
||||
if key not in _ROLE_SLOTS and color not in {"000000", "FFFFFF"}
|
||||
)[:2]
|
||||
extra_slots = dict(zip(extra_roles, ("accent5", "accent6")))
|
||||
role_slots = {**_ROLE_SLOTS, **extra_slots}
|
||||
|
||||
slots = dict(_OFFICE_DEFAULTS)
|
||||
slots.update({
|
||||
"lt1": _first_color(
|
||||
roles,
|
||||
"bg", "background", "master_bg",
|
||||
default=slots["lt1"],
|
||||
),
|
||||
"dk1": _first_color(
|
||||
roles,
|
||||
"text", "body_text", "primary",
|
||||
default=slots["dk1"],
|
||||
),
|
||||
"lt2": _first_color(
|
||||
roles,
|
||||
"secondary_bg", "bg_secondary", "bg", "background", "master_bg",
|
||||
default=slots["lt2"],
|
||||
),
|
||||
"dk2": _first_color(roles, "text_secondary", "text", default=slots["dk2"]),
|
||||
"accent1": _first_color(roles, "primary", "accent", default=slots["accent1"]),
|
||||
"accent2": _first_color(
|
||||
roles,
|
||||
"accent", "secondary_accent", "primary",
|
||||
default=slots["accent2"],
|
||||
),
|
||||
"accent3": _first_color(roles, "secondary_accent", "accent", default=slots["accent3"]),
|
||||
"accent4": _first_color(roles, "border", "primary", default=slots["accent4"]),
|
||||
"hlink": _first_color(roles, "accent", "primary", default=slots["hlink"]),
|
||||
"folHlink": _first_color(roles, "secondary_accent", "accent", default=slots["folHlink"]),
|
||||
})
|
||||
for role, slot in extra_slots.items():
|
||||
slots[slot] = roles[role]
|
||||
return ThemeColorSpec(
|
||||
slots=slots,
|
||||
roles=roles,
|
||||
role_slots=role_slots,
|
||||
extra_roles=extra_roles,
|
||||
)
|
||||
|
||||
|
||||
def color_node_xml(
|
||||
color: str,
|
||||
spec: ThemeColorSpec | None,
|
||||
usage: str,
|
||||
inner_xml: str = "",
|
||||
) -> str:
|
||||
"""Build an srgbClr or schemeClr node while preserving child transforms."""
|
||||
normalized = parse_hex_color(color) or color.strip().lstrip("#").upper()
|
||||
scheme = spec.scheme_for(normalized, usage) if spec is not None else None
|
||||
if scheme:
|
||||
return f'<a:schemeClr val="{scheme}">{inner_xml}</a:schemeClr>'
|
||||
return f'<a:srgbClr val="{normalized}">{inner_xml}</a:srgbClr>'
|
||||
|
||||
|
||||
def _set_scheme_color(parent: ET.Element, slot: str, color: str) -> None:
|
||||
target = parent.find(f"{{{DML_NS}}}{slot}")
|
||||
if target is None:
|
||||
target = ET.SubElement(parent, f"{{{DML_NS}}}{slot}")
|
||||
for child in list(target):
|
||||
target.remove(child)
|
||||
ET.SubElement(target, f"{{{DML_NS}}}srgbClr", {"val": color})
|
||||
|
||||
|
||||
def apply_theme_color_spec(extract_dir: Path, spec: ThemeColorSpec) -> None:
|
||||
"""Install the locked color scheme into every existing PPTX theme part."""
|
||||
theme_dir = extract_dir / "ppt" / "theme"
|
||||
theme_paths = sorted(theme_dir.glob("theme*.xml"))
|
||||
if not theme_paths:
|
||||
raise ThemeColorError(f"PPTX package has no theme part under {theme_dir}")
|
||||
|
||||
ET.register_namespace("a", DML_NS)
|
||||
for theme_path in theme_paths:
|
||||
try:
|
||||
tree = ET.parse(theme_path)
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
raise ThemeColorError(f"Cannot parse {theme_path}: {exc}") from exc
|
||||
color_scheme = tree.getroot().find(f".//{{{DML_NS}}}clrScheme")
|
||||
if color_scheme is None:
|
||||
raise ThemeColorError(f"Theme has no clrScheme: {theme_path}")
|
||||
color_scheme.set("name", "PPT Master")
|
||||
for slot, color in spec.slots.items():
|
||||
_set_scheme_color(color_scheme, slot, color)
|
||||
tree.write(theme_path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def rewrite_chart_accent_colors(data: bytes, spec: ThemeColorSpec | None) -> bytes:
|
||||
"""Promote exact locked accent colors inside native chart XML."""
|
||||
if spec is None or b"<a:srgbClr" not in data:
|
||||
return data
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data
|
||||
|
||||
def replacement(match: re.Match[str], *, paired: bool) -> str:
|
||||
attrs = match.group("attrs")
|
||||
value_match = _VAL_RE.search(attrs)
|
||||
if value_match is None:
|
||||
return match.group(0)
|
||||
scheme = spec.scheme_for(value_match.group("color"), "chart")
|
||||
if scheme is None:
|
||||
return match.group(0)
|
||||
new_attrs = _VAL_RE.sub(f'val="{scheme}"', attrs, count=1)
|
||||
if paired:
|
||||
return f'<a:schemeClr{new_attrs}>{match.group("body")}</a:schemeClr>'
|
||||
return f'<a:schemeClr{new_attrs}/>'
|
||||
|
||||
text = _SRGB_PAIR_RE.sub(lambda match: replacement(match, paired=True), text)
|
||||
text = _SRGB_EMPTY_RE.sub(lambda match: replacement(match, paired=False), text)
|
||||
return text.encode("utf-8")
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Theme typography contracts shared by SVG conversion and PPTX assembly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .utils import font_px_to_hpt, parse_font_family
|
||||
|
||||
|
||||
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
_LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$")
|
||||
_CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"})
|
||||
|
||||
|
||||
class ThemeFontError(RuntimeError):
|
||||
"""Raised when a project theme-font contract cannot be loaded or applied."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeFontFace:
|
||||
"""Concrete Latin, East Asian, and complex-script theme faces."""
|
||||
|
||||
latin: str
|
||||
ea: str
|
||||
cs: str
|
||||
|
||||
def matches(self, fonts: dict[str, str]) -> bool:
|
||||
"""Return whether resolved SVG fonts represent this theme face."""
|
||||
return fonts.get("latin") == self.latin and fonts.get("ea") == self.ea
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeFontSpec:
|
||||
"""Major/minor theme fonts derived from one project's typography lock."""
|
||||
|
||||
major: ThemeFontFace
|
||||
minor: ThemeFontFace
|
||||
major_family: str
|
||||
minor_family: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MasterTextStyleSpec:
|
||||
"""Title/body defaults written to one generated slide master's txStyles."""
|
||||
|
||||
title_hpt: int
|
||||
body_hpt: int
|
||||
|
||||
@property
|
||||
def body_levels_hpt(self) -> tuple[int, ...]:
|
||||
"""Return a deterministic nine-level PowerPoint body-size hierarchy."""
|
||||
factors = (16, 15, 14, 13, 12, 11, 10, 9, 8)
|
||||
minimum = min(self.body_hpt, 800)
|
||||
sizes = []
|
||||
for factor in factors:
|
||||
scaled = round((self.body_hpt * factor / 16) / 50) * 50
|
||||
sizes.append(max(minimum, scaled))
|
||||
return tuple(sizes)
|
||||
|
||||
|
||||
def _font_face(font_family: str) -> ThemeFontFace:
|
||||
fonts = parse_font_family(font_family)
|
||||
return ThemeFontFace(
|
||||
latin=fonts["latin"],
|
||||
ea=fonts["ea"],
|
||||
cs=fonts["latin"],
|
||||
)
|
||||
|
||||
|
||||
def _typography_rows(lock_path: Path) -> dict[str, str]:
|
||||
rows: dict[str, str] = {}
|
||||
current_section: str | None = None
|
||||
try:
|
||||
lines = lock_path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError as exc:
|
||||
raise ThemeFontError(f"Cannot read {lock_path}: {exc}") from exc
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if line.startswith("## "):
|
||||
current_section = line[3:].strip()
|
||||
continue
|
||||
if current_section != "typography":
|
||||
continue
|
||||
match = _LOCK_ROW_RE.fullmatch(line)
|
||||
if match:
|
||||
rows[match.group(1)] = match.group(2)
|
||||
return rows
|
||||
|
||||
|
||||
def load_theme_font_spec(project_path: Path) -> ThemeFontSpec | None:
|
||||
"""Load major/minor theme fonts from ``spec_lock.md`` typography rows."""
|
||||
lock_path = project_path / "spec_lock.md"
|
||||
if not lock_path.is_file():
|
||||
return None
|
||||
rows = _typography_rows(lock_path)
|
||||
default_family = rows.get("font_family")
|
||||
major_family = rows.get("title_family") or default_family
|
||||
minor_family = rows.get("body_family") or default_family
|
||||
if not major_family or not minor_family:
|
||||
return None
|
||||
return ThemeFontSpec(
|
||||
major=_font_face(major_family),
|
||||
minor=_font_face(minor_family),
|
||||
major_family=major_family,
|
||||
minor_family=minor_family,
|
||||
)
|
||||
|
||||
|
||||
def _font_size_hpt(raw: str, field: str) -> int:
|
||||
try:
|
||||
px = float(raw)
|
||||
except (TypeError, ValueError, OverflowError) as exc:
|
||||
raise ThemeFontError(
|
||||
f"spec_lock.md typography {field} must be a numeric px value: {raw!r}"
|
||||
) from exc
|
||||
try:
|
||||
size = font_px_to_hpt(px)
|
||||
except ValueError as exc:
|
||||
raise ThemeFontError(
|
||||
f"spec_lock.md typography {field} is outside the PowerPoint "
|
||||
f"font-size range: {raw!r}"
|
||||
) from exc
|
||||
return size
|
||||
|
||||
|
||||
def load_master_text_style_spec(project_path: Path) -> MasterTextStyleSpec:
|
||||
"""Load required title/body defaults for generated Master text styles."""
|
||||
lock_path = project_path / "spec_lock.md"
|
||||
if not lock_path.is_file():
|
||||
raise ThemeFontError(
|
||||
"Master export requires spec_lock.md typography title and body rows"
|
||||
)
|
||||
rows = _typography_rows(lock_path)
|
||||
missing = [field for field in ("title", "body") if field not in rows]
|
||||
if missing:
|
||||
raise ThemeFontError(
|
||||
"Master export requires spec_lock.md typography rows: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
return MasterTextStyleSpec(
|
||||
title_hpt=_font_size_hpt(rows["title"], "title"),
|
||||
body_hpt=_font_size_hpt(rows["body"], "body"),
|
||||
)
|
||||
|
||||
|
||||
def theme_font_tokens(
|
||||
fonts: dict[str, str],
|
||||
spec: ThemeFontSpec | None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Return DrawingML major/minor tokens for a locked SVG font face."""
|
||||
if spec is None:
|
||||
return None
|
||||
major_match = spec.major.matches(fonts)
|
||||
minor_match = spec.minor.matches(fonts)
|
||||
if major_match and not minor_match:
|
||||
prefix = "+mj"
|
||||
elif minor_match:
|
||||
# When title/body use the same family, minor is the least surprising
|
||||
# default for ordinary text boxes. Template assembly forces semantic
|
||||
# title placeholders to the major role after SVG conversion.
|
||||
prefix = "+mn"
|
||||
else:
|
||||
return None
|
||||
return {
|
||||
"latin": f"{prefix}-lt",
|
||||
"ea": f"{prefix}-ea",
|
||||
"cs": f"{prefix}-cs",
|
||||
}
|
||||
|
||||
|
||||
def _patch_font_collection(collection: ET.Element, face: ThemeFontFace) -> None:
|
||||
for tag, value in (("latin", face.latin), ("ea", face.ea), ("cs", face.cs)):
|
||||
elem = collection.find(f"{{{DML_NS}}}{tag}")
|
||||
if elem is None:
|
||||
elem = ET.SubElement(collection, f"{{{DML_NS}}}{tag}")
|
||||
elem.set("typeface", value)
|
||||
for supplemental in collection.findall(f"{{{DML_NS}}}font"):
|
||||
if supplemental.get("script") in _CJK_THEME_SCRIPTS:
|
||||
supplemental.set("typeface", face.ea)
|
||||
|
||||
|
||||
def apply_theme_font_spec(extract_dir: Path, spec: ThemeFontSpec) -> None:
|
||||
"""Install locked major/minor fonts into every existing PPTX theme part."""
|
||||
theme_dir = extract_dir / "ppt" / "theme"
|
||||
theme_paths = sorted(theme_dir.glob("theme*.xml"))
|
||||
if not theme_paths:
|
||||
raise ThemeFontError(f"PPTX package has no theme part under {theme_dir}")
|
||||
|
||||
ET.register_namespace("a", DML_NS)
|
||||
for theme_path in theme_paths:
|
||||
try:
|
||||
tree = ET.parse(theme_path)
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
raise ThemeFontError(f"Cannot parse {theme_path}: {exc}") from exc
|
||||
font_scheme = tree.getroot().find(f".//{{{DML_NS}}}fontScheme")
|
||||
if font_scheme is None:
|
||||
raise ThemeFontError(f"Theme has no fontScheme: {theme_path}")
|
||||
major = font_scheme.find(f"{{{DML_NS}}}majorFont")
|
||||
minor = font_scheme.find(f"{{{DML_NS}}}minorFont")
|
||||
if major is None or minor is None:
|
||||
raise ThemeFontError(f"Theme has no major/minor font collection: {theme_path}")
|
||||
font_scheme.set("name", "PPT Master")
|
||||
_patch_font_collection(major, spec.major)
|
||||
_patch_font_collection(minor, spec.minor)
|
||||
tree.write(theme_path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _style_run_properties(style: ET.Element, label: str) -> list[ET.Element]:
|
||||
run_properties = list(style.iter(f"{{{DML_NS}}}defRPr"))
|
||||
if not run_properties:
|
||||
raise ThemeFontError(f"slide master {label} has no a:defRPr entries")
|
||||
return run_properties
|
||||
|
||||
|
||||
def _style_level_run_properties(
|
||||
style: ET.Element,
|
||||
label: str,
|
||||
) -> tuple[ET.Element, ...]:
|
||||
"""Return direct level 1-9 defaults from one Master text style."""
|
||||
levels: list[ET.Element] = []
|
||||
for level in range(1, 10):
|
||||
run_properties = style.find(
|
||||
f"{{{DML_NS}}}lvl{level}pPr/{{{DML_NS}}}defRPr"
|
||||
)
|
||||
if run_properties is None:
|
||||
raise ThemeFontError(
|
||||
f"slide master {label} has no level-{level} a:defRPr"
|
||||
)
|
||||
levels.append(run_properties)
|
||||
return tuple(levels)
|
||||
|
||||
|
||||
def apply_master_text_style_spec(
|
||||
extract_dir: Path,
|
||||
spec: MasterTextStyleSpec,
|
||||
) -> int:
|
||||
"""Install declared title/body anchors into generated slide-master txStyles."""
|
||||
master_dir = extract_dir / "ppt" / "slideMasters"
|
||||
master_paths = sorted(master_dir.glob("slideMaster*.xml"))
|
||||
if not master_paths:
|
||||
raise ThemeFontError(f"PPTX package has no slide master under {master_dir}")
|
||||
|
||||
ET.register_namespace("a", DML_NS)
|
||||
ET.register_namespace("p", PML_NS)
|
||||
for master_path in master_paths:
|
||||
try:
|
||||
tree = ET.parse(master_path)
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
raise ThemeFontError(f"Cannot parse {master_path}: {exc}") from exc
|
||||
text_styles = tree.getroot().find(f"{{{PML_NS}}}txStyles")
|
||||
if text_styles is None:
|
||||
raise ThemeFontError(f"Slide master has no p:txStyles: {master_path}")
|
||||
|
||||
title_style = text_styles.find(f"{{{PML_NS}}}titleStyle")
|
||||
if title_style is None:
|
||||
raise ThemeFontError(
|
||||
f"Slide master has no p:titleStyle: {master_path}"
|
||||
)
|
||||
for run_properties in _style_run_properties(
|
||||
title_style,
|
||||
"p:titleStyle",
|
||||
):
|
||||
run_properties.set("sz", str(spec.title_hpt))
|
||||
|
||||
body_levels = spec.body_levels_hpt
|
||||
for style_name in ("bodyStyle", "otherStyle"):
|
||||
style = text_styles.find(f"{{{PML_NS}}}{style_name}")
|
||||
if style is None:
|
||||
raise ThemeFontError(
|
||||
f"Slide master has no p:{style_name}: {master_path}"
|
||||
)
|
||||
for run_properties, size in zip(
|
||||
_style_level_run_properties(
|
||||
style,
|
||||
f"p:{style_name}",
|
||||
),
|
||||
body_levels,
|
||||
):
|
||||
run_properties.set("sz", str(size))
|
||||
|
||||
style_sizes = (
|
||||
("titleStyle", (spec.title_hpt,)),
|
||||
("bodyStyle", body_levels),
|
||||
("otherStyle", body_levels),
|
||||
)
|
||||
|
||||
tree.write(master_path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
try:
|
||||
read_back = ET.parse(master_path).getroot()
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
raise ThemeFontError(
|
||||
f"Cannot read back slide master {master_path}: {exc}"
|
||||
) from exc
|
||||
read_back_styles = read_back.find(f"{{{PML_NS}}}txStyles")
|
||||
if read_back_styles is None:
|
||||
raise ThemeFontError(
|
||||
f"Slide master lost p:txStyles after update: {master_path}"
|
||||
)
|
||||
for style_name, expected_sizes in style_sizes:
|
||||
style = read_back_styles.find(f"{{{PML_NS}}}{style_name}")
|
||||
if style is None:
|
||||
actual_sizes: tuple[str | None, ...] = ()
|
||||
elif style_name == "titleStyle":
|
||||
actual_sizes = tuple(
|
||||
item.get("sz")
|
||||
for item in _style_run_properties(
|
||||
style,
|
||||
f"p:{style_name}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
actual_sizes = tuple(
|
||||
item.get("sz")
|
||||
for item in _style_level_run_properties(
|
||||
style,
|
||||
f"p:{style_name}",
|
||||
)
|
||||
)
|
||||
if actual_sizes != tuple(str(size) for size in expected_sizes):
|
||||
raise ThemeFontError(
|
||||
f"Slide master p:{style_name} size read-back failed: "
|
||||
f"{master_path}"
|
||||
)
|
||||
return len(master_paths)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
"""Materialize the supported inline SVG geometry-property subset.
|
||||
|
||||
SVG 2 lets selected geometry values participate in CSS. PPT Master does not
|
||||
run a CSS engine, but it can safely compile literal per-element ``style``
|
||||
declarations into the equivalent XML geometry attributes before any existing
|
||||
SVG post-processing or DrawingML conversion runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
XLINK_NS = 'http://www.w3.org/1999/xlink'
|
||||
|
||||
INLINE_GEOMETRY_PROPERTIES = {
|
||||
'rect': frozenset({'x', 'y', 'width', 'height', 'rx', 'ry'}),
|
||||
'circle': frozenset({'cx', 'cy', 'r'}),
|
||||
'ellipse': frozenset({'cx', 'cy', 'rx', 'ry'}),
|
||||
'image': frozenset({'x', 'y', 'width', 'height'}),
|
||||
'svg': frozenset({'x', 'y', 'width', 'height'}),
|
||||
'use': frozenset({'x', 'y', 'width', 'height'}),
|
||||
}
|
||||
|
||||
_GEOMETRY_LIKE_PROPERTIES = frozenset({
|
||||
'x', 'y', 'width', 'height', 'rx', 'ry', 'cx', 'cy', 'r',
|
||||
'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'points', 'd',
|
||||
})
|
||||
_NON_NEGATIVE_PROPERTIES = frozenset({'width', 'height', 'rx', 'ry', 'r'})
|
||||
_PX_LENGTH_RE = re.compile(
|
||||
r'^\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*(px)?\s*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class GeometryStyleError(ValueError):
|
||||
"""Reject inline geometry that cannot be compiled deterministically."""
|
||||
|
||||
|
||||
def _local_tag(elem: ET.Element) -> str:
|
||||
tag = str(elem.tag)
|
||||
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
|
||||
|
||||
|
||||
def _format_number(value: float) -> str:
|
||||
if abs(value) < 1e-12:
|
||||
value = 0.0
|
||||
return f'{value:.12g}'
|
||||
|
||||
|
||||
def _normalize_px_length(raw: str, tag: str, prop: str) -> str:
|
||||
"""Return one finite CSS px literal as a unitless SVG user value."""
|
||||
match = _PX_LENGTH_RE.fullmatch(raw)
|
||||
if match is None:
|
||||
raise GeometryStyleError(
|
||||
f'<{tag}> inline geometry {prop} requires a finite px literal; '
|
||||
f'got {raw!r}'
|
||||
)
|
||||
value = float(match.group(1))
|
||||
if not math.isfinite(value):
|
||||
raise GeometryStyleError(
|
||||
f'<{tag}> inline geometry {prop} must be finite; got {raw!r}'
|
||||
)
|
||||
if match.group(2) is None and value != 0:
|
||||
raise GeometryStyleError(
|
||||
f'<{tag}> inline geometry {prop} requires px for non-zero values; '
|
||||
f'got {raw!r}'
|
||||
)
|
||||
if prop in _NON_NEGATIVE_PROPERTIES and value < 0:
|
||||
raise GeometryStyleError(
|
||||
f'<{tag}> inline geometry {prop} cannot be negative; got {raw!r}'
|
||||
)
|
||||
return _format_number(value)
|
||||
|
||||
|
||||
def materialize_inline_geometry_properties(root: ET.Element) -> int:
|
||||
"""Compile supported inline geometry declarations into XML attributes."""
|
||||
materialized = 0
|
||||
for elem in root.iter():
|
||||
style = elem.get('style')
|
||||
if not style:
|
||||
continue
|
||||
tag = _local_tag(elem)
|
||||
supported = INLINE_GEOMETRY_PROPERTIES.get(tag, frozenset())
|
||||
retained: list[str] = []
|
||||
element_materialized = 0
|
||||
for raw_declaration in style.split(';'):
|
||||
declaration = raw_declaration.strip()
|
||||
if not declaration:
|
||||
continue
|
||||
if ':' not in declaration:
|
||||
retained.append(declaration)
|
||||
continue
|
||||
raw_name, raw_value = declaration.split(':', 1)
|
||||
name = raw_name.strip().lower()
|
||||
value = raw_value.strip()
|
||||
if name not in _GEOMETRY_LIKE_PROPERTIES:
|
||||
retained.append(declaration)
|
||||
continue
|
||||
if name not in supported:
|
||||
raise GeometryStyleError(
|
||||
f'<{tag}> does not support inline geometry property {name!r}; '
|
||||
'use the element\'s XML geometry attribute instead'
|
||||
)
|
||||
elem.set(name, _normalize_px_length(value, tag, name))
|
||||
materialized += 1
|
||||
element_materialized += 1
|
||||
|
||||
if element_materialized == 0:
|
||||
continue
|
||||
if retained:
|
||||
elem.set('style', '; '.join(retained))
|
||||
else:
|
||||
elem.attrib.pop('style', None)
|
||||
return materialized
|
||||
|
||||
|
||||
def validate_inline_geometry_properties(root: ET.Element) -> list[str]:
|
||||
"""Return inline geometry errors without mutating the caller's SVG tree."""
|
||||
try:
|
||||
materialize_inline_geometry_properties(copy.deepcopy(root))
|
||||
except GeometryStyleError as exc:
|
||||
return [str(exc)]
|
||||
return []
|
||||
|
||||
|
||||
def materialize_inline_geometry_in_file(svg_path: Path) -> int:
|
||||
"""Materialize inline geometry in one SVG file in place."""
|
||||
tree = ET.parse(str(svg_path))
|
||||
count = materialize_inline_geometry_properties(tree.getroot())
|
||||
if count:
|
||||
ET.register_namespace('', SVG_NS)
|
||||
ET.register_namespace('xlink', XLINK_NS)
|
||||
tree.write(str(svg_path), encoding='unicode', xml_declaration=False)
|
||||
return count
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Native PowerPoint table/chart converters for explicit SVG metadata markers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from ..drawingml.context import ConvertContext, ShapeResult
|
||||
from ..drawingml.utils import _xml_escape
|
||||
from .chart_data import _chart_data
|
||||
from .chart_style import (
|
||||
_axis_titles,
|
||||
_chart_companion_entries,
|
||||
_chart_companion_text_xml,
|
||||
_chart_text_sizes,
|
||||
_classic_chart_style,
|
||||
_native_chart_chrome_errors,
|
||||
_native_chart_chrome_warnings,
|
||||
_native_chart_export_payload,
|
||||
_validate_chart_companion_boxes,
|
||||
)
|
||||
from .chart_xml import _chart_rels_xml, _chart_xml
|
||||
from .chartex import (
|
||||
_chart_ex_colors_xml,
|
||||
_chart_ex_rels_xml,
|
||||
_chart_ex_style_xml,
|
||||
_chart_ex_xml,
|
||||
)
|
||||
from .fallback_hash import (
|
||||
native_fallback_contract_warnings,
|
||||
require_fresh_native_fallback,
|
||||
snapshot_native_fallback_freshness,
|
||||
stamp_native_fallback_baseline,
|
||||
)
|
||||
from .marker_common import (
|
||||
CHART_CONTENT_TYPE,
|
||||
CHARTEX_CONTENT_TYPE,
|
||||
CHARTEX_REL_TYPE,
|
||||
CHARTEX_URI,
|
||||
CHART_COLOR_STYLE_CONTENT_TYPE,
|
||||
CHART_REL_TYPE,
|
||||
CHART_STYLE_CONTENT_TYPE,
|
||||
CHART_URI,
|
||||
_NATIVE_KINDS,
|
||||
_bounds,
|
||||
_load_payload,
|
||||
_local_tag,
|
||||
_native_marker_validation_context,
|
||||
_validate_bounds_inputs,
|
||||
native_marker_transform,
|
||||
)
|
||||
from .marker_attributes import (
|
||||
NativeMarkerAttributeError,
|
||||
native_fallback_kind,
|
||||
native_import_source,
|
||||
native_metadata_payload_matches,
|
||||
native_marker_legacy_warnings,
|
||||
native_replacement_kind,
|
||||
native_replacement_status,
|
||||
)
|
||||
from .marker_status import native_marker_status_errors
|
||||
from .table import (
|
||||
_build_native_table,
|
||||
_native_table_warnings,
|
||||
_validate_table_payload,
|
||||
)
|
||||
from .workbook import (
|
||||
_minimal_category_chart_workbook,
|
||||
_minimal_chart_ex_workbook,
|
||||
_minimal_xy_chart_workbook,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"convert_native_object",
|
||||
"NativeMarkerAttributeError",
|
||||
"native_fallback_kind",
|
||||
"native_import_source",
|
||||
"native_metadata_payload_matches",
|
||||
"native_marker_legacy_warnings",
|
||||
"native_object_marker_warnings",
|
||||
"native_replacement_kind",
|
||||
"native_replacement_status",
|
||||
"native_marker_transform",
|
||||
"snapshot_native_fallback_freshness",
|
||||
"stamp_native_fallback_baseline",
|
||||
"validate_native_object_marker",
|
||||
"validate_native_object_marker_with_warnings",
|
||||
]
|
||||
|
||||
|
||||
def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
|
||||
chart_data = _chart_data(payload)
|
||||
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx)
|
||||
|
||||
shape_id = ctx.next_id()
|
||||
rel_id = ctx.next_rel_id()
|
||||
local_index = 1 + sum(1 for part in ctx.package_files if part.startswith("ppt/charts/chart"))
|
||||
part_index = ctx.slide_num * 100 + local_index
|
||||
workbook_name = f"Microsoft_Excel_Sheet{part_index}.xlsx"
|
||||
workbook_part = f"ppt/embeddings/{workbook_name}"
|
||||
|
||||
if chart_data["kind"] == "chartex":
|
||||
chart_name = f"chartEx{part_index}.xml"
|
||||
style_name = f"style{part_index}.xml"
|
||||
colors_name = f"colors{part_index}.xml"
|
||||
chart_part = f"ppt/charts/{chart_name}"
|
||||
chart_rels_part = f"ppt/charts/_rels/{chart_name}.rels"
|
||||
style_part = f"ppt/charts/{style_name}"
|
||||
colors_part = f"ppt/charts/{colors_name}"
|
||||
graphic_uri = CHARTEX_URI
|
||||
chart_ref_xml = (
|
||||
f'<cx:chart xmlns:cx="{CHARTEX_URI}" '
|
||||
f'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
f'r:id="{rel_id}"/>'
|
||||
)
|
||||
ctx.rel_entries.append({
|
||||
"id": rel_id,
|
||||
"type": CHARTEX_REL_TYPE,
|
||||
"target": f"../charts/{chart_name}",
|
||||
})
|
||||
ctx.package_files[chart_part] = _chart_ex_xml(payload, chart_data, chart_rels_id="rId1")
|
||||
ctx.package_files[chart_rels_part] = _chart_ex_rels_xml(
|
||||
f"../embeddings/{workbook_name}",
|
||||
style_name,
|
||||
colors_name,
|
||||
)
|
||||
ctx.package_files[style_part] = _chart_ex_style_xml()
|
||||
ctx.package_files[colors_part] = _chart_ex_colors_xml(payload)
|
||||
ctx.package_files[workbook_part] = _minimal_chart_ex_workbook(chart_data)
|
||||
ctx.content_type_overrides[chart_part] = CHARTEX_CONTENT_TYPE
|
||||
ctx.content_type_overrides[style_part] = CHART_STYLE_CONTENT_TYPE
|
||||
ctx.content_type_overrides[colors_part] = CHART_COLOR_STYLE_CONTENT_TYPE
|
||||
else:
|
||||
chart_name = f"chart{part_index}.xml"
|
||||
chart_part = f"ppt/charts/{chart_name}"
|
||||
chart_rels_part = f"ppt/charts/_rels/{chart_name}.rels"
|
||||
graphic_uri = CHART_URI
|
||||
chart_ref_xml = (
|
||||
'<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" '
|
||||
f'r:id="{rel_id}"/>'
|
||||
)
|
||||
ctx.rel_entries.append({
|
||||
"id": rel_id,
|
||||
"type": CHART_REL_TYPE,
|
||||
"target": f"../charts/{chart_name}",
|
||||
})
|
||||
ctx.package_files[chart_part] = _chart_xml(
|
||||
elem,
|
||||
payload,
|
||||
chart_rels_id="rId1",
|
||||
chart_data=chart_data,
|
||||
inherited_styles=ctx.inherited_styles,
|
||||
primary_language=ctx.primary_language,
|
||||
)
|
||||
ctx.package_files[chart_rels_part] = _chart_rels_xml(f"../embeddings/{workbook_name}")
|
||||
if chart_data["kind"] == "xy":
|
||||
ctx.package_files[workbook_part] = _minimal_xy_chart_workbook(chart_data)
|
||||
else:
|
||||
ctx.package_files[workbook_part] = _minimal_category_chart_workbook(chart_data)
|
||||
ctx.content_type_overrides[chart_part] = CHART_CONTENT_TYPE
|
||||
|
||||
name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Chart {shape_id}"))
|
||||
chart_frame_xml = f'''<p:graphicFrame>
|
||||
<p:nvGraphicFramePr>
|
||||
<p:cNvPr id="{shape_id}" name="{name}"/>
|
||||
<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr>
|
||||
<p:nvPr/>
|
||||
</p:nvGraphicFramePr>
|
||||
<p:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></p:xfrm>
|
||||
<a:graphic>
|
||||
<a:graphicData uri="{graphic_uri}">
|
||||
{chart_ref_xml}
|
||||
</a:graphicData>
|
||||
</a:graphic>
|
||||
</p:graphicFrame>'''
|
||||
text_sizes = _chart_text_sizes(payload, elem, ctx.inherited_styles)
|
||||
chart_style = _classic_chart_style(payload, elem, ctx.inherited_styles)
|
||||
companion_xml = _chart_companion_text_xml(
|
||||
ctx,
|
||||
payload,
|
||||
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
|
||||
chart_style=chart_style,
|
||||
note_font_size=text_sizes["note"],
|
||||
title_font_size=text_sizes["title"],
|
||||
include_title=chart_data["kind"] == "chartex",
|
||||
include_subtitle_as_caption=chart_data["kind"] == "chartex",
|
||||
)
|
||||
xml = chart_frame_xml + companion_xml
|
||||
return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy))
|
||||
|
||||
|
||||
def _validate_native_object_marker_payload(
|
||||
elem: ET.Element,
|
||||
*,
|
||||
validate_chrome: bool = True,
|
||||
ctx: ConvertContext | None = None,
|
||||
ancestors: tuple[ET.Element, ...] = (),
|
||||
require_fresh_fallback: bool = False,
|
||||
) -> tuple[str, dict[str, Any], list[list[Any]] | None]:
|
||||
try:
|
||||
kind = native_replacement_kind(elem)
|
||||
except NativeMarkerAttributeError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
if not kind:
|
||||
return "", {}, None
|
||||
status_errors = native_marker_status_errors(elem)
|
||||
if status_errors:
|
||||
raise RuntimeError("; ".join(status_errors))
|
||||
if kind not in _NATIVE_KINDS:
|
||||
raise RuntimeError(f"Unsupported data-pptx-replace-with value: {kind}")
|
||||
if _local_tag(elem) != "g":
|
||||
raise RuntimeError("Native PPTX table/chart markers must be <g> elements")
|
||||
native_marker_transform(elem.get("transform"))
|
||||
if require_fresh_fallback:
|
||||
require_fresh_native_fallback(elem, use_runtime_snapshot=True)
|
||||
|
||||
try:
|
||||
payload = _load_payload(elem, kind)
|
||||
except NativeMarkerAttributeError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
bounds_ctx = ctx or _native_marker_validation_context(elem, ancestors)
|
||||
off_x, off_y, ext_cx, ext_cy, _ = _validate_bounds_inputs(elem, payload, bounds_ctx)
|
||||
table_rows = None
|
||||
if kind == "table":
|
||||
table_rows, col_count, _merge_layout = _validate_table_payload(payload)
|
||||
if ext_cx < col_count or ext_cy < len(table_rows):
|
||||
raise RuntimeError(
|
||||
"Native PPTX table bounds must provide at least one EMU per row and column"
|
||||
)
|
||||
else:
|
||||
chart_data = _chart_data(payload)
|
||||
_validate_chart_companion_boxes(
|
||||
payload,
|
||||
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
|
||||
include_title=chart_data["kind"] == "chartex",
|
||||
include_subtitle_as_caption=chart_data["kind"] == "chartex",
|
||||
)
|
||||
if validate_chrome and native_import_source(elem) != "pptx":
|
||||
chrome_errors = _native_chart_chrome_errors(elem, payload)
|
||||
if chrome_errors:
|
||||
raise RuntimeError("; ".join(chrome_errors))
|
||||
return kind, payload, table_rows
|
||||
|
||||
|
||||
def validate_native_object_marker(
|
||||
elem: ET.Element,
|
||||
*,
|
||||
ancestors: tuple[ET.Element, ...] = (),
|
||||
) -> None:
|
||||
"""Validate a chart/table replacement marker without mutating the package."""
|
||||
_validate_native_object_marker_payload(elem, ancestors=ancestors)
|
||||
|
||||
|
||||
def validate_native_object_marker_with_warnings(
|
||||
elem: ET.Element,
|
||||
*,
|
||||
ancestors: tuple[ET.Element, ...] = (),
|
||||
document_root: ET.Element | None = None,
|
||||
) -> list[str]:
|
||||
"""Validate a chart/table replacement marker and return non-fatal warnings."""
|
||||
kind, payload, table_rows = _validate_native_object_marker_payload(
|
||||
elem,
|
||||
ancestors=ancestors,
|
||||
)
|
||||
warnings = (
|
||||
native_fallback_contract_warnings(
|
||||
elem,
|
||||
document_root=document_root,
|
||||
)
|
||||
if kind else []
|
||||
)
|
||||
if kind == "table" and table_rows is not None:
|
||||
warnings.extend(_native_table_warnings(elem, table_rows))
|
||||
elif kind == "chart":
|
||||
warnings.extend(_native_chart_chrome_warnings(elem, payload))
|
||||
return warnings
|
||||
|
||||
|
||||
def native_object_marker_warnings(
|
||||
elem: ET.Element,
|
||||
*,
|
||||
ancestors: tuple[ET.Element, ...] = (),
|
||||
document_root: ET.Element | None = None,
|
||||
) -> list[str]:
|
||||
"""Return non-fatal warnings for a chart/table replacement marker."""
|
||||
return validate_native_object_marker_with_warnings(
|
||||
elem,
|
||||
ancestors=ancestors,
|
||||
document_root=document_root,
|
||||
)
|
||||
|
||||
|
||||
def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
"""Convert a marked SVG group to a native PowerPoint table or chart."""
|
||||
try:
|
||||
kind = native_replacement_kind(elem)
|
||||
except NativeMarkerAttributeError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
if not kind:
|
||||
return None
|
||||
|
||||
kind, payload, _ = _validate_native_object_marker_payload(
|
||||
elem,
|
||||
validate_chrome=False,
|
||||
ctx=ctx,
|
||||
require_fresh_fallback=True,
|
||||
)
|
||||
marker_id = elem.get("id") or "<unnamed>"
|
||||
for warning in native_fallback_contract_warnings(
|
||||
elem,
|
||||
use_runtime_snapshot=True,
|
||||
):
|
||||
print(
|
||||
f" Warning: data-pptx-replace-with marker {marker_id}: {warning}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if kind == "table":
|
||||
return _build_native_table(elem, ctx, payload)
|
||||
payload, warnings = _native_chart_export_payload(elem, payload)
|
||||
for warning in warnings:
|
||||
print(
|
||||
f" Warning: data-pptx-replace-with marker {marker_id}: {warning}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return _build_native_chart(elem, ctx, payload)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,868 @@
|
||||
"""Native chart styling and companion text helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .marker_attributes import native_import_source
|
||||
|
||||
from ..drawingml.context import ConvertContext
|
||||
from ..drawingml.utils import (
|
||||
_xml_escape,
|
||||
detect_text_lang,
|
||||
parse_font_family,
|
||||
px_to_emu,
|
||||
quantize_ooxml_alpha,
|
||||
text_has_rtl_characters,
|
||||
text_uses_rtl,
|
||||
)
|
||||
from .chart_data import _DEFAULT_CHART_COLORS
|
||||
from .marker_common import (
|
||||
_bool_attr,
|
||||
_bounds,
|
||||
_chart_bool,
|
||||
_clean_hex,
|
||||
_compact_key,
|
||||
_fallback_fill_candidates,
|
||||
_fallback_stroke_colors,
|
||||
_fallback_text_colors,
|
||||
_first_present,
|
||||
_font_size_hpt,
|
||||
_hex_or_none,
|
||||
_inferred_chart_background,
|
||||
_local_tag,
|
||||
_maybe_number,
|
||||
_most_common_color,
|
||||
_number,
|
||||
_normalized_fallback_text,
|
||||
_powerpoint_emu,
|
||||
_powerpoint_emu_value,
|
||||
_relative_luminance,
|
||||
_style_attr,
|
||||
_visible_fallback_texts,
|
||||
)
|
||||
|
||||
|
||||
def _chart_style_value(payload: dict[str, Any], *keys: str) -> Any:
|
||||
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
|
||||
for source in (payload, style):
|
||||
for key in keys:
|
||||
if source.get(key) is not None:
|
||||
return source.get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _chart_style_color(
|
||||
payload: dict[str, Any],
|
||||
keys: tuple[str, ...],
|
||||
default: str | None,
|
||||
) -> str | None:
|
||||
raw = _chart_style_value(payload, *keys)
|
||||
if raw is None:
|
||||
return default
|
||||
if str(raw).strip().lower() in {"none", "transparent"}:
|
||||
return None
|
||||
return _hex_or_none(raw) or default
|
||||
|
||||
|
||||
def _fallback_text_attr_values(
|
||||
elem: ET.Element,
|
||||
attr: str,
|
||||
inherited_value: str | None = None,
|
||||
) -> list[str]:
|
||||
tag = _local_tag(elem)
|
||||
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
|
||||
return []
|
||||
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
|
||||
return []
|
||||
|
||||
own_value = _style_attr(elem, attr)
|
||||
next_value = own_value if own_value is not None else inherited_value
|
||||
values: list[str] = []
|
||||
if tag in {"text", "tspan"} and next_value:
|
||||
values.append(str(next_value).strip())
|
||||
for child in elem:
|
||||
values.extend(_fallback_text_attr_values(child, attr, next_value))
|
||||
return values
|
||||
|
||||
|
||||
def _most_common_value(values: list[str]) -> str | None:
|
||||
if not values:
|
||||
return None
|
||||
counts: dict[str, int] = {}
|
||||
for value in values:
|
||||
counts[value] = counts.get(value, 0) + 1
|
||||
return max(counts.items(), key=lambda item: item[1])[0]
|
||||
|
||||
|
||||
def _most_common_font_size(values: list[str]) -> str | None:
|
||||
if not values:
|
||||
return None
|
||||
counts: dict[str, int] = {}
|
||||
for value in values:
|
||||
counts[value] = counts.get(value, 0) + 1
|
||||
max_count = max(counts.values())
|
||||
candidates = [value for value, count in counts.items() if count == max_count]
|
||||
numeric_candidates = [
|
||||
(float(value), value)
|
||||
for value in candidates
|
||||
if _maybe_number(value) is not None
|
||||
]
|
||||
if numeric_candidates:
|
||||
return min(numeric_candidates, key=lambda item: item[0])[1]
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _classic_chart_style(
|
||||
payload: dict[str, Any],
|
||||
elem: ET.Element,
|
||||
inherited_styles: dict[str, str] | None = None,
|
||||
) -> dict[str, str | None]:
|
||||
inherited_styles = inherited_styles or {}
|
||||
fallback_background = _inferred_chart_background(elem)
|
||||
text_color = _most_common_color(
|
||||
_fallback_text_colors(elem, inherited_styles.get("fill"))
|
||||
) or "404040"
|
||||
stroke_colors = _fallback_stroke_colors(elem, inherited_styles.get("stroke"))
|
||||
darkest_stroke = min(stroke_colors, key=_relative_luminance) if stroke_colors else None
|
||||
lightest_stroke = max(stroke_colors, key=_relative_luminance) if stroke_colors else None
|
||||
raw_font_face = _chart_style_value(payload, "font_family", "fontFamily", "font_face", "fontFace")
|
||||
fallback_font_face = _most_common_value(
|
||||
_fallback_text_attr_values(
|
||||
elem,
|
||||
"font-family",
|
||||
inherited_styles.get("font-family"),
|
||||
)
|
||||
)
|
||||
font_face = str(raw_font_face).strip() if raw_font_face is not None else fallback_font_face
|
||||
axis_color = darkest_stroke or text_color
|
||||
grid_color = (
|
||||
lightest_stroke
|
||||
if lightest_stroke and _relative_luminance(lightest_stroke) > _relative_luminance(axis_color)
|
||||
else "D9DED8"
|
||||
)
|
||||
chart_fill = _chart_style_color(
|
||||
payload,
|
||||
(
|
||||
"chart_area_fill",
|
||||
"chartAreaFill",
|
||||
"chart_fill",
|
||||
"chartFill",
|
||||
"background",
|
||||
"background_color",
|
||||
"backgroundColor",
|
||||
"fill",
|
||||
),
|
||||
fallback_background,
|
||||
)
|
||||
return {
|
||||
"axis_color": _chart_style_color(
|
||||
payload,
|
||||
("axis_color", "axisColor", "axis_line_color", "axisLineColor"),
|
||||
axis_color,
|
||||
),
|
||||
"chart_fill": chart_fill,
|
||||
"grid_color": _chart_style_color(
|
||||
payload,
|
||||
("grid_color", "gridColor", "gridline_color", "gridlineColor"),
|
||||
grid_color,
|
||||
),
|
||||
"plot_fill": _chart_style_color(
|
||||
payload,
|
||||
("plot_area_fill", "plotAreaFill", "plot_background", "plotBackground"),
|
||||
None,
|
||||
),
|
||||
"text_color": _chart_style_color(
|
||||
payload,
|
||||
("text_color", "textColor", "label_color", "labelColor", "font_color", "fontColor"),
|
||||
text_color,
|
||||
),
|
||||
"font_face": font_face or None,
|
||||
}
|
||||
|
||||
|
||||
def _chart_text_sizes(
|
||||
payload: dict[str, Any],
|
||||
elem: ET.Element | None = None,
|
||||
inherited_styles: dict[str, str] | None = None,
|
||||
) -> dict[str, int]:
|
||||
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
|
||||
inherited_styles = inherited_styles or {}
|
||||
fallback_font_size = (
|
||||
_most_common_font_size(
|
||||
_fallback_text_attr_values(
|
||||
elem,
|
||||
"font-size",
|
||||
inherited_styles.get("font-size"),
|
||||
)
|
||||
)
|
||||
if elem is not None else None
|
||||
)
|
||||
base_raw = _first_present(
|
||||
payload.get("font_size"),
|
||||
payload.get("chart_font_size"),
|
||||
payload.get("chartFontSize"),
|
||||
style.get("font_size"),
|
||||
style.get("chart_font_size"),
|
||||
style.get("chartFontSize"),
|
||||
fallback_font_size,
|
||||
)
|
||||
axis_raw = _first_present(
|
||||
payload.get("axis_font_size"),
|
||||
payload.get("axisFontSize"),
|
||||
payload.get("tick_font_size"),
|
||||
payload.get("tickFontSize"),
|
||||
style.get("axis_font_size"),
|
||||
style.get("axisFontSize"),
|
||||
style.get("tick_font_size"),
|
||||
style.get("tickFontSize"),
|
||||
base_raw,
|
||||
)
|
||||
axis_title_raw = _first_present(
|
||||
payload.get("axis_title_font_size"),
|
||||
payload.get("axisTitleFontSize"),
|
||||
style.get("axis_title_font_size"),
|
||||
style.get("axisTitleFontSize"),
|
||||
axis_raw,
|
||||
)
|
||||
legend_raw = _first_present(
|
||||
payload.get("legend_font_size"),
|
||||
payload.get("legendFontSize"),
|
||||
style.get("legend_font_size"),
|
||||
style.get("legendFontSize"),
|
||||
axis_raw,
|
||||
)
|
||||
title_raw = _first_present(
|
||||
payload.get("title_font_size"),
|
||||
payload.get("titleFontSize"),
|
||||
style.get("title_font_size"),
|
||||
style.get("titleFontSize"),
|
||||
)
|
||||
subtitle_raw = _first_present(
|
||||
payload.get("subtitle_font_size"),
|
||||
payload.get("subtitleFontSize"),
|
||||
style.get("subtitle_font_size"),
|
||||
style.get("subtitleFontSize"),
|
||||
base_raw,
|
||||
)
|
||||
note_raw = _first_present(
|
||||
payload.get("note_font_size"),
|
||||
payload.get("noteFontSize"),
|
||||
style.get("note_font_size"),
|
||||
style.get("noteFontSize"),
|
||||
style.get("caption_font_size"),
|
||||
style.get("captionFontSize"),
|
||||
base_raw,
|
||||
)
|
||||
return {
|
||||
"axis": _font_size_hpt(axis_raw, 12),
|
||||
"axis_title": _font_size_hpt(axis_title_raw, 12),
|
||||
"base": _font_size_hpt(base_raw, 12),
|
||||
"legend": _font_size_hpt(legend_raw, 12),
|
||||
"note": _font_size_hpt(note_raw, 12),
|
||||
"subtitle": _font_size_hpt(subtitle_raw, 12),
|
||||
"title": _font_size_hpt(title_raw, 16),
|
||||
}
|
||||
|
||||
|
||||
def _solid_fill_xml(color: str | None) -> str:
|
||||
if not color:
|
||||
return "<a:noFill/>"
|
||||
return f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
|
||||
|
||||
|
||||
def _chart_area_sp_pr_xml(fill_color: str | None) -> str:
|
||||
return f"<c:spPr>{_solid_fill_xml(fill_color)}<a:ln><a:noFill/></a:ln></c:spPr>"
|
||||
|
||||
|
||||
def _chart_line_sp_pr_xml(color: str | None, *, width: int = 9525) -> str:
|
||||
if not color:
|
||||
line_xml = "<a:ln><a:noFill/></a:ln>"
|
||||
else:
|
||||
line_xml = (
|
||||
f'<a:ln w="{width}" cap="flat" cmpd="sng" algn="ctr">'
|
||||
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
|
||||
"<a:round/></a:ln>"
|
||||
)
|
||||
return f"<c:spPr>{line_xml}</c:spPr>"
|
||||
|
||||
|
||||
def _major_gridlines_xml(color: str | None) -> str:
|
||||
return f'<c:majorGridlines>{_chart_line_sp_pr_xml(color, width=6350)}</c:majorGridlines>'
|
||||
|
||||
|
||||
def _font_face_xml(font_face: str | None) -> str:
|
||||
if not font_face:
|
||||
return ""
|
||||
fonts = parse_font_family(font_face)
|
||||
latin_font = _xml_escape(fonts["latin"])
|
||||
ea_font = _xml_escape(fonts["ea"])
|
||||
return (
|
||||
f'<a:latin typeface="{latin_font}"/>'
|
||||
f'<a:ea typeface="{ea_font}"/>'
|
||||
f'<a:cs typeface="{latin_font}"/>'
|
||||
)
|
||||
|
||||
|
||||
def _chart_tx_pr_xml(
|
||||
font_size: int,
|
||||
color: str | None = None,
|
||||
*,
|
||||
bold: bool = False,
|
||||
font_face: str | None = None,
|
||||
language: str | None = None,
|
||||
) -> str:
|
||||
fill_xml = (
|
||||
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
|
||||
if color else ""
|
||||
)
|
||||
bold_attr = ' b="1"' if bold else ""
|
||||
resolved_language = language or 'en-US'
|
||||
rtl_attr = ' rtl="1"' if text_uses_rtl('', language) else ''
|
||||
return (
|
||||
f"<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr{rtl_attr}>"
|
||||
f'<a:defRPr lang="{resolved_language}" sz="{font_size}"{bold_attr}>'
|
||||
f'{fill_xml}{_font_face_xml(font_face)}</a:defRPr>'
|
||||
f'</a:pPr><a:endParaRPr lang="{resolved_language}"/></a:p></c:txPr>'
|
||||
)
|
||||
|
||||
|
||||
def _chart_text_entry(value: Any) -> tuple[str, dict[str, Any]] | None:
|
||||
if isinstance(value, dict):
|
||||
text = _first_present(value.get("text"), value.get("value"), value.get("content"))
|
||||
if text is None or not str(text).strip():
|
||||
return None
|
||||
return str(text).strip(), value
|
||||
if value is None or not str(value).strip():
|
||||
return None
|
||||
return str(value).strip(), {}
|
||||
|
||||
|
||||
def _chart_text_entry_font_size(item: dict[str, Any], fallback: int) -> int:
|
||||
raw = _first_present(item.get("font_size"), item.get("fontSize"))
|
||||
if raw is None:
|
||||
return fallback
|
||||
return _font_size_hpt(raw, 12)
|
||||
|
||||
|
||||
def _chart_text_entry_color(item: dict[str, Any], fallback: str | None) -> str | None:
|
||||
return _hex_or_none(_first_present(
|
||||
item.get("color"),
|
||||
item.get("font_color"),
|
||||
item.get("fontColor"),
|
||||
)) or fallback
|
||||
|
||||
|
||||
def _chart_text_entry_font_face(item: dict[str, Any], fallback: str | None) -> str | None:
|
||||
raw = _first_present(
|
||||
item.get("font_family"),
|
||||
item.get("fontFamily"),
|
||||
item.get("font_face"),
|
||||
item.get("fontFace"),
|
||||
)
|
||||
if raw is None:
|
||||
return fallback
|
||||
font_face = str(raw).strip()
|
||||
return font_face or fallback
|
||||
|
||||
|
||||
def _alpha_xml(value: Any, field_name: str = "fill_opacity") -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
raise RuntimeError(f"Native PPTX chart {field_name} must be numeric")
|
||||
try:
|
||||
alpha = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
raise RuntimeError(f"Native PPTX chart {field_name} must be numeric") from None
|
||||
if not math.isfinite(alpha):
|
||||
raise RuntimeError(f"Native PPTX chart {field_name} must be finite")
|
||||
if alpha < 0 or alpha > 1:
|
||||
raise RuntimeError(f"Native PPTX chart {field_name} must be between 0 and 1")
|
||||
return f'<a:alpha val="{quantize_ooxml_alpha(alpha)}"/>'
|
||||
|
||||
|
||||
def _axis_title_xml(
|
||||
title: Any,
|
||||
*,
|
||||
font_size: int,
|
||||
color: str | None = None,
|
||||
font_face: str | None = None,
|
||||
primary_language: str | None = None,
|
||||
) -> str:
|
||||
entry = _chart_text_entry(title)
|
||||
if entry is None:
|
||||
return ""
|
||||
text, item = entry
|
||||
text_color = _chart_text_entry_color(item, color)
|
||||
fill_xml = (
|
||||
f'<a:solidFill><a:srgbClr val="{text_color}"/></a:solidFill>'
|
||||
if text_color else ""
|
||||
)
|
||||
lang = detect_text_lang(text, primary_language)
|
||||
rtl_attr = (
|
||||
' rtl="1"'
|
||||
if text_uses_rtl(text, primary_language)
|
||||
else ''
|
||||
)
|
||||
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
|
||||
return (
|
||||
"<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>"
|
||||
f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" '
|
||||
f'sz="{_chart_text_entry_font_size(item, font_size)}">'
|
||||
f"{fill_xml}{_font_face_xml(_chart_text_entry_font_face(item, font_face))}"
|
||||
f"{run_rtl}</a:rPr>"
|
||||
f"<a:t>{_xml_escape(text)}</a:t></a:r></a:p>"
|
||||
"</c:rich></c:tx><c:layout/><c:overlay val=\"0\"/></c:title>"
|
||||
)
|
||||
|
||||
|
||||
_AXIS_TITLE_KEY_GROUPS = {
|
||||
"category": (
|
||||
("category",),
|
||||
("category_axis_title", "categoryAxisTitle"),
|
||||
),
|
||||
"value": (
|
||||
("value",),
|
||||
("value_axis_title", "valueAxisTitle"),
|
||||
),
|
||||
"x": (
|
||||
("x",),
|
||||
("x_axis_title", "xAxisTitle"),
|
||||
),
|
||||
"y": (
|
||||
("y",),
|
||||
("y_axis_title", "yAxisTitle"),
|
||||
),
|
||||
"secondary_value": (
|
||||
("secondary_value", "secondaryValue"),
|
||||
("secondary_value_axis_title", "secondaryValueAxisTitle"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _axis_titles(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
|
||||
raw = payload.get("axis_titles", payload.get("axisTitles"))
|
||||
style_raw = style.get("axis_titles", style.get("axisTitles"))
|
||||
axis_map = raw if isinstance(raw, dict) else {}
|
||||
style_axis_map = style_raw if isinstance(style_raw, dict) else {}
|
||||
|
||||
def pick(axis_keys: tuple[str, ...], root_keys: tuple[str, ...]) -> Any:
|
||||
values: list[Any] = []
|
||||
for key in root_keys:
|
||||
values.extend((
|
||||
payload.get(key),
|
||||
style.get(key),
|
||||
))
|
||||
for key in axis_keys + root_keys:
|
||||
values.extend((
|
||||
axis_map.get(key),
|
||||
style_axis_map.get(key),
|
||||
))
|
||||
return _first_present(*values)
|
||||
|
||||
return {
|
||||
field_name: pick(axis_keys, root_keys)
|
||||
for field_name, (axis_keys, root_keys) in _AXIS_TITLE_KEY_GROUPS.items()
|
||||
}
|
||||
|
||||
|
||||
def _metadata_text(value: Any) -> str | None:
|
||||
entry = _chart_text_entry(value)
|
||||
if entry is None:
|
||||
return None
|
||||
text, _ = entry
|
||||
return _normalized_fallback_text(text)
|
||||
|
||||
|
||||
def _native_chart_chrome_errors(elem: ET.Element, payload: dict[str, Any]) -> list[str]:
|
||||
fallback_texts = set(_visible_fallback_texts(elem))
|
||||
missing: list[str] = []
|
||||
|
||||
for field_name in ("title", "subtitle"):
|
||||
text = _metadata_text(payload.get(field_name))
|
||||
if text and text not in fallback_texts:
|
||||
missing.append(f"{field_name}={text!r}")
|
||||
|
||||
for field_name, value in _axis_titles(payload).items():
|
||||
text = _metadata_text(value)
|
||||
if text and text not in fallback_texts:
|
||||
missing.append(f"axis_titles.{field_name}={text!r}")
|
||||
|
||||
if not missing:
|
||||
return []
|
||||
|
||||
sample = ", ".join(missing[:5])
|
||||
suffix = "" if len(missing) <= 5 else f", and {len(missing) - 5} more"
|
||||
return [
|
||||
"Native PPTX chart metadata contains title/axis text that is not visible "
|
||||
"inside the fallback marker and would appear only after "
|
||||
f"--native-charts-and-tables: {sample}{suffix}. "
|
||||
"Use `name` for object naming, or draw the same text in the chart fallback."
|
||||
]
|
||||
|
||||
|
||||
def _native_chart_export_payload(
|
||||
elem: ET.Element,
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
if native_import_source(elem) == "pptx":
|
||||
return payload, []
|
||||
fallback_texts = set(_visible_fallback_texts(elem))
|
||||
output = payload
|
||||
messages: list[str] = []
|
||||
|
||||
def mutable_payload() -> dict[str, Any]:
|
||||
nonlocal output
|
||||
if output is payload:
|
||||
output = dict(payload)
|
||||
return output
|
||||
|
||||
def mutable_style(target: dict[str, Any]) -> dict[str, Any] | None:
|
||||
style = target.get("style")
|
||||
if not isinstance(style, dict):
|
||||
return None
|
||||
if target.get("style") is payload.get("style"):
|
||||
style = dict(style)
|
||||
target["style"] = style
|
||||
return style
|
||||
|
||||
def drop_map_keys(source: dict[str, Any], map_key: str, keys: tuple[str, ...]) -> None:
|
||||
raw_map = source.get(map_key)
|
||||
if not isinstance(raw_map, dict):
|
||||
return
|
||||
next_map = dict(raw_map)
|
||||
for key in keys:
|
||||
next_map.pop(key, None)
|
||||
if next_map:
|
||||
source[map_key] = next_map
|
||||
else:
|
||||
source.pop(map_key, None)
|
||||
|
||||
for key in ("title", "subtitle"):
|
||||
text = _metadata_text(payload.get(key))
|
||||
if text and text not in fallback_texts:
|
||||
mutable_payload().pop(key, None)
|
||||
messages.append(
|
||||
f"omitted native chart {key} {text!r} because it is not visible in the fallback"
|
||||
)
|
||||
|
||||
for field_name, value in _axis_titles(payload).items():
|
||||
text = _metadata_text(value)
|
||||
if not text or text in fallback_texts:
|
||||
continue
|
||||
axis_keys, root_keys = _AXIS_TITLE_KEY_GROUPS[field_name]
|
||||
target = mutable_payload()
|
||||
for key in root_keys:
|
||||
target.pop(key, None)
|
||||
for map_key in ("axis_titles", "axisTitles"):
|
||||
drop_map_keys(target, map_key, axis_keys + root_keys)
|
||||
style = mutable_style(target)
|
||||
if style is not None:
|
||||
for key in root_keys:
|
||||
style.pop(key, None)
|
||||
for map_key in ("axis_titles", "axisTitles"):
|
||||
drop_map_keys(style, map_key, axis_keys + root_keys)
|
||||
messages.append(
|
||||
f"omitted native chart axis_titles.{field_name} {text!r} "
|
||||
"because it is not visible in the fallback"
|
||||
)
|
||||
|
||||
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
|
||||
show_legend = payload.get("show_legend", style.get("show_legend", False))
|
||||
if show_legend:
|
||||
legend_texts = _legend_candidate_texts(payload)
|
||||
if legend_texts and not any(text in fallback_texts for text in legend_texts):
|
||||
mutable_payload()["show_legend"] = False
|
||||
messages.append(
|
||||
"omitted native chart legend because show_legend=true has no "
|
||||
"visible fallback legend text"
|
||||
)
|
||||
|
||||
return output, messages
|
||||
|
||||
|
||||
def _legend_candidate_texts(payload: dict[str, Any]) -> list[str]:
|
||||
series_values: list[str] = []
|
||||
category_values: list[str] = []
|
||||
categories = payload.get("categories")
|
||||
if isinstance(categories, list):
|
||||
category_values.extend(str(item) for item in categories if str(item).strip())
|
||||
series = payload.get("series")
|
||||
if isinstance(series, list):
|
||||
for item in series:
|
||||
if isinstance(item, dict) and item.get("name") is not None:
|
||||
series_values.append(str(item.get("name")))
|
||||
plots = payload.get("plots")
|
||||
if isinstance(plots, list):
|
||||
for plot in plots:
|
||||
if not isinstance(plot, dict):
|
||||
continue
|
||||
plot_series = plot.get("series")
|
||||
if not isinstance(plot_series, list):
|
||||
continue
|
||||
for item in plot_series:
|
||||
if isinstance(item, dict) and item.get("name") is not None:
|
||||
series_values.append(str(item.get("name")))
|
||||
chart_type = _compact_key(payload.get("type") or payload.get("chart_type") or "")
|
||||
category_legend_types = {"pie", "doughnut", "donut", "ofpie", "pieofpie", "barofpie"}
|
||||
values = category_values if chart_type in category_legend_types else series_values
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
text = _normalized_fallback_text(value)
|
||||
if text:
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
|
||||
|
||||
def _native_chart_chrome_warnings(elem: ET.Element, payload: dict[str, Any]) -> list[str]:
|
||||
fallback_texts = set(_visible_fallback_texts(elem))
|
||||
warnings: list[str] = []
|
||||
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
|
||||
show_legend = payload.get("show_legend", style.get("show_legend", False))
|
||||
if show_legend:
|
||||
legend_texts = _legend_candidate_texts(payload)
|
||||
if legend_texts and not any(text in fallback_texts for text in legend_texts):
|
||||
warnings.append(
|
||||
"Native PPTX chart has show_legend=true, but no series/category "
|
||||
"legend text is visible inside the fallback marker. Add the "
|
||||
"fallback legend or remove show_legend."
|
||||
)
|
||||
|
||||
companion_entries = _chart_companion_entries(
|
||||
payload,
|
||||
include_title=False,
|
||||
include_subtitle_as_caption=False,
|
||||
)
|
||||
missing_companion: list[str] = []
|
||||
for item in companion_entries:
|
||||
text = _normalized_fallback_text(item.get("text"))
|
||||
if text and text not in fallback_texts:
|
||||
missing_companion.append(text)
|
||||
if missing_companion:
|
||||
sample = ", ".join(repr(text) for text in missing_companion[:5])
|
||||
suffix = "" if len(missing_companion) <= 5 else f", and {len(missing_companion) - 5} more"
|
||||
warnings.append(
|
||||
"Native PPTX chart companion text is not visible inside the fallback "
|
||||
"marker and may appear only after --native-charts-and-tables: "
|
||||
f"{sample}{suffix}. "
|
||||
"Keep companion metadata aligned with visible chart annotations."
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _text_box_xml(
|
||||
ctx: ConvertContext,
|
||||
*,
|
||||
text: str,
|
||||
role: str,
|
||||
off_x: int,
|
||||
off_y: int,
|
||||
ext_cx: int,
|
||||
ext_cy: int,
|
||||
font_size: int,
|
||||
color: str | None,
|
||||
align: str = "l",
|
||||
bold: bool = False,
|
||||
font_face: str | None = None,
|
||||
) -> str:
|
||||
shape_id = ctx.next_id()
|
||||
align_key = _compact_key(align)
|
||||
algn = {
|
||||
"center": "ctr",
|
||||
"centre": "ctr",
|
||||
"ctr": "ctr",
|
||||
"middle": "ctr",
|
||||
"right": "r",
|
||||
"r": "r",
|
||||
"left": "l",
|
||||
"l": "l",
|
||||
}.get(align_key, "l")
|
||||
fill_xml = (
|
||||
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
|
||||
if color else ""
|
||||
)
|
||||
bold_attr = ' b="1"' if bold else ""
|
||||
lang = detect_text_lang(text, ctx.primary_language)
|
||||
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
|
||||
run_properties_xml = (
|
||||
f'{fill_xml}{_font_face_xml(font_face)}'
|
||||
f'{run_rtl}'
|
||||
)
|
||||
rtl_attr = (
|
||||
' rtl="1"'
|
||||
if text_uses_rtl(text, ctx.primary_language)
|
||||
else ''
|
||||
)
|
||||
name = _xml_escape(f"Chart {role.title()} {shape_id}")
|
||||
return f'''<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="{shape_id}" name="{name}"/>
|
||||
<p:cNvSpPr txBox="1"/><p:nvPr/>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
<a:noFill/>
|
||||
<a:ln><a:noFill/></a:ln>
|
||||
</p:spPr>
|
||||
<p:txBody>
|
||||
<a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t" anchorCtr="0"/>
|
||||
<a:lstStyle/>
|
||||
<a:p><a:pPr algn="{algn}"{rtl_attr}/>
|
||||
<a:r><a:rPr lang="{lang}" sz="{font_size}"{bold_attr}>{run_properties_xml}</a:rPr><a:t>{_xml_escape(text)}</a:t></a:r>
|
||||
</a:p>
|
||||
</p:txBody>
|
||||
</p:sp>'''
|
||||
|
||||
|
||||
def _chart_companion_entries(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
include_title: bool,
|
||||
include_subtitle_as_caption: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
entries: list[dict[str, Any]] = []
|
||||
|
||||
def add(role: str, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
values = value if isinstance(value, list) else [value]
|
||||
for item in values:
|
||||
if isinstance(item, dict):
|
||||
text = _first_present(item.get("text"), item.get("value"), item.get("content"))
|
||||
if text:
|
||||
entries.append({"role": role, **item, "text": str(text)})
|
||||
elif str(item).strip():
|
||||
entries.append({"role": role, "text": str(item)})
|
||||
|
||||
if include_title:
|
||||
add("title", payload.get("title"))
|
||||
caption_value = payload.get("caption")
|
||||
if caption_value is None and include_subtitle_as_caption:
|
||||
caption_value = payload.get("subtitle")
|
||||
add("caption", caption_value)
|
||||
add("source", payload.get("source"))
|
||||
for key in ("note", "notes", "footnote", "footnotes"):
|
||||
add("note", payload.get(key))
|
||||
return entries
|
||||
|
||||
|
||||
def _chart_companion_box(item: dict[str, Any]) -> tuple[int, int, int, int] | None:
|
||||
"""Validate and resolve an optional explicit companion text box."""
|
||||
box_keys = ("x", "y", "width", "height")
|
||||
provided_box_keys = [key for key in box_keys if key in item]
|
||||
if provided_box_keys and len(provided_box_keys) != len(box_keys):
|
||||
raise RuntimeError(
|
||||
"Native PPTX chart companion text boxes require x/y/width/height together"
|
||||
)
|
||||
if not provided_box_keys:
|
||||
return None
|
||||
return (
|
||||
_powerpoint_emu(item["x"], "companion text x"),
|
||||
_powerpoint_emu(item["y"], "companion text y"),
|
||||
_powerpoint_emu(item["width"], "companion text width", positive=True),
|
||||
_powerpoint_emu(item["height"], "companion text height", positive=True),
|
||||
)
|
||||
|
||||
|
||||
def _validate_chart_companion_boxes(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
chart_bounds: tuple[int, int, int, int],
|
||||
include_title: bool,
|
||||
include_subtitle_as_caption: bool,
|
||||
) -> None:
|
||||
"""Validate companion boxes without allocating shapes or relationships."""
|
||||
_, chart_off_y, _, chart_ext_cy = chart_bounds
|
||||
below_index = 0
|
||||
for item in _chart_companion_entries(
|
||||
payload,
|
||||
include_title=include_title,
|
||||
include_subtitle_as_caption=include_subtitle_as_caption,
|
||||
):
|
||||
text = str(item.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if _chart_companion_box(item) is not None:
|
||||
continue
|
||||
if str(item.get("role") or "note") != "title":
|
||||
_powerpoint_emu_value(
|
||||
chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18),
|
||||
"companion text y",
|
||||
)
|
||||
below_index += 1
|
||||
|
||||
|
||||
def _chart_companion_text_xml(
|
||||
ctx: ConvertContext,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
chart_bounds: tuple[int, int, int, int],
|
||||
chart_style: dict[str, str | None],
|
||||
note_font_size: int,
|
||||
title_font_size: int,
|
||||
include_title: bool,
|
||||
include_subtitle_as_caption: bool,
|
||||
) -> str:
|
||||
entries = _chart_companion_entries(
|
||||
payload,
|
||||
include_title=include_title,
|
||||
include_subtitle_as_caption=include_subtitle_as_caption,
|
||||
)
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
chart_off_x, chart_off_y, chart_ext_cx, chart_ext_cy = chart_bounds
|
||||
parts: list[str] = []
|
||||
below_index = 0
|
||||
for item in entries:
|
||||
role = str(item.get("role") or "note")
|
||||
text = str(item.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
font_size = _font_size_hpt(item.get("font_size", item.get("fontSize")), 16 if role == "title" else 12)
|
||||
if role == "title" and item.get("font_size") is None and item.get("fontSize") is None:
|
||||
font_size = title_font_size
|
||||
elif item.get("font_size") is None and item.get("fontSize") is None:
|
||||
font_size = note_font_size
|
||||
|
||||
color = _hex_or_none(item.get("color")) or chart_style.get("text_color")
|
||||
font_face = _chart_text_entry_font_face(item, chart_style.get("font_face"))
|
||||
align = str(item.get("align") or ("ctr" if role == "title" else "l"))
|
||||
bold = bool(item.get("bold", role == "title"))
|
||||
explicit_box = _chart_companion_box(item)
|
||||
if explicit_box is not None:
|
||||
off_x, off_y, ext_cx, ext_cy = explicit_box
|
||||
elif role == "title":
|
||||
off_x = chart_off_x
|
||||
off_y = chart_off_y
|
||||
ext_cx = chart_ext_cx
|
||||
ext_cy = px_to_emu(28)
|
||||
else:
|
||||
off_x = chart_off_x
|
||||
off_y = _powerpoint_emu_value(
|
||||
chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18),
|
||||
"companion text y",
|
||||
)
|
||||
ext_cx = chart_ext_cx
|
||||
ext_cy = px_to_emu(16)
|
||||
below_index += 1
|
||||
parts.append(_text_box_xml(
|
||||
ctx,
|
||||
text=text,
|
||||
role=role,
|
||||
off_x=off_x,
|
||||
off_y=off_y,
|
||||
ext_cx=ext_cx,
|
||||
ext_cy=ext_cy,
|
||||
font_size=font_size,
|
||||
color=color,
|
||||
align=align,
|
||||
bold=bold,
|
||||
font_face=font_face,
|
||||
))
|
||||
return "".join(parts)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,161 @@
|
||||
"""Visible-fallback fingerprint contract for native chart/table markers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx_shapes import (
|
||||
NATIVE_FALLBACK_SHA256_ATTR,
|
||||
svg_native_fallback_fingerprint,
|
||||
)
|
||||
|
||||
from .marker_attributes import native_import_source, native_replacement_kind
|
||||
|
||||
|
||||
NATIVE_FALLBACK_RUNTIME_ATTR = "data-pptx-runtime-fallback-unchanged"
|
||||
_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR = "data-pptx-runtime-fallback-token"
|
||||
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
_RUNTIME_TOKEN = secrets.token_hex(16)
|
||||
|
||||
|
||||
def stamp_native_fallback_baseline(
|
||||
elem: ET.Element,
|
||||
*,
|
||||
document_root: ET.Element | None = None,
|
||||
) -> str:
|
||||
"""Record the current canonical visible-subtree hash on one marker."""
|
||||
digest = svg_native_fallback_fingerprint(
|
||||
elem,
|
||||
document_root=document_root,
|
||||
)
|
||||
elem.set(NATIVE_FALLBACK_SHA256_ATTR, digest)
|
||||
elem.attrib.pop(NATIVE_FALLBACK_RUNTIME_ATTR, None)
|
||||
elem.attrib.pop(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, None)
|
||||
return digest
|
||||
|
||||
|
||||
def snapshot_native_fallback_freshness(root: ET.Element) -> None:
|
||||
"""Snapshot raw marker freshness before exporter preprocessing mutates SVG."""
|
||||
for elem in root.iter():
|
||||
if elem.tag.rsplit("}", 1)[-1] == "metadata":
|
||||
continue
|
||||
if not native_replacement_kind(elem):
|
||||
continue
|
||||
expected, invalid = _expected_native_fallback_hash(elem)
|
||||
if invalid:
|
||||
elem.set(NATIVE_FALLBACK_RUNTIME_ATTR, "invalid")
|
||||
elem.set(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, _RUNTIME_TOKEN)
|
||||
elif expected is None:
|
||||
elem.attrib.pop(NATIVE_FALLBACK_RUNTIME_ATTR, None)
|
||||
elem.attrib.pop(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, None)
|
||||
else:
|
||||
actual = svg_native_fallback_fingerprint(
|
||||
elem,
|
||||
document_root=root,
|
||||
)
|
||||
elem.set(
|
||||
NATIVE_FALLBACK_RUNTIME_ATTR,
|
||||
"1" if actual == expected else "0",
|
||||
)
|
||||
elem.set(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, _RUNTIME_TOKEN)
|
||||
|
||||
|
||||
def native_fallback_contract_warnings(
|
||||
elem: ET.Element,
|
||||
*,
|
||||
use_runtime_snapshot: bool = False,
|
||||
document_root: ET.Element | None = None,
|
||||
) -> list[str]:
|
||||
"""Return non-blocking diagnostics for default/checker compatibility."""
|
||||
expected, invalid = _expected_native_fallback_hash(elem)
|
||||
if invalid:
|
||||
return [
|
||||
f"{NATIVE_FALLBACK_SHA256_ATTR} must be a 64-digit SHA-256; "
|
||||
"the shape-based SVG fallback remains available, but "
|
||||
"--native-charts-and-tables will fail"
|
||||
]
|
||||
if expected is None:
|
||||
if native_import_source(elem) != "pptx":
|
||||
return []
|
||||
return [
|
||||
f"imported marker has no {NATIVE_FALLBACK_SHA256_ATTR} baseline; "
|
||||
"the marker remains compatible with Chart/Table replacement, but "
|
||||
"stale fallback edits cannot be detected"
|
||||
]
|
||||
if _native_fallback_is_fresh(
|
||||
elem,
|
||||
expected,
|
||||
use_runtime_snapshot=use_runtime_snapshot,
|
||||
document_root=document_root,
|
||||
):
|
||||
return []
|
||||
return [
|
||||
"visible SVG fallback differs from its recorded baseline; default "
|
||||
"shape-based fallback export remains available, but "
|
||||
"--native-charts-and-tables will fail"
|
||||
]
|
||||
|
||||
|
||||
def require_fresh_native_fallback(
|
||||
elem: ET.Element,
|
||||
*,
|
||||
use_runtime_snapshot: bool = False,
|
||||
document_root: ET.Element | None = None,
|
||||
) -> None:
|
||||
"""Fail Chart/Table replacement when a recorded fallback is stale."""
|
||||
expected, invalid = _expected_native_fallback_hash(elem)
|
||||
if invalid:
|
||||
raise RuntimeError(
|
||||
f"{NATIVE_FALLBACK_SHA256_ATTR} must be a 64-digit SHA-256"
|
||||
)
|
||||
if expected is None:
|
||||
return
|
||||
if _native_fallback_is_fresh(
|
||||
elem,
|
||||
expected,
|
||||
use_runtime_snapshot=use_runtime_snapshot,
|
||||
document_root=document_root,
|
||||
):
|
||||
return
|
||||
raise RuntimeError(
|
||||
"Visible native-object SVG fallback was edited after its baseline was "
|
||||
"recorded; --native-charts-and-tables stopped to avoid discarding the "
|
||||
"SVG edit. "
|
||||
"Use the default fallback export, or deliberately update the native "
|
||||
"metadata and baseline together"
|
||||
)
|
||||
|
||||
|
||||
def _expected_native_fallback_hash(
|
||||
elem: ET.Element,
|
||||
) -> tuple[str | None, bool]:
|
||||
raw = elem.get(NATIVE_FALLBACK_SHA256_ATTR)
|
||||
if raw is None:
|
||||
return None, False
|
||||
if raw != raw.strip() or _SHA256_RE.fullmatch(raw) is None:
|
||||
return None, True
|
||||
return raw.lower(), False
|
||||
|
||||
|
||||
def _native_fallback_is_fresh(
|
||||
elem: ET.Element,
|
||||
expected: str,
|
||||
*,
|
||||
use_runtime_snapshot: bool,
|
||||
document_root: ET.Element | None,
|
||||
) -> bool:
|
||||
if (
|
||||
use_runtime_snapshot
|
||||
and elem.get(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR) == _RUNTIME_TOKEN
|
||||
):
|
||||
snapshot = elem.get(NATIVE_FALLBACK_RUNTIME_ATTR)
|
||||
if snapshot == "1":
|
||||
return True
|
||||
if snapshot in {"0", "invalid"}:
|
||||
return False
|
||||
return svg_native_fallback_fingerprint(
|
||||
elem,
|
||||
document_root=document_root,
|
||||
) == expected
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Canonical and compatible attribute access for chart/table replacements."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
REPLACE_WITH_ATTR = "data-pptx-replace-with"
|
||||
LEGACY_REPLACE_WITH_ATTR = "data-pptx-native"
|
||||
REPLACEMENT_STATUS_ATTR = "data-pptx-replacement-status"
|
||||
LEGACY_REPLACEMENT_STATUS_ATTR = "data-pptx-native-status"
|
||||
IMPORT_SOURCE_ATTR = "data-pptx-import-source"
|
||||
LEGACY_IMPORT_SOURCE_ATTR = "data-pptx-native-source"
|
||||
FALLBACK_KIND_ATTR = "data-pptx-fallback-kind"
|
||||
LEGACY_FALLBACK_KIND_ATTR = "data-pptx-visual-status"
|
||||
LEGACY_ROUTE_STATUS_ATTR = "data-pptx-route-status"
|
||||
|
||||
|
||||
class NativeMarkerAttributeError(ValueError):
|
||||
"""Raised when canonical and compatible marker attributes conflict."""
|
||||
|
||||
|
||||
def _normalized_token(value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def _resolved_alias(
|
||||
elem: ET.Element,
|
||||
canonical: str,
|
||||
legacy: str,
|
||||
*,
|
||||
normalize: Callable[[str], str] = _normalized_token,
|
||||
) -> str | None:
|
||||
canonical_raw = elem.get(canonical)
|
||||
legacy_raw = elem.get(legacy)
|
||||
canonical_value = normalize(canonical_raw) if canonical_raw is not None else None
|
||||
legacy_value = normalize(legacy_raw) if legacy_raw is not None else None
|
||||
if (
|
||||
canonical_value is not None
|
||||
and legacy_value is not None
|
||||
and canonical_value != legacy_value
|
||||
):
|
||||
raise NativeMarkerAttributeError(
|
||||
f"{canonical}={canonical_raw!r} conflicts with compatible "
|
||||
f"{legacy}={legacy_raw!r}"
|
||||
)
|
||||
return canonical_value if canonical_value is not None else legacy_value
|
||||
|
||||
|
||||
def native_replacement_kind(elem: ET.Element) -> str:
|
||||
"""Return the requested chart/table replacement kind, or an empty string."""
|
||||
return _resolved_alias(elem, REPLACE_WITH_ATTR, LEGACY_REPLACE_WITH_ATTR) or ""
|
||||
|
||||
|
||||
def native_replacement_status(elem: ET.Element) -> str:
|
||||
"""Return the fallback-only replacement status, or an empty string."""
|
||||
return (
|
||||
_resolved_alias(
|
||||
elem,
|
||||
REPLACEMENT_STATUS_ATTR,
|
||||
LEGACY_REPLACEMENT_STATUS_ATTR,
|
||||
normalize=str.strip,
|
||||
)
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def native_import_source(elem: ET.Element) -> str:
|
||||
"""Return replacement payload provenance, or an empty string."""
|
||||
return (
|
||||
_resolved_alias(
|
||||
elem,
|
||||
IMPORT_SOURCE_ATTR,
|
||||
LEGACY_IMPORT_SOURCE_ATTR,
|
||||
normalize=str.strip,
|
||||
)
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def native_fallback_kind(elem: ET.Element) -> str | None:
|
||||
"""Return the visible fallback classification when declared."""
|
||||
return _resolved_alias(
|
||||
elem,
|
||||
FALLBACK_KIND_ATTR,
|
||||
LEGACY_FALLBACK_KIND_ATTR,
|
||||
normalize=str.strip,
|
||||
)
|
||||
|
||||
|
||||
def native_metadata_payload_matches(
|
||||
elem: ET.Element,
|
||||
parent_kind: str,
|
||||
) -> bool:
|
||||
"""Return whether one metadata node carries this replacement payload.
|
||||
|
||||
Compatible metadata kind attributes remain readable, but any declared
|
||||
kind must agree with both its alias and the parent replacement marker.
|
||||
"""
|
||||
native_kind = elem.get(LEGACY_REPLACE_WITH_ATTR)
|
||||
compatible_kind = elem.get("data-pptx-kind")
|
||||
for attr, raw in (
|
||||
(LEGACY_REPLACE_WITH_ATTR, native_kind),
|
||||
("data-pptx-kind", compatible_kind),
|
||||
):
|
||||
if raw is not None and raw != raw.strip():
|
||||
raise NativeMarkerAttributeError(
|
||||
f"metadata {attr} must not contain surrounding whitespace"
|
||||
)
|
||||
if native_kind is not None and compatible_kind is not None:
|
||||
if _normalized_token(native_kind) != _normalized_token(compatible_kind):
|
||||
raise NativeMarkerAttributeError(
|
||||
"metadata data-pptx-native conflicts with data-pptx-kind"
|
||||
)
|
||||
declared_raw = native_kind if native_kind is not None else compatible_kind
|
||||
declared_kind = _normalized_token(declared_raw) if declared_raw is not None else None
|
||||
if declared_kind is not None and declared_kind != parent_kind:
|
||||
raise NativeMarkerAttributeError(
|
||||
f"metadata kind {declared_kind!r} conflicts with parent "
|
||||
f"replacement kind {parent_kind!r}"
|
||||
)
|
||||
metadata_type_raw = elem.get("type")
|
||||
if metadata_type_raw is not None and metadata_type_raw != metadata_type_raw.strip():
|
||||
raise NativeMarkerAttributeError(
|
||||
"metadata type must not contain surrounding whitespace"
|
||||
)
|
||||
metadata_type = (metadata_type_raw or "").lower()
|
||||
return metadata_type == "application/json" or declared_kind == parent_kind
|
||||
|
||||
|
||||
def native_marker_legacy_warnings(elem: ET.Element) -> list[str]:
|
||||
"""Return migration advice for compatible legacy marker spellings."""
|
||||
tag = elem.tag.rsplit("}", 1)[-1]
|
||||
if tag == "metadata":
|
||||
native_kind = elem.get(LEGACY_REPLACE_WITH_ATTR)
|
||||
compatible_kind = elem.get("data-pptx-kind")
|
||||
if (
|
||||
native_kind is not None
|
||||
and compatible_kind is not None
|
||||
and _normalized_token(native_kind) != _normalized_token(compatible_kind)
|
||||
):
|
||||
return []
|
||||
warnings = []
|
||||
for legacy in (LEGACY_REPLACE_WITH_ATTR, "data-pptx-kind"):
|
||||
if elem.get(legacy) is not None:
|
||||
warnings.append(
|
||||
f"legacy metadata attribute {legacy} is compatible; use "
|
||||
'type="application/json" because the parent replacement '
|
||||
"marker determines the payload kind. No change is required "
|
||||
"for export."
|
||||
)
|
||||
return warnings
|
||||
|
||||
try:
|
||||
native_replacement_kind(elem)
|
||||
native_replacement_status(elem)
|
||||
native_import_source(elem)
|
||||
native_fallback_kind(elem)
|
||||
except NativeMarkerAttributeError:
|
||||
return []
|
||||
|
||||
warnings = []
|
||||
replacements = (
|
||||
(LEGACY_REPLACE_WITH_ATTR, REPLACE_WITH_ATTR),
|
||||
(LEGACY_REPLACEMENT_STATUS_ATTR, REPLACEMENT_STATUS_ATTR),
|
||||
(LEGACY_IMPORT_SOURCE_ATTR, IMPORT_SOURCE_ATTR),
|
||||
(LEGACY_FALLBACK_KIND_ATTR, FALLBACK_KIND_ATTR),
|
||||
)
|
||||
for legacy, canonical in replacements:
|
||||
if elem.get(legacy) is not None:
|
||||
warnings.append(
|
||||
f"legacy attribute {legacy} is compatible; use {canonical} "
|
||||
"for project-canonical SVG. No change is required for export."
|
||||
)
|
||||
if elem.get(LEGACY_ROUTE_STATUS_ATTR) is not None:
|
||||
warnings.append(
|
||||
f"legacy attribute {LEGACY_ROUTE_STATUS_ATTR} is compatible; omit it "
|
||||
f"because {FALLBACK_KIND_ATTR}='placeholder' already implies the "
|
||||
"reconstruction-only route. No change is required for export."
|
||||
)
|
||||
return warnings
|
||||
@@ -0,0 +1,806 @@
|
||||
"""Shared helpers for native PowerPoint object conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from ..drawingml.context import ConvertContext, IDENTITY_MATRIX
|
||||
from ..drawingml.paths import (
|
||||
parse_svg_path,
|
||||
parse_svg_points,
|
||||
svg_path_to_absolute,
|
||||
)
|
||||
from ..drawingml.utils import (
|
||||
EMU_PER_PX,
|
||||
ctx_h,
|
||||
ctx_w,
|
||||
ctx_x,
|
||||
ctx_y,
|
||||
font_px_to_hpt,
|
||||
matrix_multiply,
|
||||
parse_project_geometry_length,
|
||||
parse_transform_operations,
|
||||
parse_transform_matrix,
|
||||
transform_point,
|
||||
)
|
||||
from .marker_attributes import (
|
||||
NativeMarkerAttributeError,
|
||||
native_metadata_payload_matches,
|
||||
)
|
||||
|
||||
TABLE_URI = "http://schemas.openxmlformats.org/drawingml/2006/table"
|
||||
CHART_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||
CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
|
||||
CHART_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
|
||||
CHARTEX_REL_TYPE = "http://schemas.microsoft.com/office/2014/relationships/chartEx"
|
||||
CHART_COLOR_STYLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/chartColorStyle"
|
||||
CHART_STYLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/chartStyle"
|
||||
PACKAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package"
|
||||
CHART_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
|
||||
CHARTEX_CONTENT_TYPE = "application/vnd.ms-office.chartex+xml"
|
||||
CHART_COLOR_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartcolorstyle+xml"
|
||||
CHART_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartstyle+xml"
|
||||
|
||||
_NATIVE_KINDS = {"table", "chart"}
|
||||
_POWERPOINT_COORD_MIN = -(2**31)
|
||||
_POWERPOINT_COORD_MAX = 2**31 - 1
|
||||
_POWERPOINT_LINE_WIDTH_MAX = 20116800
|
||||
_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
||||
_RGB_RE = re.compile(r"^rgba?\(([^)]+)\)$", re.IGNORECASE)
|
||||
_CSS_NAMED_COLORS = {
|
||||
"aliceblue": "F0F8FF",
|
||||
"black": "000000",
|
||||
"blue": "0000FF",
|
||||
"brown": "A52A2A",
|
||||
"cyan": "00FFFF",
|
||||
"darkgray": "A9A9A9",
|
||||
"darkgrey": "A9A9A9",
|
||||
"gold": "FFD700",
|
||||
"gray": "808080",
|
||||
"green": "008000",
|
||||
"grey": "808080",
|
||||
"lightgray": "D3D3D3",
|
||||
"lightgrey": "D3D3D3",
|
||||
"magenta": "FF00FF",
|
||||
"navy": "000080",
|
||||
"orange": "FFA500",
|
||||
"purple": "800080",
|
||||
"red": "FF0000",
|
||||
"silver": "C0C0C0",
|
||||
"transparent": None,
|
||||
"white": "FFFFFF",
|
||||
"yellow": "FFFF00",
|
||||
}
|
||||
|
||||
|
||||
def _local_tag(elem: ET.Element) -> str:
|
||||
return elem.tag.rsplit("}", 1)[-1] if "}" in elem.tag else elem.tag
|
||||
|
||||
|
||||
def _clean_hex(value: Any, default: str) -> str:
|
||||
return _hex_or_none(value) or _hex_or_none(default) or "000000"
|
||||
|
||||
|
||||
def _hex_or_none(value: Any) -> str | None:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
named = _CSS_NAMED_COLORS.get(raw.lower())
|
||||
if named is not None or raw.lower() in _CSS_NAMED_COLORS:
|
||||
return named
|
||||
|
||||
match = _HEX_RE.match(raw)
|
||||
if match:
|
||||
color = match.group(1).upper()
|
||||
if len(color) == 3:
|
||||
return "".join(channel * 2 for channel in color)
|
||||
return color
|
||||
|
||||
match = _RGB_RE.match(raw)
|
||||
if not match:
|
||||
return None
|
||||
parts = [part.strip() for part in match.group(1).split(",")]
|
||||
if len(parts) not in {3, 4}:
|
||||
return None
|
||||
channels: list[int] = []
|
||||
for part in parts[:3]:
|
||||
try:
|
||||
if part.endswith("%"):
|
||||
value_float = float(part[:-1]) * 255.0 / 100.0
|
||||
else:
|
||||
value_float = float(part)
|
||||
except ValueError:
|
||||
return None
|
||||
if not math.isfinite(value_float):
|
||||
return None
|
||||
channels.append(max(0, min(255, int(round(value_float)))))
|
||||
return "".join(f"{channel:02X}" for channel in channels)
|
||||
|
||||
|
||||
def _style_attr(elem: ET.Element, name: str) -> str | None:
|
||||
if elem.get(name) is not None:
|
||||
return elem.get(name)
|
||||
style = elem.get("style")
|
||||
if not style:
|
||||
return None
|
||||
for part in style.split(";"):
|
||||
if ":" not in part:
|
||||
continue
|
||||
key, value = part.split(":", 1)
|
||||
if key.strip() == name:
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _paint_visible(elem: ET.Element, paint: str) -> bool:
|
||||
for name in ("opacity", f"{paint}-opacity"):
|
||||
raw = _style_attr(elem, name)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
if float(raw) <= 0:
|
||||
return False
|
||||
except ValueError:
|
||||
continue
|
||||
return True
|
||||
|
||||
|
||||
def _normalized_fallback_text(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def _visible_fallback_texts(elem: ET.Element, *, include_metadata: bool = False) -> list[str]:
|
||||
texts: list[str] = []
|
||||
|
||||
def visit(node: ET.Element, hidden: bool) -> None:
|
||||
tag = _local_tag(node)
|
||||
if tag in {"defs", "clipPath", "mask", "filter", "style"}:
|
||||
return
|
||||
if tag == "metadata" and not include_metadata:
|
||||
return
|
||||
node_hidden = (
|
||||
hidden
|
||||
or _style_attr(node, "display") == "none"
|
||||
or _style_attr(node, "visibility") == "hidden"
|
||||
)
|
||||
if tag == "text" and not node_hidden:
|
||||
text = _normalized_fallback_text("".join(node.itertext()))
|
||||
if text:
|
||||
texts.append(text)
|
||||
return
|
||||
for child in node:
|
||||
visit(child, node_hidden)
|
||||
|
||||
visit(elem, False)
|
||||
return texts
|
||||
|
||||
|
||||
def _number(value: Any, field_name: str) -> float:
|
||||
if isinstance(value, bool):
|
||||
raise RuntimeError(f"Native PPTX object requires numeric {field_name}")
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError, OverflowError) as exc:
|
||||
raise RuntimeError(f"Native PPTX object requires numeric {field_name}") from exc
|
||||
if not math.isfinite(number):
|
||||
raise RuntimeError(f"Native PPTX object requires finite numeric {field_name}")
|
||||
return number
|
||||
|
||||
|
||||
def _maybe_number(value: Any) -> float | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
|
||||
def _project_geometry_number(
|
||||
elem: ET.Element,
|
||||
attribute: str,
|
||||
default: float = 0.0,
|
||||
) -> float:
|
||||
"""Read one fallback geometry value through the project length contract."""
|
||||
raw = elem.get(attribute)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return parse_project_geometry_length(raw, attribute)
|
||||
except ValueError as exc:
|
||||
tag = _local_tag(elem)
|
||||
elem_id = elem.get("id")
|
||||
label = f"<{tag} id={elem_id!r}>" if elem_id else f"<{tag}>"
|
||||
raise RuntimeError(
|
||||
f"Native PPTX fallback {label} {attribute}={raw!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _powerpoint_emu_value(
|
||||
emu: int,
|
||||
field_name: str,
|
||||
*,
|
||||
positive: bool = False,
|
||||
) -> int:
|
||||
"""Validate an already-resolved EMU value for a PowerPoint coordinate."""
|
||||
lower_bound = 1 if positive else _POWERPOINT_COORD_MIN
|
||||
if emu < lower_bound or emu > _POWERPOINT_COORD_MAX:
|
||||
qualifier = "positive " if positive else ""
|
||||
raise RuntimeError(
|
||||
f"Native PPTX object {field_name} must resolve to a {qualifier}"
|
||||
"32-bit PowerPoint coordinate"
|
||||
)
|
||||
return emu
|
||||
|
||||
|
||||
def _powerpoint_emu(value: Any, field_name: str, *, positive: bool = False) -> int:
|
||||
"""Convert SVG px to an EMU value that PowerPoint can represent."""
|
||||
number = _number(value, field_name)
|
||||
scaled = number * EMU_PER_PX
|
||||
if not math.isfinite(scaled):
|
||||
raise RuntimeError(f"Native PPTX object {field_name} exceeds PowerPoint coordinates")
|
||||
return _powerpoint_emu_value(round(scaled), field_name, positive=positive)
|
||||
|
||||
|
||||
def _powerpoint_line_width_emu(value: Any, field_name: str) -> int:
|
||||
"""Convert SVG px to a legal DrawingML ``ST_LineWidth`` value."""
|
||||
emu = _powerpoint_emu(value, field_name, positive=True)
|
||||
if emu > _POWERPOINT_LINE_WIDTH_MAX:
|
||||
raise RuntimeError(
|
||||
f"Native PPTX object {field_name} exceeds DrawingML line-width range"
|
||||
)
|
||||
return emu
|
||||
|
||||
|
||||
def native_marker_transform(transform: str | None) -> tuple[float, float, float, float]:
|
||||
"""Return a strict native-marker transform as ``dx, dy, sx, sy``."""
|
||||
raw = (transform or "").strip()
|
||||
if not raw:
|
||||
return 0.0, 0.0, 1.0, 1.0
|
||||
|
||||
try:
|
||||
operations = parse_transform_operations(raw)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
"Native PPTX table/chart markers support translate/scale transforms only"
|
||||
) from exc
|
||||
if any(name not in {"translate", "scale"} for name, _args in operations):
|
||||
raise RuntimeError(
|
||||
"Native PPTX table/chart markers support translate/scale transforms only"
|
||||
)
|
||||
|
||||
a, b, c, d, e, f = parse_transform_matrix(raw)
|
||||
components = (a, b, c, d, e, f)
|
||||
if not all(math.isfinite(value) for value in components):
|
||||
raise RuntimeError("Native PPTX marker transform exceeds finite coordinates")
|
||||
if b != 0.0 or c != 0.0:
|
||||
raise RuntimeError(
|
||||
"Native PPTX table/chart markers support translate/scale transforms only"
|
||||
)
|
||||
return e, f, a, d
|
||||
|
||||
|
||||
def _native_marker_validation_context(
|
||||
elem: ET.Element,
|
||||
ancestors: Iterable[ET.Element] = (),
|
||||
) -> ConvertContext:
|
||||
"""Build the scalar context used by native export for one marker path."""
|
||||
ctx = ConvertContext()
|
||||
for current in (*ancestors, elem):
|
||||
dx, dy, sx, sy = native_marker_transform(current.get("transform"))
|
||||
ctx = ctx.child(
|
||||
dx=ctx.scale_x * dx,
|
||||
dy=ctx.scale_y * dy,
|
||||
sx=sx,
|
||||
sy=sy,
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
def _bbox_union(
|
||||
first: tuple[float, float, float, float] | None,
|
||||
second: tuple[float, float, float, float] | None,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
if first is None:
|
||||
return second
|
||||
if second is None:
|
||||
return first
|
||||
return (
|
||||
min(first[0], second[0]),
|
||||
min(first[1], second[1]),
|
||||
max(first[2], second[2]),
|
||||
max(first[3], second[3]),
|
||||
)
|
||||
|
||||
|
||||
def _bbox_from_points(points: list[tuple[float, float]]) -> tuple[float, float, float, float] | None:
|
||||
if not points:
|
||||
return None
|
||||
xs = [point[0] for point in points]
|
||||
ys = [point[1] for point in points]
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def _apply_matrix_bbox(
|
||||
bbox: tuple[float, float, float, float],
|
||||
matrix: tuple[float, float, float, float, float, float],
|
||||
) -> tuple[float, float, float, float]:
|
||||
x1, y1, x2, y2 = bbox
|
||||
points = [
|
||||
transform_point(matrix, x1, y1),
|
||||
transform_point(matrix, x2, y1),
|
||||
transform_point(matrix, x2, y2),
|
||||
transform_point(matrix, x1, y2),
|
||||
]
|
||||
result = _bbox_from_points(points)
|
||||
if result is None:
|
||||
raise RuntimeError("Native PPTX object fallback bbox inference failed")
|
||||
return result
|
||||
|
||||
|
||||
def _points_attr_bbox(
|
||||
value: str | None,
|
||||
*,
|
||||
min_points: int,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
try:
|
||||
points = parse_svg_points(value or "", min_points=min_points)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Invalid native fallback points: {exc}") from exc
|
||||
return _bbox_from_points(points)
|
||||
|
||||
|
||||
def _path_bbox(value: str | None) -> tuple[float, float, float, float] | None:
|
||||
try:
|
||||
commands = svg_path_to_absolute(parse_svg_path(value or ""))
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Invalid native fallback path d: {exc}") from exc
|
||||
|
||||
points: list[tuple[float, float]] = []
|
||||
subpath_x = 0.0
|
||||
subpath_y = 0.0
|
||||
|
||||
for command in commands:
|
||||
values = command.args
|
||||
if command.cmd == "M":
|
||||
subpath_x, subpath_y = values
|
||||
points.append((subpath_x, subpath_y))
|
||||
elif command.cmd in {"L", "T"}:
|
||||
points.append((values[0], values[1]))
|
||||
elif command.cmd == "C":
|
||||
points.extend(
|
||||
(values[index], values[index + 1])
|
||||
for index in range(0, 6, 2)
|
||||
)
|
||||
elif command.cmd in {"S", "Q"}:
|
||||
points.extend(
|
||||
(values[index], values[index + 1])
|
||||
for index in range(0, 4, 2)
|
||||
)
|
||||
elif command.cmd == "A":
|
||||
points.append((values[5], values[6]))
|
||||
elif command.cmd == "Z":
|
||||
points.append((subpath_x, subpath_y))
|
||||
|
||||
return _bbox_from_points(points)
|
||||
|
||||
|
||||
def _element_local_bbox(elem: ET.Element) -> tuple[float, float, float, float] | None:
|
||||
tag = _local_tag(elem)
|
||||
if tag == "metadata":
|
||||
return None
|
||||
if tag in {"defs", "clipPath", "mask", "filter", "style"}:
|
||||
return None
|
||||
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
|
||||
return None
|
||||
|
||||
if tag in {"g", "svg", "a"}:
|
||||
bbox = None
|
||||
for child in elem:
|
||||
bbox = _bbox_union(bbox, _fallback_bbox(child))
|
||||
return bbox
|
||||
|
||||
if tag in {"rect", "image", "use"}:
|
||||
x = _project_geometry_number(elem, "x")
|
||||
y = _project_geometry_number(elem, "y")
|
||||
width = _project_geometry_number(elem, "width")
|
||||
height = _project_geometry_number(elem, "height")
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return x, y, x + width, y + height
|
||||
|
||||
if tag == "circle":
|
||||
cx = _project_geometry_number(elem, "cx")
|
||||
cy = _project_geometry_number(elem, "cy")
|
||||
r = _project_geometry_number(elem, "r")
|
||||
if r <= 0:
|
||||
return None
|
||||
return cx - r, cy - r, cx + r, cy + r
|
||||
|
||||
if tag == "ellipse":
|
||||
cx = _project_geometry_number(elem, "cx")
|
||||
cy = _project_geometry_number(elem, "cy")
|
||||
rx = _project_geometry_number(elem, "rx")
|
||||
ry = _project_geometry_number(elem, "ry")
|
||||
if rx <= 0 or ry <= 0:
|
||||
return None
|
||||
return cx - rx, cy - ry, cx + rx, cy + ry
|
||||
|
||||
if tag == "line":
|
||||
points = [
|
||||
(
|
||||
_project_geometry_number(elem, "x1"),
|
||||
_project_geometry_number(elem, "y1"),
|
||||
),
|
||||
(
|
||||
_project_geometry_number(elem, "x2"),
|
||||
_project_geometry_number(elem, "y2"),
|
||||
),
|
||||
]
|
||||
return _bbox_from_points(points)
|
||||
|
||||
if tag in {"polygon", "polyline"}:
|
||||
return _points_attr_bbox(
|
||||
elem.get("points"),
|
||||
min_points=3 if tag == "polygon" else 2,
|
||||
)
|
||||
|
||||
if tag == "path":
|
||||
# This intentionally approximates path geometry from command endpoints.
|
||||
# Explicit metadata remains the precise path for complex arcs/curves.
|
||||
return _path_bbox(elem.get("d"))
|
||||
|
||||
if tag == "text":
|
||||
x = _project_geometry_number(elem, "x")
|
||||
y = _project_geometry_number(elem, "y")
|
||||
font_size = _maybe_number(elem.get("font-size")) or 16.0
|
||||
text = "".join(elem.itertext())
|
||||
width = max(len(text), 1) * font_size * 0.55
|
||||
height = font_size * 1.25
|
||||
return x, y - height * 0.8, x + width, y + height * 0.2
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _fallback_bbox(
|
||||
elem: ET.Element,
|
||||
matrix: tuple[float, float, float, float, float, float] = IDENTITY_MATRIX,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
local_matrix = matrix
|
||||
transform = elem.get("transform")
|
||||
if transform:
|
||||
local_matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
|
||||
|
||||
tag = _local_tag(elem)
|
||||
if tag in {"g", "svg", "a"}:
|
||||
bbox = None
|
||||
for child in elem:
|
||||
bbox = _bbox_union(bbox, _fallback_bbox(child, local_matrix))
|
||||
return bbox
|
||||
|
||||
local_bbox = _element_local_bbox(elem)
|
||||
if local_bbox is None:
|
||||
return None
|
||||
return _apply_matrix_bbox(local_bbox, local_matrix)
|
||||
|
||||
|
||||
def _inferred_bounds(elem: ET.Element) -> tuple[float, float, float, float] | None:
|
||||
bbox = None
|
||||
for child in elem:
|
||||
bbox = _bbox_union(bbox, _fallback_bbox(child))
|
||||
return bbox
|
||||
|
||||
|
||||
def _fallback_fill_candidates(
|
||||
elem: ET.Element,
|
||||
matrix: tuple[float, float, float, float, float, float] = IDENTITY_MATRIX,
|
||||
inherited_fill: str | None = None,
|
||||
) -> list[tuple[float, str]]:
|
||||
tag = _local_tag(elem)
|
||||
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
|
||||
return []
|
||||
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
|
||||
return []
|
||||
if not _paint_visible(elem, "fill"):
|
||||
return []
|
||||
|
||||
local_matrix = matrix
|
||||
transform = elem.get("transform")
|
||||
if transform:
|
||||
local_matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
|
||||
|
||||
fill = _style_attr(elem, "fill")
|
||||
next_fill = fill if fill is not None else inherited_fill
|
||||
if tag in {"g", "svg", "a"}:
|
||||
candidates: list[tuple[float, str]] = []
|
||||
for child in elem:
|
||||
candidates.extend(_fallback_fill_candidates(child, local_matrix, next_fill))
|
||||
return candidates
|
||||
|
||||
if tag != "rect":
|
||||
return []
|
||||
if not next_fill or next_fill.strip().lower() in {"none", "transparent"}:
|
||||
return []
|
||||
color = _hex_or_none(next_fill)
|
||||
if not color:
|
||||
return []
|
||||
local_bbox = _element_local_bbox(elem)
|
||||
if local_bbox is None:
|
||||
return []
|
||||
x1, y1, x2, y2 = _apply_matrix_bbox(local_bbox, local_matrix)
|
||||
area = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
|
||||
return [(area, color)] if area > 0 else []
|
||||
|
||||
|
||||
def _inferred_chart_background(elem: ET.Element) -> str | None:
|
||||
bounds = _inferred_bounds(elem)
|
||||
if bounds is None:
|
||||
return None
|
||||
x1, y1, x2, y2 = bounds
|
||||
bounds_area = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
|
||||
if bounds_area <= 0:
|
||||
return None
|
||||
|
||||
candidates: list[tuple[float, str]] = []
|
||||
for child in elem:
|
||||
candidates.extend(_fallback_fill_candidates(child))
|
||||
if not candidates:
|
||||
return None
|
||||
area, color = max(candidates, key=lambda item: item[0])
|
||||
# Avoid mistaking a large data bar for a chart background when no panel /
|
||||
# plot-area rectangle exists in the fallback drawing.
|
||||
return color if area >= bounds_area * 0.25 else None
|
||||
|
||||
|
||||
def _fallback_text_colors(
|
||||
elem: ET.Element,
|
||||
inherited_fill: str | None = None,
|
||||
) -> list[str]:
|
||||
tag = _local_tag(elem)
|
||||
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
|
||||
return []
|
||||
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
|
||||
return []
|
||||
if not _paint_visible(elem, "fill"):
|
||||
return []
|
||||
|
||||
fill = _style_attr(elem, "fill")
|
||||
next_fill = fill if fill is not None else inherited_fill
|
||||
colors: list[str] = []
|
||||
if tag in {"text", "tspan"} and next_fill:
|
||||
color = _hex_or_none(next_fill)
|
||||
if color:
|
||||
colors.append(color)
|
||||
for child in elem:
|
||||
colors.extend(_fallback_text_colors(child, next_fill))
|
||||
return colors
|
||||
|
||||
|
||||
def _fallback_stroke_colors(
|
||||
elem: ET.Element,
|
||||
inherited_stroke: str | None = None,
|
||||
) -> list[str]:
|
||||
tag = _local_tag(elem)
|
||||
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
|
||||
return []
|
||||
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
|
||||
return []
|
||||
if not _paint_visible(elem, "stroke"):
|
||||
return []
|
||||
|
||||
stroke = _style_attr(elem, "stroke")
|
||||
next_stroke = stroke if stroke is not None else inherited_stroke
|
||||
colors: list[str] = []
|
||||
if tag in {"circle", "ellipse", "line", "path", "polygon", "polyline", "rect"} and next_stroke:
|
||||
color = _hex_or_none(next_stroke)
|
||||
if color:
|
||||
colors.append(color)
|
||||
for child in elem:
|
||||
colors.extend(_fallback_stroke_colors(child, next_stroke))
|
||||
return colors
|
||||
|
||||
|
||||
def _most_common_color(colors: list[str]) -> str | None:
|
||||
if not colors:
|
||||
return None
|
||||
counts: dict[str, int] = {}
|
||||
for color in colors:
|
||||
counts[color] = counts.get(color, 0) + 1
|
||||
return max(counts.items(), key=lambda item: item[1])[0]
|
||||
|
||||
|
||||
def _relative_luminance(color: str) -> float:
|
||||
channels = [int(color[idx:idx + 2], 16) / 255.0 for idx in (0, 2, 4)]
|
||||
linear = [
|
||||
channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
|
||||
for channel in channels
|
||||
]
|
||||
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
|
||||
|
||||
|
||||
def _resolved_bounds(
|
||||
elem: ET.Element,
|
||||
payload: dict[str, Any],
|
||||
ctx: ConvertContext,
|
||||
) -> tuple[float, float, float, float, bool]:
|
||||
"""Resolve object bounds in SVG px plus whether all bounds were explicit."""
|
||||
if ctx.use_transform_matrix:
|
||||
raise RuntimeError("Native PPTX table/chart markers support translate/scale only")
|
||||
|
||||
raw_x = payload.get("x", elem.get("data-pptx-x"))
|
||||
raw_y = payload.get("y", elem.get("data-pptx-y"))
|
||||
raw_width = payload.get("width", elem.get("data-pptx-width"))
|
||||
raw_height = payload.get("height", elem.get("data-pptx-height"))
|
||||
explicit_bounds = all(value is not None for value in (raw_x, raw_y, raw_width, raw_height))
|
||||
inferred = None
|
||||
if not explicit_bounds:
|
||||
inferred = _inferred_bounds(elem)
|
||||
if inferred is None:
|
||||
raise RuntimeError(
|
||||
"Native PPTX object requires x/y/width/height or visible fallback geometry"
|
||||
)
|
||||
|
||||
x = _number(raw_x, "x") if raw_x is not None else inferred[0] # type: ignore[index]
|
||||
y = _number(raw_y, "y") if raw_y is not None else inferred[1] # type: ignore[index]
|
||||
width = (
|
||||
_number(raw_width, "width")
|
||||
if raw_width is not None else inferred[2] - inferred[0] # type: ignore[index]
|
||||
)
|
||||
height = (
|
||||
_number(raw_height, "height")
|
||||
if raw_height is not None else inferred[3] - inferred[1] # type: ignore[index]
|
||||
)
|
||||
if width <= 0 or height <= 0:
|
||||
raise RuntimeError("Native PPTX object width/height must be positive")
|
||||
|
||||
if explicit_bounds:
|
||||
resolved_x = x
|
||||
resolved_y = y
|
||||
resolved_w = width
|
||||
resolved_h = height
|
||||
else:
|
||||
resolved_x = ctx_x(x, ctx)
|
||||
resolved_y = ctx_y(y, ctx)
|
||||
resolved_w = ctx_w(width, ctx)
|
||||
resolved_h = ctx_h(height, ctx)
|
||||
return resolved_x, resolved_y, resolved_w, resolved_h, explicit_bounds
|
||||
|
||||
|
||||
def _bounds(elem: ET.Element, payload: dict[str, Any], ctx: ConvertContext) -> tuple[int, int, int, int]:
|
||||
"""Return object bounds as DrawingML EMU tuple."""
|
||||
x, y, width, height, _ = _resolved_bounds(elem, payload, ctx)
|
||||
return (
|
||||
_powerpoint_emu(x, "x"),
|
||||
_powerpoint_emu(y, "y"),
|
||||
_powerpoint_emu(width, "width", positive=True),
|
||||
_powerpoint_emu(height, "height", positive=True),
|
||||
)
|
||||
|
||||
|
||||
def _validate_bounds_inputs(
|
||||
elem: ET.Element,
|
||||
payload: dict[str, Any],
|
||||
ctx: ConvertContext,
|
||||
) -> tuple[int, int, int, int, bool]:
|
||||
x, y, width, height, explicit_bounds = _resolved_bounds(elem, payload, ctx)
|
||||
return (
|
||||
_powerpoint_emu(x, "x"),
|
||||
_powerpoint_emu(y, "y"),
|
||||
_powerpoint_emu(width, "width", positive=True),
|
||||
_powerpoint_emu(height, "height", positive=True),
|
||||
explicit_bounds,
|
||||
)
|
||||
|
||||
|
||||
def _validate_payload_xml_strings(value: Any) -> None:
|
||||
"""Reject JSON strings that cannot be serialized into PPTX XML 1.0 parts."""
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
_validate_payload_xml_strings(key)
|
||||
_validate_payload_xml_strings(item)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
_validate_payload_xml_strings(item)
|
||||
return
|
||||
if not isinstance(value, str):
|
||||
return
|
||||
for char in value:
|
||||
codepoint = ord(char)
|
||||
if (
|
||||
codepoint in {0x09, 0x0A, 0x0D}
|
||||
or 0x20 <= codepoint <= 0xD7FF
|
||||
or 0xE000 <= codepoint <= 0xFFFD
|
||||
or 0x10000 <= codepoint <= 0x10FFFF
|
||||
):
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"Native PPTX metadata contains an XML 1.0-invalid character "
|
||||
f"U+{codepoint:04X}"
|
||||
)
|
||||
|
||||
|
||||
def _load_payload(elem: ET.Element, kind: str) -> dict[str, Any]:
|
||||
raw = elem.get("data-pptx-json") or elem.get("data-pptx-data")
|
||||
if raw is None:
|
||||
for child in elem:
|
||||
if _local_tag(child) != "metadata":
|
||||
continue
|
||||
if native_metadata_payload_matches(child, kind):
|
||||
raw = "".join(child.itertext()).strip()
|
||||
break
|
||||
|
||||
if not raw:
|
||||
raise RuntimeError(
|
||||
f"PPTX {kind} replacement marker requires JSON metadata; add "
|
||||
'<metadata type="application/json"> for a real data-backed '
|
||||
"object, or remove data-pptx-replace-with from SVG-only "
|
||||
"KPI/diagram groups"
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Native PPTX {kind} metadata is not valid JSON: {exc.msg}") from exc
|
||||
except (ValueError, RecursionError) as exc:
|
||||
raise RuntimeError(f"Native PPTX {kind} metadata cannot be decoded") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"Native PPTX {kind} metadata must be a JSON object")
|
||||
_validate_payload_xml_strings(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _font_size_hpt(value: Any, default_px: int = 18) -> int:
|
||||
def convert(raw: Any) -> int | None:
|
||||
try:
|
||||
px = float(raw)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
try:
|
||||
return font_px_to_hpt(px)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return convert(value) or convert(default_px) or 1350
|
||||
|
||||
|
||||
def _first_present(*values: Any) -> Any:
|
||||
for value in values:
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _bool_attr(value: bool) -> str:
|
||||
return "1" if value else "0"
|
||||
|
||||
|
||||
def _chart_bool(value: Any, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
key = _compact_key(value)
|
||||
if key in {"1", "on", "true", "yes"}:
|
||||
return True
|
||||
if key in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _excel_col(index: int) -> str:
|
||||
result = ""
|
||||
while index:
|
||||
index, remainder = divmod(index - 1, 26)
|
||||
result = chr(65 + remainder) + result
|
||||
return result or "A"
|
||||
|
||||
|
||||
def _compact_key(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", str(value or "").lower())
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Validate chart/table replacement fallback and release-route attributes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .marker_attributes import (
|
||||
FALLBACK_KIND_ATTR,
|
||||
IMPORT_SOURCE_ATTR,
|
||||
LEGACY_FALLBACK_KIND_ATTR,
|
||||
LEGACY_IMPORT_SOURCE_ATTR,
|
||||
LEGACY_REPLACEMENT_STATUS_ATTR,
|
||||
LEGACY_REPLACE_WITH_ATTR,
|
||||
LEGACY_ROUTE_STATUS_ATTR,
|
||||
REPLACEMENT_STATUS_ATTR,
|
||||
REPLACE_WITH_ATTR,
|
||||
NativeMarkerAttributeError,
|
||||
native_fallback_kind,
|
||||
native_import_source,
|
||||
native_replacement_kind,
|
||||
native_replacement_status,
|
||||
)
|
||||
|
||||
|
||||
VISUAL_STATUSES = frozenset({"source-preview", "normalized", "placeholder"})
|
||||
ROUTE_STATUSES = frozenset({"reconstruction-only"})
|
||||
REPLACEMENT_KINDS = frozenset({"chart", "table"})
|
||||
# Closed importer outputs from chart_to_svg, chartex_to_svg, and tbl_to_svg.
|
||||
# This includes codes forwarded through their dynamic ``status`` parameters.
|
||||
REPLACEMENT_STATUS_CODES = frozenset({
|
||||
"unsupported-3d-chart",
|
||||
"unsupported-chart-analysis-features",
|
||||
"unsupported-chart-axis-number-format",
|
||||
"unsupported-chart-axis-options",
|
||||
"unsupported-chart-axis-titles",
|
||||
"unsupported-chart-bar-options",
|
||||
"unsupported-chart-bubble-options",
|
||||
"unsupported-chart-cache",
|
||||
"unsupported-chart-category-format",
|
||||
"unsupported-chart-data-labels",
|
||||
"unsupported-chart-data-table",
|
||||
"unsupported-chart-doughnut-options",
|
||||
"unsupported-chart-legend-position",
|
||||
"unsupported-chart-line-style",
|
||||
"unsupported-chart-of-pie-options",
|
||||
"unsupported-chart-parse",
|
||||
"unsupported-chart-part",
|
||||
"unsupported-chart-pie-options",
|
||||
"unsupported-chart-plot",
|
||||
"unsupported-chart-point-labels",
|
||||
"unsupported-chart-radar-style",
|
||||
"unsupported-chart-reference",
|
||||
"unsupported-chart-relationship",
|
||||
"unsupported-chart-scatter-style",
|
||||
"unsupported-chart-schema",
|
||||
"unsupported-chart-series-data-labels",
|
||||
"unsupported-chart-series-order",
|
||||
"unsupported-chart-series-style",
|
||||
"unsupported-chart-type",
|
||||
"unsupported-chart-uri",
|
||||
"unsupported-chartex-cache",
|
||||
"unsupported-chartex-data-id",
|
||||
"unsupported-chartex-dimension",
|
||||
"unsupported-chartex-parse",
|
||||
"unsupported-chartex-part",
|
||||
"unsupported-chartex-schema",
|
||||
"unsupported-chartex-series",
|
||||
"unsupported-chartex-structure",
|
||||
"unsupported-chartex-type",
|
||||
"unsupported-combo-category-format",
|
||||
"unsupported-combo-category-layout",
|
||||
"unsupported-combo-chart",
|
||||
"unsupported-combo-series-order",
|
||||
"unsupported-date-axis",
|
||||
"unsupported-date-system",
|
||||
"unsupported-formatted-category-cache",
|
||||
"unsupported-merge-topology",
|
||||
"unsupported-native-transform",
|
||||
"unsupported-stock-chart",
|
||||
"unsupported-table-direct-formatting",
|
||||
"unsupported-table-geometry",
|
||||
"unsupported-table-size",
|
||||
"unsupported-table-style",
|
||||
})
|
||||
|
||||
|
||||
def native_marker_status_errors(elem: ET.Element) -> list[str]:
|
||||
"""Return invalid or contradictory replacement status declarations."""
|
||||
errors: list[str] = []
|
||||
native_raw_values = {
|
||||
REPLACE_WITH_ATTR: elem.get(REPLACE_WITH_ATTR),
|
||||
LEGACY_REPLACE_WITH_ATTR: elem.get(LEGACY_REPLACE_WITH_ATTR),
|
||||
}
|
||||
fallback_status_raw_values = {
|
||||
REPLACEMENT_STATUS_ATTR: elem.get(REPLACEMENT_STATUS_ATTR),
|
||||
LEGACY_REPLACEMENT_STATUS_ATTR: elem.get(LEGACY_REPLACEMENT_STATUS_ATTR),
|
||||
}
|
||||
import_source_raw_values = {
|
||||
IMPORT_SOURCE_ATTR: elem.get(IMPORT_SOURCE_ATTR),
|
||||
LEGACY_IMPORT_SOURCE_ATTR: elem.get(LEGACY_IMPORT_SOURCE_ATTR),
|
||||
}
|
||||
visual_raw = elem.get(FALLBACK_KIND_ATTR)
|
||||
legacy_visual_raw = elem.get(LEGACY_FALLBACK_KIND_ATTR)
|
||||
route_raw = elem.get(LEGACY_ROUTE_STATUS_ATTR)
|
||||
try:
|
||||
visual = native_fallback_kind(elem)
|
||||
native = native_replacement_kind(elem)
|
||||
fallback = native_replacement_status(elem)
|
||||
native_import_source(elem)
|
||||
except NativeMarkerAttributeError as exc:
|
||||
errors.append(str(exc))
|
||||
return errors
|
||||
|
||||
route = route_raw.strip() if route_raw is not None else None
|
||||
|
||||
for attr, raw in (
|
||||
*native_raw_values.items(),
|
||||
*fallback_status_raw_values.items(),
|
||||
*import_source_raw_values.items(),
|
||||
):
|
||||
if raw is not None and raw != raw.strip():
|
||||
errors.append(f"{attr} must not contain surrounding whitespace")
|
||||
canonical_kind_raw = native_raw_values[REPLACE_WITH_ATTR]
|
||||
if (
|
||||
canonical_kind_raw is not None
|
||||
and canonical_kind_raw == canonical_kind_raw.strip()
|
||||
and canonical_kind_raw != canonical_kind_raw.lower()
|
||||
):
|
||||
errors.append(f"{REPLACE_WITH_ATTR} must use lowercase chart or table")
|
||||
if visual_raw is not None and visual_raw != visual_raw.strip():
|
||||
errors.append(f"{FALLBACK_KIND_ATTR} must not contain surrounding whitespace")
|
||||
if legacy_visual_raw is not None and legacy_visual_raw != legacy_visual_raw.strip():
|
||||
errors.append(
|
||||
f"{LEGACY_FALLBACK_KIND_ATTR} must not contain surrounding whitespace"
|
||||
)
|
||||
if route_raw is not None and route_raw != route:
|
||||
errors.append(f"{LEGACY_ROUTE_STATUS_ATTR} must not contain surrounding whitespace")
|
||||
if visual is not None and visual not in VISUAL_STATUSES:
|
||||
errors.append(f"unsupported {FALLBACK_KIND_ATTR} value: {visual!r}")
|
||||
if route is not None and route not in ROUTE_STATUSES:
|
||||
errors.append(f"unsupported {LEGACY_ROUTE_STATUS_ATTR} value: {route!r}")
|
||||
if any(raw is not None for raw in native_raw_values.values()):
|
||||
if native not in REPLACEMENT_KINDS:
|
||||
errors.append(f"unsupported {REPLACE_WITH_ATTR} value: {native!r}")
|
||||
if any(raw is not None for raw in fallback_status_raw_values.values()):
|
||||
if not fallback:
|
||||
errors.append(f"{REPLACEMENT_STATUS_ATTR} must not be empty")
|
||||
elif fallback not in REPLACEMENT_STATUS_CODES:
|
||||
errors.append(
|
||||
f"unsupported {REPLACEMENT_STATUS_ATTR} value: {fallback!r}"
|
||||
)
|
||||
if any(raw is not None for raw in import_source_raw_values.values()):
|
||||
source = native_import_source(elem)
|
||||
if source != "pptx":
|
||||
errors.append(f"unsupported {IMPORT_SOURCE_ATTR} value: {source!r}")
|
||||
if (
|
||||
visual_raw is None
|
||||
and legacy_visual_raw is not None
|
||||
and visual == "placeholder"
|
||||
and route != "reconstruction-only"
|
||||
):
|
||||
errors.append(
|
||||
f"{LEGACY_FALLBACK_KIND_ATTR}='placeholder' requires "
|
||||
f"{LEGACY_ROUTE_STATUS_ATTR}='reconstruction-only'"
|
||||
)
|
||||
if route == "reconstruction-only" and visual != "placeholder":
|
||||
errors.append(
|
||||
f"{LEGACY_ROUTE_STATUS_ATTR}='reconstruction-only' requires "
|
||||
f"{FALLBACK_KIND_ATTR}='placeholder'"
|
||||
)
|
||||
if native and fallback:
|
||||
errors.append(
|
||||
f"data-pptx-replace-with and {REPLACEMENT_STATUS_ATTR} are mutually exclusive"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def native_marker_release_block_reason(elem: ET.Element) -> str | None:
|
||||
"""Return invalid status metadata that must block an export.
|
||||
|
||||
A valid ``reconstruction-only`` declaration is diagnostic rather than a
|
||||
release block: default export keeps its visible placeholder, while an
|
||||
an active replacement marker may still reconstruct a native Chart/Table.
|
||||
"""
|
||||
errors = native_marker_status_errors(elem)
|
||||
if errors:
|
||||
return f"invalid-status: {errors[0]}"
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
"""Minimal workbook generation for native chart data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from xlsxwriter import Workbook as XlsxWriterWorkbook
|
||||
except ImportError: # pragma: no cover - optional compatibility enhancement
|
||||
XlsxWriterWorkbook = None
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook as OpenpyxlWorkbook
|
||||
except ImportError: # pragma: no cover - optional compatibility enhancement
|
||||
OpenpyxlWorkbook = None
|
||||
|
||||
from ..drawingml.utils import _xml_escape
|
||||
from .marker_common import _excel_col
|
||||
|
||||
|
||||
def _xlsx_cell_ref(row: int, col: int) -> str:
|
||||
return f"{_excel_col(col)}{row}"
|
||||
|
||||
|
||||
def _xlsx_cell(value: Any, row: int, col: int) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
ref = _xlsx_cell_ref(row, col)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return f'<c r="{ref}"><v>{value}</v></c>'
|
||||
return (
|
||||
f'<c r="{ref}" t="inlineStr"><is><t>{_xml_escape(str(value))}</t></is></c>'
|
||||
)
|
||||
|
||||
|
||||
def _minimal_workbook(rows: list[list[Any]]) -> bytes:
|
||||
if XlsxWriterWorkbook is not None:
|
||||
buffer = io.BytesIO()
|
||||
workbook = XlsxWriterWorkbook(buffer, {
|
||||
"in_memory": True,
|
||||
"strings_to_formulas": False,
|
||||
"strings_to_urls": False,
|
||||
})
|
||||
worksheet = workbook.add_worksheet("Sheet1")
|
||||
for row_index, row in enumerate(rows):
|
||||
for col_index, value in enumerate(row):
|
||||
worksheet.write(row_index, col_index, value)
|
||||
workbook.close()
|
||||
return buffer.getvalue()
|
||||
|
||||
if OpenpyxlWorkbook is not None:
|
||||
workbook = OpenpyxlWorkbook()
|
||||
worksheet = workbook.active
|
||||
worksheet.title = "Sheet1"
|
||||
for row_index, row in enumerate(rows, start=1):
|
||||
for col_index, value in enumerate(row, start=1):
|
||||
cell = worksheet.cell(row=row_index, column=col_index, value=value)
|
||||
if isinstance(value, str):
|
||||
cell.data_type = "s"
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
workbook.close()
|
||||
return buffer.getvalue()
|
||||
|
||||
sheet_rows = []
|
||||
for row_index, values in enumerate(rows, start=1):
|
||||
cells = "".join(
|
||||
_xlsx_cell(value, row_index, col_index)
|
||||
for col_index, value in enumerate(values, start=1)
|
||||
)
|
||||
sheet_rows.append(f'<row r="{row_index}">{cells}</row>')
|
||||
|
||||
entries = {
|
||||
"[Content_Types].xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/styles.xml"
|
||||
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||
</Types>''',
|
||||
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
|
||||
Target="xl/workbook.xml"/>
|
||||
</Relationships>''',
|
||||
"xl/workbook.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
|
||||
</workbook>''',
|
||||
"xl/_rels/workbook.xml.rels": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
|
||||
Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
|
||||
Target="styles.xml"/>
|
||||
</Relationships>''',
|
||||
"xl/worksheets/sheet1.xml": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheetData>{''.join(sheet_rows)}</sheetData>
|
||||
</worksheet>''',
|
||||
"xl/styles.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
|
||||
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
|
||||
</styleSheet>''',
|
||||
}
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||
for name, data in entries.items():
|
||||
zout.writestr(name, data.encode("utf-8"))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _minimal_category_chart_workbook(chart_data: dict[str, Any]) -> bytes:
|
||||
if chart_data.get("kind") == "combo" and chart_data.get("independent_categories"):
|
||||
plots = chart_data["plots"]
|
||||
column_count = max(
|
||||
int(plot["start_column"]) + len(plot["series"]) - 1
|
||||
for plot in plots
|
||||
)
|
||||
rows: list[list[Any]] = [[None] * column_count]
|
||||
for plot in plots:
|
||||
category_column = int(plot["category_column"]) - 1
|
||||
start_column = int(plot["start_column"]) - 1
|
||||
for offset, series in enumerate(plot["series"]):
|
||||
rows[0][start_column + offset] = series["name"]
|
||||
for point_index, category in enumerate(plot["categories"], start=1):
|
||||
while len(rows) <= point_index:
|
||||
rows.append([None] * column_count)
|
||||
rows[point_index][category_column] = category
|
||||
for offset, series in enumerate(plot["series"]):
|
||||
rows[point_index][start_column + offset] = (
|
||||
series["values"][point_index - 1]
|
||||
)
|
||||
return _minimal_workbook(rows)
|
||||
|
||||
categories = chart_data["categories"]
|
||||
series = chart_data["series"]
|
||||
rows: list[list[Any]] = [[None] + [item["name"] for item in series]]
|
||||
for row_index, category in enumerate(categories):
|
||||
rows.append([category] + [item["values"][row_index] for item in series])
|
||||
return _minimal_workbook(rows)
|
||||
|
||||
|
||||
def _minimal_xy_chart_workbook(chart_data: dict[str, Any]) -> bytes:
|
||||
series = chart_data["series"]
|
||||
is_bubble = chart_data["type"] == "bubble"
|
||||
rows: list[list[Any]] = [[]]
|
||||
for item in series:
|
||||
rows[0].extend([f"{item['name']} X", item["name"]])
|
||||
if is_bubble:
|
||||
rows[0].append(f"{item['name']} Size")
|
||||
|
||||
max_points = max(len(item["x"]) for item in series)
|
||||
for point_idx in range(max_points):
|
||||
row: list[Any] = []
|
||||
for item in series:
|
||||
if point_idx < len(item["x"]):
|
||||
row.extend([item["x"][point_idx], item["y"][point_idx]])
|
||||
if is_bubble:
|
||||
row.append(item["sizes"][point_idx])
|
||||
else:
|
||||
row.extend(["", ""])
|
||||
if is_bubble:
|
||||
row.append("")
|
||||
rows.append(row)
|
||||
return _minimal_workbook(rows)
|
||||
|
||||
|
||||
def _minimal_chart_ex_workbook(chart_data: dict[str, Any]) -> bytes:
|
||||
chart_type = chart_data["type"]
|
||||
if chart_type in {"sunburst", "treemap"}:
|
||||
levels = chart_data["levels"]
|
||||
rows: list[list[Any]] = [[f"Level {idx + 1}" for idx in range(len(levels))] + ["Value"]]
|
||||
for row_idx, value in enumerate(chart_data["values"]):
|
||||
rows.append([level[row_idx] for level in levels] + [value])
|
||||
return _minimal_workbook(rows)
|
||||
if chart_type == "histogram":
|
||||
return _minimal_workbook([["Value"]] + [[value] for value in chart_data["values"]])
|
||||
if chart_type in {"funnel", "pareto", "waterfall"}:
|
||||
rows = [["Category", "Value"]]
|
||||
rows.extend(
|
||||
[category, chart_data["values"][idx]]
|
||||
for idx, category in enumerate(chart_data["categories"])
|
||||
)
|
||||
return _minimal_workbook(rows)
|
||||
if chart_type == "box_whisker":
|
||||
rows = [[]]
|
||||
for item in chart_data["series"]:
|
||||
rows[0].extend([f"{item['name']} Category", item["name"]])
|
||||
max_rows = max(len(item["values"]) for item in chart_data["series"])
|
||||
for row_idx in range(max_rows):
|
||||
row: list[Any] = []
|
||||
for item in chart_data["series"]:
|
||||
if row_idx < len(item["values"]):
|
||||
row.extend([item["categories"][row_idx], item["values"][row_idx]])
|
||||
else:
|
||||
row.extend(["", ""])
|
||||
rows.append(row)
|
||||
return _minimal_workbook(rows)
|
||||
raise RuntimeError(f"Native PPTX {chart_type} chart is outside current basic chart support")
|
||||
@@ -0,0 +1 @@
|
||||
"""PPTX package assembly helpers for SVG-to-PPTX export."""
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
"""Slide dimensions, format detection, EMU conversion, and constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from ..canvas_contract import (
|
||||
CanvasContractError,
|
||||
ProjectViewBox,
|
||||
parse_project_viewbox,
|
||||
read_project_viewbox,
|
||||
require_consistent_project_viewboxes,
|
||||
)
|
||||
|
||||
# Import project utility modules
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
try:
|
||||
from project_utils import get_project_info
|
||||
from config import CANVAS_FORMATS
|
||||
except ImportError:
|
||||
CANVAS_FORMATS = {
|
||||
'ppt169': {'name': 'PPT 16:9', 'dimensions': '1280×720', 'viewbox': '0 0 1280 720'},
|
||||
}
|
||||
|
||||
def get_project_info(path: str) -> dict:
|
||||
return {'format': 'unknown', 'name': Path(path).name}
|
||||
|
||||
# EMU conversion constants
|
||||
EMU_PER_INCH = 914400
|
||||
EMU_PER_PIXEL = EMU_PER_INCH / 96
|
||||
|
||||
# XML namespaces
|
||||
NAMESPACES = {
|
||||
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
||||
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
|
||||
'asvg': 'http://schemas.microsoft.com/office/drawing/2016/SVG/main',
|
||||
}
|
||||
|
||||
# Register namespaces for ElementTree output
|
||||
for prefix, uri in NAMESPACES.items():
|
||||
ET.register_namespace(prefix, uri)
|
||||
|
||||
|
||||
def get_slide_dimensions(
|
||||
canvas_format: str,
|
||||
custom_pixels: tuple[int, int] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Get slide dimensions in EMU units.
|
||||
|
||||
Args:
|
||||
canvas_format: Canvas format key (e.g. 'ppt169').
|
||||
custom_pixels: Optional custom pixel dimensions override.
|
||||
|
||||
Returns:
|
||||
(width_emu, height_emu) tuple.
|
||||
"""
|
||||
if custom_pixels:
|
||||
width_px, height_px = custom_pixels
|
||||
else:
|
||||
if canvas_format not in CANVAS_FORMATS:
|
||||
canvas_format = 'ppt169'
|
||||
|
||||
dimensions = CANVAS_FORMATS[canvas_format]['dimensions']
|
||||
match = re.match(r'(\d+)[×x](\d+)', dimensions)
|
||||
if match:
|
||||
width_px = int(match.group(1))
|
||||
height_px = int(match.group(2))
|
||||
else:
|
||||
width_px, height_px = 1280, 720
|
||||
|
||||
return int(width_px * EMU_PER_PIXEL), int(height_px * EMU_PER_PIXEL)
|
||||
|
||||
|
||||
def get_pixel_dimensions(
|
||||
canvas_format: str,
|
||||
custom_pixels: tuple[int, int] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Get canvas pixel dimensions.
|
||||
|
||||
Args:
|
||||
canvas_format: Canvas format key.
|
||||
custom_pixels: Optional custom pixel dimensions override.
|
||||
|
||||
Returns:
|
||||
(width_px, height_px) tuple.
|
||||
"""
|
||||
if custom_pixels:
|
||||
return custom_pixels
|
||||
|
||||
if canvas_format not in CANVAS_FORMATS:
|
||||
canvas_format = 'ppt169'
|
||||
|
||||
dimensions = CANVAS_FORMATS[canvas_format]['dimensions']
|
||||
match = re.match(r'(\d+)[×x](\d+)', dimensions)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
return 1280, 720
|
||||
|
||||
|
||||
def get_viewbox_dimensions(svg_path: Path) -> tuple[float, float]:
|
||||
"""Extract pixel dimensions from SVG viewBox.
|
||||
|
||||
Args:
|
||||
svg_path: Path to the SVG file.
|
||||
|
||||
Returns:
|
||||
(width, height) in SVG pixels.
|
||||
|
||||
Raises:
|
||||
CanvasContractError: The root canvas is missing or invalid.
|
||||
"""
|
||||
return read_project_viewbox(svg_path).pixel_dimensions
|
||||
|
||||
|
||||
def detect_format_from_svg(svg_path: Path) -> str | None:
|
||||
"""Detect canvas format from an SVG file's viewBox.
|
||||
|
||||
Args:
|
||||
svg_path: Path to the SVG file.
|
||||
|
||||
Returns:
|
||||
Canvas format key (e.g. 'ppt169'), or None if not detected.
|
||||
"""
|
||||
viewbox = read_project_viewbox(svg_path)
|
||||
for fmt_key, fmt_info in CANVAS_FORMATS.items():
|
||||
expected = parse_project_viewbox(
|
||||
fmt_info['viewbox'],
|
||||
context=f"registered canvas {fmt_key!r}",
|
||||
)
|
||||
if viewbox == expected:
|
||||
return fmt_key
|
||||
return None
|
||||
|
||||
|
||||
def resolve_svg_canvas(
|
||||
svg_files: list[Path],
|
||||
*,
|
||||
canvas_format: str | None = None,
|
||||
expected_viewbox: str | None = None,
|
||||
) -> tuple[ProjectViewBox, str | None]:
|
||||
"""Resolve one fail-closed canvas for every public/internal SVG."""
|
||||
format_viewbox: str | None = None
|
||||
if canvas_format is not None:
|
||||
if canvas_format not in CANVAS_FORMATS:
|
||||
raise CanvasContractError(f"Unsupported canvas format: {canvas_format}")
|
||||
format_viewbox = CANVAS_FORMATS[canvas_format]['viewbox']
|
||||
|
||||
if expected_viewbox is not None and format_viewbox is not None:
|
||||
locked = parse_project_viewbox(
|
||||
expected_viewbox,
|
||||
context="locked canvas viewBox",
|
||||
)
|
||||
selected = parse_project_viewbox(
|
||||
format_viewbox,
|
||||
context=f"canvas format {canvas_format!r}",
|
||||
)
|
||||
if locked != selected:
|
||||
raise CanvasContractError(
|
||||
f"canvas format {canvas_format!r} ({selected.canonical}) conflicts "
|
||||
f"with the locked canvas ({locked.canonical})"
|
||||
)
|
||||
|
||||
required = expected_viewbox if expected_viewbox is not None else format_viewbox
|
||||
if expected_viewbox is not None:
|
||||
expected_label = "the locked canvas"
|
||||
elif canvas_format is not None:
|
||||
expected_label = f"canvas format {canvas_format!r}"
|
||||
else:
|
||||
expected_label = "the first SVG canvas"
|
||||
viewbox = require_consistent_project_viewboxes(
|
||||
svg_files,
|
||||
expected_viewbox=required,
|
||||
expected_label=expected_label,
|
||||
)
|
||||
detected_format = canvas_format
|
||||
if detected_format is None:
|
||||
for fmt_key, fmt_info in CANVAS_FORMATS.items():
|
||||
registered = parse_project_viewbox(
|
||||
fmt_info['viewbox'],
|
||||
context=f"registered canvas {fmt_key!r}",
|
||||
)
|
||||
if viewbox == registered:
|
||||
detected_format = fmt_key
|
||||
break
|
||||
return viewbox, detected_format
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Find SVG and notes files in a project directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from slide_roster import discover_slide_svgs
|
||||
|
||||
|
||||
class NotesFileReadError(RuntimeError):
|
||||
"""Report a matched notes file that cannot be decoded or read."""
|
||||
|
||||
|
||||
def find_svg_files(
|
||||
project_path: Path,
|
||||
source: str = 'output',
|
||||
*,
|
||||
allow_fallback: bool = True,
|
||||
) -> tuple[list[Path], str]:
|
||||
"""Find SVG files in the project.
|
||||
|
||||
Args:
|
||||
project_path: Project directory path.
|
||||
source: SVG source directory alias or name.
|
||||
- 'output': svg_output (hand-authored source; native default)
|
||||
- 'final': svg_final (post-processed preview; diagnostic input)
|
||||
- or any subdirectory name
|
||||
allow_fallback: Try svg_output and then the project root when the
|
||||
requested directory is missing.
|
||||
|
||||
Returns:
|
||||
(list_of_svg_files, actual_directory_name) tuple.
|
||||
"""
|
||||
dir_map = {
|
||||
'output': 'svg_output',
|
||||
'final': 'svg_final',
|
||||
}
|
||||
|
||||
dir_name = dir_map.get(source, source)
|
||||
svg_dir = project_path / dir_name
|
||||
|
||||
if not svg_dir.exists():
|
||||
if not allow_fallback:
|
||||
return [], dir_name
|
||||
print(f" Warning: {dir_name} directory does not exist, trying svg_output")
|
||||
dir_name = 'svg_output'
|
||||
svg_dir = project_path / dir_name
|
||||
|
||||
if not svg_dir.exists():
|
||||
if project_path.is_dir():
|
||||
svg_dir = project_path
|
||||
dir_name = project_path.name
|
||||
else:
|
||||
return [], ''
|
||||
|
||||
return discover_slide_svgs(svg_dir), dir_name
|
||||
|
||||
|
||||
def find_notes_files(
|
||||
project_path: Path,
|
||||
svg_files: list[Path] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Find notes files and map them to SVG files.
|
||||
|
||||
Supports two matching modes (mixed matching supported):
|
||||
1. Match by filename (priority): notes/01_cover.md -> 01_cover.svg
|
||||
2. Match by index (backward compatible): notes/slide01.md -> 1st SVG
|
||||
|
||||
Args:
|
||||
project_path: Project directory path.
|
||||
svg_files: SVG file list (for filename matching).
|
||||
|
||||
Returns:
|
||||
Dict mapping SVG filename stem to notes content.
|
||||
"""
|
||||
notes_dir = project_path / 'notes'
|
||||
notes: dict[str, str] = {}
|
||||
|
||||
if not notes_dir.exists():
|
||||
return notes
|
||||
|
||||
svg_stems_mapping: dict[str, int] = {}
|
||||
svg_index_mapping: dict[int, str] = {}
|
||||
if svg_files:
|
||||
for i, svg_path in enumerate(svg_files, 1):
|
||||
svg_stems_mapping[svg_path.stem] = i
|
||||
svg_index_mapping[i] = svg_path.stem
|
||||
|
||||
for notes_file in notes_dir.glob('*.md'):
|
||||
stem = notes_file.stem
|
||||
|
||||
# Try index-based matching (backward compat with slide01.md format).
|
||||
match = re.search(r'slide[_]?(\d+)', stem)
|
||||
mapped_stem = (
|
||||
svg_index_mapping.get(int(match.group(1)))
|
||||
if match
|
||||
else None
|
||||
)
|
||||
filename_match = stem in svg_stems_mapping
|
||||
if mapped_stem is None and not filename_match:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = notes_file.read_text(encoding='utf-8').strip()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise NotesFileReadError(
|
||||
f"Cannot read matched notes file {notes_file}: {exc}"
|
||||
) from exc
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if mapped_stem:
|
||||
notes[mapped_stem] = content
|
||||
|
||||
# Filename-based matching overrides index-based matching.
|
||||
if filename_match:
|
||||
notes[stem] = content
|
||||
|
||||
return notes
|
||||
@@ -0,0 +1,160 @@
|
||||
"""SVG to PNG conversion for Office compatibility mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# SVG to PNG library detection
|
||||
# Prefer CairoSVG (better quality), fall back to svglib
|
||||
PNG_RENDERER: str | None = None
|
||||
|
||||
try:
|
||||
import cairosvg
|
||||
PNG_RENDERER = 'cairosvg'
|
||||
except (ImportError, OSError):
|
||||
try:
|
||||
from svglib.svglib import svg2rlg
|
||||
from reportlab.graphics import renderPM
|
||||
PNG_RENDERER = 'svglib'
|
||||
except (ImportError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def get_png_renderer_info() -> tuple[str | None, str, str | None]:
|
||||
"""Get PNG renderer status information.
|
||||
|
||||
Returns:
|
||||
(renderer_name, status_text, install_hint) tuple.
|
||||
"""
|
||||
if PNG_RENDERER == 'cairosvg':
|
||||
return ('cairosvg', '(full gradient/filter support)', None)
|
||||
elif PNG_RENDERER == 'svglib':
|
||||
return ('svglib', '(some gradients may be lost)',
|
||||
'Install cairosvg for better results: pip install cairosvg')
|
||||
else:
|
||||
return (None, '(not installed)',
|
||||
'Install via: pip install cairosvg or pip install svglib reportlab')
|
||||
|
||||
|
||||
def convert_svg_to_png(
|
||||
svg_path: Path,
|
||||
png_path: Path,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
) -> bool:
|
||||
"""Convert SVG to PNG using the available renderer.
|
||||
|
||||
Args:
|
||||
svg_path: SVG file path.
|
||||
png_path: Output PNG file path.
|
||||
width: Output width in pixels.
|
||||
height: Output height in pixels.
|
||||
|
||||
Returns:
|
||||
Whether the conversion was successful.
|
||||
"""
|
||||
if PNG_RENDERER is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
if PNG_RENDERER == 'cairosvg':
|
||||
cairosvg.svg2png(
|
||||
url=str(svg_path),
|
||||
write_to=str(png_path),
|
||||
output_width=width,
|
||||
output_height=height,
|
||||
)
|
||||
return True
|
||||
|
||||
elif PNG_RENDERER == 'svglib':
|
||||
drawing = svg2rlg(str(svg_path))
|
||||
if drawing is None:
|
||||
print(f" Warning: Unable to parse SVG ({svg_path.name})")
|
||||
return False
|
||||
renderPM.drawToFile(
|
||||
drawing,
|
||||
str(png_path),
|
||||
fmt="PNG",
|
||||
configPIL={'quality': 95},
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" Warning: SVG to PNG conversion failed ({svg_path.name}): {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _cache_key(svg_path: Path, width: int | None, height: int | None) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(svg_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(65536), b''):
|
||||
h.update(chunk)
|
||||
return f"{h.hexdigest()}_{width or 0}x{height or 0}_{PNG_RENDERER or 'none'}"
|
||||
|
||||
|
||||
def convert_svg_to_png_cached(
|
||||
svg_path: Path,
|
||||
png_path: Path,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
cache_dir: Path | None = None,
|
||||
) -> bool:
|
||||
"""Cache-aware SVG→PNG conversion.
|
||||
|
||||
Returns True on success (cache hit or fresh render). Cache key bakes in
|
||||
SVG content hash + size + renderer name; switching renderers invalidates
|
||||
naturally. Failures are never cached.
|
||||
"""
|
||||
if cache_dir is None:
|
||||
return convert_svg_to_png(svg_path, png_path, width, height)
|
||||
|
||||
if PNG_RENDERER is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
key = _cache_key(svg_path, width, height)
|
||||
except OSError as e:
|
||||
print(f" Warning: Failed to hash SVG ({svg_path.name}): {e}")
|
||||
return convert_svg_to_png(svg_path, png_path, width, height)
|
||||
|
||||
cached = cache_dir / f"{key}.png"
|
||||
if cached.is_file():
|
||||
try:
|
||||
shutil.copy(cached, png_path)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f" Warning: Cache copy failed, re-rendering ({svg_path.name}): {e}")
|
||||
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_fd, tmp_name = tempfile.mkstemp(suffix='.png', dir=str(cache_dir))
|
||||
tmp_path = Path(tmp_name)
|
||||
import os
|
||||
os.close(tmp_fd)
|
||||
|
||||
ok = convert_svg_to_png(svg_path, tmp_path, width, height)
|
||||
if not ok:
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
try:
|
||||
os.replace(tmp_path, cached)
|
||||
except OSError:
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
shutil.copy(cached, png_path)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f" Warning: Cache copy failed ({svg_path.name}): {e}")
|
||||
return False
|
||||
@@ -0,0 +1,696 @@
|
||||
"""Narration audio discovery and PPTX XML helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx_transitions import (
|
||||
AdvanceUpdate,
|
||||
EnterUpdate,
|
||||
MAX_OOXML_UNSIGNED_INT,
|
||||
P14_NS,
|
||||
PML_NS,
|
||||
apply_slide_motion_xml,
|
||||
parse_source_xml,
|
||||
read_slide_transition_xml,
|
||||
serialize_source_xml,
|
||||
validate_seconds,
|
||||
)
|
||||
|
||||
|
||||
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
MARKUP_COMPATIBILITY_NS = (
|
||||
"http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
)
|
||||
|
||||
MEDIA_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/media"
|
||||
AUDIO_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
|
||||
IMAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
|
||||
|
||||
AUDIO_CONTENT_TYPES = {
|
||||
".m4a": "audio/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
}
|
||||
|
||||
NARRATION_EXTENSIONS = tuple(AUDIO_CONTENT_TYPES.keys())
|
||||
DEFAULT_NARRATION_START_FLOOR = 0.8
|
||||
|
||||
AUDIO_MARKER_SIZE_EMU = 457200 # 48 SVG px
|
||||
AUDIO_MARKER_OFF_CANVAS_EMU = -AUDIO_MARKER_SIZE_EMU
|
||||
AUDIO_MARKER_PNG_BYTES = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABsUlEQVR4nO2aQZaDIAyG"
|
||||
"cd4cQRdzgHqxeiy9mB5gFvUO7Qon0gAhJEDf+G/a92rJ9ycEqNaYS3XVSQ94uz+esWu"
|
||||
"2ZRCLKzIQBdqnXDPsL+dA+8Qx88UJpAHPHTfJsRY4Jmo1yBUoCZ8Sj2SgNHxK3KiBWv"
|
||||
"DU+EEDteGtQhxeA63AW/l4WMuolNa5zx4DNaCd/XXuWfAY15sBTXguOJTL9501GlG50"
|
||||
"Ovc/xpjzDjtP+5nqj0gkXEL7763OhmQmj4S4FZY1iGnaAUkwQMxTlWouoz65CYBq4LV"
|
||||
"0cTU6VMgw8frOO3e6273x3Nbhq7JCmCCVYDTqDkDMOuUajdnIFWXgdq6DEgLNm5oGbV"
|
||||
"qzoBPcOmES+qxkW3L0FE2s1BWJDa5cdqjm5gxf7ddmqyAC4+dQq1EDYzTTpq3mTFO56"
|
||||
"KTAam7xpJGsOxDTtUpJGEEZhw7lRb5SWlNcJs8dJx+q4DkwwcEJLsiLh86hTRNGMM3g"
|
||||
"nFVXUYlGt1rQLsKqfLxBCvQiokQR3QK1TYRi0/qgVomKHHJTVzaBDUeC0rzBnBqoljL"
|
||||
"qFY1OOP+3yf1PpX+r8TH6wW14c3/7xdFRAAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
for _prefix, _uri in (
|
||||
("p", PML_NS),
|
||||
("a", DRAWINGML_NS),
|
||||
("r", RELATIONSHIPS_NS),
|
||||
("p14", P14_NS),
|
||||
):
|
||||
try:
|
||||
ET.register_namespace(_prefix, _uri)
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _qn(namespace: str, tag: str) -> str:
|
||||
return f"{{{namespace}}}{tag}"
|
||||
|
||||
|
||||
def _normalize_title(title: str) -> str:
|
||||
text = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", title.strip())
|
||||
return re.sub(r"_+", "_", text).strip("_").lower()
|
||||
|
||||
|
||||
def _leading_number(text: str) -> int | None:
|
||||
match = re.match(r"^(\d{1,3})", text.strip())
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def find_narration_files(audio_dir: Path, svg_files: list[Path]) -> dict[str, Path]:
|
||||
"""Return `{svg_stem: audio_path}` matched by exact stem, normalized stem, or index."""
|
||||
if not audio_dir.exists() or not audio_dir.is_dir():
|
||||
return {}
|
||||
|
||||
audio_files = [
|
||||
path for path in sorted(audio_dir.iterdir())
|
||||
if path.is_file() and path.suffix.lower() in NARRATION_EXTENSIONS
|
||||
]
|
||||
exact: dict[str, list[Path]] = {}
|
||||
normalized: dict[str, list[Path]] = {}
|
||||
numbered: dict[int, list[Path]] = {}
|
||||
for path in audio_files:
|
||||
exact.setdefault(path.stem, []).append(path)
|
||||
normalized.setdefault(_normalize_title(path.stem), []).append(path)
|
||||
number = _leading_number(path.stem)
|
||||
if number is not None:
|
||||
numbered.setdefault(number, []).append(path)
|
||||
|
||||
matched: dict[str, Path] = {}
|
||||
claimed_by: dict[Path, str] = {}
|
||||
for index, svg in enumerate(svg_files, 1):
|
||||
stem = svg.stem
|
||||
candidates = exact.get(stem)
|
||||
if not candidates:
|
||||
candidates = normalized.get(_normalize_title(stem))
|
||||
if not candidates:
|
||||
candidates = numbered.get(index)
|
||||
if not candidates:
|
||||
continue
|
||||
if len(candidates) > 1:
|
||||
names = ", ".join(path.name for path in candidates)
|
||||
raise ValueError(
|
||||
f"multiple narration audio files match slide {stem!r}: "
|
||||
f"{names}; keep exactly one supported file for this slide"
|
||||
)
|
||||
candidate = candidates[0]
|
||||
previous_stem = claimed_by.get(candidate)
|
||||
if previous_stem is not None:
|
||||
raise ValueError(
|
||||
f"narration audio file {candidate.name!r} matches multiple slides: "
|
||||
f"{previous_stem!r}, {stem!r}; provide one distinct audio file "
|
||||
"per slide"
|
||||
)
|
||||
matched[stem] = candidate
|
||||
claimed_by[candidate] = stem
|
||||
return matched
|
||||
|
||||
|
||||
def probe_audio_duration(audio_path: Path) -> float | None:
|
||||
"""Return duration in seconds using ffprobe when available."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "json",
|
||||
str(audio_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = json.loads(result.stdout or "{}")
|
||||
duration = float(data.get("format", {}).get("duration", 0))
|
||||
return duration if duration > 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def next_shape_id(slide_xml: str) -> int:
|
||||
"""Return the next slide-local non-visual shape id."""
|
||||
root = parse_source_xml(slide_xml)
|
||||
if root.tag != _qn(PML_NS, "sld"):
|
||||
raise ValueError("narration source XML root must be p:sld")
|
||||
ids = _numeric_ids(
|
||||
root.iter(_qn(PML_NS, "cNvPr")),
|
||||
"shape",
|
||||
minimum=1,
|
||||
)
|
||||
next_id = max(ids, default=1) + 1
|
||||
if next_id > MAX_OOXML_UNSIGNED_INT:
|
||||
raise ValueError("narration source has no available shape identifiers")
|
||||
return next_id
|
||||
|
||||
|
||||
def _create_audio_pic_element(
|
||||
shape_id: int,
|
||||
shape_name: str,
|
||||
audio_rid: str,
|
||||
media_rid: str,
|
||||
poster_rid: str,
|
||||
) -> ET.Element:
|
||||
pic = ET.Element(_qn(PML_NS, "pic"))
|
||||
nv_pic_pr = ET.SubElement(pic, _qn(PML_NS, "nvPicPr"))
|
||||
c_nv_pr = ET.SubElement(
|
||||
nv_pic_pr,
|
||||
_qn(PML_NS, "cNvPr"),
|
||||
{"id": str(shape_id), "name": shape_name},
|
||||
)
|
||||
ET.SubElement(
|
||||
c_nv_pr,
|
||||
_qn(DRAWINGML_NS, "hlinkClick"),
|
||||
{
|
||||
_qn(RELATIONSHIPS_NS, "id"): "",
|
||||
"action": "ppaction://media",
|
||||
},
|
||||
)
|
||||
c_nv_pic_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "cNvPicPr"))
|
||||
ET.SubElement(
|
||||
c_nv_pic_pr,
|
||||
_qn(DRAWINGML_NS, "picLocks"),
|
||||
{"noChangeAspect": "1"},
|
||||
)
|
||||
nv_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "nvPr"))
|
||||
ET.SubElement(
|
||||
nv_pr,
|
||||
_qn(DRAWINGML_NS, "audioFile"),
|
||||
{_qn(RELATIONSHIPS_NS, "link"): audio_rid},
|
||||
)
|
||||
ext_list = ET.SubElement(nv_pr, _qn(PML_NS, "extLst"))
|
||||
extension = ET.SubElement(
|
||||
ext_list,
|
||||
_qn(PML_NS, "ext"),
|
||||
{"uri": "{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}"},
|
||||
)
|
||||
ET.SubElement(
|
||||
extension,
|
||||
_qn(P14_NS, "media"),
|
||||
{_qn(RELATIONSHIPS_NS, "embed"): media_rid},
|
||||
)
|
||||
|
||||
blip_fill = ET.SubElement(pic, _qn(PML_NS, "blipFill"))
|
||||
ET.SubElement(
|
||||
blip_fill,
|
||||
_qn(DRAWINGML_NS, "blip"),
|
||||
{_qn(RELATIONSHIPS_NS, "embed"): poster_rid},
|
||||
)
|
||||
stretch = ET.SubElement(blip_fill, _qn(DRAWINGML_NS, "stretch"))
|
||||
ET.SubElement(stretch, _qn(DRAWINGML_NS, "fillRect"))
|
||||
|
||||
shape_properties = ET.SubElement(pic, _qn(PML_NS, "spPr"))
|
||||
transform = ET.SubElement(shape_properties, _qn(DRAWINGML_NS, "xfrm"))
|
||||
ET.SubElement(
|
||||
transform,
|
||||
_qn(DRAWINGML_NS, "off"),
|
||||
{
|
||||
"x": str(AUDIO_MARKER_OFF_CANVAS_EMU),
|
||||
"y": str(AUDIO_MARKER_OFF_CANVAS_EMU),
|
||||
},
|
||||
)
|
||||
ET.SubElement(
|
||||
transform,
|
||||
_qn(DRAWINGML_NS, "ext"),
|
||||
{
|
||||
"cx": str(AUDIO_MARKER_SIZE_EMU),
|
||||
"cy": str(AUDIO_MARKER_SIZE_EMU),
|
||||
},
|
||||
)
|
||||
geometry = ET.SubElement(
|
||||
shape_properties,
|
||||
_qn(DRAWINGML_NS, "prstGeom"),
|
||||
{"prst": "rect"},
|
||||
)
|
||||
ET.SubElement(geometry, _qn(DRAWINGML_NS, "avLst"))
|
||||
return pic
|
||||
|
||||
|
||||
def create_audio_pic_xml(
|
||||
shape_id: int,
|
||||
shape_name: str,
|
||||
audio_rid: str,
|
||||
media_rid: str,
|
||||
poster_rid: str,
|
||||
) -> str:
|
||||
"""Create an off-canvas audio picture shape carrying narration media."""
|
||||
element = _create_audio_pic_element(
|
||||
shape_id,
|
||||
shape_name,
|
||||
audio_rid,
|
||||
media_rid,
|
||||
poster_rid,
|
||||
)
|
||||
return ET.tostring(element, encoding="unicode")
|
||||
|
||||
|
||||
def _numeric_ids(
|
||||
elements: Iterable[ET.Element],
|
||||
label: str,
|
||||
*,
|
||||
minimum: int = 0,
|
||||
maximum: int = MAX_OOXML_UNSIGNED_INT,
|
||||
) -> list[int]:
|
||||
ids: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for element in elements:
|
||||
raw_id = element.get("id")
|
||||
try:
|
||||
numeric_id = int(raw_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"narration source has invalid {label} id: {raw_id!r}") from exc
|
||||
if numeric_id < minimum:
|
||||
raise ValueError(
|
||||
f"narration source has {label} id below {minimum}: {numeric_id}"
|
||||
)
|
||||
if numeric_id > maximum:
|
||||
raise ValueError(
|
||||
f"narration source has {label} id above {maximum}: {numeric_id}"
|
||||
)
|
||||
if numeric_id in seen:
|
||||
raise ValueError(f"narration source has duplicate {label} id: {numeric_id}")
|
||||
ids.append(numeric_id)
|
||||
seen.add(numeric_id)
|
||||
return ids
|
||||
|
||||
|
||||
def narration_lead_in_seconds(
|
||||
transition_duration: float,
|
||||
*,
|
||||
start_floor: float = DEFAULT_NARRATION_START_FLOOR,
|
||||
) -> float:
|
||||
"""Return silence after a slide transition before narration starts."""
|
||||
transition_seconds = validate_seconds(
|
||||
transition_duration,
|
||||
"narration transition duration",
|
||||
allow_zero=True,
|
||||
)
|
||||
floor_seconds = validate_seconds(
|
||||
start_floor,
|
||||
"narration start floor",
|
||||
allow_zero=True,
|
||||
)
|
||||
return max(0.0, floor_seconds - transition_seconds)
|
||||
|
||||
|
||||
def _create_audio_timing_element(
|
||||
shape_id: int,
|
||||
ctn_id: int,
|
||||
start_delay_ms: int,
|
||||
) -> ET.Element:
|
||||
audio = ET.Element(_qn(PML_NS, "audio"))
|
||||
media_node = ET.SubElement(
|
||||
audio,
|
||||
_qn(PML_NS, "cMediaNode"),
|
||||
{"vol": "80000"},
|
||||
)
|
||||
time_node = ET.SubElement(
|
||||
media_node,
|
||||
_qn(PML_NS, "cTn"),
|
||||
{"id": str(ctn_id), "fill": "hold", "display": "0"},
|
||||
)
|
||||
start_conditions = ET.SubElement(time_node, _qn(PML_NS, "stCondLst"))
|
||||
ET.SubElement(
|
||||
start_conditions,
|
||||
_qn(PML_NS, "cond"),
|
||||
{"delay": str(start_delay_ms)},
|
||||
)
|
||||
target = ET.SubElement(media_node, _qn(PML_NS, "tgtEl"))
|
||||
ET.SubElement(target, _qn(PML_NS, "spTgt"), {"spid": str(shape_id)})
|
||||
return audio
|
||||
|
||||
|
||||
def _direct_child(parent: ET.Element, tag: str, label: str) -> ET.Element:
|
||||
children = [child for child in parent if child.tag == tag]
|
||||
if len(children) != 1:
|
||||
raise ValueError(
|
||||
f"narration source must contain exactly one direct {label}; found {len(children)}"
|
||||
)
|
||||
return children[0]
|
||||
|
||||
|
||||
def _existing_timing_root(timing: ET.Element) -> ET.Element:
|
||||
children = list(timing)
|
||||
for tag, label in (
|
||||
(_qn(PML_NS, "tnLst"), "p:tnLst"),
|
||||
(_qn(PML_NS, "bldLst"), "p:bldLst"),
|
||||
(_qn(PML_NS, "extLst"), "p:extLst"),
|
||||
):
|
||||
if sum(child.tag == tag for child in children) > 1:
|
||||
raise ValueError(f"narration source timing has multiple {label} elements")
|
||||
node_list = _direct_child(timing, _qn(PML_NS, "tnLst"), "p:timing/p:tnLst")
|
||||
node_index = children.index(node_list)
|
||||
for tag, label in (
|
||||
(_qn(PML_NS, "bldLst"), "p:bldLst"),
|
||||
(_qn(PML_NS, "extLst"), "p:extLst"),
|
||||
):
|
||||
sibling = next((child for child in children if child.tag == tag), None)
|
||||
if sibling is not None and node_index > children.index(sibling):
|
||||
raise ValueError(f"narration source p:tnLst must precede {label}")
|
||||
timing_roots = [
|
||||
element
|
||||
for element in node_list.iter(_qn(PML_NS, "cTn"))
|
||||
if element.get("nodeType") == "tmRoot"
|
||||
]
|
||||
if len(timing_roots) != 1:
|
||||
raise ValueError(
|
||||
"narration source timing must contain exactly one tmRoot; "
|
||||
f"found {len(timing_roots)}"
|
||||
)
|
||||
return timing_roots[0]
|
||||
|
||||
|
||||
def _new_timing(audio_timing: ET.Element, root_id: int) -> ET.Element:
|
||||
timing = ET.Element(_qn(PML_NS, "timing"))
|
||||
node_list = ET.SubElement(timing, _qn(PML_NS, "tnLst"))
|
||||
parallel = ET.SubElement(node_list, _qn(PML_NS, "par"))
|
||||
timing_root = ET.SubElement(
|
||||
parallel,
|
||||
_qn(PML_NS, "cTn"),
|
||||
{
|
||||
"id": str(root_id),
|
||||
"dur": "indefinite",
|
||||
"restart": "never",
|
||||
"nodeType": "tmRoot",
|
||||
},
|
||||
)
|
||||
child_nodes = ET.SubElement(timing_root, _qn(PML_NS, "childTnLst"))
|
||||
child_nodes.append(audio_timing)
|
||||
return timing
|
||||
|
||||
|
||||
def _root_extension_index(slide: ET.Element) -> int | None:
|
||||
extension_lists = [
|
||||
index
|
||||
for index, child in enumerate(slide)
|
||||
if child.tag == _qn(PML_NS, "extLst")
|
||||
]
|
||||
if len(extension_lists) > 1:
|
||||
raise ValueError("narration source has multiple root p:extLst elements")
|
||||
if extension_lists and extension_lists[0] != len(slide) - 1:
|
||||
raise ValueError("narration source root p:extLst is not the last slide child")
|
||||
return extension_lists[0] if extension_lists else None
|
||||
|
||||
|
||||
def _validate_root_timing_position(slide: ET.Element, timing: ET.Element) -> None:
|
||||
children = list(slide)
|
||||
timing_index = children.index(timing)
|
||||
for tag, label in (
|
||||
(_qn(PML_NS, "cSld"), "p:cSld"),
|
||||
(_qn(PML_NS, "clrMapOvr"), "p:clrMapOvr"),
|
||||
):
|
||||
siblings = [index for index, child in enumerate(children) if child.tag == tag]
|
||||
if len(siblings) > 1:
|
||||
raise ValueError(f"narration source has multiple root {label} elements")
|
||||
if siblings and siblings[0] > timing_index:
|
||||
raise ValueError(f"narration source root p:timing must follow {label}")
|
||||
extension_index = _root_extension_index(slide)
|
||||
if extension_index is not None and timing_index > extension_index:
|
||||
raise ValueError("narration source root p:timing must precede p:extLst")
|
||||
|
||||
|
||||
def _insert_root_timing(slide: ET.Element, timing: ET.Element) -> None:
|
||||
extension_index = _root_extension_index(slide)
|
||||
insert_at = extension_index if extension_index is not None else len(slide)
|
||||
slide.insert(insert_at, timing)
|
||||
|
||||
|
||||
def _animation_timing_branches(
|
||||
slide: ET.Element,
|
||||
) -> tuple[ET.Element | None, list[ET.Element]]:
|
||||
"""Return the root timing anchor and every active/fallback timing branch."""
|
||||
direct = [
|
||||
child for child in slide
|
||||
if child.tag == _qn(PML_NS, "timing")
|
||||
]
|
||||
alternates: list[tuple[ET.Element, list[ET.Element]]] = []
|
||||
for child in slide:
|
||||
if child.tag != _qn(MARKUP_COMPATIBILITY_NS, "AlternateContent"):
|
||||
continue
|
||||
timings = [
|
||||
timing
|
||||
for branch in list(child)
|
||||
for timing in list(branch)
|
||||
if timing.tag == _qn(PML_NS, "timing")
|
||||
]
|
||||
if timings:
|
||||
alternates.append((child, timings))
|
||||
if direct and alternates:
|
||||
raise ValueError(
|
||||
"narration source contains both direct and AlternateContent timing"
|
||||
)
|
||||
if len(direct) > 1 or len(alternates) > 1:
|
||||
raise ValueError("narration source has multiple root animation timings")
|
||||
if direct:
|
||||
return direct[0], direct
|
||||
if alternates:
|
||||
anchor, timings = alternates[0]
|
||||
if len(timings) != 2:
|
||||
raise ValueError(
|
||||
"narration source animation AlternateContent must contain "
|
||||
"one Choice and one Fallback timing"
|
||||
)
|
||||
return anchor, timings
|
||||
nested = list(slide.iter(_qn(PML_NS, "timing")))
|
||||
if nested:
|
||||
raise ValueError(
|
||||
"narration source contains unsupported non-root p:timing"
|
||||
)
|
||||
return None, []
|
||||
|
||||
|
||||
def inject_narration(
|
||||
slide_xml: str,
|
||||
*,
|
||||
shape_id: int,
|
||||
shape_name: str,
|
||||
audio_rid: str,
|
||||
media_rid: str,
|
||||
poster_rid: str,
|
||||
start_delay: float = 0.0,
|
||||
) -> str:
|
||||
"""Inject a hidden narration shape and delayed slide-entry autoplay timing."""
|
||||
if isinstance(shape_id, bool) or not isinstance(shape_id, int) or shape_id <= 0:
|
||||
raise ValueError("narration shape_id must be a positive integer")
|
||||
if shape_id > MAX_OOXML_UNSIGNED_INT:
|
||||
raise ValueError(
|
||||
"narration shape_id exceeds the OOXML unsigned-integer limit: "
|
||||
f"{shape_id}"
|
||||
)
|
||||
start_delay_seconds = validate_seconds(
|
||||
start_delay,
|
||||
"narration start delay",
|
||||
allow_zero=True,
|
||||
)
|
||||
start_delay_ms = round(start_delay_seconds * 1000)
|
||||
if start_delay_ms > MAX_OOXML_UNSIGNED_INT:
|
||||
raise ValueError(
|
||||
"narration start delay exceeds the OOXML unsigned-integer limit: "
|
||||
f"{start_delay_ms} ms"
|
||||
)
|
||||
|
||||
root = parse_source_xml(slide_xml)
|
||||
if root.tag != _qn(PML_NS, "sld"):
|
||||
raise ValueError("narration source XML root must be p:sld")
|
||||
common_slide_data = _direct_child(root, _qn(PML_NS, "cSld"), "p:sld/p:cSld")
|
||||
shape_tree = _direct_child(
|
||||
common_slide_data,
|
||||
_qn(PML_NS, "spTree"),
|
||||
"p:cSld/p:spTree",
|
||||
)
|
||||
|
||||
shape_ids = _numeric_ids(
|
||||
root.iter(_qn(PML_NS, "cNvPr")),
|
||||
"shape",
|
||||
minimum=1,
|
||||
)
|
||||
if shape_id in shape_ids:
|
||||
raise ValueError(f"narration shape id already exists on slide: {shape_id}")
|
||||
timing_anchor, timing_branches = _animation_timing_branches(root)
|
||||
timing_id_sets = [
|
||||
_numeric_ids(timing.iter(_qn(PML_NS, "cTn")), "timing node")
|
||||
for timing in timing_branches
|
||||
]
|
||||
timing_ids = [
|
||||
timing_id
|
||||
for timing_set in timing_id_sets
|
||||
for timing_id in timing_set
|
||||
]
|
||||
next_timing_id = max(timing_ids, default=0) + 1
|
||||
if next_timing_id > MAX_OOXML_UNSIGNED_INT:
|
||||
raise ValueError("narration source has no available timing node identifiers")
|
||||
|
||||
if not timing_branches and next_timing_id + 1 > MAX_OOXML_UNSIGNED_INT:
|
||||
raise ValueError(
|
||||
"narration source has no identifiers available for a new timing root"
|
||||
)
|
||||
|
||||
audio_picture = _create_audio_pic_element(
|
||||
shape_id,
|
||||
shape_name,
|
||||
audio_rid,
|
||||
media_rid,
|
||||
poster_rid,
|
||||
)
|
||||
shape_tree.append(audio_picture)
|
||||
|
||||
if timing_branches:
|
||||
if timing_anchor is None:
|
||||
raise AssertionError("timing branches lost their root anchor")
|
||||
_validate_root_timing_position(root, timing_anchor)
|
||||
for timing in timing_branches:
|
||||
timing_root = _existing_timing_root(timing)
|
||||
child_nodes = _direct_child(
|
||||
timing_root,
|
||||
_qn(PML_NS, "childTnLst"),
|
||||
"tmRoot/p:childTnLst",
|
||||
)
|
||||
child_nodes.append(
|
||||
_create_audio_timing_element(
|
||||
shape_id,
|
||||
next_timing_id,
|
||||
start_delay_ms,
|
||||
)
|
||||
)
|
||||
else:
|
||||
audio_timing = _create_audio_timing_element(
|
||||
shape_id,
|
||||
next_timing_id + 1,
|
||||
start_delay_ms,
|
||||
)
|
||||
_insert_root_timing(root, _new_timing(audio_timing, next_timing_id))
|
||||
|
||||
return serialize_source_xml(root, slide_xml).decode("utf-8")
|
||||
|
||||
|
||||
def read_narration_start_delay_xml(slide_xml: str) -> int:
|
||||
"""Return the latest embedded narration picture's autoplay delay in ms."""
|
||||
root = parse_source_xml(slide_xml)
|
||||
if root.tag != _qn(PML_NS, "sld"):
|
||||
raise ValueError("narration source XML root must be p:sld")
|
||||
|
||||
audio_shape_properties: list[ET.Element] = []
|
||||
for picture in root.iter(_qn(PML_NS, "pic")):
|
||||
if not any(
|
||||
element.tag == _qn(DRAWINGML_NS, "audioFile")
|
||||
for element in picture.iter()
|
||||
):
|
||||
continue
|
||||
properties = list(picture.iter(_qn(PML_NS, "cNvPr")))
|
||||
if len(properties) != 1:
|
||||
raise ValueError(
|
||||
"narration audio picture must contain exactly one p:cNvPr"
|
||||
)
|
||||
audio_shape_properties.extend(properties)
|
||||
if not audio_shape_properties:
|
||||
raise ValueError("narration source has no embedded audio picture")
|
||||
|
||||
narration_shape_id = max(
|
||||
_numeric_ids(
|
||||
audio_shape_properties,
|
||||
"narration audio shape",
|
||||
minimum=1,
|
||||
)
|
||||
)
|
||||
delays: list[int] = []
|
||||
for audio in root.iter(_qn(PML_NS, "audio")):
|
||||
media_node = audio.find(_qn(PML_NS, "cMediaNode"))
|
||||
if media_node is None:
|
||||
continue
|
||||
target = media_node.find(
|
||||
f"{_qn(PML_NS, 'tgtEl')}/{_qn(PML_NS, 'spTgt')}"
|
||||
)
|
||||
if target is None or target.get("spid") != str(narration_shape_id):
|
||||
continue
|
||||
time_node = _direct_child(
|
||||
media_node,
|
||||
_qn(PML_NS, "cTn"),
|
||||
"p:audio/p:cMediaNode/p:cTn",
|
||||
)
|
||||
start_conditions = _direct_child(
|
||||
time_node,
|
||||
_qn(PML_NS, "stCondLst"),
|
||||
"p:cTn/p:stCondLst",
|
||||
)
|
||||
condition = _direct_child(
|
||||
start_conditions,
|
||||
_qn(PML_NS, "cond"),
|
||||
"p:stCondLst/p:cond",
|
||||
)
|
||||
raw_delay = condition.get("delay")
|
||||
try:
|
||||
delay = int(raw_delay)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
f"narration timing has invalid start delay: {raw_delay!r}"
|
||||
) from exc
|
||||
if delay < 0 or delay > MAX_OOXML_UNSIGNED_INT:
|
||||
raise ValueError(
|
||||
"narration timing start delay is outside the OOXML "
|
||||
f"unsigned-integer range: {delay}"
|
||||
)
|
||||
delays.append(delay)
|
||||
|
||||
if not delays:
|
||||
raise ValueError("narration source has no autoplay timing for its audio picture")
|
||||
if len(set(delays)) != 1:
|
||||
raise ValueError(
|
||||
"narration source timing branches disagree on autoplay delay: "
|
||||
f"{sorted(set(delays))}"
|
||||
)
|
||||
return delays[0]
|
||||
|
||||
|
||||
def apply_recorded_timing(
|
||||
slide_xml: str,
|
||||
*,
|
||||
advance_after: float,
|
||||
transition_duration: float,
|
||||
transition_effect: str | None = "fade",
|
||||
) -> str:
|
||||
"""Set slide auto-advance timing so exported video follows narration length."""
|
||||
summary = read_slide_transition_xml(slide_xml)
|
||||
if summary.logical_count:
|
||||
enter = EnterUpdate(policy="preserve")
|
||||
elif transition_effect is None or transition_effect == "none":
|
||||
enter = EnterUpdate(policy="none")
|
||||
else:
|
||||
enter = EnterUpdate(
|
||||
policy="replace",
|
||||
effect=transition_effect,
|
||||
duration=transition_duration,
|
||||
)
|
||||
updated, _uses_timings = apply_slide_motion_xml(
|
||||
slide_xml,
|
||||
enter=enter,
|
||||
advance=AdvanceUpdate(mode="narration", after=advance_after),
|
||||
)
|
||||
return updated
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Markdown to plain text conversion and notes slide XML generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from language_tags import normalize_language_tag
|
||||
|
||||
from ..drawingml.utils import (
|
||||
detect_text_lang,
|
||||
text_has_rtl_characters,
|
||||
text_uses_rtl,
|
||||
)
|
||||
|
||||
|
||||
def markdown_to_plain_text(md_content: str) -> str:
|
||||
"""Convert Markdown notes to plain text for PPTX notes.
|
||||
|
||||
Args:
|
||||
md_content: Markdown formatted notes content.
|
||||
|
||||
Returns:
|
||||
Plain text content.
|
||||
"""
|
||||
def strip_inline_bold(text: str) -> str:
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
||||
text = re.sub(r'__(.+?)__', r'\1', text)
|
||||
return text
|
||||
|
||||
lines: list[str] = []
|
||||
for line in md_content.split('\n'):
|
||||
if line.startswith('#'):
|
||||
text = re.sub(r'^#+\s*', '', line).strip()
|
||||
text = strip_inline_bold(text)
|
||||
if text:
|
||||
lines.append(text)
|
||||
lines.append('')
|
||||
elif line.strip().startswith('- '):
|
||||
item_text = line.strip()[2:]
|
||||
item_text = strip_inline_bold(item_text)
|
||||
lines.append('• ' + item_text)
|
||||
elif line.strip():
|
||||
text = strip_inline_bold(line.strip())
|
||||
lines.append(text)
|
||||
else:
|
||||
lines.append('')
|
||||
|
||||
# Merge consecutive empty lines
|
||||
result: list[str] = []
|
||||
is_prev_empty = False
|
||||
for line in lines:
|
||||
if line == '':
|
||||
if not is_prev_empty:
|
||||
result.append(line)
|
||||
is_prev_empty = True
|
||||
else:
|
||||
result.append(line)
|
||||
is_prev_empty = False
|
||||
|
||||
return '\n'.join(result).strip()
|
||||
|
||||
|
||||
def create_notes_slide_xml(
|
||||
slide_num: int,
|
||||
notes_text: str,
|
||||
primary_language: str | None = None,
|
||||
) -> str:
|
||||
"""Create notes slide XML.
|
||||
|
||||
Args:
|
||||
slide_num: Slide number.
|
||||
notes_text: Notes text in plain text format.
|
||||
primary_language: Canonical BCP-47 deck language, when available.
|
||||
|
||||
Returns:
|
||||
Notes slide XML string.
|
||||
"""
|
||||
primary_language = (
|
||||
normalize_language_tag(primary_language)
|
||||
if primary_language is not None
|
||||
else None
|
||||
)
|
||||
default_language = primary_language or 'en-US'
|
||||
notes_text = (notes_text
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>'))
|
||||
|
||||
paragraphs: list[str] = []
|
||||
for para in notes_text.split('\n'):
|
||||
if para.strip():
|
||||
lang = detect_text_lang(para, primary_language)
|
||||
paragraph_rtl = ' rtl="1"' if text_uses_rtl(
|
||||
para,
|
||||
primary_language,
|
||||
) else ''
|
||||
run_rtl = (
|
||||
'<a:rtl val="1"/>'
|
||||
if text_has_rtl_characters(para)
|
||||
else ''
|
||||
)
|
||||
paragraphs.append(f'''<a:p>
|
||||
<a:pPr{paragraph_rtl}/>
|
||||
<a:r>
|
||||
<a:rPr lang="{lang}" dirty="0">{run_rtl}</a:rPr>
|
||||
<a:t>{para}</a:t>
|
||||
</a:r>
|
||||
</a:p>''')
|
||||
else:
|
||||
paragraph_rtl = (
|
||||
' rtl="1"'
|
||||
if primary_language and text_uses_rtl('', primary_language)
|
||||
else ''
|
||||
)
|
||||
paragraphs.append(
|
||||
f'<a:p><a:pPr{paragraph_rtl}/>'
|
||||
f'<a:endParaRPr lang="{default_language}" dirty="0"/></a:p>'
|
||||
)
|
||||
|
||||
paragraphs_xml = (
|
||||
'\n '.join(paragraphs)
|
||||
if paragraphs
|
||||
else (
|
||||
f'<a:p><a:endParaRPr lang="{default_language}" dirty="0"/></a:p>'
|
||||
)
|
||||
)
|
||||
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr>
|
||||
<p:cNvPr id="1" name=""/>
|
||||
<p:cNvGrpSpPr/>
|
||||
<p:nvPr/>
|
||||
</p:nvGrpSpPr>
|
||||
<p:grpSpPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="0" cy="0"/>
|
||||
<a:chOff x="0" y="0"/>
|
||||
<a:chExt cx="0" cy="0"/>
|
||||
</a:xfrm>
|
||||
</p:grpSpPr>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="2" name="Slide Image Placeholder 1"/>
|
||||
<p:cNvSpPr>
|
||||
<a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/>
|
||||
</p:cNvSpPr>
|
||||
<p:nvPr>
|
||||
<p:ph type="sldImg" idx="2"/>
|
||||
</p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr/>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="3" name="Notes Placeholder 2"/>
|
||||
<p:cNvSpPr>
|
||||
<a:spLocks noGrp="1"/>
|
||||
</p:cNvSpPr>
|
||||
<p:nvPr>
|
||||
<p:ph type="body" sz="quarter" idx="3"/>
|
||||
</p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr/>
|
||||
<p:txBody>
|
||||
<a:bodyPr/>
|
||||
<a:lstStyle/>
|
||||
{paragraphs_xml}
|
||||
</p:txBody>
|
||||
</p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
<p:clrMapOvr>
|
||||
<a:masterClrMapping/>
|
||||
</p:clrMapOvr>
|
||||
</p:notes>'''
|
||||
|
||||
|
||||
def create_notes_slide_rels_xml(slide_num: int) -> str:
|
||||
"""Create notes slide relationship file XML.
|
||||
|
||||
Args:
|
||||
slide_num: Slide number.
|
||||
|
||||
Returns:
|
||||
Relationship file XML string.
|
||||
"""
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide{slide_num}.xml"/>
|
||||
</Relationships>'''
|
||||
|
||||
|
||||
def create_notes_master_xml(primary_language: str | None = None) -> str:
|
||||
"""Create a minimal PowerPoint-compatible notes master XML."""
|
||||
language = (
|
||||
normalize_language_tag(primary_language)
|
||||
if primary_language is not None
|
||||
else 'en-US'
|
||||
)
|
||||
paragraph_rtl = ' rtl="1"' if text_uses_rtl('', language) else ''
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr>
|
||||
<p:cNvPr id="1" name=""/>
|
||||
<p:cNvGrpSpPr/>
|
||||
<p:nvPr/>
|
||||
</p:nvGrpSpPr>
|
||||
<p:grpSpPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="0" cy="0"/>
|
||||
<a:chOff x="0" y="0"/>
|
||||
<a:chExt cx="0" cy="0"/>
|
||||
</a:xfrm>
|
||||
</p:grpSpPr>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="2" name="Header Placeholder 1"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="hdr" sz="quarter"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="2971800" cy="457200"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="3" name="Date Placeholder 2"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="dt" sz="half" idx="1"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="3884613" y="0"/>
|
||||
<a:ext cx="2971800" cy="457200"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="4" name="Slide Image Placeholder 3"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="sldImg" idx="2"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="1143000" y="685800"/>
|
||||
<a:ext cx="4572000" cy="3429000"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="5" name="Notes Placeholder 4"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="body" sz="quarter" idx="3"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="685800" y="4343400"/>
|
||||
<a:ext cx="5486400" cy="4114800"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="6" name="Footer Placeholder 5"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="ftr" sz="quarter" idx="4"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="8685213"/>
|
||||
<a:ext cx="2971800" cy="457200"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="7" name="Slide Number Placeholder 6"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="sldNum" sz="quarter" idx="5"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="3884613" y="8685213"/>
|
||||
<a:ext cx="2971800" cy="457200"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2"
|
||||
accent1="accent1" accent2="accent2" accent3="accent3"
|
||||
accent4="accent4" accent5="accent5" accent6="accent6"
|
||||
hlink="hlink" folHlink="folHlink"/>
|
||||
<p:hf/>
|
||||
<p:notesStyle>
|
||||
<a:lvl1pPr marL="0" algn="l"{paragraph_rtl}>
|
||||
<a:defRPr sz="1200" lang="{language}"/>
|
||||
</a:lvl1pPr>
|
||||
</p:notesStyle>
|
||||
</p:notesMaster>'''
|
||||
|
||||
|
||||
def create_notes_master_rels_xml() -> str:
|
||||
"""Create notes master relationships XML."""
|
||||
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme2.xml"/>
|
||||
</Relationships>'''
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Slide XML and slide relationship XML generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pptx_transitions import create_transition_xml
|
||||
|
||||
|
||||
def create_slide_xml_with_svg(
|
||||
slide_num: int,
|
||||
png_rid: str,
|
||||
svg_rid: str,
|
||||
width_emu: int,
|
||||
height_emu: int,
|
||||
transition: str | None = 'fade',
|
||||
transition_duration: float = 0.5,
|
||||
auto_advance: float | None = None,
|
||||
use_compat_mode: bool = True,
|
||||
transition_effect_options: dict[str, object] | None = None,
|
||||
) -> str:
|
||||
"""Create slide XML containing an SVG image.
|
||||
|
||||
Args:
|
||||
slide_num: Slide number.
|
||||
png_rid: PNG fallback image relationship ID.
|
||||
svg_rid: SVG relationship ID.
|
||||
width_emu: Width in EMU.
|
||||
height_emu: Height in EMU.
|
||||
transition: Transition effect name.
|
||||
transition_effect_options: PowerPoint Effect Options for the selected
|
||||
native transition.
|
||||
transition_duration: Transition duration in seconds.
|
||||
auto_advance: Auto-advance interval in seconds.
|
||||
use_compat_mode: Whether to use compatibility mode (PNG + SVG dual format).
|
||||
"""
|
||||
transition_xml = ''
|
||||
if transition is not None or auto_advance is not None:
|
||||
transition_fragment = create_transition_xml(
|
||||
effect=transition,
|
||||
duration=transition_duration,
|
||||
advance_after=auto_advance,
|
||||
effect_options=transition_effect_options,
|
||||
)
|
||||
if transition_fragment:
|
||||
transition_xml = '\n' + transition_fragment
|
||||
|
||||
if use_compat_mode:
|
||||
blip_xml = f'''<a:blip r:embed="{png_rid}">
|
||||
<a:extLst>
|
||||
<a:ext uri="{{96DAC541-7B7A-43D3-8B79-37D633B846F1}}">
|
||||
<asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="{svg_rid}"/>
|
||||
</a:ext>
|
||||
</a:extLst>
|
||||
</a:blip>'''
|
||||
else:
|
||||
blip_xml = f'<a:blip r:embed="{svg_rid}"/>'
|
||||
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr>
|
||||
<p:cNvPr id="1" name=""/>
|
||||
<p:cNvGrpSpPr/>
|
||||
<p:nvPr/>
|
||||
</p:nvGrpSpPr>
|
||||
<p:grpSpPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="0" cy="0"/>
|
||||
<a:chOff x="0" y="0"/>
|
||||
<a:chExt cx="0" cy="0"/>
|
||||
</a:xfrm>
|
||||
</p:grpSpPr>
|
||||
<p:pic>
|
||||
<p:nvPicPr>
|
||||
<p:cNvPr id="2" name="SVG Image {slide_num}"/>
|
||||
<p:cNvPicPr>
|
||||
<a:picLocks noChangeAspect="1"/>
|
||||
</p:cNvPicPr>
|
||||
<p:nvPr/>
|
||||
</p:nvPicPr>
|
||||
<p:blipFill>
|
||||
{blip_xml}
|
||||
<a:stretch>
|
||||
<a:fillRect/>
|
||||
</a:stretch>
|
||||
</p:blipFill>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="{width_emu}" cy="{height_emu}"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect">
|
||||
<a:avLst/>
|
||||
</a:prstGeom>
|
||||
</p:spPr>
|
||||
</p:pic>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
<p:clrMapOvr>
|
||||
<a:masterClrMapping/>
|
||||
</p:clrMapOvr>{transition_xml}
|
||||
</p:sld>'''
|
||||
|
||||
|
||||
def create_slide_rels_xml(
|
||||
png_rid: str,
|
||||
png_filename: str,
|
||||
svg_rid: str,
|
||||
svg_filename: str,
|
||||
use_compat_mode: bool = True,
|
||||
slide_layout_target: str = "../slideLayouts/slideLayout1.xml",
|
||||
) -> str:
|
||||
"""Create slide relationship file XML.
|
||||
|
||||
Args:
|
||||
png_rid: PNG image relationship ID.
|
||||
png_filename: PNG filename.
|
||||
svg_rid: SVG relationship ID.
|
||||
svg_filename: SVG filename.
|
||||
use_compat_mode: Whether to use compatibility mode.
|
||||
slide_layout_target: Target for the slide's actual layout part.
|
||||
"""
|
||||
if use_compat_mode:
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="{slide_layout_target}"/>
|
||||
<Relationship Id="{png_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{png_filename}"/>
|
||||
<Relationship Id="{svg_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{svg_filename}"/>
|
||||
</Relationships>'''
|
||||
else:
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="{slide_layout_target}"/>
|
||||
<Relationship Id="{svg_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{svg_filename}"/>
|
||||
</Relationships>'''
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Semantic SVG Markers
|
||||
|
||||
Owns the minimal page and structural-role marker vocabulary used by SVG
|
||||
authoring, validation, conversion traces, and native PPTX structure
|
||||
reconstruction. These markers are compiler hints, not a parallel content model.
|
||||
|
||||
Usage:
|
||||
Import from svg_quality_checker.py or svg_to_pptx internals.
|
||||
|
||||
Examples:
|
||||
validate_semantic_markers(root, require_page_role=True)
|
||||
|
||||
Dependencies:
|
||||
None (standard library only)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
PAGE_ROLE_TO_LAYOUT = {
|
||||
"cover": "Cover",
|
||||
"toc": "Agenda",
|
||||
"section": "Section",
|
||||
"content": "Content",
|
||||
"ending": "Closing",
|
||||
}
|
||||
|
||||
STRUCTURAL_ROLES = frozenset({
|
||||
"background",
|
||||
"chrome",
|
||||
"decoration",
|
||||
"footer",
|
||||
"header",
|
||||
"logo",
|
||||
"page-number",
|
||||
"watermark",
|
||||
})
|
||||
|
||||
CHROME_ROLE_TO_TOKEN = {
|
||||
"chrome": "chrome",
|
||||
"footer": "footer",
|
||||
"header": "header",
|
||||
"logo": "logo",
|
||||
"page-number": "pagenumber",
|
||||
"watermark": "watermark",
|
||||
}
|
||||
|
||||
ANIMATION_CHROME_ROLES = STRUCTURAL_ROLES
|
||||
|
||||
SEMANTIC_ATTRS = frozenset({
|
||||
"data-pptx-page-role",
|
||||
"data-pptx-role",
|
||||
})
|
||||
|
||||
_NON_VISUAL_TAGS = frozenset({"defs", "desc", "metadata", "style", "title"})
|
||||
_MARKER_TOKEN_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticMarkerIssue:
|
||||
"""One semantic-marker validation issue."""
|
||||
|
||||
severity: str
|
||||
message: str
|
||||
|
||||
|
||||
def _local_tag(elem: ET.Element) -> str:
|
||||
"""Return one element's local tag name."""
|
||||
return elem.tag.rsplit("}", 1)[-1] if "}" in str(elem.tag) else str(elem.tag)
|
||||
|
||||
|
||||
def _normalized_marker(raw: str | None) -> str | None:
|
||||
"""Normalize one marker value, preserving absence."""
|
||||
if raw is None:
|
||||
return None
|
||||
return raw.strip().lower()
|
||||
|
||||
|
||||
def chrome_token_from_role(role: str | None) -> str | None:
|
||||
"""Return the baseline chrome token represented by a semantic role."""
|
||||
normalized = _normalized_marker(role)
|
||||
return CHROME_ROLE_TO_TOKEN.get(normalized or "")
|
||||
|
||||
|
||||
def chrome_token_from_markers(
|
||||
role: str | None,
|
||||
placeholder: str | None,
|
||||
) -> str | None:
|
||||
"""Return chrome behavior from specialized markers before generic roles."""
|
||||
if _normalized_marker(placeholder) == "slide-number":
|
||||
return "pagenumber"
|
||||
return chrome_token_from_role(role)
|
||||
|
||||
|
||||
def is_chrome_role(role: str | None) -> bool:
|
||||
"""Return whether a semantic role represents non-content page chrome."""
|
||||
normalized = _normalized_marker(role)
|
||||
return normalized in ANIMATION_CHROME_ROLES
|
||||
|
||||
|
||||
def is_static_page_frame(
|
||||
role: str | None,
|
||||
placeholder: str | None,
|
||||
) -> bool:
|
||||
"""Return whether explicit markers identify a static page-frame object."""
|
||||
return (
|
||||
_normalized_marker(placeholder) is not None
|
||||
or is_chrome_role(role)
|
||||
)
|
||||
|
||||
|
||||
def page_layout_name_from_svg(svg_path: Path) -> str | None:
|
||||
"""Return the native baseline Layout name declared by the SVG page role."""
|
||||
root = ET.parse(svg_path).getroot()
|
||||
role = _normalized_marker(root.get("data-pptx-page-role"))
|
||||
return PAGE_ROLE_TO_LAYOUT.get(role or "")
|
||||
|
||||
|
||||
def validate_semantic_markers(
|
||||
root: ET.Element,
|
||||
*,
|
||||
require_page_role: bool = False,
|
||||
) -> list[SemanticMarkerIssue]:
|
||||
"""Validate semantic markers without changing SVG rendering semantics."""
|
||||
issues: list[SemanticMarkerIssue] = []
|
||||
page_role_raw = root.get("data-pptx-page-role")
|
||||
page_role = _normalized_marker(page_role_raw)
|
||||
if page_role_raw is None:
|
||||
if require_page_role:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"warning",
|
||||
"page SVG is missing root data-pptx-page-role",
|
||||
))
|
||||
elif not page_role:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"error",
|
||||
"root data-pptx-page-role must not be empty",
|
||||
))
|
||||
elif not _MARKER_TOKEN_RE.fullmatch(page_role):
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"error",
|
||||
f"invalid data-pptx-page-role={page_role_raw!r}; use lowercase kebab-case",
|
||||
))
|
||||
elif page_role not in PAGE_ROLE_TO_LAYOUT:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"warning",
|
||||
f"unknown data-pptx-page-role={page_role_raw!r}",
|
||||
))
|
||||
elif page_role_raw != page_role:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"warning",
|
||||
f"data-pptx-page-role should use canonical lowercase value {page_role!r}",
|
||||
))
|
||||
|
||||
id_counts: dict[str, int] = {}
|
||||
for elem in root.iter():
|
||||
elem_id = (elem.get("id") or "").strip()
|
||||
if elem_id:
|
||||
id_counts[elem_id] = id_counts.get(elem_id, 0) + 1
|
||||
|
||||
marked_ids: set[str] = set()
|
||||
for elem in root.iter():
|
||||
tag = _local_tag(elem)
|
||||
elem_id = (elem.get("id") or "").strip()
|
||||
if elem is not root and elem.get("data-pptx-page-role") is not None:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"error",
|
||||
f"{elem_id or tag}: data-pptx-page-role belongs on the root <svg> only",
|
||||
))
|
||||
|
||||
role_raw = elem.get("data-pptx-role")
|
||||
role = _normalized_marker(role_raw)
|
||||
if role_raw is not None:
|
||||
if elem is root or tag in _NON_VISUAL_TAGS:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"error",
|
||||
f"{elem_id or tag}: data-pptx-role belongs on a visual SVG element",
|
||||
))
|
||||
if not role:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"error",
|
||||
f"{elem_id or tag}: data-pptx-role must not be empty",
|
||||
))
|
||||
elif not _MARKER_TOKEN_RE.fullmatch(role):
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"error",
|
||||
f"{elem_id or tag}: invalid data-pptx-role={role_raw!r}; "
|
||||
"use lowercase kebab-case",
|
||||
))
|
||||
elif role not in STRUCTURAL_ROLES:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"warning",
|
||||
f"{elem_id or tag}: unknown data-pptx-role={role_raw!r}",
|
||||
))
|
||||
elif role_raw != role:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"warning",
|
||||
f"{elem_id or tag}: data-pptx-role should use canonical "
|
||||
f"lowercase value {role!r}",
|
||||
))
|
||||
if not elem_id:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"error",
|
||||
f"<{tag}> with data-pptx-role requires a stable id",
|
||||
))
|
||||
else:
|
||||
marked_ids.add(elem_id)
|
||||
|
||||
layer = _normalized_marker(elem.get("data-pptx-layer"))
|
||||
placeholder = _normalized_marker(elem.get("data-pptx-placeholder"))
|
||||
if layer:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"warning",
|
||||
f"{elem_id or tag}: data-pptx-role is redundant when "
|
||||
"data-pptx-layer already owns structure/animation behavior",
|
||||
))
|
||||
if role == "page-number" and placeholder == "slide-number":
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"warning",
|
||||
f"{elem_id or tag}: data-pptx-role='page-number' is redundant; "
|
||||
"data-pptx-placeholder='slide-number' already owns the behavior",
|
||||
))
|
||||
|
||||
duplicates = sorted(
|
||||
elem_id for elem_id in marked_ids
|
||||
if id_counts.get(elem_id, 0) > 1
|
||||
)
|
||||
if duplicates:
|
||||
issues.append(SemanticMarkerIssue(
|
||||
"error",
|
||||
"semantic markers require unique ids; duplicate: " + ", ".join(duplicates),
|
||||
))
|
||||
return issues
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
"""In-memory flattening of positional ``<tspan>`` elements.
|
||||
|
||||
DrawingML's text-run model has no way to express "jump to a new x/y inside
|
||||
the same paragraph". Every ``<tspan>`` carrying ``x``, ``y`` or non-zero
|
||||
``dy`` is therefore a layout instruction this converter cannot honour
|
||||
inline — without flattening, a 4-line dy-stacked block collapses onto a
|
||||
single baseline and an x-anchored tspan jumps to the wrong column.
|
||||
|
||||
The on-disk ``finalize_svg`` pipeline solves this by promoting each
|
||||
positional tspan to an independent ``<text>`` element. This module
|
||||
performs the same transformation in memory so ``svg_to_pptx`` can consume
|
||||
``svg_output/`` directly without that disk step.
|
||||
|
||||
Public API:
|
||||
flatten_positional_tspans(tree) -> bool
|
||||
Walk the SVG element tree, replace every positional ``<tspan>``
|
||||
with an independent ``<text>``, and return whether anything
|
||||
changed.
|
||||
|
||||
Heavy lifting is delegated to ``svg_finalize.flatten_tspan`` so the two
|
||||
pipelines stay behaviourally aligned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
def _flatten_module():
|
||||
"""Load the shared on-disk flattener after exposing the scripts root."""
|
||||
scripts_dir = Path(__file__).resolve().parent.parent
|
||||
if str(scripts_dir) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
from svg_finalize import flatten_tspan # type: ignore
|
||||
return flatten_tspan
|
||||
|
||||
|
||||
def flatten_positional_tspans(
|
||||
tree: ET.ElementTree,
|
||||
merge_paragraphs: bool = False,
|
||||
preserve_line_breaks: bool = False,
|
||||
) -> bool:
|
||||
"""Flatten positional ``<tspan>`` elements into independent ``<text>``.
|
||||
|
||||
Delegates to ``svg_finalize.flatten_tspan.flatten_text_with_tspans`` so
|
||||
the in-memory transform exactly matches the on-disk one. When
|
||||
``merge_paragraphs`` is True, mergeable paragraph blocks are preserved
|
||||
as a single <text>. ``preserve_line_breaks`` marks visual rows for hard
|
||||
DrawingML line breaks instead of reflow.
|
||||
|
||||
Returns True if any tspan was rewritten.
|
||||
"""
|
||||
return _flatten_module().flatten_text_with_tspans(
|
||||
tree,
|
||||
merge_paragraphs=merge_paragraphs,
|
||||
preserve_line_breaks=preserve_line_breaks,
|
||||
)
|
||||
|
||||
|
||||
def nested_positional_tspan_errors(root: ET.Element) -> list[str]:
|
||||
"""Return shared diagnostics for unsupported nested baseline jumps."""
|
||||
return _flatten_module().nested_positional_tspan_errors(root)
|
||||
@@ -0,0 +1,577 @@
|
||||
"""In-memory expansion of project icons and static local ``<use>`` elements.
|
||||
|
||||
The icon placeholder ``<use data-icon="...">`` is a project-internal SVG
|
||||
extension; standard renderers (browsers, PowerPoint's SVG parser) and our
|
||||
own DrawingML dispatcher do not understand it. ``finalize_svg`` already
|
||||
expands it on disk into ``svg_final/``; this module provides the same
|
||||
expansion in memory so ``svg_to_pptx`` can consume ``svg_output/`` directly
|
||||
without first running the on-disk finalize step.
|
||||
|
||||
Public API:
|
||||
expand_use_data_icons(root, icons_dir, fallback_dir=None) -> int
|
||||
Walk the SVG element tree, replace every ``<use data-icon="...">``
|
||||
with its expanded ``<g>`` group of primitive shapes, and return
|
||||
the number of replacements made.
|
||||
expand_local_use_references(root) -> int
|
||||
Materialize same-document ``href="#id"`` references, including
|
||||
``<symbol>`` viewBox mapping, and return the number of instances.
|
||||
expand_local_use_references_in_file(svg_path) -> int
|
||||
Apply the same expansion to an SVG file in place.
|
||||
|
||||
The heavy lifting (icon resolution, color application, scaling) is
|
||||
delegated to ``svg_finalize.embed_icons`` so the two pipelines stay
|
||||
behaviourally aligned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
XLINK_NS = 'http://www.w3.org/1999/xlink'
|
||||
|
||||
_LOCAL_HREF_RE = re.compile(r'^#([^#\s]+)$')
|
||||
_LENGTH_RE = re.compile(
|
||||
r'^\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*(?:px)?\s*$'
|
||||
)
|
||||
_VIEWBOX_SPLIT_RE = re.compile(r'[\s,]+')
|
||||
_URL_REF_RE = re.compile(r'''url\(#([^#\s()'"\\]+)\)''')
|
||||
_URL_FUNCTION_RE = re.compile(r'\burl\s*\([^)]*\)', re.IGNORECASE)
|
||||
_URL_FUNCTION_START_RE = re.compile(r'\burl\s*\(', re.IGNORECASE)
|
||||
_MAX_LOCAL_USE_DEPTH = 64
|
||||
_MAX_LOCAL_USE_INSTANCES = 10_000
|
||||
_NON_REUSABLE_METADATA_PREFIXES = (
|
||||
'data-pptx-authoring',
|
||||
'data-pptx-object',
|
||||
'data-pptx-prst',
|
||||
'data-pptx-frame',
|
||||
'data-pptx-av-',
|
||||
'data-pptx-layer',
|
||||
'data-pptx-replace-with',
|
||||
'data-pptx-replacement-',
|
||||
'data-pptx-import-source',
|
||||
'data-pptx-fallback-',
|
||||
'data-pptx-native',
|
||||
'data-pptx-visual-status',
|
||||
'data-pptx-route-status',
|
||||
'data-pptx-placeholder',
|
||||
)
|
||||
_REFERENCE_TAGS = frozenset({
|
||||
'symbol', 'g', 'use',
|
||||
'rect', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline',
|
||||
'text', 'image',
|
||||
})
|
||||
|
||||
|
||||
class UseExpansionError(ValueError):
|
||||
"""Raised when a static local ``<use>`` cannot be expanded safely."""
|
||||
|
||||
|
||||
def _local_tag(elem: ET.Element) -> str:
|
||||
"""Return an ElementTree node's local tag name."""
|
||||
return elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag)
|
||||
|
||||
|
||||
def _qualified_tag(elem: ET.Element, local: str) -> str:
|
||||
"""Return ``local`` in the same namespace as ``elem``."""
|
||||
tag = str(elem.tag)
|
||||
if tag.startswith('{') and '}' in tag:
|
||||
return f'{tag.split("}", 1)[0]}}}{local}'
|
||||
return local
|
||||
|
||||
|
||||
def _fmt_number(value: float) -> str:
|
||||
"""Format a finite SVG transform number without negative zero."""
|
||||
if abs(value) < 1e-12:
|
||||
value = 0.0
|
||||
return f'{value:.12g}'
|
||||
|
||||
|
||||
def _numeric_length(
|
||||
elem: ET.Element,
|
||||
name: str,
|
||||
default: float | None = None,
|
||||
) -> float | None:
|
||||
"""Parse a unitless/px local-use geometry value."""
|
||||
raw = elem.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
match = _LENGTH_RE.fullmatch(raw)
|
||||
if match is None:
|
||||
raise UseExpansionError(
|
||||
f'<use> {name} must be a finite unitless or px value, got {raw!r}'
|
||||
)
|
||||
value = float(match.group(1))
|
||||
if not math.isfinite(value):
|
||||
raise UseExpansionError(f'<use> {name} must be finite, got {raw!r}')
|
||||
return value
|
||||
|
||||
|
||||
def _parse_viewbox(symbol: ET.Element) -> tuple[float, float, float, float]:
|
||||
"""Parse a positive four-number symbol viewBox."""
|
||||
raw = symbol.get('viewBox', '')
|
||||
parts = [part for part in _VIEWBOX_SPLIT_RE.split(raw.strip()) if part]
|
||||
if len(parts) != 4:
|
||||
raise UseExpansionError(
|
||||
'<symbol> referenced by <use> must define a four-number viewBox'
|
||||
)
|
||||
try:
|
||||
values = tuple(float(part) for part in parts)
|
||||
except ValueError as exc:
|
||||
raise UseExpansionError(
|
||||
f'<symbol> viewBox contains a non-numeric value: {raw!r}'
|
||||
) from exc
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
raise UseExpansionError(f'<symbol> viewBox must be finite, got {raw!r}')
|
||||
min_x, min_y, width, height = values
|
||||
if width <= 0 or height <= 0:
|
||||
raise UseExpansionError(
|
||||
f'<symbol> viewBox width/height must be positive, got {raw!r}'
|
||||
)
|
||||
return min_x, min_y, width, height
|
||||
|
||||
|
||||
def _symbol_viewport_transform(symbol: ET.Element, use_elem: ET.Element) -> str:
|
||||
"""Map one symbol viewBox into the use element's explicit viewport."""
|
||||
for attr in ('refX', 'refY'):
|
||||
if symbol.get(attr) is not None:
|
||||
raise UseExpansionError(
|
||||
f'<symbol> {attr} is not supported by local <use> expansion'
|
||||
)
|
||||
|
||||
min_x, min_y, view_width, view_height = _parse_viewbox(symbol)
|
||||
width = _numeric_length(use_elem, 'width')
|
||||
height = _numeric_length(use_elem, 'height')
|
||||
if width is None or height is None or width <= 0 or height <= 0:
|
||||
raise UseExpansionError(
|
||||
'<use> referencing <symbol> requires positive numeric width and height'
|
||||
)
|
||||
|
||||
raw_aspect = symbol.get('preserveAspectRatio', 'xMidYMid meet').strip()
|
||||
parts = raw_aspect.split()
|
||||
if parts and parts[0] == 'defer':
|
||||
parts.pop(0)
|
||||
align = parts[0] if parts else 'xMidYMid'
|
||||
mode = parts[1] if len(parts) > 1 else 'meet'
|
||||
if len(parts) > 2 or mode not in {'meet', 'slice'}:
|
||||
raise UseExpansionError(
|
||||
f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}'
|
||||
)
|
||||
|
||||
if align == 'none':
|
||||
if len(parts) > 1:
|
||||
raise UseExpansionError(
|
||||
f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}'
|
||||
)
|
||||
scale_x = width / view_width
|
||||
scale_y = height / view_height
|
||||
translate_x = -min_x * scale_x
|
||||
translate_y = -min_y * scale_y
|
||||
else:
|
||||
alignments = {
|
||||
'xMinYMin': (0.0, 0.0), 'xMidYMin': (0.5, 0.0), 'xMaxYMin': (1.0, 0.0),
|
||||
'xMinYMid': (0.0, 0.5), 'xMidYMid': (0.5, 0.5), 'xMaxYMid': (1.0, 0.5),
|
||||
'xMinYMax': (0.0, 1.0), 'xMidYMax': (0.5, 1.0), 'xMaxYMax': (1.0, 1.0),
|
||||
}
|
||||
if align not in alignments:
|
||||
raise UseExpansionError(
|
||||
f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}'
|
||||
)
|
||||
if mode == 'slice':
|
||||
raise UseExpansionError(
|
||||
'symbol preserveAspectRatio="... slice" requires viewport clipping, '
|
||||
'which local <use> expansion does not approximate'
|
||||
)
|
||||
scale = min(width / view_width, height / view_height)
|
||||
scale_x = scale_y = scale
|
||||
align_x, align_y = alignments[align]
|
||||
translate_x = (width - view_width * scale) * align_x - min_x * scale
|
||||
translate_y = (height - view_height * scale) * align_y - min_y * scale
|
||||
|
||||
return (
|
||||
f'matrix({_fmt_number(scale_x)} 0 0 {_fmt_number(scale_y)} '
|
||||
f'{_fmt_number(translate_x)} {_fmt_number(translate_y)})'
|
||||
)
|
||||
|
||||
|
||||
class _LocalUseExpander:
|
||||
"""Materialize static same-document SVG use references."""
|
||||
|
||||
def __init__(self, root: ET.Element):
|
||||
self.root = root
|
||||
self.targets: dict[str, ET.Element] = {}
|
||||
self.duplicate_ids: set[str] = set()
|
||||
self.used_ids: set[str] = set()
|
||||
self.instance_index = 0
|
||||
self.instances_started = 0
|
||||
self.expanded = 0
|
||||
for elem in root.iter():
|
||||
elem_id = elem.get('id')
|
||||
if not elem_id:
|
||||
continue
|
||||
self.used_ids.add(elem_id)
|
||||
if elem_id in self.targets:
|
||||
self.duplicate_ids.add(elem_id)
|
||||
else:
|
||||
self.targets[elem_id] = elem
|
||||
|
||||
def expand(self) -> int:
|
||||
"""Expand every visible non-data-icon use node in the document."""
|
||||
self._expand_children(self.root, ())
|
||||
return self.expanded
|
||||
|
||||
def _expand_children(self, parent: ET.Element, stack: tuple[str, ...]) -> None:
|
||||
for index, child in enumerate(list(parent)):
|
||||
if _local_tag(child) == 'defs':
|
||||
continue
|
||||
if _local_tag(child) == 'use' and not child.get('data-icon'):
|
||||
replacement = self._materialize_use(child, stack)
|
||||
parent.remove(child)
|
||||
parent.insert(index, replacement)
|
||||
continue
|
||||
self._expand_children(child, stack)
|
||||
|
||||
def _materialize_use(
|
||||
self,
|
||||
use_elem: ET.Element,
|
||||
stack: tuple[str, ...],
|
||||
) -> ET.Element:
|
||||
self.instances_started += 1
|
||||
if self.instances_started > _MAX_LOCAL_USE_INSTANCES:
|
||||
raise UseExpansionError(
|
||||
'Local <use> expansion exceeds the 10000-instance safety limit'
|
||||
)
|
||||
|
||||
href = use_elem.get('href')
|
||||
xlink_href = use_elem.get(f'{{{XLINK_NS}}}href')
|
||||
if href is not None and xlink_href is not None:
|
||||
if href != xlink_href:
|
||||
raise UseExpansionError(
|
||||
'Conflicting href and xlink:href values on local <use>'
|
||||
)
|
||||
if href is None:
|
||||
href = xlink_href
|
||||
match = _LOCAL_HREF_RE.fullmatch(href or '')
|
||||
if match is None:
|
||||
raise UseExpansionError(
|
||||
'<use> must reference a same-document fragment with href="#id"; '
|
||||
f'got {href!r}'
|
||||
)
|
||||
ref_id = match.group(1)
|
||||
if len(stack) >= _MAX_LOCAL_USE_DEPTH:
|
||||
chain = ' -> '.join((*stack, ref_id))
|
||||
raise UseExpansionError(
|
||||
f'Local <use> expansion exceeds the 64-reference depth limit: {chain}'
|
||||
)
|
||||
if ref_id in self.duplicate_ids:
|
||||
raise UseExpansionError(
|
||||
f'<use href="#{ref_id}"> is ambiguous because the id is duplicated'
|
||||
)
|
||||
use_id = use_elem.get('id')
|
||||
if use_id and use_id in self.duplicate_ids:
|
||||
raise UseExpansionError(
|
||||
f'Local <use> instance id {use_id!r} is duplicated in this SVG'
|
||||
)
|
||||
target = self.targets.get(ref_id)
|
||||
if target is None:
|
||||
raise UseExpansionError(
|
||||
f'<use href="#{ref_id}"> has no matching element in this SVG'
|
||||
)
|
||||
if ref_id in stack:
|
||||
chain = ' -> '.join((*stack, ref_id))
|
||||
raise UseExpansionError(f'Circular local <use> reference: {chain}')
|
||||
|
||||
target_tag = _local_tag(target)
|
||||
if target_tag not in _REFERENCE_TAGS:
|
||||
raise UseExpansionError(
|
||||
f'<use href="#{ref_id}"> references unsupported <{target_tag}>'
|
||||
)
|
||||
|
||||
self._reject_structural_metadata(use_elem, 'instance')
|
||||
self._reject_structural_metadata(target, f'target #{ref_id}')
|
||||
self._validate_fragment_reference_syntax(use_elem, 'instance')
|
||||
self._validate_fragment_reference_syntax(target, f'target #{ref_id}')
|
||||
|
||||
target_ids = {
|
||||
elem_id
|
||||
for elem in target.iter()
|
||||
if (elem_id := elem.get('id'))
|
||||
}
|
||||
ambiguous_ids = sorted(target_ids & self.duplicate_ids)
|
||||
if ambiguous_ids:
|
||||
joined = ', '.join(ambiguous_ids)
|
||||
raise UseExpansionError(
|
||||
f'<use href="#{ref_id}"> references a subtree with duplicate id(s): '
|
||||
f'{joined}'
|
||||
)
|
||||
|
||||
next_stack = (*stack, ref_id)
|
||||
target_clone = copy.deepcopy(target)
|
||||
if target_tag == 'use':
|
||||
clone = self._materialize_use(target_clone, next_stack)
|
||||
else:
|
||||
clone = target_clone
|
||||
if target_tag == 'symbol':
|
||||
clone.tag = _qualified_tag(clone, 'g')
|
||||
viewport_transform = _symbol_viewport_transform(target, use_elem)
|
||||
existing_transform = clone.get('transform', '').strip()
|
||||
clone.set(
|
||||
'transform',
|
||||
f'{existing_transform} {viewport_transform}'.strip(),
|
||||
)
|
||||
for attr in ('viewBox', 'preserveAspectRatio', 'x', 'y', 'width', 'height'):
|
||||
clone.attrib.pop(attr, None)
|
||||
self._expand_children(clone, next_stack)
|
||||
|
||||
self._rewrite_clone_ids(clone, self._next_instance_prefix(clone))
|
||||
wrapper = self._build_wrapper(use_elem)
|
||||
wrapper.append(clone)
|
||||
self.expanded += 1
|
||||
return wrapper
|
||||
|
||||
@staticmethod
|
||||
def _reject_structural_metadata(elem: ET.Element, label: str) -> None:
|
||||
"""Reject reusable template/native markers parsed before expansion."""
|
||||
for candidate in elem.iter():
|
||||
for attr in candidate.attrib:
|
||||
if attr.startswith(_NON_REUSABLE_METADATA_PREFIXES):
|
||||
raise UseExpansionError(
|
||||
f'Local <use> {label} cannot carry structural {attr} metadata'
|
||||
)
|
||||
|
||||
def _validate_fragment_reference_syntax(
|
||||
self,
|
||||
elem: ET.Element,
|
||||
label: str,
|
||||
) -> None:
|
||||
"""Reject URL fragment forms the clone rewriter cannot preserve."""
|
||||
for candidate in elem.iter():
|
||||
for value in candidate.attrib.values():
|
||||
starts = list(_URL_FUNCTION_START_RE.finditer(value))
|
||||
if not starts:
|
||||
continue
|
||||
functions = list(_URL_FUNCTION_RE.finditer(value))
|
||||
if len(functions) != len(starts):
|
||||
raise UseExpansionError(
|
||||
f'Local <use> {label} has malformed url(...) reference {value!r}'
|
||||
)
|
||||
for function in functions:
|
||||
raw = function.group(0)
|
||||
match = _URL_REF_RE.fullmatch(raw)
|
||||
if match is None:
|
||||
raise UseExpansionError(
|
||||
f'Local <use> {label} requires exact url(#id) fragments; '
|
||||
f'got {raw!r}'
|
||||
)
|
||||
ref_id = match.group(1)
|
||||
if ref_id in self.duplicate_ids:
|
||||
raise UseExpansionError(
|
||||
f'Local <use> {label} has ambiguous url(#{ref_id}); '
|
||||
'the referenced id is duplicated'
|
||||
)
|
||||
if ref_id not in self.targets:
|
||||
raise UseExpansionError(
|
||||
f'Local <use> {label} has unresolved url(#{ref_id})'
|
||||
)
|
||||
|
||||
def _next_instance_prefix(self, clone: ET.Element) -> str:
|
||||
"""Reserve a deterministic clone prefix that cannot collide."""
|
||||
clone_ids = {
|
||||
elem_id
|
||||
for elem in clone.iter()
|
||||
if (elem_id := elem.get('id'))
|
||||
}
|
||||
while True:
|
||||
self.instance_index += 1
|
||||
prefix = f'use-instance-{self.instance_index}-'
|
||||
generated_ids = {f'{prefix}{elem_id}' for elem_id in clone_ids}
|
||||
if not generated_ids & self.used_ids:
|
||||
self.used_ids.update(generated_ids)
|
||||
return prefix
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_clone_ids(clone: ET.Element, prefix: str) -> None:
|
||||
"""Make materialized IDs instance-local and rewrite fragment refs."""
|
||||
id_map: dict[str, str] = {}
|
||||
for elem in clone.iter():
|
||||
elem_id = elem.get('id')
|
||||
if elem_id:
|
||||
id_map[elem_id] = f'{prefix}{elem_id}'
|
||||
if not id_map:
|
||||
return
|
||||
for elem in clone.iter():
|
||||
elem_id = elem.get('id')
|
||||
if elem_id in id_map:
|
||||
elem.set('id', id_map[elem_id])
|
||||
for attr, value in list(elem.attrib.items()):
|
||||
if attr in {'href', f'{{{XLINK_NS}}}href'} and value.startswith('#'):
|
||||
ref_id = value[1:]
|
||||
if ref_id in id_map:
|
||||
elem.set(attr, f'#{id_map[ref_id]}')
|
||||
continue
|
||||
rewritten = _URL_REF_RE.sub(
|
||||
lambda match: f'url(#{id_map.get(match.group(1), match.group(1))})',
|
||||
value,
|
||||
)
|
||||
if rewritten != value:
|
||||
elem.set(attr, rewritten)
|
||||
|
||||
@staticmethod
|
||||
def _build_wrapper(use_elem: ET.Element) -> ET.Element:
|
||||
"""Create an inheriting group for use styles and instance geometry."""
|
||||
wrapper = ET.Element(_qualified_tag(use_elem, 'g'))
|
||||
skipped = {
|
||||
'href', f'{{{XLINK_NS}}}href',
|
||||
'x', 'y', 'width', 'height', 'preserveAspectRatio', 'transform',
|
||||
}
|
||||
for attr, value in use_elem.attrib.items():
|
||||
if attr not in skipped:
|
||||
wrapper.set(attr, value)
|
||||
|
||||
x = _numeric_length(use_elem, 'x', 0.0) or 0.0
|
||||
y = _numeric_length(use_elem, 'y', 0.0) or 0.0
|
||||
transforms = []
|
||||
use_transform = use_elem.get('transform', '').strip()
|
||||
if use_transform:
|
||||
transforms.append(use_transform)
|
||||
if x or y:
|
||||
transforms.append(f'translate({_fmt_number(x)} {_fmt_number(y)})')
|
||||
if transforms:
|
||||
wrapper.set('transform', ' '.join(transforms))
|
||||
return wrapper
|
||||
|
||||
|
||||
def expand_local_use_references(root: ET.Element) -> int:
|
||||
"""Expand static same-document ``<use href="#id">`` references."""
|
||||
return _LocalUseExpander(root).expand()
|
||||
|
||||
|
||||
def validate_local_use_references(root: ET.Element) -> list[str]:
|
||||
"""Return expansion errors without mutating the caller's SVG tree."""
|
||||
try:
|
||||
expand_local_use_references(copy.deepcopy(root))
|
||||
except UseExpansionError as exc:
|
||||
return [str(exc)]
|
||||
return []
|
||||
|
||||
|
||||
def expand_local_use_references_in_file(svg_path: Path) -> int:
|
||||
"""Expand local use references in one SVG file in place."""
|
||||
tree = ET.parse(str(svg_path))
|
||||
count = expand_local_use_references(tree.getroot())
|
||||
if count:
|
||||
ET.register_namespace('', SVG_NS)
|
||||
ET.register_namespace('xlink', XLINK_NS)
|
||||
tree.write(str(svg_path), encoding='unicode', xml_declaration=False)
|
||||
return count
|
||||
|
||||
|
||||
def _import_embed_icons():
|
||||
"""Lazy import so svg_to_pptx doesn't hard-require svg_finalize at import time."""
|
||||
scripts_dir = Path(__file__).resolve().parent.parent
|
||||
if str(scripts_dir) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
from svg_finalize import embed_icons # type: ignore
|
||||
return embed_icons
|
||||
|
||||
|
||||
def _build_replacement_g(
|
||||
use_elem: ET.Element,
|
||||
icons_dir: Path,
|
||||
fallback_dir: Path | None,
|
||||
embed_icons_mod,
|
||||
) -> ET.Element | None:
|
||||
"""Resolve a single ``<use data-icon="...">`` into an expanded ``<g>``.
|
||||
|
||||
Returns None when the icon name is missing, unresolved, or the icon
|
||||
file cannot be parsed. Callers should leave the original ``<use>`` in
|
||||
place in that case (matching the on-disk finalize_svg behaviour, which
|
||||
also leaves unresolvable placeholders untouched).
|
||||
"""
|
||||
use_str = ET.tostring(use_elem, encoding='unicode')
|
||||
attrs = embed_icons_mod.parse_use_element(use_str)
|
||||
if 'icon' not in attrs:
|
||||
return None
|
||||
|
||||
icon_path, _base_size = embed_icons_mod.resolve_icon_path(
|
||||
attrs['icon'], icons_dir, fallback_dir,
|
||||
)
|
||||
if not icon_path.exists():
|
||||
return None
|
||||
|
||||
color = attrs.get('fill', '#000000')
|
||||
elements, style, base_size = embed_icons_mod.extract_paths_from_icon(
|
||||
icon_path, color,
|
||||
)
|
||||
if not elements:
|
||||
return None
|
||||
|
||||
g_xml = embed_icons_mod.generate_icon_group(attrs, elements, style, base_size)
|
||||
|
||||
# Wrap with a namespaced root so the parsed subtree carries the SVG
|
||||
# namespace through to every primitive (path/circle/...).
|
||||
wrapped = f'<svg xmlns="{SVG_NS}">{g_xml}</svg>'
|
||||
try:
|
||||
parsed_root = ET.fromstring(wrapped)
|
||||
except ET.ParseError:
|
||||
return None
|
||||
|
||||
for child in parsed_root:
|
||||
local = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if local == 'g':
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def expand_use_data_icons(
|
||||
root: ET.Element,
|
||||
icons_dir: Path,
|
||||
fallback_dir: Path | None = None,
|
||||
) -> int:
|
||||
"""Replace every ``<use data-icon="...">`` in *root* with its expansion.
|
||||
|
||||
Walks the tree, finds use elements that carry a ``data-icon`` attribute,
|
||||
builds a new ``<g>`` subtree from the project icon library (falling back to
|
||||
the global library when supplied), and swaps it into the parent element at
|
||||
the same position.
|
||||
|
||||
Returns the number of placeholders successfully expanded. Unresolvable
|
||||
placeholders are left in place so callers can decide whether to warn.
|
||||
"""
|
||||
if not icons_dir.exists():
|
||||
return 0
|
||||
|
||||
embed_icons_mod = _import_embed_icons()
|
||||
|
||||
# ElementTree elements don't carry a parent reference, so build a map.
|
||||
parent_of: dict[ET.Element, ET.Element] = {}
|
||||
for parent in root.iter():
|
||||
for child in parent:
|
||||
parent_of[child] = parent
|
||||
|
||||
targets: list[ET.Element] = []
|
||||
for elem in root.iter():
|
||||
local = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
|
||||
if local == 'use' and elem.get('data-icon'):
|
||||
targets.append(elem)
|
||||
|
||||
expanded = 0
|
||||
for use_elem in targets:
|
||||
parent = parent_of.get(use_elem)
|
||||
if parent is None:
|
||||
continue
|
||||
replacement = _build_replacement_g(use_elem, icons_dir, fallback_dir, embed_icons_mod)
|
||||
if replacement is None:
|
||||
continue
|
||||
idx = list(parent).index(use_elem)
|
||||
parent.remove(use_elem)
|
||||
parent.insert(idx, replacement)
|
||||
expanded += 1
|
||||
|
||||
return expanded
|
||||
Reference in New Issue
Block a user