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