Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;'))
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