Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1 @@
"""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