3534 lines
122 KiB
Python
3534 lines
122 KiB
Python
"""Coordinate, transform, color, and font helpers for DrawingML conversion.
|
||
|
||
See references/shared-standards-core.md §2.1 for project geometry and
|
||
references/svg-effects.md §§6.2–6.8 for paint, image-fit, line-presentation,
|
||
and transform authoring contracts.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import colorsys
|
||
import math
|
||
import re
|
||
import unicodedata
|
||
from collections import Counter
|
||
from collections.abc import Iterator
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
from xml.etree import ElementTree as ET
|
||
|
||
from pptx_shapes import (
|
||
OOXML_COORDINATE_MAX,
|
||
resolve_preset_preview_hash,
|
||
svg_preset_preview_fingerprint,
|
||
validate_ooxml_xfrm,
|
||
)
|
||
from language_tags import language_base, language_uses_rtl
|
||
|
||
from .context import AffineMatrix, ConvertContext, IDENTITY_MATRIX
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
SVG_NS = 'http://www.w3.org/2000/svg'
|
||
XLINK_NS = 'http://www.w3.org/1999/xlink'
|
||
|
||
EMU_PER_PX = 9525 # 1 SVG px = 9525 EMU (96 DPI)
|
||
FONT_PX_TO_HUNDREDTHS_PT = 75 # 1px = 0.75pt -> 75 hundredths-of-a-point
|
||
DRAWINGML_TEXT_FONT_SIZE_MIN = 100
|
||
DRAWINGML_TEXT_FONT_SIZE_MAX = 400_000
|
||
ANGLE_UNIT = 60000 # DrawingML angle: 60000ths of a degree
|
||
|
||
# SVG attributes inheritable from parent <g>
|
||
INHERITABLE_ATTRS = [
|
||
'fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap',
|
||
'stroke-linejoin', 'fill-opacity', 'stroke-opacity',
|
||
'font-family', 'font-size', 'font-weight', 'font-style',
|
||
'text-anchor', 'letter-spacing', 'text-decoration',
|
||
]
|
||
|
||
# Known East Asian fonts
|
||
EA_FONTS = {
|
||
'PingFang SC', 'PingFang TC', 'PingFang HK',
|
||
'Microsoft YaHei', 'Microsoft JhengHei',
|
||
'SimSun', 'SimHei', 'FangSong', 'KaiTi', 'STKaiti',
|
||
'STHeiti', 'STSong', 'STFangsong', 'STXihei', 'STZhongsong',
|
||
'Hiragino Sans', 'Hiragino Sans GB', 'Hiragino Mincho ProN',
|
||
'Hiragino Kaku Gothic ProN', 'Hiragino Kaku Gothic Pro',
|
||
'Hiragino Mincho Pro',
|
||
'Noto Sans SC', 'Noto Sans TC', 'Noto Serif SC', 'Noto Serif TC',
|
||
'Noto Sans CJK SC',
|
||
'Noto Sans JP', 'Noto Serif JP', 'Noto Sans CJK JP',
|
||
'Source Han Sans SC', 'Source Han Sans TC',
|
||
'Source Han Serif SC', 'Source Han Serif TC',
|
||
'Source Han Sans JP', 'Source Han Serif JP',
|
||
'WenQuanYi Micro Hei', 'WenQuanYi Zen Hei',
|
||
'YouYuan', 'LiSu', 'HuaWenKaiTi',
|
||
'Heiti TC', 'Kaiti TC', 'Songti SC', 'Songti TC',
|
||
# Windows 10/11 + Office default / common Simplified Chinese
|
||
'DengXian', 'DengXian Light', 'DengXian Bold', 'Microsoft YaHei UI',
|
||
# Office display Chinese (华文 / 方正) — usually title-only, not on every client
|
||
'STXingkai', 'STLiti', 'STXinwei', 'STHupo', 'STCaiyun',
|
||
'FZShuTi', 'FZYaoti',
|
||
# Common Traditional Chinese (Office)
|
||
'DFKai-SB', 'MingLiU', 'PMingLiU', 'MingLiU_HKSCS',
|
||
'MingLiU-ExtB', 'PMingLiU-ExtB',
|
||
'Microsoft JhengHei UI',
|
||
# Japanese fonts (Windows-available)
|
||
'Yu Gothic', 'Yu Gothic UI', 'Yu Mincho',
|
||
'Meiryo', 'Meiryo UI', 'メイリオ',
|
||
'MS Gothic', 'MS Mincho', 'MS PGothic', 'MS PMincho', 'MS UI Gothic',
|
||
# Korean
|
||
'Malgun Gothic', 'Gulim', 'Dotum', 'Batang',
|
||
'Noto Sans KR', 'Noto Serif KR',
|
||
}
|
||
SYSTEM_FONTS = {'system-ui', '-apple-system', 'BlinkMacSystemFont'}
|
||
|
||
# macOS/Linux-only fonts -> Windows equivalents
|
||
FONT_FALLBACK_WIN = {
|
||
'PingFang SC': 'Microsoft YaHei',
|
||
'PingFang TC': 'Microsoft JhengHei',
|
||
'PingFang HK': 'Microsoft JhengHei',
|
||
'Heiti TC': 'Microsoft JhengHei',
|
||
'Kaiti TC': 'DFKai-SB',
|
||
'Hiragino Sans': 'Microsoft YaHei',
|
||
'Hiragino Sans GB': 'Microsoft YaHei',
|
||
'Hiragino Mincho ProN': 'SimSun',
|
||
'STHeiti': 'SimHei',
|
||
'STSong': 'SimSun',
|
||
'STKaiti': 'KaiTi',
|
||
'STFangsong': 'FangSong',
|
||
'STXihei': 'Microsoft YaHei',
|
||
'STZhongsong': 'SimSun',
|
||
'Songti SC': 'SimSun',
|
||
'Songti TC': 'PMingLiU',
|
||
'Noto Sans SC': 'Microsoft YaHei',
|
||
'Noto Sans CJK SC': 'Microsoft YaHei',
|
||
'Noto Sans TC': 'Microsoft JhengHei',
|
||
'Noto Serif SC': 'SimSun',
|
||
'Noto Serif TC': 'PMingLiU',
|
||
# Japanese: keep as-is if user specified (PowerPoint will fallback if uninstalled)
|
||
# 'Noto Sans JP': → keep as 'Noto Sans JP' (do not map)
|
||
# 'メイリオ': → keep as 'メイリオ' (Meiryo alias)
|
||
'メイリオ': 'Meiryo',
|
||
'Source Han Sans SC': 'Microsoft YaHei',
|
||
'Source Han Sans TC': 'Microsoft JhengHei',
|
||
'Source Han Serif SC': 'SimSun',
|
||
'Source Han Serif TC': 'PMingLiU',
|
||
'Source Han Sans JP': 'Noto Sans JP',
|
||
'Source Han Serif JP': 'Noto Serif JP',
|
||
'WenQuanYi Micro Hei': 'Microsoft YaHei',
|
||
'WenQuanYi Zen Hei': 'Microsoft YaHei',
|
||
# Latin fonts (macOS / Linux / Web -> Windows)
|
||
'SF Pro': 'Segoe UI',
|
||
'SF Pro Display': 'Segoe UI',
|
||
'SF Pro Text': 'Segoe UI',
|
||
'SF Mono': 'Consolas',
|
||
'Menlo': 'Consolas',
|
||
'Monaco': 'Consolas',
|
||
'Helvetica Neue': 'Arial',
|
||
'Helvetica': 'Arial',
|
||
'Roboto': 'Segoe UI',
|
||
'Ubuntu': 'Segoe UI',
|
||
'Liberation Sans': 'Arial',
|
||
'Liberation Serif': 'Times New Roman',
|
||
'Liberation Mono': 'Consolas',
|
||
'DejaVu Sans': 'Segoe UI',
|
||
'DejaVu Serif': 'Times New Roman',
|
||
'DejaVu Sans Mono': 'Consolas',
|
||
}
|
||
|
||
GENERIC_FONT_MAP = {
|
||
'monospace': 'Consolas',
|
||
'sans-serif': 'Segoe UI',
|
||
'serif': 'Times New Roman',
|
||
}
|
||
|
||
# When the latin font is serif and no EA font is specified,
|
||
# prefer SimSun (serif CJK) over Microsoft YaHei (sans-serif CJK).
|
||
_SERIF_LATIN = {
|
||
'Times New Roman', 'Georgia', 'Garamond', 'Palatino', 'Palatino Linotype',
|
||
'Book Antiqua', 'Cambria', 'SimSun', 'Liberation Serif', 'DejaVu Serif',
|
||
}
|
||
|
||
# Common Office/OS faces accepted without a custom-font warning on their
|
||
# corresponding target locale. Actual playback availability remains
|
||
# target-specific; keep these examples aligned with strategist.md §g.
|
||
PPT_SAFE_FONTS = frozenset({
|
||
'microsoft yahei', 'simhei', 'simsun', 'kaiti', 'fangsong',
|
||
'dengxian',
|
||
'microsoft jhenghei', 'microsoft jhenghei ui', 'pmingliu', 'mingliu',
|
||
'mingliu_hkscs', 'dfkai-sb',
|
||
'pingfang sc', 'heiti sc', 'songti sc', 'stsong',
|
||
'pingfang tc', 'pingfang hk', 'heiti tc', 'songti tc', 'kaiti tc',
|
||
'yu gothic', 'yu gothic ui', 'yu mincho',
|
||
'meiryo', 'meiryo ui',
|
||
'ms gothic', 'ms mincho', 'ms pgothic', 'ms pmincho', 'ms ui gothic',
|
||
'malgun gothic', 'gulim', 'dotum', 'batang',
|
||
'arial', 'arial black', 'calibri', 'segoe ui', 'verdana',
|
||
'helvetica', 'helvetica neue', 'tahoma', 'trebuchet ms',
|
||
'times new roman', 'times', 'georgia', 'cambria', 'palatino',
|
||
'garamond', 'book antiqua',
|
||
'consolas', 'courier new', 'menlo', 'monaco',
|
||
'impact',
|
||
})
|
||
|
||
# Parsed SVG stroke-dasharray values -> DrawingML prstDash
|
||
DASH_PRESETS = {
|
||
(4.0, 4.0): 'dash',
|
||
(6.0, 3.0): 'dash',
|
||
(2.0, 2.0): 'sysDot',
|
||
(8.0, 4.0): 'lgDash',
|
||
(8.0, 4.0, 2.0, 4.0): 'lgDashDot',
|
||
}
|
||
PROJECT_STROKE_ENUM_VALUES = {
|
||
'stroke-linecap': frozenset({'butt', 'round', 'square'}),
|
||
'stroke-linejoin': frozenset({'bevel', 'miter', 'round'}),
|
||
'vector-effect': frozenset({'none', 'non-scaling-stroke'}),
|
||
}
|
||
PROJECT_IMAGE_ASPECT_RATIO_ANCHORS = {
|
||
'xMinYMin': (0.0, 0.0),
|
||
'xMidYMin': (0.5, 0.0),
|
||
'xMaxYMin': (1.0, 0.0),
|
||
'xMinYMid': (0.0, 0.5),
|
||
'xMidYMid': (0.5, 0.5),
|
||
'xMaxYMid': (1.0, 0.5),
|
||
'xMinYMax': (0.0, 1.0),
|
||
'xMidYMax': (0.5, 1.0),
|
||
'xMaxYMax': (1.0, 1.0),
|
||
}
|
||
PROJECT_IMAGE_ASPECT_RATIO_MODES = frozenset({'meet', 'slice'})
|
||
PROJECT_OPACITY_PROPERTIES = (
|
||
'opacity',
|
||
'fill-opacity',
|
||
'stroke-opacity',
|
||
'stop-opacity',
|
||
'flood-opacity',
|
||
)
|
||
PROJECT_PERCENTAGE_OPACITY_PROPERTIES = frozenset({
|
||
'stop-opacity',
|
||
'flood-opacity',
|
||
})
|
||
PROJECT_PAINT_PROPERTIES = (
|
||
'fill',
|
||
'stroke',
|
||
'stop-color',
|
||
'flood-color',
|
||
'data-pptx-fg',
|
||
'data-pptx-bg',
|
||
)
|
||
PROJECT_REFERENCE_PAINT_PROPERTIES = frozenset({'fill', 'stroke'})
|
||
PROJECT_DEFINITION_TAGS = frozenset({
|
||
'clipPath',
|
||
'filter',
|
||
'linearGradient',
|
||
'marker',
|
||
'pattern',
|
||
'radialGradient',
|
||
})
|
||
PROJECT_GRADIENT_TAGS = frozenset({'linearGradient', 'radialGradient'})
|
||
PROJECT_TEXT_IMAGE_FILL_ATTR = 'data-pptx-text-image-fill'
|
||
PROJECT_TEXT_IMAGE_FILL_MODES = frozenset({'stretch', 'tile'})
|
||
# PPTX angle projection can overshoot a unit box by at most ~0.1036.
|
||
PROJECT_LINEAR_GRADIENT_COORDINATE_MIN = -0.105
|
||
PROJECT_LINEAR_GRADIENT_COORDINATE_MAX = 1.105
|
||
PROJECT_RADIAL_FOCUS_TOLERANCE = 0.00001
|
||
PROJECT_FILTER_PRIMITIVES = frozenset({
|
||
'feDropShadow',
|
||
'feGaussianBlur',
|
||
'feOffset',
|
||
'feFlood',
|
||
'feComposite',
|
||
'feMerge',
|
||
'feMergeNode',
|
||
'feComponentTransfer',
|
||
'feFuncA',
|
||
})
|
||
PROJECT_FILTER_EFFECT_PRIMITIVES = frozenset({
|
||
'feDropShadow',
|
||
'feGaussianBlur',
|
||
})
|
||
PROJECT_FILTER_PUBLIC_TARGETS = frozenset({
|
||
'rect',
|
||
'circle',
|
||
'image',
|
||
'path',
|
||
'text',
|
||
})
|
||
_PROJECT_MARKER_NUMBER_TOKEN = (
|
||
r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?'
|
||
)
|
||
_PROJECT_MARKER_POINT_TOKEN = (
|
||
rf'{_PROJECT_MARKER_NUMBER_TOKEN}'
|
||
rf'(?:\s*,\s*|\s+){_PROJECT_MARKER_NUMBER_TOKEN}'
|
||
)
|
||
_PROJECT_MARKER_TRIANGLE_PATH_RE = re.compile(
|
||
rf'^\s*M\s*{_PROJECT_MARKER_POINT_TOKEN}'
|
||
rf'(?:\s*L\s*{_PROJECT_MARKER_POINT_TOKEN}){{2}}\s*Z\s*$',
|
||
re.IGNORECASE,
|
||
)
|
||
_PROJECT_MARKER_DIAMOND_PATH_RE = re.compile(
|
||
rf'^\s*M\s*{_PROJECT_MARKER_POINT_TOKEN}'
|
||
rf'(?:\s*L\s*{_PROJECT_MARKER_POINT_TOKEN}){{3}}\s*Z\s*$',
|
||
re.IGNORECASE,
|
||
)
|
||
_PROJECT_MARKER_ARROW_PATH_RE = re.compile(
|
||
rf'^\s*M\s*{_PROJECT_MARKER_POINT_TOKEN}'
|
||
rf'(?:\s*L\s*{_PROJECT_MARKER_POINT_TOKEN}){{2}}\s*$',
|
||
re.IGNORECASE,
|
||
)
|
||
_PROJECT_MARKER_COMMAND_POINT_RE = re.compile(
|
||
rf'[ML]\s*({_PROJECT_MARKER_NUMBER_TOKEN})'
|
||
rf'(?:\s*,\s*|\s+)({_PROJECT_MARKER_NUMBER_TOKEN})',
|
||
re.IGNORECASE,
|
||
)
|
||
PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS = frozenset({
|
||
'defs',
|
||
'desc',
|
||
'metadata',
|
||
'style',
|
||
'title',
|
||
})
|
||
THICK_CIRCLE_COVERAGE_TOLERANCE = 1.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Coordinate helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def px_to_emu(px: float) -> int:
|
||
"""Convert SVG pixels to EMU."""
|
||
return round(px * EMU_PER_PX)
|
||
|
||
|
||
def font_px_to_hpt(font_size_px: float) -> int:
|
||
"""Convert one legal SVG font size to DrawingML hundredths-of-a-point."""
|
||
try:
|
||
px = float(font_size_px)
|
||
except (TypeError, ValueError, OverflowError) as exc:
|
||
raise ValueError(
|
||
f"SVG font-size must be numeric, got {font_size_px!r}"
|
||
) from exc
|
||
scaled = px * FONT_PX_TO_HUNDREDTHS_PT
|
||
if not math.isfinite(scaled):
|
||
raise ValueError(f"SVG font-size must be finite, got {font_size_px!r}")
|
||
size = int(round(scaled / 10.0)) * 10
|
||
if not DRAWINGML_TEXT_FONT_SIZE_MIN <= size <= DRAWINGML_TEXT_FONT_SIZE_MAX:
|
||
raise ValueError(
|
||
f"SVG font-size {font_size_px!r}px converts to DrawingML sz={size}; "
|
||
f"expected {DRAWINGML_TEXT_FONT_SIZE_MIN}.."
|
||
f"{DRAWINGML_TEXT_FONT_SIZE_MAX} (1..4000pt)"
|
||
)
|
||
return size
|
||
|
||
|
||
def _f(val: str | None, default: float = 0.0) -> float:
|
||
"""Parse a float attribute value, returning default if missing."""
|
||
if val is None:
|
||
return default
|
||
try:
|
||
return float(val)
|
||
except (ValueError, TypeError):
|
||
return default
|
||
|
||
|
||
_LENGTH_RE = re.compile(r'^\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)\s*([A-Za-z%]*)\s*$')
|
||
_CANONICAL_PROJECT_GEOMETRY_LENGTH_RE = re.compile(
|
||
r'^-?(?:\d+(?:\.\d+)?|\.\d+)$'
|
||
)
|
||
_PROJECT_STROKE_DASH_NUMBER_PATTERN = (
|
||
r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?'
|
||
)
|
||
_PROJECT_STROKE_DASH_NUMBER_RE = re.compile(
|
||
_PROJECT_STROKE_DASH_NUMBER_PATTERN
|
||
)
|
||
_PROJECT_STROKE_DASHARRAY_RE = re.compile(
|
||
rf'\s*{_PROJECT_STROKE_DASH_NUMBER_PATTERN}'
|
||
rf'(?:(?:\s+|\s*,\s*){_PROJECT_STROKE_DASH_NUMBER_PATTERN})+\s*'
|
||
)
|
||
PROJECT_GEOMETRY_LENGTH_ATTRIBUTES = {
|
||
'svg': frozenset({'x', 'y', 'width', 'height'}),
|
||
'rect': frozenset({'x', 'y', 'width', 'height', 'rx', 'ry'}),
|
||
'circle': frozenset({'cx', 'cy', 'r'}),
|
||
'ellipse': frozenset({'cx', 'cy', 'rx', 'ry'}),
|
||
'line': frozenset({'x1', 'y1', 'x2', 'y2'}),
|
||
'text': frozenset({'x', 'y'}),
|
||
'tspan': frozenset({'x', 'y', 'dx', 'dy'}),
|
||
'image': frozenset({'x', 'y', 'width', 'height'}),
|
||
'use': frozenset({'x', 'y', 'width', 'height'}),
|
||
}
|
||
PROJECT_NON_NEGATIVE_LENGTH_ATTRIBUTES = frozenset({
|
||
'width', 'height', 'r', 'rx', 'ry', 'stroke-width',
|
||
})
|
||
|
||
|
||
def _parse_svg_length_parts(val: str) -> tuple[float, str]:
|
||
"""Parse one finite SVG length into its numeric part and lowercase unit."""
|
||
match = _LENGTH_RE.match(str(val))
|
||
if not match:
|
||
raise ValueError(f'SVG length must be one finite literal, got {val!r}')
|
||
number = float(match.group(1))
|
||
if not math.isfinite(number):
|
||
raise ValueError(f'SVG length must be finite, got {val!r}')
|
||
return number, match.group(2).lower()
|
||
|
||
|
||
def parse_svg_length(
|
||
val: str | None,
|
||
default: float = 0.0,
|
||
*,
|
||
percent_base: float | None = None,
|
||
font_size: float = 16.0,
|
||
) -> float:
|
||
"""Parse SVG/CSS length values into SVG px.
|
||
|
||
Unitless and ``px`` values are already SVG px. Percentages need a caller
|
||
supplied reference length because SVG uses different bases for x, y,
|
||
width, height, and radii.
|
||
|
||
A default applies only when the attribute is absent. Present but malformed,
|
||
non-finite, unsupported, or context-free percentage values fail closed.
|
||
"""
|
||
if val is None:
|
||
return default
|
||
number, unit = _parse_svg_length_parts(str(val))
|
||
if unit == '%':
|
||
if percent_base is None:
|
||
raise ValueError(
|
||
f'SVG percentage length requires a reference length, got {val!r}'
|
||
)
|
||
return percent_base * number / 100.0
|
||
if unit in ('', 'px'):
|
||
return number
|
||
if unit == 'pt':
|
||
return number * 96.0 / 72.0
|
||
if unit in ('pc', 'pica'):
|
||
return number * 16.0
|
||
if unit == 'in':
|
||
return number * 96.0
|
||
if unit == 'cm':
|
||
return number * 96.0 / 2.54
|
||
if unit == 'mm':
|
||
return number * 96.0 / 25.4
|
||
if unit == 'q':
|
||
return number * 96.0 / 101.6
|
||
if unit in ('em', 'rem'):
|
||
if not math.isfinite(font_size):
|
||
raise ValueError(
|
||
f'SVG relative length requires a finite font size, got {val!r}'
|
||
)
|
||
return number * font_size
|
||
raise ValueError(f'Unsupported SVG length unit {unit!r} in {val!r}')
|
||
|
||
|
||
def parse_project_geometry_length(raw: str, attribute: str) -> float:
|
||
"""Parse one project geometry value without widening the authoring surface."""
|
||
number, unit = _parse_svg_length_parts(raw)
|
||
if unit not in {'', 'px'}:
|
||
raise ValueError(
|
||
f'uses unsupported unit {unit!r}; project geometry accepts only '
|
||
'unitless values or the compatible px suffix'
|
||
)
|
||
numeric_literal = raw.strip()
|
||
if unit == 'px':
|
||
numeric_literal = numeric_literal[:-2].strip()
|
||
if not _CANONICAL_PROJECT_GEOMETRY_LENGTH_RE.fullmatch(numeric_literal):
|
||
raise ValueError(
|
||
'uses an unsupported numeric spelling; use an ordinary decimal '
|
||
'without a leading plus sign, exponent, or trailing decimal point'
|
||
)
|
||
if attribute in PROJECT_NON_NEGATIVE_LENGTH_ATTRIBUTES and number < 0:
|
||
raise ValueError('must be non-negative')
|
||
return number
|
||
|
||
|
||
def is_canonical_project_geometry_length(raw: str) -> bool:
|
||
"""Return whether a project geometry value uses the generated-SVG spelling."""
|
||
return bool(_CANONICAL_PROJECT_GEOMETRY_LENGTH_RE.fullmatch(raw.strip()))
|
||
|
||
|
||
def format_project_geometry_length(value: float) -> str:
|
||
"""Format a parsed project geometry value as a plain unitless decimal."""
|
||
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_opacity(
|
||
raw: str,
|
||
*,
|
||
allow_percentage: bool = False,
|
||
) -> float:
|
||
"""Parse and clamp one opacity value from the closed project grammar."""
|
||
try:
|
||
number, unit = _parse_svg_length_parts(raw)
|
||
except ValueError as exc:
|
||
raise ValueError('must be one finite numeric opacity') from exc
|
||
|
||
if unit == '%':
|
||
if not allow_percentage:
|
||
raise ValueError('must be unitless; percentages are not supported')
|
||
number /= 100.0
|
||
elif unit:
|
||
raise ValueError(f'uses unsupported unit {unit!r}')
|
||
return max(0.0, min(1.0, number))
|
||
|
||
|
||
def is_project_opacity_default_form(raw: str) -> bool:
|
||
"""Return whether opacity uses the generated finite unitless ``0..1`` form."""
|
||
try:
|
||
number, unit = _parse_svg_length_parts(raw)
|
||
except ValueError:
|
||
return False
|
||
return unit == '' and 0.0 <= number <= 1.0
|
||
|
||
|
||
def format_project_opacity(value: float) -> str:
|
||
"""Format one parsed opacity as a compact unitless ``0..1`` value."""
|
||
bounded = max(0.0, min(1.0, value))
|
||
return f'{bounded:.6f}'.rstrip('0').rstrip('.') or '0'
|
||
|
||
|
||
def parse_project_image_aspect_ratio(raw: str | None) -> tuple[str, str]:
|
||
"""Parse the closed project ``<image>`` aspect-ratio grammar."""
|
||
if raw is None:
|
||
return 'xMidYMid', 'meet'
|
||
|
||
text = raw.strip()
|
||
if not text:
|
||
raise ValueError('must not be empty; omit the attribute for the default')
|
||
|
||
parts = text.split()
|
||
align = parts[0]
|
||
if align == 'none':
|
||
if len(parts) != 1:
|
||
raise ValueError('value "none" must appear alone')
|
||
return align, 'meet'
|
||
|
||
if align not in PROJECT_IMAGE_ASPECT_RATIO_ANCHORS:
|
||
choices = ', '.join(PROJECT_IMAGE_ASPECT_RATIO_ANCHORS)
|
||
raise ValueError(
|
||
f'alignment must be "none" or one of: {choices}'
|
||
)
|
||
if len(parts) > 2:
|
||
raise ValueError('accepts at most one alignment and one mode token')
|
||
|
||
mode = parts[1] if len(parts) == 2 else 'meet'
|
||
if mode not in PROJECT_IMAGE_ASPECT_RATIO_MODES:
|
||
choices = ', '.join(sorted(PROJECT_IMAGE_ASPECT_RATIO_MODES))
|
||
raise ValueError(f'mode must be one of: {choices}')
|
||
return align, mode
|
||
|
||
|
||
def format_project_image_aspect_ratio(align: str, mode: str) -> str:
|
||
"""Format one parsed image aspect ratio for generated project SVG."""
|
||
if align == 'none':
|
||
return 'none'
|
||
return f'{align} {mode}'
|
||
|
||
|
||
def _parse_project_stroke_dasharray(
|
||
raw: str,
|
||
*,
|
||
allow_zero_gap: bool = False,
|
||
) -> tuple[str | None, tuple[float, ...], tuple[str, ...]] | None:
|
||
"""Parse one project dash array without accepting general SVG lengths."""
|
||
text = raw.strip()
|
||
if text == 'none':
|
||
return None
|
||
if not _PROJECT_STROKE_DASHARRAY_RE.fullmatch(text):
|
||
raise ValueError(
|
||
'must be "none" or at least two finite unitless numbers separated '
|
||
'by spaces or single commas'
|
||
)
|
||
tokens = tuple(_PROJECT_STROKE_DASH_NUMBER_RE.findall(text))
|
||
values = tuple(float(token) for token in tokens)
|
||
if not all(math.isfinite(value) for value in values):
|
||
raise ValueError('must contain only finite numbers')
|
||
if values[0] <= 0:
|
||
raise ValueError('dash length must be positive')
|
||
if allow_zero_gap:
|
||
if values[1] < 0:
|
||
raise ValueError('dash gap must be non-negative')
|
||
elif values[1] <= 0:
|
||
raise ValueError('dash gap must be positive')
|
||
if any(value <= 0 for value in values[2:]):
|
||
raise ValueError('additional dash and gap values must be positive')
|
||
return DASH_PRESETS.get(values), values, tokens
|
||
|
||
|
||
def parse_project_stroke_dasharray(
|
||
raw: str,
|
||
*,
|
||
allow_zero_gap: bool = False,
|
||
) -> tuple[str | None, tuple[float, ...]] | None:
|
||
"""Return the registered preset and numeric values for one dash array."""
|
||
parsed = _parse_project_stroke_dasharray(
|
||
raw,
|
||
allow_zero_gap=allow_zero_gap,
|
||
)
|
||
if parsed is None:
|
||
return None
|
||
preset, values, _tokens = parsed
|
||
return preset, values
|
||
|
||
|
||
def noncanonical_stroke_dash_numbers(raw: str) -> tuple[str, ...]:
|
||
"""Return compatible dash numbers outside the generated-SVG spelling."""
|
||
parsed = _parse_project_stroke_dasharray(raw, allow_zero_gap=True)
|
||
if parsed is None:
|
||
return ()
|
||
_preset, _values, tokens = parsed
|
||
return tuple(
|
||
token
|
||
for token in tokens
|
||
if not _CANONICAL_PROJECT_GEOMETRY_LENGTH_RE.fullmatch(token)
|
||
)
|
||
|
||
|
||
def parse_project_stroke_enum(attribute: str, raw: str) -> str:
|
||
"""Parse one closed line-presentation enumeration."""
|
||
allowed = PROJECT_STROKE_ENUM_VALUES.get(attribute)
|
||
if allowed is None:
|
||
raise ValueError(f'has no registered project enumeration for {attribute!r}')
|
||
value = raw.strip()
|
||
if value not in allowed:
|
||
choices = ', '.join(sorted(allowed))
|
||
raise ValueError(f'must be one of: {choices}')
|
||
return value
|
||
|
||
|
||
def is_thick_circle_shorthand(
|
||
dasharray: str | None,
|
||
stroke: str | None,
|
||
fill: str | None,
|
||
stroke_width: float,
|
||
radius: float,
|
||
) -> bool:
|
||
"""Return whether one circle uses the converter's thick-arc shorthand."""
|
||
if (
|
||
not dasharray
|
||
or not stroke
|
||
or stroke.strip().lower() in {'none', 'transparent'}
|
||
):
|
||
return False
|
||
if not fill or fill.strip().lower() != 'none':
|
||
return False
|
||
if stroke_width <= 0 or radius <= 0 or stroke_width >= 2 * radius:
|
||
return False
|
||
if stroke_width / radius < 0.15:
|
||
return False
|
||
try:
|
||
parsed = parse_project_stroke_dasharray(
|
||
dasharray,
|
||
allow_zero_gap=True,
|
||
)
|
||
except ValueError:
|
||
return False
|
||
if parsed is None:
|
||
return False
|
||
preset, values = parsed
|
||
if preset is not None or len(values) != 2:
|
||
return False
|
||
dash, gap = values
|
||
circumference = 2 * math.pi * radius
|
||
return (
|
||
dash < circumference
|
||
and dash + gap + THICK_CIRCLE_COVERAGE_TOLERANCE >= circumference
|
||
)
|
||
|
||
|
||
def svg_length_x(val: str | None, ctx: ConvertContext, default: float = 0.0) -> float:
|
||
return parse_svg_length(val, default, percent_base=ctx.viewport_width)
|
||
|
||
|
||
def svg_length_y(val: str | None, ctx: ConvertContext, default: float = 0.0) -> float:
|
||
return parse_svg_length(val, default, percent_base=ctx.viewport_height)
|
||
|
||
|
||
def svg_length_size(val: str | None, ctx: ConvertContext, default: float = 0.0) -> float:
|
||
base = min(ctx.viewport_width, ctx.viewport_height)
|
||
return parse_svg_length(val, default, percent_base=base)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SVG transform matrix helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_TRANSFORM_NUMBER_PATTERN = (
|
||
r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?'
|
||
)
|
||
_TRANSFORM_NUMBER_RE = re.compile(_TRANSFORM_NUMBER_PATTERN)
|
||
_CANONICAL_TRANSFORM_NUMBER_RE = re.compile(
|
||
r'-?(?:\d+(?:\.\d+)?|\.\d+)$'
|
||
)
|
||
_TRANSFORM_OPERATION_RE = re.compile(r'([A-Za-z]+)\(([^()]*)\)')
|
||
_TRANSFORM_WHITESPACE_RE = re.compile(r'[ \t\r\n]*')
|
||
_TRANSFORM_SEPARATOR_PATTERN = (
|
||
r'(?:[ \t\r\n]+|[ \t\r\n]*,[ \t\r\n]*)'
|
||
)
|
||
_TRANSFORM_SEPARATOR_RE = re.compile(_TRANSFORM_SEPARATOR_PATTERN)
|
||
_TRANSFORM_ARGUMENTS_RE = re.compile(
|
||
rf'[ \t\r\n]*{_TRANSFORM_NUMBER_PATTERN}'
|
||
rf'(?:{_TRANSFORM_SEPARATOR_PATTERN}{_TRANSFORM_NUMBER_PATTERN})*'
|
||
r'[ \t\r\n]*'
|
||
)
|
||
_TRANSFORM_ARITIES = {
|
||
'matrix': frozenset({6}),
|
||
'translate': frozenset({1, 2}),
|
||
'scale': frozenset({1, 2}),
|
||
'rotate': frozenset({1, 3}),
|
||
}
|
||
_FULL_TRANSFORM_TAGS = frozenset({
|
||
'rect', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline',
|
||
'image',
|
||
})
|
||
_TRANSFORM_CONTAINER_TAGS = frozenset({'g', 'use'})
|
||
_TRANSFORM_DEFINITION_BOUNDARIES = frozenset({'clipPath', 'marker', 'pattern'})
|
||
_NON_VISUAL_TRANSFORM_CHILD_TAGS = frozenset({
|
||
'defs', 'title', 'desc', 'metadata', 'style',
|
||
})
|
||
|
||
|
||
def matrix_multiply(left: AffineMatrix, right: AffineMatrix) -> AffineMatrix:
|
||
"""Compose two SVG affine matrices, applying ``right`` before ``left``."""
|
||
a1, b1, c1, d1, e1, f1 = left
|
||
a2, b2, c2, d2, e2, f2 = right
|
||
return (
|
||
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,
|
||
)
|
||
|
||
|
||
def _translate_matrix(tx: float, ty: float = 0.0) -> AffineMatrix:
|
||
return (1.0, 0.0, 0.0, 1.0, tx, ty)
|
||
|
||
|
||
def _scale_matrix(sx: float, sy: float | None = None) -> AffineMatrix:
|
||
return (sx, 0.0, 0.0, sx if sy is None else sy, 0.0, 0.0)
|
||
|
||
|
||
def _rotate_matrix(angle_deg: float, cx: float | None = None, cy: float | None = None) -> AffineMatrix:
|
||
rad = math.radians(angle_deg)
|
||
cos_a = math.cos(rad)
|
||
sin_a = math.sin(rad)
|
||
rot = (cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0)
|
||
if cx is None or cy is None:
|
||
return rot
|
||
return matrix_multiply(
|
||
matrix_multiply(_translate_matrix(cx, cy), rot),
|
||
_translate_matrix(-cx, -cy),
|
||
)
|
||
|
||
|
||
def _parse_transform_operations(
|
||
transform_str: str,
|
||
) -> tuple[
|
||
tuple[tuple[str, tuple[float, ...]], ...],
|
||
tuple[str, ...],
|
||
]:
|
||
"""Parse a complete project transform list and retain numeric tokens."""
|
||
if not transform_str:
|
||
return (), ()
|
||
|
||
operations: list[tuple[str, tuple[float, ...]]] = []
|
||
number_tokens: list[str] = []
|
||
cursor = 0
|
||
matches = list(_TRANSFORM_OPERATION_RE.finditer(transform_str))
|
||
if not matches:
|
||
if _TRANSFORM_WHITESPACE_RE.fullmatch(transform_str):
|
||
raise ValueError('SVG transform must not be empty')
|
||
raise ValueError(f'Invalid SVG transform syntax {transform_str!r}')
|
||
|
||
for index, match in enumerate(matches):
|
||
gap = transform_str[cursor:match.start()]
|
||
gap_pattern = (
|
||
_TRANSFORM_WHITESPACE_RE
|
||
if index == 0 else _TRANSFORM_SEPARATOR_RE
|
||
)
|
||
if gap_pattern.fullmatch(gap) is None:
|
||
if index > 0 and not gap:
|
||
raise ValueError(
|
||
f'SVG transform operations require a separator at '
|
||
f'offset {cursor}'
|
||
)
|
||
raise ValueError(
|
||
f'Invalid SVG transform syntax at offset {cursor}: '
|
||
f'{gap!r}'
|
||
)
|
||
name, raw_args = match.groups()
|
||
if name not in _TRANSFORM_ARITIES:
|
||
raise ValueError(
|
||
f'Unsupported SVG transform operation {name!r}; use lowercase '
|
||
'matrix, translate, scale, or rotate'
|
||
)
|
||
if raw_args.strip() and _TRANSFORM_ARGUMENTS_RE.fullmatch(raw_args) is None:
|
||
raise ValueError(
|
||
f'Invalid arguments for SVG transform {name!r}: {raw_args!r}'
|
||
)
|
||
tokens = tuple(_TRANSFORM_NUMBER_RE.findall(raw_args))
|
||
values = tuple(float(token) for token in tokens)
|
||
if not all(math.isfinite(value) for value in values):
|
||
raise ValueError(f'Non-finite arguments for SVG transform {name!r}')
|
||
if len(values) not in _TRANSFORM_ARITIES[name]:
|
||
expected = '/'.join(str(value) for value in sorted(_TRANSFORM_ARITIES[name]))
|
||
raise ValueError(
|
||
f'SVG transform {name!r} has {len(values)} argument(s); '
|
||
f'expected {expected}'
|
||
)
|
||
operations.append((name, values))
|
||
number_tokens.extend(tokens)
|
||
cursor = match.end()
|
||
|
||
trailing = transform_str[cursor:]
|
||
if _TRANSFORM_WHITESPACE_RE.fullmatch(trailing) is None:
|
||
raise ValueError(
|
||
f'Invalid SVG transform trailing syntax at offset {cursor}: '
|
||
f'{trailing!r}'
|
||
)
|
||
|
||
return tuple(operations), tuple(number_tokens)
|
||
|
||
|
||
def parse_transform_operations(
|
||
transform_str: str,
|
||
) -> tuple[tuple[str, tuple[float, ...]], ...]:
|
||
"""Parse one complete supported SVG transform list."""
|
||
operations, _ = _parse_transform_operations(transform_str)
|
||
return operations
|
||
|
||
|
||
def noncanonical_transform_numbers(transform_str: str) -> tuple[str, ...]:
|
||
"""Return compatible transform numbers generated SVG should normalize."""
|
||
_, tokens = _parse_transform_operations(transform_str)
|
||
return tuple(
|
||
token
|
||
for token in tokens
|
||
if _CANONICAL_TRANSFORM_NUMBER_RE.fullmatch(token) is None
|
||
)
|
||
|
||
|
||
def _transform_operations_matrix(
|
||
operations: tuple[tuple[str, tuple[float, ...]], ...],
|
||
) -> AffineMatrix:
|
||
matrix = IDENTITY_MATRIX
|
||
for name, args in operations:
|
||
if name == 'matrix':
|
||
local = (args[0], args[1], args[2], args[3], args[4], args[5])
|
||
elif name == 'translate':
|
||
local = _translate_matrix(
|
||
args[0],
|
||
args[1] if len(args) > 1 else 0.0,
|
||
)
|
||
elif name == 'scale':
|
||
local = _scale_matrix(
|
||
args[0],
|
||
args[1] if len(args) > 1 else None,
|
||
)
|
||
else:
|
||
local = _rotate_matrix(
|
||
args[0],
|
||
args[1] if len(args) > 2 else None,
|
||
args[2] if len(args) > 2 else None,
|
||
)
|
||
matrix = matrix_multiply(matrix, local)
|
||
return matrix
|
||
|
||
|
||
def parse_transform_matrix(transform_str: str) -> AffineMatrix:
|
||
"""Parse a complete SVG transform list into one affine matrix.
|
||
|
||
Unsupported or malformed operations fail closed. Treating an unknown
|
||
operation as the identity would silently discard a visible SVG edit.
|
||
"""
|
||
if not transform_str:
|
||
return IDENTITY_MATRIX
|
||
return _transform_operations_matrix(parse_transform_operations(transform_str))
|
||
|
||
|
||
def transform_point(matrix: AffineMatrix, x: float, y: float) -> tuple[float, float]:
|
||
"""Apply an SVG affine matrix to a point."""
|
||
a, b, c, d, e, f = matrix
|
||
return a * x + c * y + e, b * x + d * y + f
|
||
|
||
|
||
def validate_dml_shape_matrix(matrix: AffineMatrix) -> None:
|
||
"""Reject affine shear that a DrawingML shape transform cannot express."""
|
||
if not all(math.isfinite(value) for value in matrix):
|
||
raise ValueError('SVG transform produces non-finite matrix values')
|
||
a, b, c, d, _e, _f = matrix
|
||
x_length = math.hypot(a, b)
|
||
y_length = math.hypot(c, d)
|
||
if not math.isfinite(x_length) or not math.isfinite(y_length):
|
||
raise ValueError('SVG transform produces non-finite axis lengths')
|
||
if x_length <= 1e-12 or y_length <= 1e-12:
|
||
raise ValueError(
|
||
'SVG zero-scale transform cannot be represented by a visible '
|
||
'DrawingML shape'
|
||
)
|
||
normalized_dot = (
|
||
(a / x_length) * (c / y_length)
|
||
+ (b / x_length) * (d / y_length)
|
||
)
|
||
if not math.isfinite(normalized_dot) or abs(normalized_dot) > 1e-9:
|
||
raise ValueError(
|
||
'SVG shear/skew cannot be represented by a DrawingML '
|
||
'shape transform'
|
||
)
|
||
|
||
|
||
def _svg_element_tag(elem: ET.Element) -> str | None:
|
||
raw_tag = str(elem.tag)
|
||
if raw_tag.startswith('{'):
|
||
namespace, tag = raw_tag[1:].split('}', 1)
|
||
return tag if namespace == SVG_NS else None
|
||
return raw_tag
|
||
|
||
|
||
def _transform_element_label(elem: ET.Element) -> str:
|
||
tag = _svg_element_tag(elem) or str(elem.tag)
|
||
elem_id = elem.get('id')
|
||
return f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>'
|
||
|
||
|
||
def _visual_transform_children(elem: ET.Element) -> list[ET.Element]:
|
||
return [
|
||
child
|
||
for child in elem
|
||
if _svg_element_tag(child) not in _NON_VISUAL_TRANSFORM_CHILD_TAGS
|
||
]
|
||
|
||
|
||
def _iter_visual_transform_tree(elem: ET.Element) -> Iterator[ET.Element]:
|
||
"""Yield one rendered subtree while excluding definition/metadata branches."""
|
||
yield elem
|
||
for child in _visual_transform_children(elem):
|
||
yield from _iter_visual_transform_tree(child)
|
||
|
||
|
||
_TRANSFORM_ARC_STYLE_ATTRS = (
|
||
'fill',
|
||
'stroke',
|
||
'stroke-width',
|
||
'stroke-dasharray',
|
||
)
|
||
|
||
|
||
def _transform_arc_styles(
|
||
elem: ET.Element,
|
||
inherited: dict[str, str] | None = None,
|
||
) -> dict[str, str]:
|
||
values = dict(inherited or {})
|
||
inline_style = parse_inline_style(elem.get('style'))
|
||
for name in _TRANSFORM_ARC_STYLE_ATTRS:
|
||
direct = elem.get(name)
|
||
if direct is not None:
|
||
values[name] = direct
|
||
if name in inline_style:
|
||
values[name] = inline_style[name]
|
||
return values
|
||
|
||
|
||
def _is_project_thick_circle(
|
||
elem: ET.Element,
|
||
arc_styles: dict[str, str],
|
||
) -> bool:
|
||
if _svg_element_tag(elem) != 'circle':
|
||
return False
|
||
try:
|
||
radius = parse_project_geometry_length(elem.get('r') or '0', 'r')
|
||
stroke_width = parse_project_geometry_length(
|
||
arc_styles.get('stroke-width', '0'),
|
||
'stroke-width',
|
||
)
|
||
except ValueError:
|
||
# Geometry preflight owns malformed length diagnostics.
|
||
return False
|
||
return is_thick_circle_shorthand(
|
||
arc_styles.get('stroke-dasharray'),
|
||
arc_styles.get('stroke'),
|
||
arc_styles.get('fill'),
|
||
stroke_width,
|
||
radius,
|
||
)
|
||
|
||
|
||
def supports_full_project_transform(
|
||
elem: ET.Element,
|
||
inherited_arc_styles: dict[str, str] | None = None,
|
||
) -> bool:
|
||
"""Return whether one subtree can consume an affine matrix without text loss."""
|
||
tag = _svg_element_tag(elem)
|
||
arc_styles = _transform_arc_styles(elem, inherited_arc_styles)
|
||
if _is_project_thick_circle(elem, arc_styles):
|
||
# Thick-circle arcs consume scalar context plus one local rotation;
|
||
# treating an ancestor as a full matrix would silently drop it.
|
||
return False
|
||
if tag in _FULL_TRANSFORM_TAGS:
|
||
return True
|
||
if tag == 'use':
|
||
# Local/data-icon use references are validated again after expansion.
|
||
return True
|
||
if tag == 'svg':
|
||
children = _visual_transform_children(elem)
|
||
return len(children) == 1 and _svg_element_tag(children[0]) == 'image'
|
||
if tag == 'g':
|
||
children = _visual_transform_children(elem)
|
||
return bool(children) and all(
|
||
supports_full_project_transform(child, arc_styles)
|
||
for child in children
|
||
)
|
||
return False
|
||
|
||
|
||
def iter_project_transforms(
|
||
root: ET.Element,
|
||
) -> Iterator[tuple[ET.Element, str]]:
|
||
"""Yield explicit SVG transform attributes from the project surface."""
|
||
for elem in root.iter():
|
||
if _svg_element_tag(elem) is None:
|
||
continue
|
||
raw = elem.get('transform')
|
||
if raw is not None:
|
||
yield elem, raw
|
||
|
||
|
||
def _has_positive_rounding(elem: ET.Element) -> bool:
|
||
for attr in ('rx', 'ry'):
|
||
raw = elem.get(attr)
|
||
if raw is None:
|
||
continue
|
||
try:
|
||
if parse_project_geometry_length(raw, attr) > 0:
|
||
return True
|
||
except ValueError:
|
||
# Geometry preflight owns the malformed length diagnostic.
|
||
continue
|
||
return False
|
||
|
||
|
||
def _contains_rounded_rect(elem: ET.Element) -> bool:
|
||
return any(
|
||
_svg_element_tag(descendant) == 'rect'
|
||
and _has_positive_rounding(descendant)
|
||
for descendant in _iter_visual_transform_tree(elem)
|
||
)
|
||
|
||
|
||
def _contains_native_marker(elem: ET.Element) -> bool:
|
||
# Import lazily to avoid the native-object package's dependency on this
|
||
# shared DrawingML utility module during initialization.
|
||
from ..native_objects.marker_attributes import native_replacement_kind
|
||
|
||
return any(
|
||
native_replacement_kind(descendant) in {'table', 'chart'}
|
||
for descendant in _iter_visual_transform_tree(elem)
|
||
)
|
||
|
||
|
||
def _project_thick_circle_ids(
|
||
root: ET.Element,
|
||
) -> set[int]:
|
||
thick_circle_ids: set[int] = set()
|
||
|
||
def visit(
|
||
elem: ET.Element,
|
||
inherited: dict[str, str] | None = None,
|
||
) -> None:
|
||
arc_styles = _transform_arc_styles(elem, inherited)
|
||
if _is_project_thick_circle(elem, arc_styles):
|
||
thick_circle_ids.add(id(elem))
|
||
for child in elem:
|
||
visit(child, arc_styles)
|
||
|
||
visit(root)
|
||
return thick_circle_ids
|
||
|
||
|
||
_PROJECT_STROKE_STYLE_ATTRIBUTES = (
|
||
'stroke-dasharray',
|
||
'stroke-dashoffset',
|
||
'stroke-linecap',
|
||
'stroke-linejoin',
|
||
'vector-effect',
|
||
)
|
||
|
||
|
||
def iter_project_stroke_styles(
|
||
root: ET.Element,
|
||
) -> Iterator[tuple[ET.Element, str, str, str]]:
|
||
"""Yield project line-style values with their declaration source."""
|
||
for elem in root.iter():
|
||
for attribute in _PROJECT_STROKE_STYLE_ATTRIBUTES:
|
||
raw = elem.get(attribute)
|
||
if raw is not None:
|
||
yield elem, attribute, raw, 'attribute'
|
||
inline_style = parse_inline_style(elem.get('style'))
|
||
for attribute in _PROJECT_STROKE_STYLE_ATTRIBUTES:
|
||
raw = inline_style.get(attribute)
|
||
if raw is not None:
|
||
yield elem, attribute, raw, 'inline style'
|
||
|
||
|
||
def project_stroke_style_errors(root: ET.Element) -> list[str]:
|
||
"""Return blocking line-style grammar and mapping errors for preflight."""
|
||
thick_circle_ids = _project_thick_circle_ids(root)
|
||
errors: set[str] = set()
|
||
for elem, attribute, raw, source in iter_project_stroke_styles(root):
|
||
label = _transform_element_label(elem)
|
||
try:
|
||
if attribute == 'stroke-dasharray':
|
||
parse_project_stroke_dasharray(
|
||
raw,
|
||
allow_zero_gap=id(elem) in thick_circle_ids,
|
||
)
|
||
elif attribute == 'stroke-dashoffset':
|
||
if source != 'attribute':
|
||
raise ValueError(
|
||
'is supported only as a direct attribute on a '
|
||
'thick-circle arc'
|
||
)
|
||
parse_project_geometry_length(raw, attribute)
|
||
if id(elem) not in thick_circle_ids:
|
||
raise ValueError(
|
||
'is supported only on a circle that satisfies the '
|
||
'thick-circle arc contract'
|
||
)
|
||
else:
|
||
parse_project_stroke_enum(attribute, raw)
|
||
except ValueError as exc:
|
||
errors.add(f'{label} {source} {attribute}={raw!r}: {exc}')
|
||
return sorted(errors)
|
||
|
||
|
||
def _contains_thick_circle(elem: ET.Element, thick_circle_ids: set[int]) -> bool:
|
||
return any(
|
||
id(descendant) in thick_circle_ids
|
||
for descendant in _iter_visual_transform_tree(elem)
|
||
)
|
||
|
||
|
||
def _is_unit_axis_reflection(
|
||
operations: tuple[tuple[str, tuple[float, ...]], ...],
|
||
) -> bool:
|
||
"""Return whether a transform is translation plus an unscaled axis flip."""
|
||
has_explicit_flip = any(
|
||
(
|
||
name == 'scale'
|
||
and (
|
||
args[0] < 0
|
||
or (len(args) > 1 and args[1] < 0)
|
||
)
|
||
)
|
||
or (
|
||
name == 'matrix'
|
||
and (args[0] < 0 or args[3] < 0)
|
||
)
|
||
for name, args in operations
|
||
)
|
||
if not has_explicit_flip:
|
||
return False
|
||
matrix = _transform_operations_matrix(operations)
|
||
a, b, c, d, _e, _f = matrix
|
||
return (
|
||
abs(b) <= 1e-9
|
||
and abs(c) <= 1e-9
|
||
and math.isclose(abs(a), 1.0, abs_tol=1e-9)
|
||
and math.isclose(abs(d), 1.0, abs_tol=1e-9)
|
||
and (a < 0 or d < 0)
|
||
)
|
||
|
||
|
||
def _transform_semantic_error(
|
||
elem: ET.Element,
|
||
operations: tuple[tuple[str, tuple[float, ...]], ...],
|
||
*,
|
||
is_root: bool,
|
||
thick_circle_ids: set[int],
|
||
) -> str | None:
|
||
tag = _svg_element_tag(elem)
|
||
names = tuple(name for name, _args in operations)
|
||
label = _transform_element_label(elem)
|
||
|
||
if is_root:
|
||
return (
|
||
'Root <svg> transform is unsupported; apply transforms to child '
|
||
'elements or groups'
|
||
)
|
||
|
||
if tag == 'text':
|
||
if all(name == 'translate' for name in names):
|
||
return None
|
||
if len(names) == 1 and names[0] == 'rotate':
|
||
return None
|
||
return (
|
||
f'{label} text transform must be a translate-only list or one '
|
||
'rotate operation; text scale, matrix, and mixed operations are '
|
||
'not mapped'
|
||
)
|
||
|
||
if tag in _TRANSFORM_CONTAINER_TAGS:
|
||
if _contains_native_marker(elem):
|
||
if all(name in {'translate', 'scale'} for name in names):
|
||
return None
|
||
return (
|
||
f'{label} native table/chart marker transforms support only '
|
||
'translate and scale'
|
||
)
|
||
if _contains_thick_circle(elem, thick_circle_ids):
|
||
if all(name == 'translate' for name in names):
|
||
return None
|
||
return (
|
||
f'{label} contains a thick-circle arc shorthand; ancestor '
|
||
'transforms must be translate-only'
|
||
)
|
||
if _is_unit_axis_reflection(operations):
|
||
# Imported PowerPoint groups encode flipH/flipV as a translate /
|
||
# unit-scale / translate list. The converter distributes that
|
||
# signed unit scale to child geometry and text positions without
|
||
# scaling font metrics, so this exact no-shear case is lossless.
|
||
return None
|
||
if supports_full_project_transform(elem):
|
||
if 'matrix' in names and _contains_rounded_rect(elem):
|
||
return (
|
||
f'{label} matrix transform cannot target a rounded '
|
||
'rectangle subtree'
|
||
)
|
||
return None
|
||
if all(name == 'translate' for name in names):
|
||
return None
|
||
if len(names) == 1 and names[0] == 'rotate':
|
||
return None
|
||
return (
|
||
f'{label} contains text or another non-matrix visual; its transform '
|
||
'must be a translate-only list or one rotate operation'
|
||
)
|
||
|
||
if tag in _FULL_TRANSFORM_TAGS:
|
||
if id(elem) in thick_circle_ids:
|
||
if len(names) == 1 and names[0] == 'rotate':
|
||
return None
|
||
return (
|
||
f'{label} thick-circle arc transform must be one rotate '
|
||
'operation'
|
||
)
|
||
if tag == 'rect' and 'matrix' in names and _has_positive_rounding(elem):
|
||
return f'{label} rounded rectangles cannot use matrix transforms'
|
||
return None
|
||
|
||
if tag == 'svg' and supports_full_project_transform(elem):
|
||
return None
|
||
|
||
return f'{label} has no registered project transform mapping'
|
||
|
||
|
||
def project_transform_errors(root: ET.Element) -> list[str]:
|
||
"""Return blocking transform grammar and mapping errors for preflight."""
|
||
parent_by_id = {
|
||
id(child): parent
|
||
for parent in root.iter()
|
||
for child in list(parent)
|
||
}
|
||
thick_circle_ids = _project_thick_circle_ids(root)
|
||
parsed: dict[int, tuple[AffineMatrix, bool]] = {}
|
||
errors: set[str] = set()
|
||
|
||
for elem, raw in iter_project_transforms(root):
|
||
label = _transform_element_label(elem)
|
||
restricted_ancestor = None
|
||
current = parent_by_id.get(id(elem))
|
||
while current is not None:
|
||
current_tag = _svg_element_tag(current)
|
||
if current_tag in _TRANSFORM_DEFINITION_BOUNDARIES:
|
||
restricted_ancestor = current_tag
|
||
break
|
||
current = parent_by_id.get(id(current))
|
||
if restricted_ancestor is not None:
|
||
errors.add(
|
||
f'{label} cannot use transform inside <{restricted_ancestor}>'
|
||
)
|
||
parsed[id(elem)] = (IDENTITY_MATRIX, False)
|
||
continue
|
||
|
||
try:
|
||
operations = parse_transform_operations(raw)
|
||
if not operations:
|
||
raise ValueError('SVG transform must not be empty')
|
||
matrix = _transform_operations_matrix(operations)
|
||
except ValueError as exc:
|
||
errors.add(f'{label} transform={raw!r}: {exc}')
|
||
parsed[id(elem)] = (IDENTITY_MATRIX, False)
|
||
continue
|
||
|
||
semantic_error = _transform_semantic_error(
|
||
elem,
|
||
operations,
|
||
is_root=elem is root,
|
||
thick_circle_ids=thick_circle_ids,
|
||
)
|
||
if semantic_error is not None:
|
||
errors.add(semantic_error)
|
||
parsed[id(elem)] = (matrix, semantic_error is None)
|
||
|
||
def validate_branch(elem: ET.Element, parent_matrix: AffineMatrix) -> None:
|
||
current_matrix = parent_matrix
|
||
entry = parsed.get(id(elem))
|
||
if entry is not None:
|
||
local_matrix, semantic_ok = entry
|
||
if not semantic_ok:
|
||
return
|
||
current_matrix = matrix_multiply(parent_matrix, local_matrix)
|
||
try:
|
||
validate_dml_shape_matrix(current_matrix)
|
||
except ValueError as exc:
|
||
errors.add(
|
||
f'{_transform_element_label(elem)} has an unsupported '
|
||
f'cumulative transform: {exc}'
|
||
)
|
||
return
|
||
for child in elem:
|
||
validate_branch(child, current_matrix)
|
||
|
||
validate_branch(root, IDENTITY_MATRIX)
|
||
return sorted(errors)
|
||
|
||
|
||
def rect_to_dml_xfrm(
|
||
x: float,
|
||
y: float,
|
||
w: float,
|
||
h: float,
|
||
matrix: AffineMatrix,
|
||
*,
|
||
preserve_degenerate_axes: bool = False,
|
||
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||
"""Map a transformed SVG rectangle to DrawingML xfrm attributes.
|
||
|
||
DrawingML can represent rotated/flipped rectangles, but not arbitrary
|
||
shear. Template-import picture wrappers only use translate/rotate/scale,
|
||
so decomposing the transformed local X/Y axes is sufficient here.
|
||
"""
|
||
p0 = transform_point(matrix, x, y)
|
||
p1 = transform_point(matrix, x + w, y)
|
||
p2 = transform_point(matrix, x + w, y + h)
|
||
p3 = transform_point(matrix, x, y + h)
|
||
|
||
ux = p1[0] - p0[0]
|
||
uy = p1[1] - p0[1]
|
||
vx = p3[0] - p0[0]
|
||
vy = p3[1] - p0[1]
|
||
|
||
rect_w = math.hypot(ux, uy)
|
||
rect_h = math.hypot(vx, vy)
|
||
validate_dml_shape_matrix(matrix)
|
||
if not preserve_degenerate_axes:
|
||
rect_w = max(rect_w, 0.001)
|
||
rect_h = max(rect_h, 0.001)
|
||
cross = ux * vy - uy * vx
|
||
|
||
if rect_w <= 1e-12 and rect_h > 1e-12:
|
||
angle_deg = math.degrees(math.atan2(vy, vx)) - 90.0
|
||
flip_attr = ''
|
||
elif cross < 0:
|
||
angle_deg = math.degrees(math.atan2(-uy, -ux))
|
||
flip_attr = ' flipH="1"'
|
||
else:
|
||
angle_deg = math.degrees(math.atan2(uy, ux))
|
||
flip_attr = ''
|
||
|
||
rot = round(angle_deg * ANGLE_UNIT)
|
||
rot_attr = f' rot="{rot}"' if rot else ''
|
||
|
||
center_x = (p0[0] + p2[0]) / 2
|
||
center_y = (p0[1] + p2[1]) / 2
|
||
off_x = px_to_emu(center_x - rect_w / 2)
|
||
off_y = px_to_emu(center_y - rect_h / 2)
|
||
ext_cx = px_to_emu(rect_w)
|
||
ext_cy = px_to_emu(rect_h)
|
||
validate_ooxml_xfrm(off_x, off_y, ext_cx, ext_cy)
|
||
|
||
xs = [p0[0], p1[0], p2[0], p3[0]]
|
||
ys = [p0[1], p1[1], p2[1], p3[1]]
|
||
bounds = (
|
||
px_to_emu(min(xs)),
|
||
px_to_emu(min(ys)),
|
||
px_to_emu(max(xs)),
|
||
px_to_emu(max(ys)),
|
||
)
|
||
|
||
return f'{flip_attr}{rot_attr}', off_x, off_y, ext_cx, ext_cy, bounds
|
||
|
||
|
||
def _extract_inheritable_styles(elem: ET.Element) -> dict[str, str]:
|
||
"""Extract all SVG-inheritable presentation attributes from an element."""
|
||
styles: dict[str, str] = {}
|
||
for attr in INHERITABLE_ATTRS:
|
||
val = elem.get(attr)
|
||
if val is not None:
|
||
styles[attr] = val
|
||
styles.update({
|
||
attr: val
|
||
for attr, val in parse_inline_style(elem.get('style')).items()
|
||
if attr in INHERITABLE_ATTRS
|
||
})
|
||
return styles
|
||
|
||
|
||
def _get_attr(elem: ET.Element, attr: str, ctx: ConvertContext) -> str | None:
|
||
"""Get effective attribute: element's own value first, then inherited."""
|
||
style_val = parse_inline_style(elem.get('style')).get(attr)
|
||
if style_val is not None:
|
||
return style_val
|
||
val = elem.get(attr)
|
||
if val is not None:
|
||
return val
|
||
return ctx.inherited_styles.get(attr)
|
||
|
||
|
||
def ctx_x(val: float, ctx: ConvertContext) -> float:
|
||
"""Apply context scale + translate to an X coordinate."""
|
||
return val * ctx.scale_x + ctx.translate_x
|
||
|
||
|
||
def ctx_y(val: float, ctx: ConvertContext) -> float:
|
||
"""Apply context scale + translate to a Y coordinate."""
|
||
return val * ctx.scale_y + ctx.translate_y
|
||
|
||
|
||
def ctx_w(val: float, ctx: ConvertContext) -> float:
|
||
"""Apply context scale to a width value."""
|
||
return val * ctx.scale_x
|
||
|
||
|
||
def ctx_h(val: float, ctx: ConvertContext) -> float:
|
||
"""Apply context scale to a height value."""
|
||
return val * ctx.scale_y
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Color / style parsing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_CSS_NAMED_COLORS = {
|
||
'black': '000000',
|
||
'silver': 'C0C0C0',
|
||
'gray': '808080',
|
||
'grey': '808080',
|
||
'white': 'FFFFFF',
|
||
'maroon': '800000',
|
||
'red': 'FF0000',
|
||
'purple': '800080',
|
||
'fuchsia': 'FF00FF',
|
||
'magenta': 'FF00FF',
|
||
'green': '008000',
|
||
'lime': '00FF00',
|
||
'olive': '808000',
|
||
'yellow': 'FFFF00',
|
||
'navy': '000080',
|
||
'blue': '0000FF',
|
||
'teal': '008080',
|
||
'aqua': '00FFFF',
|
||
'cyan': '00FFFF',
|
||
'orange': 'FFA500',
|
||
'brown': 'A52A2A',
|
||
'pink': 'FFC0CB',
|
||
'gold': 'FFD700',
|
||
'transparent': None,
|
||
'lightgray': 'D3D3D3',
|
||
'lightgrey': 'D3D3D3',
|
||
'darkgray': 'A9A9A9',
|
||
'darkgrey': 'A9A9A9',
|
||
}
|
||
|
||
|
||
def parse_inline_style(style_str: str | None) -> dict[str, str]:
|
||
"""Parse an SVG inline style declaration into ``property: value`` pairs."""
|
||
styles: dict[str, str] = {}
|
||
if not style_str:
|
||
return styles
|
||
for part in style_str.split(';'):
|
||
if ':' not in part:
|
||
continue
|
||
name, value = part.split(':', 1)
|
||
name = name.strip().lower()
|
||
value = value.strip()
|
||
if name and value:
|
||
styles[name] = value
|
||
return styles
|
||
|
||
|
||
def iter_project_geometry_lengths(
|
||
root: ET.Element,
|
||
) -> Iterator[tuple[ET.Element, str, str, str]]:
|
||
"""Yield project geometry values as element, attribute, raw value, source."""
|
||
for elem in root.iter():
|
||
tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag)
|
||
for attribute in sorted(
|
||
PROJECT_GEOMETRY_LENGTH_ATTRIBUTES.get(tag, frozenset())
|
||
):
|
||
raw = elem.get(attribute)
|
||
if raw is not None:
|
||
yield elem, attribute, raw, 'attribute'
|
||
|
||
direct_stroke_width = elem.get('stroke-width')
|
||
if direct_stroke_width is not None:
|
||
yield elem, 'stroke-width', direct_stroke_width, 'attribute'
|
||
|
||
style_stroke_width = parse_inline_style(elem.get('style')).get('stroke-width')
|
||
if style_stroke_width is not None:
|
||
yield elem, 'stroke-width', style_stroke_width, 'inline style'
|
||
|
||
|
||
def project_geometry_length_errors(root: ET.Element) -> list[str]:
|
||
"""Return blocking project geometry errors for converter preflight."""
|
||
errors: list[str] = []
|
||
for elem, attribute, raw, source in iter_project_geometry_lengths(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:
|
||
parse_project_geometry_length(raw, attribute)
|
||
except ValueError as exc:
|
||
errors.append(
|
||
f'{label} {source} {attribute}={raw!r}: {exc}'
|
||
)
|
||
return errors
|
||
|
||
|
||
def iter_project_image_aspect_ratios(
|
||
root: ET.Element,
|
||
) -> Iterator[tuple[ET.Element, str]]:
|
||
"""Yield explicit ``preserveAspectRatio`` values from image elements."""
|
||
for elem in root.iter():
|
||
if _svg_element_tag(elem) != 'image':
|
||
continue
|
||
raw = elem.get('preserveAspectRatio')
|
||
if raw is not None:
|
||
yield elem, raw
|
||
|
||
|
||
def project_image_aspect_ratio_errors(root: ET.Element) -> list[str]:
|
||
"""Return blocking project image aspect-ratio errors for preflight."""
|
||
errors: list[str] = []
|
||
for elem, raw in iter_project_image_aspect_ratios(root):
|
||
elem_id = elem.get('id')
|
||
label = f'<image id={elem_id!r}>' if elem_id else '<image>'
|
||
try:
|
||
parse_project_image_aspect_ratio(raw)
|
||
except ValueError as exc:
|
||
errors.append(f'{label} preserveAspectRatio={raw!r}: {exc}')
|
||
return errors
|
||
|
||
|
||
def iter_project_opacities(
|
||
root: ET.Element,
|
||
) -> Iterator[tuple[ET.Element, str, str, str]]:
|
||
"""Yield project opacity values as element, property, raw value, source."""
|
||
for elem in root.iter():
|
||
for property_name in PROJECT_OPACITY_PROPERTIES:
|
||
raw = elem.get(property_name)
|
||
if raw is not None:
|
||
yield elem, property_name, raw, 'attribute'
|
||
|
||
for fragment in (elem.get('style') or '').split(';'):
|
||
fragment = fragment.strip()
|
||
if not fragment:
|
||
continue
|
||
if ':' in fragment:
|
||
name, raw = fragment.split(':', 1)
|
||
name = name.strip().lower()
|
||
raw = raw.strip()
|
||
else:
|
||
name = fragment.lower()
|
||
raw = ''
|
||
if name in PROJECT_OPACITY_PROPERTIES:
|
||
yield elem, name, raw, 'inline style'
|
||
|
||
|
||
def project_opacity_errors(root: ET.Element) -> list[str]:
|
||
"""Return blocking project opacity errors for converter preflight."""
|
||
errors: list[str] = []
|
||
for elem, property_name, raw, source in iter_project_opacities(root):
|
||
tag = _svg_element_tag(elem) or str(elem.tag)
|
||
elem_id = elem.get('id')
|
||
label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>'
|
||
try:
|
||
parse_project_opacity(
|
||
raw,
|
||
allow_percentage=(
|
||
property_name in PROJECT_PERCENTAGE_OPACITY_PROPERTIES
|
||
),
|
||
)
|
||
except ValueError as exc:
|
||
errors.append(
|
||
f'{label} {source} {property_name}={raw!r}: {exc}'
|
||
)
|
||
return errors
|
||
|
||
|
||
def _finite_float(raw: str) -> float:
|
||
"""Parse a finite floating-point number."""
|
||
value = float(raw)
|
||
if not math.isfinite(value):
|
||
raise ValueError(f'Non-finite numeric value: {raw}')
|
||
return value
|
||
|
||
|
||
def _parse_color_channel(raw: str) -> int:
|
||
raw = raw.strip()
|
||
if raw.endswith('%'):
|
||
value = _finite_float(raw[:-1]) * 255.0 / 100.0
|
||
else:
|
||
value = _finite_float(raw)
|
||
return max(0, min(255, int(round(value))))
|
||
|
||
|
||
def _parse_alpha_channel(raw: str) -> float:
|
||
"""Parse a CSS alpha channel as a clamped ``0..1`` ratio."""
|
||
raw = raw.strip()
|
||
value = (
|
||
_finite_float(raw[:-1]) / 100.0
|
||
if raw.endswith('%')
|
||
else _finite_float(raw)
|
||
)
|
||
return max(0.0, min(1.0, value))
|
||
|
||
|
||
def parse_opacity(
|
||
raw: str | None,
|
||
default: float = 1.0,
|
||
*,
|
||
allow_percentage: bool = False,
|
||
) -> float:
|
||
"""Parse one project opacity or return the code-owned missing default."""
|
||
if raw is None:
|
||
return max(0.0, min(1.0, default))
|
||
return parse_project_opacity(raw, allow_percentage=allow_percentage)
|
||
|
||
|
||
def quantize_ooxml_unit_ratio(value: float) -> int:
|
||
"""Quantize one normalized ratio to DrawingML 1/100000 units."""
|
||
if not math.isfinite(value):
|
||
raise ValueError(f'OOXML unit ratio must be finite; got {value!r}')
|
||
normalized = max(0.0, min(1.0, value))
|
||
scaled = Decimal(str(normalized)) * Decimal(100000)
|
||
return int(scaled.to_integral_value(rounding=ROUND_HALF_UP))
|
||
|
||
|
||
def quantize_ooxml_alpha(opacity: float) -> int:
|
||
"""Quantize one normalized alpha to DrawingML 1/100000 units."""
|
||
if not math.isfinite(opacity):
|
||
raise ValueError(f'Opacity must be finite; got {opacity!r}')
|
||
return quantize_ooxml_unit_ratio(opacity)
|
||
|
||
|
||
def _functional_color_parts(body: str) -> tuple[list[str], str | None]:
|
||
"""Split legacy comma or modern space/slash functional color syntax."""
|
||
before, separator, after = body.partition('/')
|
||
parts = [part for part in re.split(r'[\s,]+', before.strip()) if part]
|
||
alpha = after.strip() if separator else None
|
||
if alpha is None and len(parts) > 3:
|
||
alpha = parts.pop()
|
||
return parts, alpha
|
||
|
||
|
||
def _parse_hue_degrees(raw: str) -> float:
|
||
"""Normalize a CSS hue angle to degrees."""
|
||
value = raw.strip().lower()
|
||
for suffix, multiplier in (
|
||
('turn', 360.0),
|
||
('grad', 0.9),
|
||
('rad', 180.0 / math.pi),
|
||
('deg', 1.0),
|
||
):
|
||
if value.endswith(suffix):
|
||
return _finite_float(value[:-len(suffix)]) * multiplier
|
||
return _finite_float(value)
|
||
|
||
|
||
def _parse_percentage(raw: str) -> float:
|
||
"""Parse a CSS percentage channel as a clamped ``0..1`` ratio."""
|
||
value = raw.strip()
|
||
ratio = (
|
||
_finite_float(value[:-1]) / 100.0
|
||
if value.endswith('%')
|
||
else _finite_float(value) / 100.0
|
||
)
|
||
return max(0.0, min(1.0, ratio))
|
||
|
||
|
||
def parse_svg_color(color_str: str) -> tuple[str | None, float]:
|
||
"""Parse an SVG/CSS color into ``(RRGGBB, alpha)``."""
|
||
if not color_str:
|
||
return None, 1.0
|
||
color_str = color_str.strip()
|
||
named = _CSS_NAMED_COLORS.get(color_str.lower())
|
||
if named is not None or color_str.lower() in _CSS_NAMED_COLORS:
|
||
if color_str.lower() == 'transparent':
|
||
return '000000', 0.0
|
||
return named, 1.0
|
||
|
||
rgb_match = re.match(r'rgba?\((.+)\)$', color_str, flags=re.IGNORECASE)
|
||
if rgb_match:
|
||
channels, alpha_raw = _functional_color_parts(rgb_match.group(1))
|
||
if len(channels) == 3:
|
||
try:
|
||
r, g, b = (_parse_color_channel(ch) for ch in channels)
|
||
alpha = _parse_alpha_channel(alpha_raw) if alpha_raw is not None else 1.0
|
||
return f'{r:02X}{g:02X}{b:02X}', alpha
|
||
except ValueError:
|
||
return None, 1.0
|
||
|
||
hsl_match = re.match(r'hsla?\((.+)\)$', color_str, flags=re.IGNORECASE)
|
||
if hsl_match:
|
||
channels, alpha_raw = _functional_color_parts(hsl_match.group(1))
|
||
if len(channels) == 3:
|
||
try:
|
||
hue = (_parse_hue_degrees(channels[0]) % 360.0) / 360.0
|
||
saturation = _parse_percentage(channels[1])
|
||
lightness = _parse_percentage(channels[2])
|
||
red, green, blue = colorsys.hls_to_rgb(hue, lightness, saturation)
|
||
alpha = _parse_alpha_channel(alpha_raw) if alpha_raw is not None else 1.0
|
||
return (
|
||
f'{round(red * 255):02X}{round(green * 255):02X}{round(blue * 255):02X}',
|
||
alpha,
|
||
)
|
||
except ValueError:
|
||
return None, 1.0
|
||
|
||
if color_str.startswith('#'):
|
||
color_str = color_str[1:]
|
||
if len(color_str) == 3:
|
||
color_str = ''.join(c * 2 for c in color_str)
|
||
elif len(color_str) == 4:
|
||
color_str = ''.join(c * 2 for c in color_str)
|
||
if len(color_str) == 8 and all(c in '0123456789abcdefABCDEF' for c in color_str):
|
||
return color_str[:6].upper(), int(color_str[6:], 16) / 255.0
|
||
if len(color_str) == 6 and all(c in '0123456789abcdefABCDEF' for c in color_str):
|
||
return color_str.upper(), 1.0
|
||
return None, 1.0
|
||
|
||
|
||
def parse_project_paint(
|
||
raw: str,
|
||
property_name: str,
|
||
) -> tuple[str, str | None, float]:
|
||
"""Parse one paint value from the closed project grammar.
|
||
|
||
Returns ``(kind, value, alpha)`` where ``kind`` is ``color``, ``none``,
|
||
or ``reference``. Color values are normalized to ``RRGGBB``; reference
|
||
values contain the local definition id.
|
||
"""
|
||
if property_name not in PROJECT_PAINT_PROPERTIES:
|
||
raise ValueError(f'unknown project paint property {property_name!r}')
|
||
|
||
value = raw.strip()
|
||
if property_name in PROJECT_REFERENCE_PAINT_PROPERTIES:
|
||
if value.lower() == 'none':
|
||
return 'none', None, 1.0
|
||
reference = re.fullmatch(r'url\(#([^)]+)\)', value)
|
||
if reference is not None:
|
||
return 'reference', reference.group(1), 1.0
|
||
|
||
color, alpha = parse_svg_color(value)
|
||
if color is not None:
|
||
return 'color', color, alpha
|
||
|
||
accepted = (
|
||
'a supported color, none, or an exact local url(#id) reference'
|
||
if property_name in PROJECT_REFERENCE_PAINT_PROPERTIES
|
||
else 'a supported color'
|
||
)
|
||
raise ValueError(f'must be {accepted}')
|
||
|
||
|
||
def is_project_paint_default_form(raw: str, property_name: str) -> bool:
|
||
"""Return whether paint uses the generated project spelling."""
|
||
value = raw.strip()
|
||
if property_name in PROJECT_REFERENCE_PAINT_PROPERTIES:
|
||
if value == 'none':
|
||
return True
|
||
if re.fullmatch(r'url\(#[^)]+\)', value) is not None:
|
||
return True
|
||
return re.fullmatch(r'#[0-9A-F]{6}', value) is not None
|
||
|
||
|
||
def iter_project_paints(
|
||
root: ET.Element,
|
||
) -> Iterator[tuple[ET.Element, str, str, str]]:
|
||
"""Yield project paint values as element, property, raw value, source."""
|
||
for elem in root.iter():
|
||
for property_name in PROJECT_PAINT_PROPERTIES:
|
||
raw = elem.get(property_name)
|
||
if raw is not None:
|
||
yield elem, property_name, raw, 'attribute'
|
||
|
||
for fragment in (elem.get('style') or '').split(';'):
|
||
fragment = fragment.strip()
|
||
if not fragment:
|
||
continue
|
||
if ':' in fragment:
|
||
name, raw = fragment.split(':', 1)
|
||
name = name.strip().lower()
|
||
raw = raw.strip()
|
||
else:
|
||
name = fragment.lower()
|
||
raw = ''
|
||
if name in PROJECT_PAINT_PROPERTIES:
|
||
yield elem, name, raw, 'inline style'
|
||
|
||
|
||
def project_paint_errors(root: ET.Element) -> list[str]:
|
||
"""Return blocking project paint errors for converter preflight."""
|
||
errors: list[str] = []
|
||
for elem, property_name, raw, source in iter_project_paints(root):
|
||
tag = _svg_element_tag(elem) or str(elem.tag)
|
||
elem_id = elem.get('id')
|
||
label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>'
|
||
try:
|
||
parse_project_paint(raw, property_name)
|
||
except ValueError as exc:
|
||
errors.append(
|
||
f'{label} {source} {property_name}={raw!r}: {exc}'
|
||
)
|
||
return errors
|
||
|
||
|
||
def project_definition_index(
|
||
root: ET.Element,
|
||
) -> tuple[dict[str, ET.Element], set[str]]:
|
||
"""Return direct ``<defs>`` children by id plus duplicate ids."""
|
||
definitions: dict[str, ET.Element] = {}
|
||
duplicates: set[str] = set()
|
||
for defs_elem in root.iter():
|
||
if _svg_element_tag(defs_elem) != 'defs':
|
||
continue
|
||
for child in defs_elem:
|
||
definition_id = (child.get('id') or '').strip()
|
||
if not definition_id:
|
||
continue
|
||
if definition_id in definitions:
|
||
duplicates.add(definition_id)
|
||
definitions[definition_id] = child
|
||
return definitions, duplicates
|
||
|
||
|
||
def project_definition_errors(root: ET.Element) -> list[str]:
|
||
"""Return errors for definitions outside the closed local-ref contract."""
|
||
parent_by_id = {
|
||
id(child): parent
|
||
for parent in root.iter()
|
||
for child in list(parent)
|
||
}
|
||
definitions, duplicate_definition_ids = project_definition_index(root)
|
||
errors = {
|
||
f'Duplicate direct <defs> id {definition_id!r} makes local references ambiguous'
|
||
for definition_id in duplicate_definition_ids
|
||
}
|
||
all_id_counts = Counter(
|
||
elem.get('id')
|
||
for elem in root.iter()
|
||
if (elem.get('id') or '').strip()
|
||
)
|
||
for definition_id in definitions:
|
||
if all_id_counts[definition_id] > 1:
|
||
errors.add(
|
||
f'Definition id {definition_id!r} is duplicated in the SVG; '
|
||
'local references require one unique target'
|
||
)
|
||
|
||
for elem in root.iter():
|
||
tag = _svg_element_tag(elem)
|
||
if tag not in PROJECT_DEFINITION_TAGS:
|
||
continue
|
||
label = _transform_element_label(elem)
|
||
parent = parent_by_id.get(id(elem))
|
||
if parent is None or _svg_element_tag(parent) != 'defs':
|
||
errors.add(f'{label} must be a direct child of <defs>')
|
||
if not (elem.get('id') or '').strip():
|
||
errors.add(f'{label} requires a non-empty unique id')
|
||
return sorted(errors)
|
||
|
||
|
||
def _project_marker_polygon_points(
|
||
raw: str,
|
||
) -> list[tuple[float, float]] | None:
|
||
"""Parse finite marker polygon points from the closed project grammar."""
|
||
tokens = [token for token in re.split(r'[\s,]+', raw.strip()) if token]
|
||
if not tokens or len(tokens) % 2:
|
||
return None
|
||
try:
|
||
values = [float(token) for token in tokens]
|
||
except ValueError:
|
||
return None
|
||
if not all(math.isfinite(value) for value in values):
|
||
return None
|
||
return list(zip(values[::2], values[1::2]))
|
||
|
||
|
||
def _project_marker_path_points(raw: str) -> list[tuple[float, float]]:
|
||
"""Return the explicit M/L points from an already-validated marker path."""
|
||
points = [
|
||
(float(x), float(y))
|
||
for x, y in _PROJECT_MARKER_COMMAND_POINT_RE.findall(raw)
|
||
]
|
||
return [
|
||
point
|
||
for point in points
|
||
if all(math.isfinite(coordinate) for coordinate in point)
|
||
]
|
||
|
||
|
||
def _project_marker_cross(
|
||
first: tuple[float, float],
|
||
second: tuple[float, float],
|
||
third: tuple[float, float],
|
||
) -> float:
|
||
"""Return the signed turn for three marker vertices."""
|
||
return (
|
||
(second[0] - first[0]) * (third[1] - second[1])
|
||
- (second[1] - first[1]) * (third[0] - second[0])
|
||
)
|
||
|
||
|
||
def _project_marker_segments_cross(
|
||
first_start: tuple[float, float],
|
||
first_end: tuple[float, float],
|
||
second_start: tuple[float, float],
|
||
second_end: tuple[float, float],
|
||
) -> bool:
|
||
"""Return whether two non-adjacent marker edges strictly intersect."""
|
||
first_a = _project_marker_cross(first_start, first_end, second_start)
|
||
first_b = _project_marker_cross(first_start, first_end, second_end)
|
||
second_a = _project_marker_cross(second_start, second_end, first_start)
|
||
second_b = _project_marker_cross(second_start, second_end, first_end)
|
||
return first_a * first_b < 0 and second_a * second_b < 0
|
||
|
||
|
||
def _project_marker_quadrilateral_type(
|
||
points: list[tuple[float, float]],
|
||
) -> str | None:
|
||
"""Classify one simple four-point marker as diamond or stealth."""
|
||
if len(points) != 4:
|
||
return None
|
||
if (
|
||
_project_marker_segments_cross(points[0], points[1], points[2], points[3])
|
||
or _project_marker_segments_cross(
|
||
points[1], points[2], points[3], points[0]
|
||
)
|
||
):
|
||
return None
|
||
turns = [
|
||
_project_marker_cross(
|
||
points[index],
|
||
points[(index + 1) % 4],
|
||
points[(index + 2) % 4],
|
||
)
|
||
for index in range(4)
|
||
]
|
||
if any(abs(turn) <= 1e-12 for turn in turns):
|
||
return None
|
||
signs = {turn > 0 for turn in turns}
|
||
return 'diamond' if len(signs) == 1 else 'stealth'
|
||
|
||
|
||
def classify_project_marker_shape(marker_elem: ET.Element) -> str | None:
|
||
"""Classify one marker into a DrawingML line-end shape, if representable."""
|
||
visual_children = [
|
||
child
|
||
for child in list(marker_elem)
|
||
if _svg_element_tag(child)
|
||
not in PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS
|
||
]
|
||
if len(visual_children) != 1:
|
||
return None
|
||
shape = visual_children[0]
|
||
tag = (_svg_element_tag(shape) or '').lower()
|
||
if tag in {'circle', 'ellipse'}:
|
||
return 'oval'
|
||
if tag == 'path':
|
||
path_data = shape.get('d', '')
|
||
if _PROJECT_MARKER_TRIANGLE_PATH_RE.fullmatch(path_data):
|
||
return 'triangle'
|
||
if _PROJECT_MARKER_ARROW_PATH_RE.fullmatch(path_data):
|
||
return 'arrow'
|
||
if _PROJECT_MARKER_DIAMOND_PATH_RE.fullmatch(path_data):
|
||
points = _project_marker_path_points(path_data)
|
||
return _project_marker_quadrilateral_type(points)
|
||
return None
|
||
if tag == 'polygon':
|
||
points = _project_marker_polygon_points(shape.get('points', ''))
|
||
if points is None:
|
||
return None
|
||
if len(points) == 3:
|
||
return 'triangle'
|
||
return _project_marker_quadrilateral_type(points)
|
||
return None
|
||
|
||
|
||
def _project_effective_presentation_value(
|
||
elem: ET.Element,
|
||
name: str,
|
||
parent_by_id: dict[int, ET.Element],
|
||
) -> str | None:
|
||
"""Resolve one inherited presentation value for project validation."""
|
||
current: ET.Element | None = elem
|
||
while current is not None:
|
||
style_values = parse_inline_style(current.get('style'))
|
||
if name in style_values:
|
||
return style_values[name]
|
||
direct = current.get(name)
|
||
if direct is not None:
|
||
return direct
|
||
current = parent_by_id.get(id(current))
|
||
return None
|
||
|
||
|
||
def project_marker_errors(root: ET.Element) -> list[str]:
|
||
"""Validate SVG line-end markers against the native arrow contract."""
|
||
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()
|
||
checked_markers: set[str] = set()
|
||
|
||
for elem in root.iter():
|
||
for attribute_name in ('marker-start', 'marker-end'):
|
||
raw_reference = elem.get(attribute_name)
|
||
if (
|
||
raw_reference is None
|
||
or raw_reference.strip().lower() == 'none'
|
||
):
|
||
continue
|
||
|
||
label = _transform_element_label(elem)
|
||
tag = (_svg_element_tag(elem) or '').lower()
|
||
if tag not in {'line', 'path'}:
|
||
errors.add(
|
||
f'{label} {attribute_name} is allowed only on <line> '
|
||
'or <path>'
|
||
)
|
||
|
||
match = re.fullmatch(r'url\(#([^)]+)\)', raw_reference.strip())
|
||
if match is None:
|
||
errors.add(
|
||
f'{label} {attribute_name} must be an exact local '
|
||
f'url(#id) reference; got {raw_reference!r}'
|
||
)
|
||
continue
|
||
|
||
marker_id = match.group(1)
|
||
marker = definitions.get(marker_id)
|
||
if marker is None or _svg_element_tag(marker) != 'marker':
|
||
errors.add(
|
||
f'{label} {attribute_name}=url(#{marker_id}) has no '
|
||
f'matching direct <defs><marker id="{marker_id}"> '
|
||
'definition'
|
||
)
|
||
continue
|
||
|
||
visual_children = [
|
||
child
|
||
for child in list(marker)
|
||
if _svg_element_tag(child)
|
||
not in PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS
|
||
]
|
||
shape = visual_children[0] if len(visual_children) == 1 else None
|
||
marker_shape_type = (
|
||
classify_project_marker_shape(marker)
|
||
if shape is not None
|
||
else None
|
||
)
|
||
if marker_id not in checked_markers:
|
||
checked_markers.add(marker_id)
|
||
marker_label = f'<marker id="{marker_id}">'
|
||
if marker.get('orient') not in {
|
||
'auto',
|
||
'auto-start-reverse',
|
||
}:
|
||
errors.add(
|
||
f'{marker_label} requires orient="auto" or '
|
||
'orient="auto-start-reverse"'
|
||
)
|
||
marker_units = marker.get('markerUnits', 'strokeWidth')
|
||
if marker_units not in {'strokeWidth', 'userSpaceOnUse'}:
|
||
errors.add(
|
||
f'{marker_label} has unsupported '
|
||
f'markerUnits={marker_units!r}'
|
||
)
|
||
for size_attribute in ('markerWidth', 'markerHeight'):
|
||
raw_size = marker.get(size_attribute)
|
||
if raw_size is None:
|
||
continue
|
||
try:
|
||
size = float(raw_size)
|
||
except ValueError:
|
||
size = math.nan
|
||
if not math.isfinite(size) or size <= 0:
|
||
errors.add(
|
||
f'{marker_label} {size_attribute} must be a '
|
||
f'positive finite number; got {raw_size!r}'
|
||
)
|
||
|
||
if shape is None:
|
||
errors.add(
|
||
f'{marker_label} must contain exactly one direct '
|
||
'triangle, stealth, arrow, diamond, or oval shape'
|
||
)
|
||
else:
|
||
shape_tag = (_svg_element_tag(shape) or '').lower()
|
||
if shape.get('transform'):
|
||
errors.add(
|
||
f'{marker_label} child <{shape_tag}> cannot use '
|
||
'transform'
|
||
)
|
||
if marker_shape_type is None and shape_tag == 'path':
|
||
errors.add(
|
||
f'{marker_label} path must be a closed 3-vertex '
|
||
'triangle, a simple closed 4-vertex '
|
||
'diamond/stealth, or an open 3-vertex arrow, '
|
||
'with one explicit M/L command per vertex'
|
||
)
|
||
elif (
|
||
marker_shape_type is None
|
||
and shape_tag == 'polygon'
|
||
):
|
||
errors.add(
|
||
f'{marker_label} polygon must contain exactly '
|
||
'3 finite vertices or 4 finite vertices forming '
|
||
'a simple diamond/stealth quadrilateral'
|
||
)
|
||
elif (
|
||
marker_shape_type is None
|
||
and shape_tag not in {'circle', 'ellipse'}
|
||
):
|
||
errors.add(
|
||
f'{marker_label} child <{shape_tag}> has no native '
|
||
'line-end mapping'
|
||
)
|
||
|
||
if shape is None:
|
||
continue
|
||
stroke_value = _project_effective_presentation_value(
|
||
elem,
|
||
'stroke',
|
||
parent_by_id,
|
||
)
|
||
marker_fill = _project_effective_presentation_value(
|
||
shape,
|
||
'fill',
|
||
parent_by_id,
|
||
) or '#000000'
|
||
if marker_shape_type == 'arrow':
|
||
if marker_fill.strip().lower() != 'none':
|
||
errors.add(
|
||
f'{label} {attribute_name}=url(#{marker_id}) open '
|
||
'arrow marker requires fill="none"'
|
||
)
|
||
marker_channel = 'stroke'
|
||
marker_paint = _project_effective_presentation_value(
|
||
shape,
|
||
marker_channel,
|
||
parent_by_id,
|
||
) or 'none'
|
||
else:
|
||
marker_channel = 'fill'
|
||
marker_paint = marker_fill
|
||
stroke_color, _stroke_alpha = parse_svg_color(stroke_value or '')
|
||
marker_color, _marker_alpha = parse_svg_color(marker_paint)
|
||
if stroke_color is None or marker_color is None:
|
||
errors.add(
|
||
f'{label} {attribute_name} marker {marker_channel} and '
|
||
'line stroke must both be supported solid colors'
|
||
)
|
||
elif stroke_color != marker_color:
|
||
errors.add(
|
||
f'{label} {attribute_name}=url(#{marker_id}) marker '
|
||
f'{marker_channel} {marker_paint!r} does not match '
|
||
f'effective line stroke {stroke_value!r}'
|
||
)
|
||
|
||
return sorted(errors)
|
||
|
||
|
||
def resolve_project_text_image_fill(pattern: ET.Element) -> tuple[str, ET.Element]:
|
||
"""Resolve the controlled one-image pattern used for native text picture fills."""
|
||
if _svg_element_tag(pattern) != 'pattern':
|
||
raise ValueError('definition must be an SVG <pattern>')
|
||
|
||
mode = pattern.get(PROJECT_TEXT_IMAGE_FILL_ATTR, '')
|
||
if mode not in PROJECT_TEXT_IMAGE_FILL_MODES:
|
||
supported = ', '.join(sorted(PROJECT_TEXT_IMAGE_FILL_MODES))
|
||
raise ValueError(f'{PROJECT_TEXT_IMAGE_FILL_ATTR} must be one of: {supported}')
|
||
preset_attributes = [
|
||
name
|
||
for name in ('data-pptx-pattern', 'data-pptx-fg', 'data-pptx-bg')
|
||
if pattern.get(name) is not None
|
||
]
|
||
if preset_attributes:
|
||
raise ValueError(
|
||
'text image fill must not combine preset-pattern attributes: '
|
||
f'{", ".join(preset_attributes)}'
|
||
)
|
||
if pattern.get('patternTransform') is not None:
|
||
raise ValueError('pattern must not use patternTransform')
|
||
|
||
children = list(pattern)
|
||
if len(children) != 1 or children[0].tag != f'{{{SVG_NS}}}image':
|
||
raise ValueError('pattern must contain exactly one direct SVG <image> child')
|
||
|
||
image = children[0]
|
||
unsupported = [
|
||
name
|
||
for name in (
|
||
'clip-path',
|
||
'filter',
|
||
'fill-opacity',
|
||
'mask',
|
||
'opacity',
|
||
'style',
|
||
'transform',
|
||
)
|
||
if image.get(name) is not None
|
||
]
|
||
if unsupported:
|
||
raise ValueError(f"pattern image must not use {', '.join(unsupported)}")
|
||
return mode, image
|
||
|
||
|
||
def project_paint_reference_errors(root: ET.Element) -> list[str]:
|
||
"""Validate local paint-server references and their native contexts."""
|
||
definitions, _duplicates = project_definition_index(root)
|
||
pattern_descendant_ids = {
|
||
id(descendant)
|
||
for pattern in root.iter()
|
||
if _svg_element_tag(pattern) == 'pattern'
|
||
for descendant in pattern.iter()
|
||
if descendant is not pattern
|
||
}
|
||
fill_shape_tags = frozenset({
|
||
'rect', 'circle', 'ellipse', 'path', 'polygon', 'polyline',
|
||
})
|
||
stroke_shape_tags = fill_shape_tags | {'line'}
|
||
errors: set[str] = set()
|
||
|
||
for elem in root.iter():
|
||
style_values = parse_inline_style(elem.get('style'))
|
||
for property_name in PROJECT_REFERENCE_PAINT_PROPERTIES:
|
||
raw = (
|
||
style_values[property_name]
|
||
if property_name in style_values
|
||
else elem.get(property_name)
|
||
)
|
||
if raw is None:
|
||
continue
|
||
try:
|
||
kind, reference_id, _alpha = parse_project_paint(
|
||
raw,
|
||
property_name,
|
||
)
|
||
except ValueError:
|
||
continue
|
||
if kind != 'reference' or reference_id is None:
|
||
continue
|
||
|
||
elem_tag = _svg_element_tag(elem) or str(elem.tag)
|
||
elem_tag_lower = elem_tag.lower()
|
||
target = definitions.get(reference_id)
|
||
if target is None:
|
||
errors.add(
|
||
f'<{elem_tag}> {property_name}=url(#{reference_id}) has no '
|
||
'matching direct <defs> definition'
|
||
)
|
||
continue
|
||
|
||
has_text_descendant = any(
|
||
(_svg_element_tag(descendant) or '').lower() in {'text', 'tspan'}
|
||
for descendant in elem.iter()
|
||
if descendant is not elem
|
||
)
|
||
if id(elem) in pattern_descendant_ids:
|
||
allowed_tags: tuple[str, ...] = ()
|
||
elif property_name == 'fill' and elem_tag_lower in fill_shape_tags:
|
||
allowed_tags = ('lineargradient', 'radialgradient', 'pattern')
|
||
elif property_name == 'stroke' and elem_tag_lower in stroke_shape_tags:
|
||
allowed_tags = ('lineargradient', 'radialgradient')
|
||
elif property_name == 'fill' and elem_tag_lower in {'text', 'tspan'}:
|
||
target_tag = (_svg_element_tag(target) or str(target.tag)).lower()
|
||
if target_tag == 'pattern' and target.get(PROJECT_TEXT_IMAGE_FILL_ATTR) is not None:
|
||
allowed_tags = ('lineargradient', 'radialgradient', 'pattern')
|
||
else:
|
||
allowed_tags = ('lineargradient', 'radialgradient')
|
||
elif property_name == 'fill' and elem_tag_lower == 'g':
|
||
allowed_tags = (
|
||
('lineargradient', 'radialgradient')
|
||
if has_text_descendant
|
||
else ('lineargradient', 'radialgradient', 'pattern')
|
||
)
|
||
elif (
|
||
property_name == 'stroke'
|
||
and elem_tag_lower == 'g'
|
||
and not has_text_descendant
|
||
):
|
||
allowed_tags = ('lineargradient', 'radialgradient')
|
||
else:
|
||
allowed_tags = ()
|
||
|
||
if not allowed_tags:
|
||
errors.add(
|
||
f'<{elem_tag}> {property_name}=url(#{reference_id}) is not '
|
||
'supported by native PPTX conversion in this context'
|
||
)
|
||
continue
|
||
|
||
target_tag = (_svg_element_tag(target) or str(target.tag)).lower()
|
||
is_text_image_fill = (
|
||
target_tag == 'pattern'
|
||
and target.get(PROJECT_TEXT_IMAGE_FILL_ATTR) is not None
|
||
)
|
||
if is_text_image_fill and not (
|
||
property_name == 'fill'
|
||
and elem_tag_lower in {'text', 'tspan'}
|
||
):
|
||
errors.add(
|
||
f'<{elem_tag}> {property_name}=url(#{reference_id}) uses a '
|
||
'text image fill pattern outside <text>/<tspan>'
|
||
)
|
||
continue
|
||
if target_tag not in allowed_tags:
|
||
tag_labels = {
|
||
'lineargradient': 'linearGradient',
|
||
'radialgradient': 'radialGradient',
|
||
'pattern': 'pattern',
|
||
}
|
||
expected = '/'.join(tag_labels[tag] for tag in allowed_tags)
|
||
errors.add(
|
||
f'<{elem_tag}> {property_name}=url(#{reference_id}) resolves '
|
||
f'to <{_svg_element_tag(target) or target.tag}>; expected '
|
||
f'{expected}'
|
||
)
|
||
continue
|
||
|
||
if property_name == 'fill' and elem_tag_lower in {'text', 'tspan'} and target_tag == 'pattern':
|
||
try:
|
||
resolve_project_text_image_fill(target)
|
||
except ValueError as exc:
|
||
errors.add(
|
||
f'<{elem_tag}> fill=url(#{reference_id}) has an invalid '
|
||
f'text image fill: {exc}'
|
||
)
|
||
return sorted(errors)
|
||
|
||
|
||
def project_mask_errors(root: ET.Element) -> list[str]:
|
||
"""Reject SVG masks that native PPTX conversion cannot preserve."""
|
||
errors: set[str] = set()
|
||
for elem in root.iter():
|
||
label = _transform_element_label(elem)
|
||
if _svg_element_tag(elem) == 'mask':
|
||
errors.add(
|
||
f'{label} is an unsupported SVG mask definition; replace it '
|
||
'with editable overlay or Boolean/cutout shapes, an image '
|
||
'clip-path, or pre-rendered alpha imagery'
|
||
)
|
||
|
||
sources: list[str] = []
|
||
if any(
|
||
name.rsplit('}', 1)[-1].lower() == 'mask'
|
||
for name in elem.attrib
|
||
):
|
||
sources.append('mask attribute')
|
||
if 'mask' in parse_inline_style(elem.get('style')):
|
||
sources.append('inline style mask property')
|
||
if sources:
|
||
errors.add(
|
||
f'{label} uses unsupported SVG mask presentation via '
|
||
f'{", ".join(sources)}; native PPTX export would drop the '
|
||
'effect. Use editable overlay or Boolean/cutout shapes, an '
|
||
'image clip-path, or pre-rendered alpha imagery'
|
||
)
|
||
return sorted(errors)
|
||
|
||
|
||
def parse_project_gradient_ratio(raw: str) -> float:
|
||
"""Parse one normalized gradient coordinate or stop offset."""
|
||
number, unit = _parse_svg_length_parts(raw)
|
||
if unit == '%':
|
||
number /= 100.0
|
||
elif unit:
|
||
raise ValueError('must be unitless or a percentage')
|
||
if not 0.0 <= number <= 1.0:
|
||
raise ValueError('must be within 0..1 or 0%..100%')
|
||
return number
|
||
|
||
|
||
def parse_project_linear_gradient_coordinate(raw: str) -> float:
|
||
"""Parse one objectBoundingBox linear-gradient projection coordinate."""
|
||
number, unit = _parse_svg_length_parts(raw)
|
||
if unit == '%':
|
||
number /= 100.0
|
||
elif unit:
|
||
raise ValueError('must be unitless or a percentage')
|
||
if not (
|
||
PROJECT_LINEAR_GRADIENT_COORDINATE_MIN
|
||
<= number
|
||
<= PROJECT_LINEAR_GRADIENT_COORDINATE_MAX
|
||
):
|
||
raise ValueError(
|
||
'must be within -0.105..1.105 or -10.5%..110.5%'
|
||
)
|
||
return number
|
||
|
||
|
||
def is_project_radial_focus_point(focus_x: float, focus_y: float) -> bool:
|
||
"""Return whether a focus lies inside the canonical SVG radial circle."""
|
||
if not math.isfinite(focus_x) or not math.isfinite(focus_y):
|
||
return False
|
||
return (
|
||
(focus_x - 0.5) ** 2 + (focus_y - 0.5) ** 2
|
||
<= 0.25 + PROJECT_RADIAL_FOCUS_TOLERANCE
|
||
)
|
||
|
||
|
||
def project_gradient_errors(root: ET.Element) -> list[str]:
|
||
"""Validate the normalized native gradient authoring interface."""
|
||
errors: set[str] = set()
|
||
for gradient in root.iter():
|
||
tag = _svg_element_tag(gradient)
|
||
if tag not in PROJECT_GRADIENT_TAGS:
|
||
continue
|
||
gradient_id = gradient.get('id')
|
||
label = f'<{tag} id="{gradient_id}">' if gradient_id else f'<{tag}>'
|
||
attribute_names = {
|
||
name.rsplit('}', 1)[-1]
|
||
for name in gradient.attrib
|
||
}
|
||
if 'href' in attribute_names:
|
||
errors.add(
|
||
f'{label} cannot inherit from href/xlink:href; '
|
||
'define gradient stops directly'
|
||
)
|
||
if 'gradientTransform' in attribute_names:
|
||
errors.add(f'{label} cannot use gradientTransform')
|
||
if 'spreadMethod' in attribute_names:
|
||
errors.add(f'{label} cannot use spreadMethod')
|
||
gradient_units = gradient.get('gradientUnits')
|
||
if gradient_units not in {None, 'objectBoundingBox'}:
|
||
errors.add(
|
||
f'{label} cannot use gradientUnits={gradient_units!r}; '
|
||
'use normalized objectBoundingBox coordinates'
|
||
)
|
||
|
||
if tag == 'linearGradient':
|
||
coordinate_defaults = {
|
||
'x1': '0',
|
||
'y1': '0',
|
||
'x2': '1',
|
||
'y2': '0',
|
||
}
|
||
coordinates: dict[str, float] = {}
|
||
for coordinate_name, default in coordinate_defaults.items():
|
||
raw_coordinate = gradient.get(coordinate_name, default)
|
||
try:
|
||
coordinates[coordinate_name] = (
|
||
parse_project_linear_gradient_coordinate(raw_coordinate)
|
||
)
|
||
except ValueError:
|
||
errors.add(
|
||
f'{label} {coordinate_name} must be a finite '
|
||
'objectBoundingBox projection coordinate within '
|
||
'-0.105..1.105 or -10.5%..110.5%; '
|
||
f'got {raw_coordinate!r}'
|
||
)
|
||
if len(coordinates) == 4 and (
|
||
math.isclose(
|
||
coordinates['x1'],
|
||
coordinates['x2'],
|
||
rel_tol=0.0,
|
||
abs_tol=1e-12,
|
||
)
|
||
and math.isclose(
|
||
coordinates['y1'],
|
||
coordinates['y2'],
|
||
rel_tol=0.0,
|
||
abs_tol=1e-12,
|
||
)
|
||
):
|
||
errors.add(
|
||
f'{label} linear gradient axis must not collapse to one '
|
||
'point; use different x1/y1 and x2/y2 coordinates'
|
||
)
|
||
else:
|
||
radial_coordinates: dict[str, float] = {}
|
||
for coordinate_name in ('cx', 'cy', 'r', 'fx', 'fy'):
|
||
raw_coordinate = gradient.get(coordinate_name)
|
||
if raw_coordinate is None:
|
||
continue
|
||
try:
|
||
coordinate = parse_project_gradient_ratio(raw_coordinate)
|
||
except ValueError:
|
||
errors.add(
|
||
f'{label} {coordinate_name} must be a normalized finite '
|
||
'value from 0 to 1 or 0% to 100%; '
|
||
f'got {raw_coordinate!r}'
|
||
)
|
||
continue
|
||
radial_coordinates[coordinate_name] = coordinate
|
||
if coordinate_name == 'r' and coordinate <= 0:
|
||
errors.add(f'{label} r must be greater than 0')
|
||
focus_x_name = (
|
||
'fx' if gradient.get('fx') is not None else 'cx'
|
||
)
|
||
focus_y_name = (
|
||
'fy' if gradient.get('fy') is not None else 'cy'
|
||
)
|
||
focus_is_valid = (
|
||
(
|
||
gradient.get(focus_x_name) is None
|
||
or focus_x_name in radial_coordinates
|
||
)
|
||
and (
|
||
gradient.get(focus_y_name) is None
|
||
or focus_y_name in radial_coordinates
|
||
)
|
||
)
|
||
if focus_is_valid:
|
||
focus_x = radial_coordinates.get(focus_x_name, 0.5)
|
||
focus_y = radial_coordinates.get(focus_y_name, 0.5)
|
||
if not is_project_radial_focus_point(focus_x, focus_y):
|
||
errors.add(
|
||
f'{label} effective focus (fx/fy, otherwise cx/cy) '
|
||
'must lie within the canonical circle centered at '
|
||
f'0.5,0.5 with radius 0.5; got ({focus_x}, {focus_y})'
|
||
)
|
||
|
||
stops: list[ET.Element] = []
|
||
for child in list(gradient):
|
||
child_tag = _svg_element_tag(child) or str(child.tag)
|
||
if child_tag in PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS:
|
||
continue
|
||
if child_tag != 'stop':
|
||
errors.add(
|
||
f'{label} has unsupported direct child <{child_tag}>; '
|
||
'gradient definitions may contain only direct <stop> children'
|
||
)
|
||
continue
|
||
stops.append(child)
|
||
if len(stops) < 2:
|
||
errors.add(
|
||
f'{label} requires at least two direct <stop> children for '
|
||
'native PPTX gradient interpolation'
|
||
)
|
||
previous_offset: float | None = None
|
||
for index, stop in enumerate(stops, start=1):
|
||
stop_label = f'{label} stop #{index}'
|
||
raw_offset = stop.get('offset')
|
||
try:
|
||
if raw_offset is None:
|
||
raise ValueError
|
||
offset = parse_project_gradient_ratio(raw_offset)
|
||
except ValueError:
|
||
errors.add(
|
||
f'{stop_label} offset must be explicit and within 0..1 '
|
||
f'or 0%..100%; got {raw_offset!r}'
|
||
)
|
||
else:
|
||
if (
|
||
previous_offset is not None
|
||
and offset < previous_offset
|
||
):
|
||
errors.add(
|
||
f'{label} stop offsets must be non-decreasing; '
|
||
f'stop #{index} offset {raw_offset!r} precedes a '
|
||
f'larger offset at stop #{index - 1}'
|
||
)
|
||
previous_offset = offset
|
||
style_values = parse_inline_style(stop.get('style'))
|
||
if not (style_values.get('stop-color') or stop.get('stop-color')):
|
||
errors.add(f'{stop_label} requires an explicit stop-color')
|
||
return sorted(errors)
|
||
|
||
|
||
def parse_project_filter_params(
|
||
filter_elem: ET.Element,
|
||
) -> dict[str, float | str | bool]:
|
||
"""Extract the shared native shadow/glow parameters from one filter."""
|
||
primitive_units = filter_elem.get('primitiveUnits')
|
||
if primitive_units not in (None, 'userSpaceOnUse'):
|
||
raise ValueError(
|
||
'filter primitiveUnits must be userSpaceOnUse when explicit; '
|
||
f'got {primitive_units!r}'
|
||
)
|
||
std_dev: float | None = None
|
||
dx = 0.0
|
||
dy = 0.0
|
||
paint_opacity: float | None = None
|
||
transfer_opacity: float | None = None
|
||
color_alpha = 1.0
|
||
color = '000000'
|
||
has_offset = False
|
||
|
||
def required_number(primitive: ET.Element, attribute_name: str) -> float:
|
||
primitive_tag = _svg_element_tag(primitive) or str(primitive.tag)
|
||
raw_value = primitive.get(attribute_name)
|
||
if raw_value is None:
|
||
raise ValueError(
|
||
f'<{primitive_tag}> requires explicit {attribute_name}'
|
||
)
|
||
try:
|
||
value = float(raw_value)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError(
|
||
f'<{primitive_tag}> {attribute_name} must be a finite number; '
|
||
f'got {raw_value!r}'
|
||
) from exc
|
||
if not math.isfinite(value):
|
||
raise ValueError(
|
||
f'<{primitive_tag}> {attribute_name} must be a finite number; '
|
||
f'got {raw_value!r}'
|
||
)
|
||
return value
|
||
|
||
for child in filter_elem.iter():
|
||
tag = _svg_element_tag(child)
|
||
style_values = parse_inline_style(child.get('style'))
|
||
|
||
def effect_attr(name: str, default: str | None = None) -> str | None:
|
||
return style_values.get(name) or child.get(name, default)
|
||
|
||
def required_effect_attr(name: str) -> str:
|
||
raw_value = effect_attr(name)
|
||
if raw_value is None:
|
||
raise ValueError(f'<{tag}> requires explicit {name}')
|
||
return raw_value
|
||
|
||
if tag == 'feDropShadow':
|
||
std_dev = required_number(child, 'stdDeviation')
|
||
dx = required_number(child, 'dx')
|
||
dy = required_number(child, 'dy')
|
||
if abs(dx) > 0.01 or abs(dy) > 0.01:
|
||
has_offset = True
|
||
paint_opacity = parse_opacity(
|
||
required_effect_attr('flood-opacity'),
|
||
allow_percentage=True,
|
||
)
|
||
parsed_color, parsed_alpha = parse_svg_color(
|
||
effect_attr('flood-color', '#000000')
|
||
)
|
||
if parsed_color:
|
||
color = parsed_color
|
||
color_alpha = parsed_alpha
|
||
elif tag == 'feGaussianBlur':
|
||
if child.get('edgeMode') is not None:
|
||
raise ValueError(
|
||
'<feGaussianBlur> edgeMode is unsupported by the native '
|
||
'effect mapping'
|
||
)
|
||
std_dev = required_number(child, 'stdDeviation')
|
||
elif tag == 'feOffset':
|
||
dx = _f(child.get('dx'), 0.0)
|
||
dy = _f(child.get('dy'), 0.0)
|
||
if abs(dx) > 0.01 or abs(dy) > 0.01:
|
||
has_offset = True
|
||
elif tag == 'feFlood':
|
||
paint_opacity = parse_opacity(
|
||
required_effect_attr('flood-opacity'),
|
||
allow_percentage=True,
|
||
)
|
||
parsed_color, parsed_alpha = parse_svg_color(
|
||
effect_attr('flood-color', '#000000')
|
||
)
|
||
if parsed_color:
|
||
color = parsed_color
|
||
color_alpha = parsed_alpha
|
||
elif tag == 'feFuncA' and child.get('type') == 'linear':
|
||
if child.get('intercept') is not None:
|
||
raise ValueError(
|
||
'<feFuncA> intercept is unsupported; project alpha '
|
||
'transfer maps slope multiplication only'
|
||
)
|
||
slope = required_number(child, 'slope')
|
||
transfer_opacity = (
|
||
slope
|
||
if transfer_opacity is None
|
||
else transfer_opacity * slope
|
||
)
|
||
|
||
if paint_opacity is None:
|
||
opacity = transfer_opacity if transfer_opacity is not None else 0.3
|
||
elif transfer_opacity is None:
|
||
opacity = paint_opacity
|
||
else:
|
||
opacity = paint_opacity * transfer_opacity
|
||
opacity = max(0.0, min(1.0, opacity * color_alpha))
|
||
|
||
if std_dev is None:
|
||
raise ValueError('filter requires feDropShadow or feGaussianBlur')
|
||
|
||
return {
|
||
'std_dev': std_dev,
|
||
'dx': dx,
|
||
'dy': dy,
|
||
'opacity': opacity,
|
||
'color': color,
|
||
'has_offset': has_offset,
|
||
}
|
||
|
||
|
||
def project_filter_drawingml_coordinates(
|
||
params: dict[str, float | str | bool],
|
||
effect_kind: str | None = None,
|
||
) -> dict[str, int]:
|
||
"""Map filter geometry into validated DrawingML effect coordinates."""
|
||
kind = effect_kind or ('shadow' if params['has_offset'] else 'glow')
|
||
std_dev = float(params['std_dev'])
|
||
dx = float(params['dx'])
|
||
dy = float(params['dy'])
|
||
if kind == 'shadow':
|
||
coordinates_px = {
|
||
'blurRad': std_dev * 2.0,
|
||
'dist': math.hypot(dx, dy),
|
||
}
|
||
elif kind == 'glow':
|
||
coordinates_px = {'rad': std_dev}
|
||
else:
|
||
raise ValueError(f'unsupported native filter kind {kind!r}')
|
||
|
||
coordinates: dict[str, int] = {}
|
||
for attribute_name, value_px in coordinates_px.items():
|
||
scaled = value_px * EMU_PER_PX
|
||
if not math.isfinite(scaled):
|
||
raise ValueError(
|
||
f'DrawingML {attribute_name} must be finite after EMU mapping'
|
||
)
|
||
mapped = round(scaled)
|
||
if not 0 <= mapped <= OOXML_COORDINATE_MAX:
|
||
raise ValueError(
|
||
f'DrawingML {attribute_name} must map within '
|
||
f'0..{OOXML_COORDINATE_MAX}; got {mapped}'
|
||
)
|
||
coordinates[attribute_name] = mapped
|
||
return coordinates
|
||
|
||
|
||
def project_filter_errors(root: ET.Element) -> list[str]:
|
||
"""Validate filters against the native shadow/glow approximation."""
|
||
definitions, _duplicates = project_definition_index(root)
|
||
filters_by_id = {
|
||
filter_id: elem
|
||
for filter_id, elem in definitions.items()
|
||
if _svg_element_tag(elem) == 'filter'
|
||
}
|
||
errors: set[str] = set()
|
||
parents = {
|
||
child: parent
|
||
for parent in root.iter()
|
||
for child in parent
|
||
}
|
||
|
||
for elem in root.iter():
|
||
tag = (_svg_element_tag(elem) or str(elem.tag)).lower()
|
||
label = _transform_element_label(elem)
|
||
style_values = parse_inline_style(elem.get('style'))
|
||
if style_values.get('filter'):
|
||
errors.add(
|
||
f'{label} filter must use a direct filter="url(#id)" '
|
||
'attribute; inline style filters are not supported'
|
||
)
|
||
|
||
raw_filter = elem.get('filter')
|
||
if raw_filter is None:
|
||
continue
|
||
if (
|
||
tag not in PROJECT_FILTER_PUBLIC_TARGETS
|
||
and not _is_imported_preset_preview_filter_target(elem, parents)
|
||
and not is_picture_effect_carrier(elem)
|
||
):
|
||
errors.add(
|
||
f'{label} cannot use filter; supported native targets are '
|
||
'rect, circle, image, path, text, and an exact single clipped-'
|
||
'image carrier group'
|
||
)
|
||
if tag == 'image' and elem.get('clip-path') is not None:
|
||
errors.add(
|
||
f'{label} cannot combine filter and clip-path on the same '
|
||
'image; put the filter on an exact single-image outer <g>'
|
||
)
|
||
match = re.fullmatch(r'url\(#([^)]+)\)', raw_filter.strip())
|
||
if match is None:
|
||
errors.add(
|
||
f'{label} filter must be an exact local url(#id) reference; '
|
||
f'got {raw_filter!r}'
|
||
)
|
||
continue
|
||
filter_id = match.group(1)
|
||
if filter_id not in filters_by_id:
|
||
errors.add(
|
||
f'{label} filter=url(#{filter_id}) has no matching direct '
|
||
f'<defs><filter id="{filter_id}"> definition'
|
||
)
|
||
|
||
for filter_id, filter_elem in filters_by_id.items():
|
||
label = f'filter #{filter_id}'
|
||
parameters_are_valid = True
|
||
primitive_units = filter_elem.get('primitiveUnits')
|
||
if primitive_units not in (None, 'userSpaceOnUse'):
|
||
parameters_are_valid = False
|
||
errors.add(
|
||
f'{label} primitiveUnits must be userSpaceOnUse when '
|
||
f'explicit; got {primitive_units!r}'
|
||
)
|
||
primitives = [
|
||
_svg_element_tag(descendant) or str(descendant.tag)
|
||
for descendant in filter_elem.iter()
|
||
if descendant is not filter_elem
|
||
]
|
||
unsupported = sorted(set(primitives) - PROJECT_FILTER_PRIMITIVES)
|
||
if unsupported:
|
||
errors.add(
|
||
f'{label} uses unsupported filter primitive(s): '
|
||
f'{", ".join(unsupported)}'
|
||
)
|
||
effect_primitives = [
|
||
primitive
|
||
for primitive in primitives
|
||
if primitive in PROJECT_FILTER_EFFECT_PRIMITIVES
|
||
]
|
||
if not effect_primitives:
|
||
errors.add(f'{label} must contain feDropShadow or feGaussianBlur')
|
||
elif len(effect_primitives) > 1:
|
||
errors.add(
|
||
f'{label} contains multiple shadow/glow primitives; one '
|
||
'filter must map to exactly one native effect'
|
||
)
|
||
if any(
|
||
_svg_element_tag(descendant) == 'feFuncA'
|
||
and descendant.get('type') != 'linear'
|
||
for descendant in filter_elem.iter()
|
||
):
|
||
errors.add(f'{label} requires feFuncA type="linear"')
|
||
|
||
for primitive in filter_elem.iter():
|
||
primitive_tag = _svg_element_tag(primitive)
|
||
if primitive_tag in {'feDropShadow', 'feFlood'}:
|
||
style_values = parse_inline_style(primitive.get('style'))
|
||
if (
|
||
primitive.get('flood-opacity') is None
|
||
and 'flood-opacity' not in style_values
|
||
):
|
||
parameters_are_valid = False
|
||
errors.add(
|
||
f'{label} <{primitive_tag}> requires explicit '
|
||
'flood-opacity'
|
||
)
|
||
if (
|
||
primitive_tag == 'feFuncA'
|
||
and primitive.get('intercept') is not None
|
||
):
|
||
parameters_are_valid = False
|
||
errors.add(
|
||
f'{label} <feFuncA> intercept is unsupported; project '
|
||
'alpha transfer maps slope multiplication only'
|
||
)
|
||
if (
|
||
primitive_tag == 'feGaussianBlur'
|
||
and primitive.get('edgeMode') is not None
|
||
):
|
||
parameters_are_valid = False
|
||
errors.add(
|
||
f'{label} <feGaussianBlur> edgeMode is unsupported by '
|
||
'the native effect mapping'
|
||
)
|
||
numeric_attrs: tuple[tuple[str, bool, bool], ...] = ()
|
||
if primitive_tag in {'feDropShadow', 'feGaussianBlur'}:
|
||
numeric_attrs = (('stdDeviation', True, True),)
|
||
elif primitive_tag == 'feOffset':
|
||
numeric_attrs = (
|
||
('dx', False, False),
|
||
('dy', False, False),
|
||
)
|
||
elif primitive_tag == 'feFuncA':
|
||
numeric_attrs = (('slope', True, True),)
|
||
if primitive_tag == 'feDropShadow':
|
||
numeric_attrs += (
|
||
('dx', False, True),
|
||
('dy', False, True),
|
||
)
|
||
for attribute_name, non_negative, required in numeric_attrs:
|
||
raw_value = primitive.get(attribute_name)
|
||
if raw_value is None:
|
||
if required:
|
||
parameters_are_valid = False
|
||
errors.add(
|
||
f'{label} <{primitive_tag}> requires explicit '
|
||
f'{attribute_name}'
|
||
)
|
||
continue
|
||
try:
|
||
value = float(raw_value)
|
||
except (TypeError, ValueError):
|
||
value = math.nan
|
||
if (
|
||
not math.isfinite(value)
|
||
or (non_negative and value < 0)
|
||
or (
|
||
primitive_tag == 'feFuncA'
|
||
and attribute_name == 'slope'
|
||
and value > 1
|
||
)
|
||
):
|
||
if attribute_name in {'stdDeviation', 'dx', 'dy'}:
|
||
parameters_are_valid = False
|
||
qualifier = (
|
||
' from 0 to 1'
|
||
if primitive_tag == 'feFuncA'
|
||
else ''
|
||
)
|
||
errors.add(
|
||
f'{label} <{primitive_tag}> {attribute_name} must be a '
|
||
f'finite number{qualifier}; got {raw_value!r}'
|
||
)
|
||
if len(effect_primitives) == 1 and parameters_are_valid:
|
||
try:
|
||
params = parse_project_filter_params(filter_elem)
|
||
project_filter_drawingml_coordinates(params)
|
||
except (TypeError, ValueError) as exc:
|
||
errors.add(f'{label} {exc}')
|
||
return sorted(errors)
|
||
|
||
|
||
def _is_imported_preset_preview_filter_target(
|
||
elem: ET.Element,
|
||
parents: dict[ET.Element, ET.Element],
|
||
) -> bool:
|
||
"""Recognize the render-only aggregate filter on an imported preset.
|
||
|
||
DrawingML presets can contain several visible path layers but own one
|
||
shape-level effect. The lossless importer therefore keeps the native
|
||
filter on the hidden geometry carrier and mirrors the same reference onto
|
||
its hash-locked preview group. The preview group is never exported as a
|
||
separate PowerPoint object; ordinary authored ``<g filter>`` remains
|
||
outside the project contract.
|
||
"""
|
||
if (
|
||
_svg_element_tag(elem) != 'g'
|
||
or elem.get('data-pptx-part') != 'geometry-preview'
|
||
):
|
||
return False
|
||
parent = parents.get(elem)
|
||
if (
|
||
parent is None
|
||
or _svg_element_tag(parent) != 'g'
|
||
or parent.get('data-pptx-object') not in {'shape', 'connector'}
|
||
or not parent.get('data-pptx-prst')
|
||
or not parent.get('data-pptx-frame')
|
||
):
|
||
return False
|
||
previews = [
|
||
child
|
||
for child in parent
|
||
if child.get('data-pptx-part') == 'geometry-preview'
|
||
]
|
||
if len(previews) != 1 or previews[0] is not elem:
|
||
return False
|
||
preview_children = list(elem)
|
||
if not preview_children or any(
|
||
_svg_element_tag(child) != 'path'
|
||
or child.get('data-pptx-part') != 'geometry-detail'
|
||
or len(child) != 0
|
||
for child in preview_children
|
||
):
|
||
return False
|
||
carriers = [
|
||
child
|
||
for child in parent
|
||
if child.get('data-pptx-part') == 'geometry'
|
||
]
|
||
if len(carriers) != 1:
|
||
return False
|
||
carrier = carriers[0]
|
||
if not (
|
||
_svg_element_tag(carrier) == 'path'
|
||
and carrier.get('visibility') == 'hidden'
|
||
and carrier.get('pointer-events') == 'none'
|
||
and carrier.get('data-pptx-object') == parent.get('data-pptx-object')
|
||
and carrier.get('data-pptx-prst') == parent.get('data-pptx-prst')
|
||
and carrier.get('data-pptx-frame') == parent.get('data-pptx-frame')
|
||
and carrier.get('filter') == elem.get('filter')
|
||
):
|
||
return False
|
||
try:
|
||
expected_hash = resolve_preset_preview_hash(parent)
|
||
except ValueError:
|
||
return False
|
||
return (
|
||
expected_hash is not None
|
||
and svg_preset_preview_fingerprint(parent) == expected_hash
|
||
)
|
||
|
||
|
||
def is_picture_effect_carrier(elem: ET.Element) -> bool:
|
||
"""Recognize one effect carrier around exactly one clipped picture."""
|
||
if (
|
||
_svg_element_tag(elem) != 'g'
|
||
or elem.get('data-pptx-object') == 'group'
|
||
or re.fullmatch(
|
||
r'url\(#([^)]+)\)',
|
||
(elem.get('filter') or '').strip(),
|
||
) is None
|
||
or elem.get('data-pptx-layer') not in {None, 'master', 'layout'}
|
||
or any(
|
||
elem.get(attribute) is not None
|
||
for attribute in (
|
||
'data-pptx-placeholder',
|
||
'data-pptx-binding',
|
||
'data-pptx-replace-with',
|
||
'data-pptx-native',
|
||
)
|
||
)
|
||
):
|
||
return False
|
||
children = [
|
||
child for child in elem
|
||
if _svg_element_tag(child) not in PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS
|
||
]
|
||
if len(children) != 1:
|
||
return False
|
||
picture = children[0]
|
||
if picture.get('filter') is not None:
|
||
return False
|
||
owner_kind = elem.get('data-pptx-object')
|
||
if _svg_element_tag(picture) == 'image':
|
||
if resolve_url_id(picture.get('clip-path', '')) is None:
|
||
return False
|
||
if owner_kind is None:
|
||
return True
|
||
shape_id = elem.get('data-pptx-shape-id')
|
||
return (
|
||
owner_kind == 'picture'
|
||
and shape_id is not None
|
||
and picture.get('data-pptx-object') == 'picture'
|
||
and picture.get('data-pptx-shape-id') == shape_id
|
||
)
|
||
if (
|
||
_svg_element_tag(picture) != 'svg'
|
||
or owner_kind != 'picture'
|
||
or picture.get('data-pptx-object') != 'picture'
|
||
or picture.get('viewBox') is None
|
||
or picture.get('preserveAspectRatio') != 'none'
|
||
):
|
||
return False
|
||
shape_id = elem.get('data-pptx-shape-id')
|
||
if (
|
||
shape_id is None
|
||
or picture.get('data-pptx-shape-id') != shape_id
|
||
):
|
||
return False
|
||
crop_children = list(picture)
|
||
return (
|
||
len(crop_children) == 1
|
||
and _svg_element_tag(crop_children[0]) == 'image'
|
||
)
|
||
|
||
|
||
def parse_hex_color(color_str: str) -> str | None:
|
||
"""Parse SVG color values to ``RRGGBB``, ignoring any alpha channel."""
|
||
if color_str and color_str.strip().lower() == 'transparent':
|
||
return None
|
||
color, _alpha = parse_svg_color(color_str)
|
||
return color
|
||
|
||
|
||
def combine_opacity(*values: float | None) -> float | None:
|
||
"""Multiply opacity components, returning ``None`` when fully opaque."""
|
||
combined = 1.0
|
||
for value in values:
|
||
if value is not None:
|
||
combined *= max(0.0, min(1.0, value))
|
||
return combined if combined < 1.0 else None
|
||
|
||
|
||
def parse_stop_style(style_str: str) -> tuple[str | None, float]:
|
||
"""Parse a gradient stop's style attribute.
|
||
|
||
Args:
|
||
style_str: Style string like 'stop-color:#XXX;stop-opacity:N'.
|
||
|
||
Returns:
|
||
(color, opacity) tuple.
|
||
"""
|
||
color = None
|
||
color_alpha = 1.0
|
||
stop_opacity = 1.0
|
||
style_values = parse_inline_style(style_str)
|
||
if not style_values:
|
||
return color, stop_opacity
|
||
|
||
if 'stop-color' in style_values:
|
||
color, color_alpha = parse_svg_color(style_values['stop-color'])
|
||
if 'stop-opacity' in style_values:
|
||
stop_opacity = parse_opacity(
|
||
style_values['stop-opacity'],
|
||
allow_percentage=True,
|
||
)
|
||
|
||
return color, color_alpha * stop_opacity
|
||
|
||
|
||
def resolve_url_id(url_str: str) -> str | None:
|
||
"""Extract ID from 'url(#someId)' reference."""
|
||
if not url_str:
|
||
return None
|
||
m = re.match(r'url\(#([^)]+)\)', url_str.strip())
|
||
return m.group(1) if m else None
|
||
|
||
|
||
def get_effective_filter_id(elem: ET.Element, ctx: ConvertContext) -> str | None:
|
||
"""Get the effective filter ID for an element, including inherited context."""
|
||
filt = elem.get('filter')
|
||
if filt:
|
||
return resolve_url_id(filt)
|
||
return ctx.filter_id
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Font parsing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def parse_font_family(font_family_str: str) -> dict[str, str]:
|
||
"""Parse CSS font-family into latin/ea typeface names.
|
||
|
||
Prioritizes Windows-available fonts since PPTX is primarily opened on
|
||
Windows. macOS/Linux-only fonts are mapped via FONT_FALLBACK_WIN.
|
||
"""
|
||
if not font_family_str:
|
||
return {'latin': 'Segoe UI', 'ea': 'Microsoft YaHei'}
|
||
|
||
fonts = [f.strip().strip("'\"") for f in font_family_str.split(',')]
|
||
latin_font = None
|
||
ea_font = None
|
||
|
||
for font in fonts:
|
||
if font in SYSTEM_FONTS:
|
||
continue
|
||
if font in GENERIC_FONT_MAP:
|
||
resolved = GENERIC_FONT_MAP[font]
|
||
latin_font = latin_font or resolved
|
||
continue
|
||
|
||
win_font = FONT_FALLBACK_WIN.get(font, font)
|
||
if font in EA_FONTS:
|
||
ea_font = ea_font or win_font
|
||
else:
|
||
latin_font = latin_font or win_font
|
||
|
||
# PPT renders CJK text via latin typeface when ea doesn't match
|
||
if not latin_font and ea_font:
|
||
latin_font = ea_font
|
||
|
||
final_latin = latin_font or 'Segoe UI'
|
||
|
||
# EA must always be a CJK-capable font
|
||
if not ea_font:
|
||
ea_font = 'SimSun' if final_latin in _SERIF_LATIN else 'Microsoft YaHei'
|
||
|
||
return {'latin': final_latin, 'ea': ea_font}
|
||
|
||
|
||
def unsafe_exported_font_faces(font_family_str: str) -> dict[str, str]:
|
||
"""Return resolved PPTX typefaces that require a custom installation."""
|
||
return {
|
||
role: family
|
||
for role, family in parse_font_family(font_family_str).items()
|
||
if family.strip().lower() not in PPT_SAFE_FONTS
|
||
}
|
||
|
||
|
||
def _is_han_char(ch: str) -> bool:
|
||
"""Return whether one character belongs to a Han ideograph block."""
|
||
cp = ord(ch)
|
||
return (
|
||
0x3400 <= cp <= 0x4DBF
|
||
or 0x4E00 <= cp <= 0x9FFF
|
||
or 0xF900 <= cp <= 0xFAFF
|
||
or 0x20000 <= cp <= 0x2EE5F
|
||
or 0x30000 <= cp <= 0x323AF
|
||
)
|
||
|
||
|
||
def _is_hiragana_char(ch: str) -> bool:
|
||
cp = ord(ch)
|
||
return (
|
||
0x3040 <= cp <= 0x309F
|
||
or 0x1B001 <= cp <= 0x1B11F
|
||
)
|
||
|
||
|
||
def _is_katakana_char(ch: str) -> bool:
|
||
cp = ord(ch)
|
||
return (
|
||
0x30A0 <= cp <= 0x30FF
|
||
or 0x31F0 <= cp <= 0x31FF
|
||
or 0xFF65 <= cp <= 0xFF9F
|
||
or 0x1AFF0 <= cp <= 0x1AFFF
|
||
or cp == 0x1B000
|
||
or 0x1B120 <= cp <= 0x1B16F
|
||
)
|
||
|
||
|
||
def _is_hangul_char(ch: str) -> bool:
|
||
cp = ord(ch)
|
||
return (
|
||
0x1100 <= cp <= 0x11FF
|
||
or 0x3130 <= cp <= 0x318F
|
||
or 0xA960 <= cp <= 0xA97F
|
||
or 0xAC00 <= cp <= 0xD7AF
|
||
or 0xD7B0 <= cp <= 0xD7FF
|
||
or 0xFFA0 <= cp <= 0xFFDC
|
||
)
|
||
|
||
|
||
def is_cjk_char(ch: str) -> bool:
|
||
"""Return whether one character uses the project East Asian width model."""
|
||
cp = ord(ch)
|
||
return (
|
||
_is_han_char(ch)
|
||
or _is_hiragana_char(ch)
|
||
or _is_katakana_char(ch)
|
||
or _is_hangul_char(ch)
|
||
or 0x2E80 <= cp <= 0x2FFF
|
||
or 0x3000 <= cp <= 0x303F
|
||
or 0x3100 <= cp <= 0x312F
|
||
or 0x31A0 <= cp <= 0x31BF
|
||
or 0x31C0 <= cp <= 0x31EF
|
||
or 0xFF00 <= cp <= 0xFFEF
|
||
)
|
||
|
||
|
||
def _contains_codepoint_range(
|
||
text: str,
|
||
ranges: tuple[tuple[int, int], ...],
|
||
) -> bool:
|
||
"""Return whether text contains a code point in one of the ranges."""
|
||
return any(
|
||
start <= ord(ch) <= end
|
||
for ch in text
|
||
for start, end in ranges
|
||
)
|
||
|
||
|
||
def _default_language_for_script(
|
||
default_language: str | None,
|
||
bases: frozenset[str],
|
||
fallback: str,
|
||
) -> str:
|
||
"""Prefer the project language when it belongs to the detected script."""
|
||
if default_language and language_base(default_language) in bases:
|
||
return default_language
|
||
return fallback
|
||
|
||
|
||
def text_has_rtl_characters(text: str) -> bool:
|
||
"""Return whether text contains a strong right-to-left character."""
|
||
return any(unicodedata.bidirectional(ch) in {'R', 'AL'} for ch in text)
|
||
|
||
|
||
def text_uses_rtl(text: str, default_language: str | None = None) -> bool:
|
||
"""Resolve paragraph direction from its first strong character or project."""
|
||
for char in text:
|
||
direction = unicodedata.bidirectional(char)
|
||
if direction in {'R', 'AL'}:
|
||
return True
|
||
if direction == 'L':
|
||
return False
|
||
return bool(default_language and language_uses_rtl(default_language))
|
||
|
||
|
||
def detect_text_lang(
|
||
text: str,
|
||
default_language: str | None = None,
|
||
) -> str:
|
||
"""Return a DrawingML language tag, preferring the project contract."""
|
||
has_hangul = False
|
||
has_kana = False
|
||
has_east_asian_text = False
|
||
for ch in text:
|
||
has_hangul = has_hangul or _is_hangul_char(ch)
|
||
has_kana = (
|
||
has_kana
|
||
or _is_hiragana_char(ch)
|
||
or _is_katakana_char(ch)
|
||
)
|
||
has_east_asian_text = has_east_asian_text or is_cjk_char(ch)
|
||
if has_hangul:
|
||
return _default_language_for_script(
|
||
default_language,
|
||
frozenset({'ko'}),
|
||
'ko-KR',
|
||
)
|
||
if has_kana:
|
||
return _default_language_for_script(
|
||
default_language,
|
||
frozenset({'ja'}),
|
||
'ja-JP',
|
||
)
|
||
if has_east_asian_text:
|
||
return _default_language_for_script(
|
||
default_language,
|
||
frozenset({'zh', 'ja', 'ko'}),
|
||
'zh-CN',
|
||
)
|
||
if _contains_codepoint_range(text, (
|
||
(0x0600, 0x06FF),
|
||
(0x0750, 0x077F),
|
||
(0x08A0, 0x08FF),
|
||
(0xFB50, 0xFDFF),
|
||
(0xFE70, 0xFEFF),
|
||
(0x1EE00, 0x1EEFF),
|
||
)):
|
||
return _default_language_for_script(
|
||
default_language,
|
||
frozenset({'ar', 'fa', 'ps', 'sd', 'ug', 'ur'}),
|
||
'ar-SA',
|
||
)
|
||
if _contains_codepoint_range(text, (
|
||
(0x0590, 0x05FF),
|
||
(0xFB1D, 0xFB4F),
|
||
)):
|
||
return _default_language_for_script(
|
||
default_language,
|
||
frozenset({'he', 'yi'}),
|
||
'he-IL',
|
||
)
|
||
if _contains_codepoint_range(text, (
|
||
(0x0900, 0x097F),
|
||
(0xA8E0, 0xA8FF),
|
||
)):
|
||
return _default_language_for_script(
|
||
default_language,
|
||
frozenset({'hi', 'mr', 'ne', 'sa'}),
|
||
'hi-IN',
|
||
)
|
||
if _contains_codepoint_range(text, ((0x0E00, 0x0E7F),)):
|
||
return _default_language_for_script(
|
||
default_language,
|
||
frozenset({'th'}),
|
||
'th-TH',
|
||
)
|
||
if _contains_codepoint_range(text, (
|
||
(0x0400, 0x052F),
|
||
(0x1C80, 0x1C8F),
|
||
(0x2DE0, 0x2DFF),
|
||
(0xA640, 0xA69F),
|
||
)):
|
||
return _default_language_for_script(
|
||
default_language,
|
||
frozenset({'be', 'bg', 'kk', 'ky', 'mk', 'mn', 'ru', 'sr', 'uk'}),
|
||
'ru-RU',
|
||
)
|
||
if _contains_codepoint_range(text, (
|
||
(0x0370, 0x03FF),
|
||
(0x1F00, 0x1FFF),
|
||
)):
|
||
return _default_language_for_script(
|
||
default_language,
|
||
frozenset({'el'}),
|
||
'el-GR',
|
||
)
|
||
return default_language or 'en-US'
|
||
|
||
|
||
def _is_grapheme_extend(ch: str) -> bool:
|
||
"""Return whether ``ch`` extends the preceding rendered character."""
|
||
cp = ord(ch)
|
||
return (
|
||
unicodedata.category(ch) in {'Mn', 'Mc', 'Me'}
|
||
or 0xFE00 <= cp <= 0xFE0F
|
||
or 0xE0100 <= cp <= 0xE01EF
|
||
or 0x1F3FB <= cp <= 0x1F3FF
|
||
or 0xE0020 <= cp <= 0xE007F
|
||
)
|
||
|
||
|
||
def _is_regional_indicator(ch: str) -> bool:
|
||
return 0x1F1E6 <= ord(ch) <= 0x1F1FF
|
||
|
||
|
||
def _is_virama(ch: str) -> bool:
|
||
name = unicodedata.name(ch, '')
|
||
return (
|
||
unicodedata.combining(ch) == 9
|
||
or 'VIRAMA' in name
|
||
or name.endswith(' SIGN HALANT')
|
||
)
|
||
|
||
|
||
def _is_emoji_base(ch: str) -> bool:
|
||
cp = ord(ch)
|
||
return 0x2600 <= cp <= 0x27BF or 0x1F000 <= cp <= 0x1FAFF
|
||
|
||
|
||
def _unicode_script_key(ch: str) -> str | None:
|
||
"""Return the stable Unicode-name prefix used for project script joins."""
|
||
name = unicodedata.name(ch, '')
|
||
if not name:
|
||
return None
|
||
tokens = name.split()
|
||
boundary_tokens = {
|
||
'CONSONANT',
|
||
'LETTER',
|
||
'SIGN',
|
||
'SYLLABLE',
|
||
'VOWEL',
|
||
}
|
||
for index, token in enumerate(tokens):
|
||
if index > 0 and token in boundary_tokens:
|
||
return ' '.join(tokens[:index])
|
||
if tokens[0] in {'MEETEI', 'OL', 'TAI'} and len(tokens) > 1:
|
||
return ' '.join(tokens[:2])
|
||
return tokens[0]
|
||
|
||
|
||
def _virama_script_key(cluster: str, virama: str) -> str | None:
|
||
virama_script = _unicode_script_key(virama)
|
||
for ch in reversed(cluster):
|
||
if not unicodedata.category(ch).startswith('L'):
|
||
continue
|
||
base_script = _unicode_script_key(ch)
|
||
return base_script if base_script == virama_script else None
|
||
return None
|
||
|
||
|
||
def split_project_text_clusters(text: str) -> list[str]:
|
||
"""Split text into the rendered units used by project width estimates.
|
||
|
||
This intentionally implements only the Unicode joins that affect SVG to
|
||
DrawingML tracking: combining marks, variation selectors, emoji modifiers,
|
||
ZWJ sequences, regional-indicator pairs, and common virama conjuncts.
|
||
"""
|
||
clusters: list[str] = []
|
||
virama_script: str | None = None
|
||
emoji_join = False
|
||
for ch in text:
|
||
if not clusters:
|
||
clusters.append(ch)
|
||
continue
|
||
|
||
cluster = clusters[-1]
|
||
previous = cluster[-1]
|
||
if ch == '\n' and previous == '\r':
|
||
clusters[-1] += ch
|
||
virama_script = None
|
||
emoji_join = False
|
||
elif _is_grapheme_extend(ch):
|
||
if _is_virama(ch):
|
||
virama_script = _virama_script_key(cluster, ch)
|
||
clusters[-1] += ch
|
||
elif ch == '\u200d':
|
||
clusters[-1] += ch
|
||
emoji_join = any(_is_emoji_base(item) for item in cluster)
|
||
elif ch == '\u200c':
|
||
clusters[-1] += ch
|
||
virama_script = None
|
||
emoji_join = False
|
||
elif (
|
||
virama_script is not None
|
||
and unicodedata.category(ch).startswith('L')
|
||
and _unicode_script_key(ch) == virama_script
|
||
):
|
||
clusters[-1] += ch
|
||
virama_script = None
|
||
emoji_join = False
|
||
elif emoji_join and _is_emoji_base(ch):
|
||
clusters[-1] += ch
|
||
emoji_join = False
|
||
elif (
|
||
len(cluster) == 1
|
||
and _is_regional_indicator(cluster)
|
||
and _is_regional_indicator(ch)
|
||
):
|
||
clusters[-1] += ch
|
||
else:
|
||
clusters.append(ch)
|
||
virama_script = None
|
||
emoji_join = False
|
||
return clusters
|
||
|
||
|
||
def resolve_text_run_fonts(text: str, fonts: dict[str, str]) -> dict[str, str]:
|
||
"""Return DrawingML latin/ea/cs typefaces for one text run."""
|
||
latin = fonts['latin']
|
||
if any(is_cjk_char(ch) for ch in text):
|
||
ea = fonts['ea']
|
||
else:
|
||
ea = latin
|
||
return {'latin': latin, 'ea': ea, 'cs': latin}
|
||
|
||
|
||
def _estimate_character_width(ch: str, font_size: float) -> float:
|
||
if is_cjk_char(ch):
|
||
return font_size
|
||
if ch == ' ':
|
||
return font_size * 0.3
|
||
if ch in 'mMwWOQ%':
|
||
return font_size * 0.75
|
||
if ch in 'iIlj!|':
|
||
return font_size * 0.3
|
||
if ch.isdigit():
|
||
# digits are tabular (uniform ~0.55em) in most UI fonts, including
|
||
# '1' — classing it with 'il|' under-sizes the box and makes
|
||
# renderers that ignore wrap="none" (LibreOffice) wrap the line
|
||
return font_size * 0.55
|
||
return font_size * 0.55
|
||
|
||
|
||
def _estimate_grapheme_width(cluster: str, font_size: float) -> float:
|
||
bases = [
|
||
ch for ch in cluster
|
||
if ch not in {'\u200c', '\u200d'} and not _is_grapheme_extend(ch)
|
||
]
|
||
if not bases:
|
||
return font_size * 0.55
|
||
if (
|
||
len(bases) > 1
|
||
and all(_is_regional_indicator(ch) for ch in bases)
|
||
) or '\u20e3' in cluster or any(_is_emoji_base(ch) for ch in bases):
|
||
return font_size
|
||
return max(_estimate_character_width(ch, font_size) for ch in bases)
|
||
|
||
|
||
def estimate_text_cluster_widths(
|
||
text: str,
|
||
font_size: float,
|
||
font_weight: str = '400',
|
||
) -> list[float]:
|
||
"""Estimate each project text cluster without inserting tracking."""
|
||
widths = [
|
||
_estimate_grapheme_width(cluster, font_size)
|
||
for cluster in split_project_text_clusters(text)
|
||
]
|
||
if font_weight in ('bold', '600', '700', '800', '900'):
|
||
widths = [width * 1.05 for width in widths]
|
||
return widths
|
||
|
||
|
||
def estimate_text_width(text: str, font_size: float, font_weight: str = '400') -> float:
|
||
"""Estimate text width in SVG pixels."""
|
||
return sum(estimate_text_cluster_widths(text, font_size, font_weight))
|
||
|
||
|
||
def _xml_escape(text: str) -> str:
|
||
"""Escape XML special characters."""
|
||
return (text.replace('&', '&')
|
||
.replace('<', '<')
|
||
.replace('>', '>')
|
||
.replace('"', '"'))
|