Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user