5060 lines
171 KiB
Python
5060 lines
171 KiB
Python
"""SVG element converters: rect, circle, line, path, polygon, polyline, text, image, ellipse."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import binascii
|
||
import hashlib
|
||
import io
|
||
import math
|
||
import re
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import unquote_to_bytes
|
||
from xml.etree import ElementTree as ET
|
||
|
||
from pptx_shapes import (
|
||
CONNECTOR_PRESET_TYPES,
|
||
OOXML_COORDINATE_MAX,
|
||
OOXML_COORDINATE_MIN,
|
||
get_preset_registry,
|
||
has_relationship_attributes,
|
||
load_shape_type_values,
|
||
validate_ooxml_xfrm,
|
||
)
|
||
from pptx_effects import EFFECT_REASON_ATTR, EFFECT_STATUS_ATTR
|
||
from pptx_to_svg.preset_authoring import AUTHORING_ATTR, AUTHORING_VALUE
|
||
from resource_paths import (
|
||
resolve_external_image_reference,
|
||
svg_image_payload_error,
|
||
)
|
||
|
||
from .context import (
|
||
TEXT_FLOW_PRESERVE,
|
||
TEXT_FLOW_SPLIT,
|
||
ConvertContext,
|
||
ShapeResult,
|
||
)
|
||
from .theme_colors import color_node_xml
|
||
from .theme_fonts import theme_font_tokens
|
||
from .text_properties import (
|
||
drawingml_letter_spacing,
|
||
normalize_project_text_segments,
|
||
parse_project_font_style,
|
||
parse_project_font_weight,
|
||
parse_project_letter_spacing,
|
||
parse_project_text_anchor,
|
||
parse_project_text_decoration,
|
||
resolve_project_xml_space,
|
||
)
|
||
from .utils import (
|
||
SVG_NS, XLINK_NS, ANGLE_UNIT, FONT_PX_TO_HUNDREDTHS_PT,
|
||
PROJECT_IMAGE_ASPECT_RATIO_ANCHORS,
|
||
px_to_emu, _f, _get_attr, parse_svg_length,
|
||
svg_length_x, svg_length_y, svg_length_size,
|
||
ctx_x, ctx_y, ctx_w, ctx_h,
|
||
rect_to_dml_xfrm,
|
||
combine_opacity, parse_hex_color, parse_svg_color,
|
||
resolve_project_text_image_fill, resolve_url_id, get_effective_filter_id,
|
||
parse_inline_style, parse_font_family, is_cjk_char,
|
||
detect_text_lang, estimate_text_cluster_widths, font_px_to_hpt,
|
||
resolve_text_run_fonts, split_project_text_clusters,
|
||
text_has_rtl_characters, text_uses_rtl,
|
||
is_thick_circle_shorthand, parse_project_geometry_length,
|
||
is_canonical_project_geometry_length,
|
||
parse_project_image_aspect_ratio,
|
||
parse_project_opacity,
|
||
parse_project_stroke_dasharray,
|
||
quantize_ooxml_alpha,
|
||
project_definition_index,
|
||
matrix_multiply, parse_transform_matrix, parse_transform_operations,
|
||
transform_point, _xml_escape,
|
||
)
|
||
from .styles import (
|
||
build_solid_fill, build_gradient_fill,
|
||
build_fill_xml, build_stroke_xml, build_effect_xml, classify_filter_effect,
|
||
get_element_opacity, get_fill_opacity, get_stroke_opacity,
|
||
)
|
||
from .paths import (
|
||
PathCommand, parse_svg_path, parse_svg_points, svg_path_to_absolute,
|
||
normalize_path_commands, path_commands_to_drawingml,
|
||
transform_path_commands,
|
||
)
|
||
|
||
|
||
def _resolve_external_image(svg_dir: Path, href: str) -> Path:
|
||
"""Resolve a non-data-URI image href to a file on disk.
|
||
|
||
Search order: next to the SVG (``svg_output/``), the project root, the
|
||
project's ``images/`` (the single runtime image pool — template-bundled
|
||
bitmaps plus AI / web / user images all live here), then ``templates/``
|
||
(legacy flat-copied template assets). Raises ``FileNotFoundError`` if none
|
||
of these exist.
|
||
"""
|
||
candidate = resolve_external_image_reference(svg_dir, href)
|
||
if candidate is not None:
|
||
return candidate
|
||
raise FileNotFoundError(f'External image not found: {href}')
|
||
|
||
|
||
_PROJECT_IMAGE_FORMATS = {
|
||
'bmp': 'bmp',
|
||
'emf': 'emf',
|
||
'gif': 'gif',
|
||
'jpeg': 'jpg',
|
||
'jpg': 'jpg',
|
||
'png': 'png',
|
||
'svg': 'svg',
|
||
'svg+xml': 'svg',
|
||
'tif': 'tif',
|
||
'tiff': 'tiff',
|
||
'webp': 'webp',
|
||
'wmf': 'wmf',
|
||
'x-emf': 'emf',
|
||
'x-wmf': 'wmf',
|
||
}
|
||
_PIL_IMAGE_FORMATS = {
|
||
'bmp': 'BMP',
|
||
'gif': 'GIF',
|
||
'jpg': 'JPEG',
|
||
'png': 'PNG',
|
||
'tif': 'TIFF',
|
||
'tiff': 'TIFF',
|
||
'webp': 'WEBP',
|
||
}
|
||
|
||
|
||
def _normalize_project_image_format(raw: str) -> str | None:
|
||
return _PROJECT_IMAGE_FORMATS.get(raw.strip().lower().lstrip('.'))
|
||
|
||
|
||
def _little_uint(data: bytes, offset: int, size: int) -> int:
|
||
return int.from_bytes(data[offset:offset + size], 'little', signed=False)
|
||
|
||
|
||
def _valid_emf_payload(data: bytes) -> bool:
|
||
"""Validate the EMF header and complete bounded record stream."""
|
||
if len(data) < 88:
|
||
return False
|
||
header_size = _little_uint(data, 4, 4)
|
||
total_size = _little_uint(data, 48, 4)
|
||
record_count = _little_uint(data, 52, 4)
|
||
header_palette_entries = _little_uint(data, 68, 4)
|
||
if (
|
||
_little_uint(data, 0, 4) != 1
|
||
or data[40:44] != b' EMF'
|
||
or header_size < 88
|
||
or header_size > total_size
|
||
or total_size != len(data)
|
||
or record_count < 1
|
||
):
|
||
return False
|
||
|
||
offset = 0
|
||
count = 0
|
||
last_type = 0
|
||
last_size = 0
|
||
while offset < total_size:
|
||
if offset + 8 > total_size:
|
||
return False
|
||
record_type = _little_uint(data, offset, 4)
|
||
record_size = _little_uint(data, offset + 4, 4)
|
||
if record_size < 8 or record_size % 4 or offset + record_size > total_size:
|
||
return False
|
||
record_end = offset + record_size
|
||
if count == 0 and (record_type != 1 or record_size != header_size):
|
||
return False
|
||
if count > 0 and record_type == 1:
|
||
return False
|
||
if record_type == 14:
|
||
if (
|
||
record_size < 20
|
||
or record_end != total_size
|
||
or _little_uint(data, record_end - 4, 4) != record_size
|
||
):
|
||
return False
|
||
palette_entries = _little_uint(data, offset + 8, 4)
|
||
palette_offset = _little_uint(data, offset + 12, 4)
|
||
if (
|
||
palette_entries != header_palette_entries
|
||
or palette_entries and (
|
||
palette_offset < 16
|
||
or palette_offset + palette_entries * 4 > record_size - 4
|
||
)
|
||
):
|
||
return False
|
||
offset = record_end
|
||
count += 1
|
||
last_type = record_type
|
||
last_size = record_size
|
||
return (
|
||
offset == total_size
|
||
# MS-EMF counts all records. LibreOffice-generated EMF files in the
|
||
# wild count records after the header, so retain that interoperable
|
||
# spelling while keeping the complete stream bounded.
|
||
and count in {record_count, record_count + 1}
|
||
and last_type == 14
|
||
and last_size >= 20
|
||
)
|
||
|
||
|
||
def _valid_wmf_payload(data: bytes) -> bool:
|
||
"""Validate a standard or placeable WMF header and record stream."""
|
||
meta_offset = 0
|
||
if data.startswith(b'\xd7\xcd\xc6\x9a'):
|
||
if len(data) < 40:
|
||
return False
|
||
checksum = 0
|
||
for offset in range(0, 20, 2):
|
||
checksum ^= _little_uint(data, offset, 2)
|
||
if checksum != _little_uint(data, 20, 2):
|
||
return False
|
||
meta_offset = 22
|
||
if len(data) < meta_offset + 24:
|
||
return False
|
||
meta_type = _little_uint(data, meta_offset, 2)
|
||
header_words = _little_uint(data, meta_offset + 2, 2)
|
||
version = _little_uint(data, meta_offset + 4, 2)
|
||
total_words = _little_uint(data, meta_offset + 6, 4)
|
||
max_record_words = _little_uint(data, meta_offset + 12, 4)
|
||
if (
|
||
meta_type not in {1, 2}
|
||
or header_words != 9
|
||
or version not in {0x0100, 0x0300}
|
||
or total_words < 12
|
||
or max_record_words < 3
|
||
):
|
||
return False
|
||
total_end = meta_offset + total_words * 2
|
||
if total_end != len(data):
|
||
return False
|
||
|
||
offset = meta_offset + header_words * 2
|
||
last_function = -1
|
||
last_record_words = 0
|
||
observed_max_record_words = 0
|
||
while offset < total_end:
|
||
if offset + 6 > total_end:
|
||
return False
|
||
record_words = _little_uint(data, offset, 4)
|
||
function = _little_uint(data, offset + 4, 2)
|
||
if (
|
||
record_words < 3
|
||
or record_words > max_record_words
|
||
or offset + record_words * 2 > total_end
|
||
):
|
||
return False
|
||
record_end = offset + record_words * 2
|
||
if function == 0 and (record_words != 3 or record_end != total_end):
|
||
return False
|
||
offset = record_end
|
||
last_function = function
|
||
last_record_words = record_words
|
||
observed_max_record_words = max(
|
||
observed_max_record_words,
|
||
record_words,
|
||
)
|
||
return (
|
||
offset == total_end
|
||
and last_function == 0
|
||
and last_record_words == 3
|
||
and observed_max_record_words == max_record_words
|
||
)
|
||
|
||
|
||
def _valid_project_image_payload(img_format: str, img_data: bytes) -> bool:
|
||
"""Return whether bytes are a supported image of the declared format."""
|
||
if not img_data:
|
||
return False
|
||
if img_format == 'svg':
|
||
return svg_image_payload_error(img_data) is None
|
||
if img_format == 'emf':
|
||
return _valid_emf_payload(img_data)
|
||
if img_format == 'wmf':
|
||
return _valid_wmf_payload(img_data)
|
||
|
||
expected = _PIL_IMAGE_FORMATS.get(img_format)
|
||
if expected is None:
|
||
return False
|
||
try:
|
||
from PIL import Image, UnidentifiedImageError # type: ignore
|
||
except ImportError:
|
||
return False
|
||
try:
|
||
with Image.open(io.BytesIO(img_data)) as image:
|
||
actual = (image.format or '').upper()
|
||
image.verify()
|
||
except (UnidentifiedImageError, OSError, ValueError, SyntaxError):
|
||
return False
|
||
return actual == expected
|
||
|
||
|
||
def _decode_data_image_uri(href: str) -> tuple[str, bytes] | None:
|
||
"""Decode and validate one closed-project image data URI."""
|
||
if not href.startswith('data:') or ',' not in href:
|
||
return None
|
||
|
||
header, payload = href.split(',', 1)
|
||
match = re.fullmatch(
|
||
r'data:image/([A-Za-z0-9.+-]+)(?:;[^;,]*)*?(?:;base64)?',
|
||
header,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
if not match:
|
||
return None
|
||
|
||
img_format = _normalize_project_image_format(match.group(1))
|
||
if img_format is None:
|
||
return None
|
||
|
||
is_base64 = any(
|
||
part.strip().lower() == 'base64'
|
||
for part in header.split(';')[1:]
|
||
)
|
||
try:
|
||
if is_base64:
|
||
img_data = base64.b64decode(payload, validate=True)
|
||
else:
|
||
img_data = unquote_to_bytes(payload)
|
||
except (ValueError, binascii.Error):
|
||
return None
|
||
if not _valid_project_image_payload(img_format, img_data):
|
||
return None
|
||
return img_format, img_data
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ProjectImageSource:
|
||
"""Validated bytes and package extension for one SVG image reference."""
|
||
|
||
img_format: str
|
||
img_data: bytes
|
||
|
||
|
||
def _project_image_href(elem: ET.Element) -> str:
|
||
href_keys = tuple(
|
||
key for key in ('href', f'{{{XLINK_NS}}}href')
|
||
if key in elem.attrib
|
||
)
|
||
if len(href_keys) != 1:
|
||
raise ValueError('requires exactly one href or xlink:href')
|
||
href = elem.get(href_keys[0])
|
||
if href is None or not href.strip():
|
||
raise ValueError('href cannot be empty')
|
||
return href
|
||
|
||
|
||
def load_project_image_source(
|
||
elem: ET.Element,
|
||
svg_dir: Path | None,
|
||
) -> ProjectImageSource:
|
||
"""Load one exact SVG image source or raise a contract error."""
|
||
if elem.tag != f'{{{SVG_NS}}}image':
|
||
raise ValueError('expected an SVG-namespace <image> element')
|
||
href = _project_image_href(elem)
|
||
if href.startswith('data:'):
|
||
decoded = _decode_data_image_uri(href)
|
||
if decoded is None:
|
||
raise ValueError(
|
||
'data URI must contain a supported, non-empty image with '
|
||
'valid encoding and bytes'
|
||
)
|
||
img_format, img_data = decoded
|
||
return ProjectImageSource(img_format, img_data)
|
||
|
||
if svg_dir is None:
|
||
raise ValueError('external image requires an SVG directory context')
|
||
try:
|
||
img_path = _resolve_external_image(svg_dir, href)
|
||
except FileNotFoundError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
img_format = _normalize_project_image_format(img_path.suffix)
|
||
if img_format is None:
|
||
raise ValueError(
|
||
f'external image has unsupported file extension {img_path.suffix!r}'
|
||
)
|
||
try:
|
||
img_data = img_path.read_bytes()
|
||
except OSError as exc:
|
||
raise ValueError(f'cannot read external image {href!r}: {exc}') from exc
|
||
if not _valid_project_image_payload(img_format, img_data):
|
||
raise ValueError(
|
||
f'external image {href!r} is empty, corrupt, or does not match '
|
||
f'its {img_path.suffix} extension'
|
||
)
|
||
return ProjectImageSource(img_format, img_data)
|
||
|
||
|
||
def project_image_errors(
|
||
root: ET.Element,
|
||
svg_dir: Path | None,
|
||
*,
|
||
allow_template_placeholders: bool = False,
|
||
) -> list[str]:
|
||
"""Return source and frame errors for exact SVG image elements."""
|
||
errors: list[str] = []
|
||
for elem in root.iter():
|
||
if elem.tag.rsplit('}', 1)[-1] != 'image':
|
||
continue
|
||
label = _element_contract_label(elem)
|
||
if elem.tag != f'{{{SVG_NS}}}image':
|
||
errors.append(
|
||
f'{label} must use the SVG namespace '
|
||
f'{SVG_NS!r}'
|
||
)
|
||
continue
|
||
style_values = parse_inline_style(elem.get('style'))
|
||
for attribute in ('width', 'height'):
|
||
raw = style_values.get(attribute)
|
||
if raw is None:
|
||
raw = elem.get(attribute)
|
||
if raw is None:
|
||
errors.append(
|
||
f'{label} requires explicit positive {attribute}'
|
||
)
|
||
continue
|
||
try:
|
||
value = parse_project_geometry_length(raw, attribute)
|
||
except ValueError:
|
||
# The shared geometry-length contract owns syntax diagnostics.
|
||
continue
|
||
if value <= 0:
|
||
errors.append(
|
||
f'{label} {attribute} must be positive; got {raw!r}'
|
||
)
|
||
try:
|
||
raw_href = _project_image_href(elem)
|
||
except ValueError as exc:
|
||
errors.append(f'{label} invalid image source: {exc}')
|
||
continue
|
||
if (
|
||
allow_template_placeholders
|
||
and '{{' in raw_href
|
||
and '}}' in raw_href
|
||
):
|
||
continue
|
||
try:
|
||
load_project_image_source(elem, svg_dir)
|
||
except ValueError as exc:
|
||
errors.append(f'{label} invalid image source: {exc}')
|
||
return sorted(errors)
|
||
|
||
|
||
def _wrap_shape(
|
||
shape_id: int, name: str,
|
||
off_x: int, off_y: int,
|
||
ext_cx: int, ext_cy: int,
|
||
geom_xml: str, fill_xml: str, stroke_xml: str,
|
||
effect_xml: str = '', extra_xml: str = '',
|
||
rot: int = 0,
|
||
xfrm_attr: str = '',
|
||
) -> str:
|
||
"""Wrap DrawingML content into a <p:sp> shape element."""
|
||
rot_attr = f' rot="{rot}"' if rot else ''
|
||
xfrm_attrs = f'{xfrm_attr}{rot_attr}'
|
||
return f'''<p:sp>
|
||
<p:nvSpPr>
|
||
<p:cNvPr id="{shape_id}" name="{_xml_escape(name)}"/>
|
||
<p:cNvSpPr/><p:nvPr/>
|
||
</p:nvSpPr>
|
||
<p:spPr>
|
||
<a:xfrm{xfrm_attrs}><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||
{geom_xml}
|
||
{fill_xml}
|
||
{stroke_xml}
|
||
{effect_xml}
|
||
</p:spPr>
|
||
{extra_xml}
|
||
</p:sp>'''
|
||
|
||
|
||
def _wrap_connector(
|
||
shape_id: int,
|
||
name: str,
|
||
off_x: int,
|
||
off_y: int,
|
||
ext_cx: int,
|
||
ext_cy: int,
|
||
geom_xml: str,
|
||
fill_xml: str,
|
||
stroke_xml: str,
|
||
effect_xml: str = '',
|
||
rot: int = 0,
|
||
xfrm_attr: str = '',
|
||
connection_xml: str = '',
|
||
extra_xml: str = '',
|
||
) -> str:
|
||
"""Wrap DrawingML content into a native ``p:cxnSp`` connector."""
|
||
rot_attr = f' rot="{rot}"' if rot else ''
|
||
xfrm_attrs = f'{xfrm_attr}{rot_attr}'
|
||
return f'''<p:cxnSp>
|
||
<p:nvCxnSpPr>
|
||
<p:cNvPr id="{shape_id}" name="{_xml_escape(name)}"/>
|
||
<p:cNvCxnSpPr>{connection_xml}</p:cNvCxnSpPr><p:nvPr/>
|
||
</p:nvCxnSpPr>
|
||
<p:spPr>
|
||
<a:xfrm{xfrm_attrs}><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||
{geom_xml}
|
||
{fill_xml}
|
||
{stroke_xml}
|
||
{effect_xml}
|
||
</p:spPr>
|
||
{extra_xml}
|
||
</p:cxnSp>'''
|
||
|
||
|
||
def _wrap_geometry_object(
|
||
elem: ET.Element,
|
||
ctx: ConvertContext,
|
||
shape_id: int,
|
||
name: str,
|
||
off_x: int,
|
||
off_y: int,
|
||
ext_cx: int,
|
||
ext_cy: int,
|
||
geom_xml: str,
|
||
fill_xml: str,
|
||
stroke_xml: str,
|
||
effect_xml: str = '',
|
||
xfrm_attr: str = '',
|
||
) -> str:
|
||
"""Wrap a semantic leaf as a shape or connector without guessing."""
|
||
name = elem.get('data-pptx-shape-name') or name
|
||
shape_style_xml = _decode_shape_style(elem)
|
||
object_kind = elem.get('data-pptx-object')
|
||
if object_kind != 'connector':
|
||
return _wrap_shape(
|
||
shape_id,
|
||
name,
|
||
off_x,
|
||
off_y,
|
||
ext_cx,
|
||
ext_cy,
|
||
geom_xml,
|
||
fill_xml,
|
||
stroke_xml,
|
||
effect_xml,
|
||
extra_xml=shape_style_xml,
|
||
xfrm_attr=xfrm_attr,
|
||
)
|
||
|
||
prst = elem.get('data-pptx-prst')
|
||
is_custom = elem.get('data-pptx-geometry-kind') == 'custom'
|
||
if prst is None and not is_custom:
|
||
raise ValueError(
|
||
'data-pptx-object="connector" requires preset or preserved '
|
||
'custom geometry'
|
||
)
|
||
return _wrap_connector(
|
||
shape_id,
|
||
name,
|
||
off_x,
|
||
off_y,
|
||
ext_cx,
|
||
ext_cy,
|
||
geom_xml,
|
||
fill_xml,
|
||
stroke_xml,
|
||
effect_xml,
|
||
xfrm_attr=xfrm_attr,
|
||
connection_xml=_connector_connection_xml(elem, ctx),
|
||
extra_xml=shape_style_xml,
|
||
)
|
||
|
||
|
||
def _decode_shape_style(elem: ET.Element) -> str:
|
||
encoded = elem.get('data-pptx-shape-style')
|
||
if not encoded:
|
||
return ''
|
||
try:
|
||
raw = base64.b64decode(encoded, validate=True)
|
||
style = ET.fromstring(raw)
|
||
decoded = raw.decode('utf-8')
|
||
except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc:
|
||
raise ValueError(f'Invalid shape-style metadata: {exc}') from exc
|
||
if style.tag != (
|
||
'{http://schemas.openxmlformats.org/presentationml/2006/main}style'
|
||
):
|
||
raise ValueError('Shape-style metadata payload must be p:style')
|
||
if has_relationship_attributes(style):
|
||
raise ValueError(
|
||
'Shape-style metadata must not contain relationship attributes'
|
||
)
|
||
return decoded
|
||
|
||
|
||
def _connector_connection_xml(elem: ET.Element, ctx: ConvertContext) -> str:
|
||
"""Restore connector endpoint attachment using the reserved source id map."""
|
||
parts: list[str] = []
|
||
for endpoint, tag in (('start', 'stCxn'), ('end', 'endCxn')):
|
||
raw_shape_id = elem.get(f'data-pptx-{endpoint}-shape-id')
|
||
raw_site = elem.get(f'data-pptx-{endpoint}-site')
|
||
if raw_shape_id is None and raw_site is None:
|
||
continue
|
||
if raw_shape_id is None or raw_site is None:
|
||
raise ValueError(
|
||
f'Connector {endpoint} endpoint requires both shape-id and site'
|
||
)
|
||
target_scope = (
|
||
elem.get(f'data-pptx-{endpoint}-shape-scope')
|
||
or elem.get('data-pptx-shape-scope')
|
||
or 'slide'
|
||
)
|
||
target_id = ctx.reference_shape_id(raw_shape_id, target_scope)
|
||
try:
|
||
site = int(raw_site)
|
||
except ValueError as exc:
|
||
raise ValueError(
|
||
f'Invalid connector {endpoint} site {raw_site!r}'
|
||
) from exc
|
||
if site < 0 or site > 0xFFFFFFFF:
|
||
raise ValueError(
|
||
f'Connector {endpoint} site is outside unsigned integer range: {site}'
|
||
)
|
||
parts.append(f'<a:{tag} id="{target_id}" idx="{site}"/>')
|
||
return ''.join(parts)
|
||
|
||
|
||
def _claim_element_shape_id(elem: ET.Element, ctx: ConvertContext) -> int:
|
||
return ctx.claim_shape_id(
|
||
elem.get('data-pptx-shape-id'),
|
||
elem.get('data-pptx-shape-scope'),
|
||
)
|
||
|
||
|
||
def _context_transform_matrix(ctx: ConvertContext) -> tuple[float, float, float, float, float, float]:
|
||
"""Return the current context as a full SVG affine matrix."""
|
||
if ctx.use_transform_matrix:
|
||
return ctx.transform_matrix
|
||
return (
|
||
ctx.scale_x, 0.0,
|
||
0.0, ctx.scale_y,
|
||
ctx.translate_x, ctx.translate_y,
|
||
)
|
||
|
||
|
||
def _combined_transform_matrix(
|
||
ctx: ConvertContext,
|
||
transform: str | None,
|
||
) -> tuple[float, float, float, float, float, float]:
|
||
"""Compose context transform with an element-level transform attribute."""
|
||
matrix = _context_transform_matrix(ctx)
|
||
if transform:
|
||
matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
|
||
return matrix
|
||
|
||
|
||
def _uses_full_transform(ctx: ConvertContext, transform: str | None) -> bool:
|
||
return ctx.use_transform_matrix or bool(transform)
|
||
|
||
|
||
def _transformed_point(
|
||
ctx: ConvertContext,
|
||
x: float,
|
||
y: float,
|
||
transform: str | None,
|
||
) -> tuple[float, float]:
|
||
if _uses_full_transform(ctx, transform):
|
||
return transform_point(_combined_transform_matrix(ctx, transform), x, y)
|
||
return ctx_x(x, ctx), ctx_y(y, ctx)
|
||
|
||
|
||
def _shape_xfrm_from_svg_rect(
|
||
ctx: ConvertContext,
|
||
raw_x: float,
|
||
raw_y: float,
|
||
raw_w: float,
|
||
raw_h: float,
|
||
resolved_x: float,
|
||
resolved_y: float,
|
||
resolved_w: float,
|
||
resolved_h: float,
|
||
transform: str | None,
|
||
*,
|
||
preserve_degenerate_axes: bool = False,
|
||
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||
"""Build DrawingML xfrm data for an SVG rectangle-like element."""
|
||
if _uses_full_transform(ctx, transform):
|
||
return rect_to_dml_xfrm(
|
||
raw_x, raw_y, raw_w, raw_h,
|
||
_combined_transform_matrix(ctx, transform),
|
||
preserve_degenerate_axes=preserve_degenerate_axes,
|
||
)
|
||
|
||
off_x = px_to_emu(resolved_x)
|
||
off_y = px_to_emu(resolved_y)
|
||
ext_cx = px_to_emu(resolved_w)
|
||
ext_cy = px_to_emu(resolved_h)
|
||
return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# rect
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Cubic-Bézier control distance for approximating a quarter circle / ellipse.
|
||
# Distance from corner to control point along the tangent, expressed as a
|
||
# fraction of the radius. Standard "magic number" for a 90° arc (max error
|
||
# ~0.027% of the radius).
|
||
_BEZIER_QUARTER_K = 0.5522847498
|
||
|
||
|
||
# The hash-locked shared catalog is the single source of truth for the 187
|
||
# ECMA-376 ``ST_ShapeType`` values. Loading it here makes exporter validation
|
||
# fail closed if the catalog is missing, corrupt, or incomplete.
|
||
PPTX_PRESET_SHAPE_TYPES = frozenset(load_shape_type_values())
|
||
|
||
_PPTX_AV_PREFIX = 'data-pptx-av-'
|
||
_PPTX_GUIDE_NAME_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_.-]{0,63}')
|
||
_PPTX_VAL_FORMULA_RE = re.compile(r'val[\t ]+([+-]?\d+)')
|
||
def _parse_preset_geometry_metadata(
|
||
elem: ET.Element,
|
||
) -> tuple[str | None, list[tuple[str, str]], tuple[float, float, float, float] | None]:
|
||
"""Parse and validate rendering-neutral preset geometry metadata."""
|
||
status = (elem.get('data-pptx-geometry-status') or '').strip()
|
||
authoring = elem.get(AUTHORING_ATTR)
|
||
if authoring not in {None, AUTHORING_VALUE}:
|
||
raise ValueError(f'Unsupported {AUTHORING_ATTR} value {authoring!r}')
|
||
if authoring == AUTHORING_VALUE:
|
||
object_kind = elem.get('data-pptx-object')
|
||
if object_kind not in {'shape', 'connector'}:
|
||
raise ValueError(
|
||
'Authored preset metadata requires data-pptx-object='
|
||
'"shape" or "connector"'
|
||
)
|
||
preset = elem.get('data-pptx-prst')
|
||
if preset is None:
|
||
raise ValueError('Authored preset metadata requires data-pptx-prst')
|
||
if preset in CONNECTOR_PRESET_TYPES and object_kind != 'connector':
|
||
raise ValueError(
|
||
f'Connector preset {preset!r} requires '
|
||
'data-pptx-object="connector"'
|
||
)
|
||
if object_kind == 'connector' and preset not in CONNECTOR_PRESET_TYPES:
|
||
raise ValueError(
|
||
f'Authored connector requires a connector preset, got {preset!r}'
|
||
)
|
||
if elem.get('data-pptx-frame') is None:
|
||
raise ValueError('Authored preset metadata requires data-pptx-frame')
|
||
if status not in {'', 'exact', 'unsupported'}:
|
||
raise ValueError(
|
||
f'Unsupported data-pptx-geometry-status {status!r}; '
|
||
'expected exact or unsupported'
|
||
)
|
||
raw_reason = elem.get('data-pptx-geometry-reason')
|
||
if raw_reason is not None and status != 'unsupported':
|
||
raise ValueError(
|
||
'data-pptx-geometry-reason requires '
|
||
'data-pptx-geometry-status="unsupported"'
|
||
)
|
||
if status == 'unsupported':
|
||
reason = (raw_reason or 'unspecified').strip()
|
||
raise ValueError(f'Unsupported source PPTX geometry: {reason}')
|
||
|
||
prst = elem.get('data-pptx-prst')
|
||
allowed_guide_names: frozenset[str] = frozenset()
|
||
if prst is not None:
|
||
if prst != prst.strip() or prst not in PPTX_PRESET_SHAPE_TYPES:
|
||
raise ValueError(f'Unknown or invalid data-pptx-prst {prst!r}')
|
||
allowed_guide_names = frozenset(
|
||
guide.name
|
||
for guide in get_preset_registry().get(prst).adjustments
|
||
)
|
||
|
||
guide_formulas: dict[str, str] = {}
|
||
for attr_name, raw_fmla in elem.attrib.items():
|
||
if not attr_name.startswith(_PPTX_AV_PREFIX):
|
||
continue
|
||
if prst is None:
|
||
raise ValueError(f'{attr_name} requires data-pptx-prst')
|
||
guide_name = attr_name[len(_PPTX_AV_PREFIX):]
|
||
if not _PPTX_GUIDE_NAME_RE.fullmatch(guide_name):
|
||
raise ValueError(f'Invalid preset adjustment guide name {guide_name!r}')
|
||
if guide_name not in allowed_guide_names:
|
||
raise ValueError(
|
||
f'Preset {prst!r} has no adjustment guide named {guide_name!r}'
|
||
)
|
||
formula = raw_fmla.strip()
|
||
if not formula:
|
||
raise ValueError(f'{attr_name} must not be empty')
|
||
match = _PPTX_VAL_FORMULA_RE.fullmatch(formula)
|
||
if match is not None:
|
||
value = int(match.group(1))
|
||
if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX:
|
||
raise ValueError(
|
||
f'{attr_name} value {value} is outside OOXML coordinate range'
|
||
)
|
||
guide_formulas[guide_name] = formula
|
||
|
||
# Compatibility for SVGs emitted before the generic ``data-pptx-av-*``
|
||
# contract. New imports always use the canonical full-formula attributes.
|
||
if prst == 'round2SameRect':
|
||
guide_names = set(guide_formulas)
|
||
for guide_name, default in (('adj1', 16667), ('adj2', 0)):
|
||
legacy_name = f'data-pptx-{guide_name}'
|
||
if guide_name in guide_names or elem.get(legacy_name) is None:
|
||
continue
|
||
raw_value = elem.get(legacy_name, str(default))
|
||
try:
|
||
value = int(float(raw_value))
|
||
except ValueError as exc:
|
||
raise ValueError(f'{legacy_name} must be numeric, got {raw_value!r}') from exc
|
||
value = max(0, min(100000, value))
|
||
guide_formulas[guide_name] = f'val {value}'
|
||
|
||
guides: list[tuple[str, str]] = []
|
||
if prst is not None and guide_formulas:
|
||
registry = get_preset_registry()
|
||
try:
|
||
evaluated = registry.evaluate(
|
||
prst,
|
||
100000,
|
||
100000,
|
||
adjustments=guide_formulas,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(
|
||
f'Invalid adjustment formula for preset {prst!r}: {exc}'
|
||
) from exc
|
||
for name, value in evaluated.adjustments.items():
|
||
if (
|
||
name in guide_formulas
|
||
and not OOXML_COORDINATE_MIN
|
||
<= value
|
||
<= OOXML_COORDINATE_MAX
|
||
):
|
||
raise ValueError(
|
||
f'data-pptx-av-{name} evaluates outside OOXML coordinate range'
|
||
)
|
||
guides = [
|
||
(guide.name, guide_formulas[guide.name])
|
||
for guide in registry.get(prst).adjustments
|
||
if guide.name in guide_formulas
|
||
]
|
||
|
||
frame = None
|
||
raw_frame = elem.get('data-pptx-frame')
|
||
if raw_frame is not None:
|
||
parts = re.split(r'[\s,]+', raw_frame.strip())
|
||
if len(parts) != 4:
|
||
raise ValueError(
|
||
'data-pptx-frame must contain exactly four numbers: x y width height'
|
||
)
|
||
try:
|
||
frame = tuple(float(part) for part in parts)
|
||
except ValueError as exc:
|
||
raise ValueError(f'Invalid data-pptx-frame {raw_frame!r}') from exc
|
||
if not all(math.isfinite(value) for value in frame):
|
||
raise ValueError(f'data-pptx-frame must contain finite numbers, got {raw_frame!r}')
|
||
is_connector = (
|
||
elem.get('data-pptx-object') == 'connector'
|
||
or prst in CONNECTOR_PRESET_TYPES
|
||
)
|
||
if is_connector:
|
||
if frame[2] < 0 or frame[3] < 0 or (frame[2] == 0 and frame[3] == 0):
|
||
raise ValueError(
|
||
'Connector data-pptx-frame dimensions must be non-negative '
|
||
f'and not both zero, got {raw_frame!r}'
|
||
)
|
||
elif frame[2] <= 0 or frame[3] <= 0:
|
||
raise ValueError(
|
||
f'data-pptx-frame width and height must be positive, got {raw_frame!r}'
|
||
)
|
||
validate_ooxml_xfrm(
|
||
px_to_emu(frame[0]),
|
||
px_to_emu(frame[1]),
|
||
px_to_emu(frame[2]),
|
||
px_to_emu(frame[3]),
|
||
)
|
||
|
||
return prst, guides, frame
|
||
|
||
|
||
def validate_preset_geometry_metadata(elem: ET.Element) -> list[str]:
|
||
"""Return native shape metadata errors for authoring-time validation."""
|
||
errors: list[str] = []
|
||
try:
|
||
_parse_preset_geometry_metadata(elem)
|
||
except ValueError as exc:
|
||
errors.append(str(exc))
|
||
if elem.get('data-pptx-custgeom') is not None:
|
||
try:
|
||
_build_preserved_custom_geom(elem)
|
||
except ValueError as exc:
|
||
errors.append(str(exc))
|
||
if elem.get('data-pptx-shape-style') is not None:
|
||
try:
|
||
_decode_shape_style(elem)
|
||
except ValueError as exc:
|
||
errors.append(str(exc))
|
||
raw_shape_id = elem.get('data-pptx-shape-id')
|
||
if raw_shape_id is not None:
|
||
try:
|
||
shape_id = int(raw_shape_id)
|
||
except ValueError:
|
||
errors.append(f'Invalid data-pptx-shape-id {raw_shape_id!r}')
|
||
else:
|
||
if shape_id < 2 or shape_id > 0xFFFFFFFF:
|
||
errors.append(
|
||
'data-pptx-shape-id must be between 2 and 4294967295'
|
||
)
|
||
scope = elem.get('data-pptx-shape-scope')
|
||
if scope is not None and re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', scope) is None:
|
||
errors.append(f'Invalid data-pptx-shape-scope {scope!r}')
|
||
for endpoint in ('start', 'end'):
|
||
target = elem.get(f'data-pptx-{endpoint}-shape-id')
|
||
site = elem.get(f'data-pptx-{endpoint}-site')
|
||
if (target is None) != (site is None):
|
||
errors.append(
|
||
f'Connector {endpoint} endpoint requires both shape-id and site'
|
||
)
|
||
if target is not None:
|
||
try:
|
||
target_id = int(target)
|
||
site_id = int(site or '')
|
||
except ValueError:
|
||
errors.append(f'Invalid connector {endpoint} endpoint metadata')
|
||
else:
|
||
if target_id < 2 or target_id > 0xFFFFFFFF:
|
||
errors.append(f'Connector {endpoint} shape-id is out of range')
|
||
if site_id < 0 or site_id > 0xFFFFFFFF:
|
||
errors.append(f'Connector {endpoint} site is out of range')
|
||
return errors
|
||
|
||
|
||
def _build_preset_geom_from_meta(elem: ET.Element) -> str | None:
|
||
"""Build validated native DrawingML preset geometry from SVG metadata."""
|
||
prst, guides, _frame = _parse_preset_geometry_metadata(elem)
|
||
if prst is None:
|
||
return None
|
||
if not guides:
|
||
return f'<a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>'
|
||
guide_xml = ''.join(
|
||
f'<a:gd name="{_xml_escape(name)}" fmla="{_xml_escape(fmla)}"/>'
|
||
for name, fmla in guides
|
||
)
|
||
return f'<a:prstGeom prst="{prst}"><a:avLst>{guide_xml}</a:avLst></a:prstGeom>'
|
||
|
||
|
||
def _build_preserved_custom_geom(elem: ET.Element) -> str | None:
|
||
"""Return unchanged native ``a:custGeom`` metadata, or mark it stale."""
|
||
kind = elem.get('data-pptx-geometry-kind')
|
||
if kind is None:
|
||
return None
|
||
if kind != 'custom':
|
||
raise ValueError(f'Unsupported data-pptx-geometry-kind {kind!r}')
|
||
encoded = elem.get('data-pptx-custgeom')
|
||
expected_hash = elem.get('data-pptx-geometry-sha256')
|
||
if not encoded or not expected_hash:
|
||
raise ValueError(
|
||
'Custom geometry metadata requires data-pptx-custgeom and '
|
||
'data-pptx-geometry-sha256'
|
||
)
|
||
actual_hash = hashlib.sha256(
|
||
(elem.get('d') or '').strip().encode('utf-8')
|
||
).hexdigest()
|
||
if actual_hash != expected_hash:
|
||
return None
|
||
try:
|
||
raw = base64.b64decode(encoded, validate=True)
|
||
custom = ET.fromstring(raw)
|
||
decoded = raw.decode('utf-8')
|
||
except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc:
|
||
raise ValueError(f'Invalid custom geometry metadata: {exc}') from exc
|
||
if custom.tag != (
|
||
'{http://schemas.openxmlformats.org/drawingml/2006/main}custGeom'
|
||
):
|
||
raise ValueError('Custom geometry metadata payload must be a:custGeom')
|
||
if has_relationship_attributes(custom):
|
||
raise ValueError(
|
||
'Custom geometry metadata must not contain relationship attributes'
|
||
)
|
||
return decoded
|
||
|
||
|
||
def _shape_xfrm_from_preset_frame(
|
||
elem: ET.Element,
|
||
ctx: ConvertContext,
|
||
fallback_raw_rect: tuple[float, float, float, float],
|
||
fallback_resolved_rect: tuple[float, float, float, float],
|
||
transform: str | None,
|
||
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||
"""Use the preserved logical frame for native preset size when present."""
|
||
prst, _guides, frame = _parse_preset_geometry_metadata(elem)
|
||
if frame is None:
|
||
raw_x, raw_y, raw_w, raw_h = fallback_raw_rect
|
||
x, y, w, h = fallback_resolved_rect
|
||
else:
|
||
raw_x, raw_y, raw_w, raw_h = frame
|
||
x = ctx_x(raw_x, ctx)
|
||
y = ctx_y(raw_y, ctx)
|
||
w = ctx_w(raw_w, ctx)
|
||
h = ctx_h(raw_h, ctx)
|
||
preserves_zero_axis = (
|
||
elem.get('data-pptx-object') == 'connector'
|
||
or prst in CONNECTOR_PRESET_TYPES
|
||
)
|
||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect(
|
||
ctx,
|
||
raw_x,
|
||
raw_y,
|
||
raw_w,
|
||
raw_h,
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
transform,
|
||
preserve_degenerate_axes=preserves_zero_axis,
|
||
)
|
||
if not preserves_zero_axis:
|
||
ext_cx = max(ext_cx, 1)
|
||
ext_cy = max(ext_cy, 1)
|
||
bounds_emu = (
|
||
bounds_emu[0],
|
||
bounds_emu[1],
|
||
max(bounds_emu[2], off_x + ext_cx),
|
||
max(bounds_emu[3], off_y + ext_cy),
|
||
)
|
||
return xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu
|
||
|
||
|
||
def _pathlike_preset_xfrm(
|
||
elem: ET.Element,
|
||
ctx: ConvertContext,
|
||
transform: str | None,
|
||
min_x: float,
|
||
min_y: float,
|
||
width: float,
|
||
height: float,
|
||
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||
"""Resolve a path-like preset xfrm from its logical frame or visual bounds."""
|
||
_prst, _guides, frame = _parse_preset_geometry_metadata(elem)
|
||
if frame is None:
|
||
if _uses_full_transform(ctx, transform):
|
||
tag = elem.tag.rsplit('}', 1)[-1]
|
||
raise ValueError(
|
||
f'Transformed preset-bearing <{tag}> requires data-pptx-frame '
|
||
'to preserve its logical size'
|
||
)
|
||
off_x = px_to_emu(min_x)
|
||
off_y = px_to_emu(min_y)
|
||
ext_cx = max(px_to_emu(width), 1)
|
||
ext_cy = max(px_to_emu(height), 1)
|
||
return (
|
||
'',
|
||
off_x,
|
||
off_y,
|
||
ext_cx,
|
||
ext_cy,
|
||
(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
|
||
)
|
||
return _shape_xfrm_from_preset_frame(
|
||
elem,
|
||
ctx,
|
||
(0.0, 0.0, 1.0, 1.0),
|
||
(min_x, min_y, width, height),
|
||
transform,
|
||
)
|
||
|
||
|
||
def _build_round_rect_custgeom(w: float, h: float, rx: float, ry: float) -> str:
|
||
"""Build a DrawingML ``custGeom`` for a rectangle with elliptical corners.
|
||
|
||
Used when ``<rect>`` has rx ≠ ry, which DrawingML's preset ``roundRect``
|
||
cannot express (the preset takes a single ``adj`` shared by all four
|
||
corners and is implicitly symmetric). Each 90° elliptical arc is
|
||
approximated by one cubic Bézier — within 0.03% of the true ellipse, far
|
||
below any visible threshold at slide resolution.
|
||
|
||
Trade-off vs. the symmetric ``prstGeom roundRect`` path: this geometry
|
||
is custom, so PowerPoint's yellow corner-radius handle is gone and the
|
||
shape can no longer be retuned in-place. That matches the underlying
|
||
reality — rx ≠ ry has no single "radius" to drag — and remains far
|
||
better than the previous behaviour (silently dropping all corners and
|
||
rendering a hard rectangle).
|
||
|
||
Args:
|
||
w, h: Pixel dimensions of the rectangle (post ctx-scale).
|
||
rx, ry: Pixel corner radii along x and y. Will be clamped to half
|
||
of w / h respectively per the SVG spec.
|
||
|
||
Returns:
|
||
A complete ``<a:custGeom>...</a:custGeom>`` XML string. Coordinates
|
||
are emitted in EMU within a path-local coordinate system whose
|
||
``w`` / ``h`` equal the rectangle's pixel-converted dimensions.
|
||
"""
|
||
# Clamp radii (SVG spec): rx > w/2 collapses to a half-circle end.
|
||
rx = min(max(rx, 0.0), w / 2)
|
||
ry = min(max(ry, 0.0), h / 2)
|
||
|
||
width_emu = px_to_emu(w)
|
||
height_emu = px_to_emu(h)
|
||
rx_emu = px_to_emu(rx)
|
||
ry_emu = px_to_emu(ry)
|
||
|
||
cx_off = int(round(rx_emu * _BEZIER_QUARTER_K))
|
||
cy_off = int(round(ry_emu * _BEZIER_QUARTER_K))
|
||
|
||
def pt(x: int, y: int) -> str:
|
||
return f'<a:pt x="{x}" y="{y}"/>'
|
||
|
||
def cubic(c1: tuple[int, int], c2: tuple[int, int], end: tuple[int, int]) -> str:
|
||
return (
|
||
f'<a:cubicBezTo>{pt(*c1)}{pt(*c2)}{pt(*end)}</a:cubicBezTo>'
|
||
)
|
||
|
||
# Path traversed clockwise, starting just past the top-left corner.
|
||
parts = [
|
||
f'<a:moveTo>{pt(rx_emu, 0)}</a:moveTo>',
|
||
f'<a:lnTo>{pt(width_emu - rx_emu, 0)}</a:lnTo>',
|
||
# Top-right corner: (W-Rx, 0) → (W, Ry)
|
||
cubic(
|
||
(width_emu - rx_emu + cx_off, 0),
|
||
(width_emu, ry_emu - cy_off),
|
||
(width_emu, ry_emu),
|
||
),
|
||
f'<a:lnTo>{pt(width_emu, height_emu - ry_emu)}</a:lnTo>',
|
||
# Bottom-right corner: (W, H-Ry) → (W-Rx, H)
|
||
cubic(
|
||
(width_emu, height_emu - ry_emu + cy_off),
|
||
(width_emu - rx_emu + cx_off, height_emu),
|
||
(width_emu - rx_emu, height_emu),
|
||
),
|
||
f'<a:lnTo>{pt(rx_emu, height_emu)}</a:lnTo>',
|
||
# Bottom-left corner: (Rx, H) → (0, H-Ry)
|
||
cubic(
|
||
(rx_emu - cx_off, height_emu),
|
||
(0, height_emu - ry_emu + cy_off),
|
||
(0, height_emu - ry_emu),
|
||
),
|
||
f'<a:lnTo>{pt(0, ry_emu)}</a:lnTo>',
|
||
# Top-left corner: (0, Ry) → (Rx, 0)
|
||
cubic(
|
||
(0, ry_emu - cy_off),
|
||
(rx_emu - cx_off, 0),
|
||
(rx_emu, 0),
|
||
),
|
||
'<a:close/>',
|
||
]
|
||
|
||
path_xml = '\n'.join(parts)
|
||
return (
|
||
'<a:custGeom>'
|
||
'<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>'
|
||
'<a:rect l="l" t="t" r="r" b="b"/>'
|
||
f'<a:pathLst><a:path w="{width_emu}" h="{height_emu}">'
|
||
f'\n{path_xml}\n'
|
||
'</a:path></a:pathLst>'
|
||
'</a:custGeom>'
|
||
)
|
||
|
||
|
||
def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <rect> to DrawingML shape.
|
||
|
||
Symmetric rounded corners (rx == ry) are emitted as ``prstGeom roundRect``
|
||
so PowerPoint treats them as a native rounded-rectangle shape: the yellow
|
||
adjustment handle stays draggable, and "Reset Picture / Shape" works as
|
||
expected. Elliptical corners (rx != ry) fall back to plain rect geometry
|
||
for now — current corpora contain none, but the branch keeps callers from
|
||
silently producing distorted custom geometry if one ever appears.
|
||
"""
|
||
raw_x = svg_length_x(elem.get('x'), ctx)
|
||
raw_y = svg_length_y(elem.get('y'), ctx)
|
||
raw_w = svg_length_x(elem.get('width'), ctx)
|
||
raw_h = svg_length_y(elem.get('height'), ctx)
|
||
x = ctx_x(raw_x, ctx)
|
||
y = ctx_y(raw_y, ctx)
|
||
w = ctx_w(raw_w, ctx)
|
||
h = ctx_h(raw_h, ctx)
|
||
preset_geom = _build_preset_geom_from_meta(elem)
|
||
|
||
if w <= 0 or h <= 0:
|
||
return None
|
||
|
||
# SVG spec: when only one of rx/ry is specified, the other inherits its
|
||
# value. Real-world svg_output decks always write only `rx`, so ry must
|
||
# be inferred to keep round corners from collapsing to zero on one axis.
|
||
rx_attr = elem.get('rx')
|
||
ry_attr = elem.get('ry')
|
||
rx_raw = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0
|
||
ry_raw = svg_length_y(ry_attr, ctx) if ry_attr is not None else 0.0
|
||
if rx_attr is not None and ry_attr is None:
|
||
ry_raw = rx_raw
|
||
elif ry_attr is not None and rx_attr is None:
|
||
rx_raw = ry_raw
|
||
rx = rx_raw * ctx.scale_x
|
||
ry = ry_raw * ctx.scale_y
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
effect = ''
|
||
filt_id = get_effective_filter_id(elem, ctx)
|
||
if filt_id and filt_id in ctx.defs:
|
||
effect = build_effect_xml(
|
||
ctx.defs[filt_id],
|
||
get_element_opacity(elem, ctx),
|
||
)
|
||
|
||
transform = elem.get('transform')
|
||
|
||
if preset_geom is not None:
|
||
geom = preset_geom
|
||
elif rx > 0 and abs(rx - ry) < 0.5:
|
||
# Symmetric corners → native PowerPoint rounded rectangle. adj is
|
||
# the corner radius as a fraction of the shorter side, in 1/1000-
|
||
# percent units, capped at 50000 (= radius equals half the shorter
|
||
# side, i.e. capsule end).
|
||
short_side = min(w, h)
|
||
radius = min(rx, short_side / 2)
|
||
adj = max(0, min(50000, int(round(radius / short_side * 100000))))
|
||
geom = (
|
||
'<a:prstGeom prst="roundRect">'
|
||
f'<a:avLst><a:gd name="adj" fmla="val {adj}"/></a:avLst>'
|
||
'</a:prstGeom>'
|
||
)
|
||
elif rx > 0 or ry > 0:
|
||
# Asymmetric corners (rx != ry) → DrawingML has no preset for
|
||
# elliptical-corner rectangles, so emit a custGeom with one cubic
|
||
# Bézier per 90° arc. We lose the prstGeom roundRect adjustment
|
||
# handle, but symmetric and asymmetric cases now both render with
|
||
# rounded corners instead of one of them silently flattening to
|
||
# a hard rectangle.
|
||
geom = _build_round_rect_custgeom(w, h, rx, ry)
|
||
else:
|
||
geom = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
if preset_geom is not None:
|
||
xfrm = _shape_xfrm_from_preset_frame(
|
||
elem,
|
||
ctx,
|
||
(raw_x, raw_y, raw_w, raw_h),
|
||
(x, y, w, h),
|
||
transform,
|
||
)
|
||
else:
|
||
xfrm = _shape_xfrm_from_svg_rect(
|
||
ctx,
|
||
raw_x,
|
||
raw_y,
|
||
raw_w,
|
||
raw_h,
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
transform,
|
||
)
|
||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm
|
||
return ShapeResult(
|
||
xml=_wrap_geometry_object(
|
||
elem,
|
||
ctx,
|
||
shape_id, f'Rectangle {shape_id}',
|
||
off_x, off_y, ext_cx, ext_cy,
|
||
geom, fill, stroke, effect, xfrm_attr=xfrm_attr,
|
||
),
|
||
bounds_emu=bounds_emu,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# circle (including donut-chart arc segments)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _build_arc_ring_path(
|
||
cx: float, cy: float, r: float,
|
||
stroke_width: float,
|
||
dash_len: float, dash_offset: float,
|
||
rotate_deg: float,
|
||
sx: float, sy: float,
|
||
) -> tuple[str, int, int, int, int]:
|
||
"""Build a filled annular-sector (donut segment) as DrawingML custGeom.
|
||
|
||
SVG donut charts use stroke-dasharray on a circle to draw arc segments.
|
||
DrawingML cannot reproduce this, so we convert each arc segment into a
|
||
filled ring shape (outer arc -> line -> inner arc -> close).
|
||
|
||
Returns:
|
||
(geom_xml, min_x_emu, min_y_emu, w_emu, h_emu).
|
||
"""
|
||
circumference = 2 * math.pi * r
|
||
if circumference <= 0:
|
||
return '', 0, 0, 0, 0
|
||
|
||
start_frac = -dash_offset / circumference
|
||
end_frac = start_frac + dash_len / circumference
|
||
|
||
start_angle = start_frac * 2 * math.pi + math.radians(rotate_deg)
|
||
end_angle = end_frac * 2 * math.pi + math.radians(rotate_deg)
|
||
|
||
half_sw = stroke_width / 2
|
||
r_outer = r + half_sw
|
||
r_inner = r - half_sw
|
||
|
||
num_segments = max(16, int(abs(end_angle - start_angle) / (math.pi / 32)))
|
||
angles = [
|
||
start_angle + (end_angle - start_angle) * i / num_segments
|
||
for i in range(num_segments + 1)
|
||
]
|
||
|
||
outer_pts = [(cx + r_outer * math.sin(a), cy - r_outer * math.cos(a)) for a in angles]
|
||
inner_pts = [(cx + r_inner * math.sin(a), cy - r_inner * math.cos(a)) for a in reversed(angles)]
|
||
|
||
all_pts = [(px * sx, py * sy) for px, py in outer_pts + inner_pts]
|
||
|
||
xs = [p[0] for p in all_pts]
|
||
ys = [p[1] for p in all_pts]
|
||
min_x, max_x = min(xs), max(xs)
|
||
min_y, max_y = min(ys), max(ys)
|
||
width = max_x - min_x
|
||
height = max_y - min_y
|
||
|
||
if width < 0.5 or height < 0.5:
|
||
return '', 0, 0, 0, 0
|
||
|
||
w_emu = px_to_emu(width)
|
||
h_emu = px_to_emu(height)
|
||
|
||
lines: list[str] = []
|
||
for i, (px, py) in enumerate(all_pts):
|
||
lx = px_to_emu(px - min_x)
|
||
ly = px_to_emu(py - min_y)
|
||
if i == 0:
|
||
lines.append(f'<a:moveTo><a:pt x="{lx}" y="{ly}"/></a:moveTo>')
|
||
else:
|
||
lines.append(f'<a:lnTo><a:pt x="{lx}" y="{ly}"/></a:lnTo>')
|
||
lines.append('<a:close/>')
|
||
|
||
path_xml = '\n'.join(lines)
|
||
geom = f'''<a:custGeom>
|
||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||
<a:rect l="l" t="t" r="r" b="b"/>
|
||
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
|
||
{path_xml}
|
||
</a:path></a:pathLst>
|
||
</a:custGeom>'''
|
||
|
||
return geom, px_to_emu(min_x), px_to_emu(min_y), w_emu, h_emu
|
||
|
||
|
||
def _is_donut_circle(elem: ET.Element, ctx: ConvertContext) -> bool:
|
||
"""Detect if a circle uses stroke-dasharray to simulate an arc segment."""
|
||
dasharray = _get_attr(elem, 'stroke-dasharray', ctx)
|
||
stroke = _get_attr(elem, 'stroke', ctx)
|
||
fill = _get_attr(elem, 'fill', ctx)
|
||
sw = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 0)
|
||
r = svg_length_size(elem.get('r'), ctx, 0)
|
||
return is_thick_circle_shorthand(dasharray, stroke, fill, sw, r)
|
||
|
||
|
||
def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <circle> to DrawingML ellipse or donut-arc shape."""
|
||
cx_ = svg_length_x(elem.get('cx'), ctx)
|
||
cy_ = svg_length_y(elem.get('cy'), ctx)
|
||
r = svg_length_size(elem.get('r'), ctx)
|
||
preset_geom = _build_preset_geom_from_meta(elem)
|
||
|
||
if r <= 0:
|
||
return None
|
||
|
||
# --- Donut-chart arc segment detection ---
|
||
if preset_geom is None and _is_donut_circle(elem, ctx):
|
||
dasharray = _get_attr(elem, 'stroke-dasharray', ctx)
|
||
parsed_dasharray = parse_project_stroke_dasharray(
|
||
dasharray,
|
||
allow_zero_gap=True,
|
||
)
|
||
if parsed_dasharray is None:
|
||
raise ValueError('Thick-circle arc requires one dash/gap pair')
|
||
_preset, dash_values = parsed_dasharray
|
||
dash_len = dash_values[0]
|
||
raw_dash_offset = elem.get('stroke-dashoffset')
|
||
dash_offset = (
|
||
parse_project_geometry_length(
|
||
raw_dash_offset,
|
||
'stroke-dashoffset',
|
||
)
|
||
if raw_dash_offset is not None else 0.0
|
||
)
|
||
stroke_width = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 1)
|
||
|
||
rotate_deg = 0.0
|
||
transform = elem.get('transform', '')
|
||
if transform:
|
||
operations = parse_transform_operations(transform)
|
||
if len(operations) != 1 or operations[0][0] != 'rotate':
|
||
raise ValueError(
|
||
'Thick-circle arc transform must be one rotate operation'
|
||
)
|
||
rotate_deg = operations[0][1][0]
|
||
|
||
geom, min_x, min_y, w_emu, h_emu = _build_arc_ring_path(
|
||
ctx_x(cx_, ctx) / ctx.scale_x,
|
||
ctx_y(cy_, ctx) / ctx.scale_y,
|
||
r, stroke_width, dash_len, dash_offset, rotate_deg,
|
||
ctx.scale_x, ctx.scale_y,
|
||
)
|
||
if not geom:
|
||
return None
|
||
|
||
# Use the stroke color/gradient as fill for the arc shape
|
||
stroke_val = _get_attr(elem, 'stroke', ctx)
|
||
op = get_stroke_opacity(elem, ctx)
|
||
grad_id = resolve_url_id(stroke_val) if stroke_val else None
|
||
if grad_id and grad_id in ctx.defs:
|
||
fill = build_gradient_fill(
|
||
ctx.defs[grad_id],
|
||
op,
|
||
ctx.theme_color_spec,
|
||
"fill",
|
||
)
|
||
elif stroke_val:
|
||
color, color_alpha = parse_svg_color(stroke_val)
|
||
fill = (
|
||
build_solid_fill(
|
||
color,
|
||
combine_opacity(op, color_alpha),
|
||
ctx.theme_color_spec,
|
||
"fill",
|
||
)
|
||
if color else '<a:noFill/>'
|
||
)
|
||
else:
|
||
fill = '<a:noFill/>'
|
||
|
||
stroke_xml = '<a:ln><a:noFill/></a:ln>'
|
||
|
||
effect = ''
|
||
filt_id = get_effective_filter_id(elem, ctx)
|
||
if filt_id and filt_id in ctx.defs:
|
||
effect = build_effect_xml(
|
||
ctx.defs[filt_id],
|
||
get_element_opacity(elem, ctx),
|
||
)
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
return ShapeResult(
|
||
xml=_wrap_shape(
|
||
shape_id, f'Arc {shape_id}',
|
||
min_x, min_y, w_emu, h_emu,
|
||
geom, fill, stroke_xml, effect,
|
||
),
|
||
bounds_emu=(min_x, min_y, min_x + w_emu, min_y + h_emu),
|
||
)
|
||
|
||
# --- Normal circle ---
|
||
transform = elem.get('transform')
|
||
cx_s = ctx_x(cx_, ctx)
|
||
cy_s = ctx_y(cy_, ctx)
|
||
r_x = r * ctx.scale_x
|
||
r_y = r * ctx.scale_y
|
||
|
||
x = cx_s - r_x
|
||
y = cy_s - r_y
|
||
w = r_x * 2
|
||
h = r_y * 2
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
effect = ''
|
||
filt_id = get_effective_filter_id(elem, ctx)
|
||
if filt_id and filt_id in ctx.defs:
|
||
effect = build_effect_xml(
|
||
ctx.defs[filt_id],
|
||
get_element_opacity(elem, ctx),
|
||
)
|
||
|
||
geom = preset_geom or '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
if preset_geom is not None:
|
||
xfrm = _shape_xfrm_from_preset_frame(
|
||
elem,
|
||
ctx,
|
||
(cx_ - r, cy_ - r, r * 2, r * 2),
|
||
(x, y, w, h),
|
||
transform,
|
||
)
|
||
else:
|
||
xfrm = _shape_xfrm_from_svg_rect(
|
||
ctx,
|
||
cx_ - r,
|
||
cy_ - r,
|
||
r * 2,
|
||
r * 2,
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
transform,
|
||
)
|
||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm
|
||
return ShapeResult(
|
||
xml=_wrap_geometry_object(
|
||
elem,
|
||
ctx,
|
||
shape_id, f'Ellipse {shape_id}',
|
||
off_x, off_y, ext_cx, ext_cy,
|
||
geom, fill, stroke, effect, xfrm_attr=xfrm_attr,
|
||
),
|
||
bounds_emu=bounds_emu,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# line
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <line> to DrawingML shape.
|
||
|
||
Lines with marker-start / marker-end are converted using the 'line' preset
|
||
geometry (prstGeom prst="line") so that PowerPoint renders native arrow
|
||
heads (headEnd / tailEnd) correctly. Plain lines (no markers) continue to
|
||
use custom geometry which is sufficient and avoids flipH/flipV complexity.
|
||
"""
|
||
preset_geom = _build_preset_geom_from_meta(elem)
|
||
transform = elem.get('transform')
|
||
raw_x1 = svg_length_x(elem.get('x1'), ctx)
|
||
raw_y1 = svg_length_y(elem.get('y1'), ctx)
|
||
raw_x2 = svg_length_x(elem.get('x2'), ctx)
|
||
raw_y2 = svg_length_y(elem.get('y2'), ctx)
|
||
x1, y1 = _transformed_point(
|
||
ctx,
|
||
raw_x1,
|
||
raw_y1,
|
||
transform,
|
||
)
|
||
x2, y2 = _transformed_point(
|
||
ctx,
|
||
raw_x2,
|
||
raw_y2,
|
||
transform,
|
||
)
|
||
|
||
min_x = min(x1, x2)
|
||
min_y = min(y1, y2)
|
||
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
off_x = px_to_emu(min_x)
|
||
off_y = px_to_emu(min_y)
|
||
|
||
# Determine if this line carries arrow markers.
|
||
has_marker = bool(
|
||
_get_attr(elem, 'marker-start', ctx) or
|
||
_get_attr(elem, 'marker-end', ctx)
|
||
)
|
||
|
||
if preset_geom is not None:
|
||
# The preserved logical frame, not the rendered stroke/marker bounds,
|
||
# owns the native shape size. Horizontal/vertical connectors retain a
|
||
# one-EMU extent on the degenerate axis as required by DrawingML.
|
||
raw_w = abs(raw_x2 - raw_x1)
|
||
raw_h = abs(raw_y2 - raw_y1)
|
||
resolved_w = abs(x2 - x1)
|
||
resolved_h = abs(y2 - y1)
|
||
xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = (
|
||
_shape_xfrm_from_preset_frame(
|
||
elem,
|
||
ctx,
|
||
(min(raw_x1, raw_x2), min(raw_y1, raw_y2), raw_w, raw_h),
|
||
(min_x, min_y, resolved_w, resolved_h),
|
||
transform,
|
||
)
|
||
)
|
||
if not _uses_full_transform(ctx, transform):
|
||
flip_attrs = []
|
||
if x1 > x2:
|
||
flip_attrs.append(' flipH="1"')
|
||
if y1 > y2:
|
||
flip_attrs.append(' flipV="1"')
|
||
xfrm_attr += ''.join(flip_attrs)
|
||
xml = _wrap_geometry_object(
|
||
elem,
|
||
ctx,
|
||
shape_id,
|
||
f'Connector {shape_id}' if elem.get('data-pptx-object') == 'connector'
|
||
else f'Line {shape_id}',
|
||
off_x,
|
||
off_y,
|
||
w_emu,
|
||
h_emu,
|
||
preset_geom,
|
||
'<a:noFill/>',
|
||
stroke,
|
||
xfrm_attr=xfrm_attr,
|
||
)
|
||
return ShapeResult(xml=xml, bounds_emu=bounds_emu)
|
||
|
||
if has_marker:
|
||
# ----------------------------------------------------------------
|
||
# Preset geometry approach: prstGeom prst="line"
|
||
# PowerPoint only renders headEnd / tailEnd on lines whose geometry
|
||
# it can intrinsically understand as a "line" (i.e. preset or
|
||
# connector shapes). Custom geometry shapes silently ignore
|
||
# headEnd / tailEnd in most PowerPoint versions.
|
||
#
|
||
# The "line" preset draws from (0,0) to (w,h).
|
||
# headEnd → placed at the start of the line = (x1, y1)
|
||
# tailEnd → placed at the end of the line = (x2, y2)
|
||
# We set flipH / flipV so that the preset start/end align with the
|
||
# original SVG endpoints:
|
||
# default (no flip) : top-left → bottom-right (x1≤x2, y1≤y2)
|
||
# flipH : top-right → bottom-left (x1>x2, y1≤y2)
|
||
# flipV : bottom-left → top-right (x1≤x2, y1>y2)
|
||
# flipH + flipV : bottom-right → top-left (x1>x2, y1>y2)
|
||
# ----------------------------------------------------------------
|
||
w = abs(x2 - x1)
|
||
h = abs(y2 - y1)
|
||
# DrawingML requires ext cx/cy ≥ 1 EMU
|
||
w_emu = px_to_emu(w) if w > 0 else 1
|
||
h_emu = px_to_emu(h) if h > 0 else 1
|
||
|
||
flip_h = x1 > x2
|
||
flip_v = y1 > y2
|
||
flip_attr = ''
|
||
if flip_h and flip_v:
|
||
flip_attr = ' flipH="1" flipV="1"'
|
||
elif flip_h:
|
||
flip_attr = ' flipH="1"'
|
||
elif flip_v:
|
||
flip_attr = ' flipV="1"'
|
||
|
||
xml = _wrap_shape(
|
||
shape_id,
|
||
f'Line {shape_id}',
|
||
off_x,
|
||
off_y,
|
||
w_emu,
|
||
h_emu,
|
||
'<a:prstGeom prst="line"><a:avLst/></a:prstGeom>',
|
||
'<a:noFill/>',
|
||
stroke,
|
||
xfrm_attr=flip_attr,
|
||
)
|
||
else:
|
||
# ----------------------------------------------------------------
|
||
# Custom geometry (original behaviour) for plain lines.
|
||
# ----------------------------------------------------------------
|
||
w = max(abs(x2 - x1), 1)
|
||
h = max(abs(y2 - y1), 1)
|
||
w_emu = px_to_emu(w)
|
||
h_emu = px_to_emu(h)
|
||
|
||
lx1 = px_to_emu(x1 - min_x)
|
||
ly1 = px_to_emu(y1 - min_y)
|
||
lx2 = px_to_emu(x2 - min_x)
|
||
ly2 = px_to_emu(y2 - min_y)
|
||
|
||
geom = (
|
||
f'<a:custGeom>'
|
||
f'<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>'
|
||
f'<a:rect l="l" t="t" r="r" b="b"/>'
|
||
f'<a:pathLst><a:path w="{w_emu}" h="{h_emu}">'
|
||
f'<a:moveTo><a:pt x="{lx1}" y="{ly1}"/></a:moveTo>'
|
||
f'<a:lnTo><a:pt x="{lx2}" y="{ly2}"/></a:lnTo>'
|
||
f'</a:path></a:pathLst>'
|
||
f'</a:custGeom>'
|
||
)
|
||
xml = _wrap_shape(
|
||
shape_id, f'Line {shape_id}',
|
||
off_x, off_y, w_emu, h_emu,
|
||
geom, '<a:noFill/>', stroke,
|
||
)
|
||
|
||
return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# path
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <path> to DrawingML custom geometry shape."""
|
||
preset_geom = _build_preset_geom_from_meta(elem)
|
||
preserved_custom_geom = _build_preserved_custom_geom(elem)
|
||
native_geom = preset_geom or preserved_custom_geom
|
||
d = elem.get('d', '')
|
||
if not d:
|
||
if native_geom is not None:
|
||
raise ValueError('Native-geometry <path> requires a non-empty d attribute')
|
||
return None
|
||
|
||
commands = parse_svg_path(d)
|
||
commands = svg_path_to_absolute(commands)
|
||
commands = normalize_path_commands(commands)
|
||
|
||
transform = elem.get('transform')
|
||
if _uses_full_transform(ctx, transform):
|
||
commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
|
||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||
commands, 0, 0, 1.0, 1.0,
|
||
)
|
||
else:
|
||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||
commands, ctx.translate_x, ctx.translate_y,
|
||
ctx.scale_x, ctx.scale_y,
|
||
)
|
||
|
||
if not path_xml:
|
||
return None
|
||
|
||
w_emu = px_to_emu(width)
|
||
h_emu = px_to_emu(height)
|
||
|
||
geom = native_geom
|
||
if geom is None:
|
||
geom = f'''<a:custGeom>
|
||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||
<a:rect l="l" t="t" r="r" b="b"/>
|
||
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
|
||
{path_xml}
|
||
</a:path></a:pathLst>
|
||
</a:custGeom>'''
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
effect = ''
|
||
filt_id = get_effective_filter_id(elem, ctx)
|
||
if filt_id and filt_id in ctx.defs:
|
||
effect = build_effect_xml(
|
||
ctx.defs[filt_id],
|
||
get_element_opacity(elem, ctx),
|
||
)
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
xfrm_attr = ''
|
||
off_x = px_to_emu(min_x)
|
||
off_y = px_to_emu(min_y)
|
||
bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu)
|
||
if native_geom is not None:
|
||
xfrm = _pathlike_preset_xfrm(
|
||
elem,
|
||
ctx,
|
||
transform,
|
||
min_x,
|
||
min_y,
|
||
width,
|
||
height,
|
||
)
|
||
xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm
|
||
return ShapeResult(
|
||
xml=_wrap_geometry_object(
|
||
elem,
|
||
ctx,
|
||
shape_id, f'Freeform {shape_id}',
|
||
off_x, off_y, w_emu, h_emu,
|
||
geom, fill, stroke, effect, xfrm_attr=xfrm_attr,
|
||
),
|
||
bounds_emu=bounds_emu,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# polygon / polyline
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <polygon> to DrawingML custom geometry shape."""
|
||
preset_geom = _build_preset_geom_from_meta(elem)
|
||
points = parse_svg_points(elem.get('points', ''), min_points=3)
|
||
|
||
commands = [PathCommand('M', [points[0][0], points[0][1]])]
|
||
for px_, py_ in points[1:]:
|
||
commands.append(PathCommand('L', [px_, py_]))
|
||
commands.append(PathCommand('Z', []))
|
||
|
||
transform = elem.get('transform')
|
||
if _uses_full_transform(ctx, transform):
|
||
commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
|
||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||
commands, 0, 0, 1.0, 1.0,
|
||
)
|
||
else:
|
||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||
commands, ctx.translate_x, ctx.translate_y,
|
||
ctx.scale_x, ctx.scale_y,
|
||
)
|
||
|
||
if not path_xml:
|
||
return None
|
||
|
||
w_emu = px_to_emu(width)
|
||
h_emu = px_to_emu(height)
|
||
|
||
geom = preset_geom or f'''<a:custGeom>
|
||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||
<a:rect l="l" t="t" r="r" b="b"/>
|
||
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
|
||
{path_xml}
|
||
</a:path></a:pathLst>
|
||
</a:custGeom>'''
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
xfrm_attr = ''
|
||
off_x = px_to_emu(min_x)
|
||
off_y = px_to_emu(min_y)
|
||
bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu)
|
||
if preset_geom is not None:
|
||
xfrm = _pathlike_preset_xfrm(
|
||
elem,
|
||
ctx,
|
||
transform,
|
||
min_x,
|
||
min_y,
|
||
width,
|
||
height,
|
||
)
|
||
xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm
|
||
return ShapeResult(
|
||
xml=_wrap_geometry_object(
|
||
elem,
|
||
ctx,
|
||
shape_id, f'Polygon {shape_id}',
|
||
off_x, off_y, w_emu, h_emu,
|
||
geom, fill, stroke, xfrm_attr=xfrm_attr,
|
||
),
|
||
bounds_emu=bounds_emu,
|
||
)
|
||
|
||
|
||
def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <polyline> to DrawingML custom geometry shape."""
|
||
preset_geom = _build_preset_geom_from_meta(elem)
|
||
points = parse_svg_points(elem.get('points', ''), min_points=2)
|
||
|
||
commands = [PathCommand('M', [points[0][0], points[0][1]])]
|
||
for px_, py_ in points[1:]:
|
||
commands.append(PathCommand('L', [px_, py_]))
|
||
|
||
transform = elem.get('transform')
|
||
if _uses_full_transform(ctx, transform):
|
||
commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
|
||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||
commands, 0, 0, 1.0, 1.0,
|
||
)
|
||
else:
|
||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||
commands, ctx.translate_x, ctx.translate_y,
|
||
ctx.scale_x, ctx.scale_y,
|
||
)
|
||
|
||
if not path_xml:
|
||
return None
|
||
|
||
w_emu = px_to_emu(width)
|
||
h_emu = px_to_emu(height)
|
||
|
||
geom = preset_geom or f'''<a:custGeom>
|
||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||
<a:rect l="l" t="t" r="r" b="b"/>
|
||
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
|
||
{path_xml}
|
||
</a:path></a:pathLst>
|
||
</a:custGeom>'''
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
xfrm_attr = ''
|
||
off_x = px_to_emu(min_x)
|
||
off_y = px_to_emu(min_y)
|
||
bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu)
|
||
if preset_geom is not None:
|
||
xfrm = _pathlike_preset_xfrm(
|
||
elem,
|
||
ctx,
|
||
transform,
|
||
min_x,
|
||
min_y,
|
||
width,
|
||
height,
|
||
)
|
||
xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm
|
||
return ShapeResult(
|
||
xml=_wrap_geometry_object(
|
||
elem,
|
||
ctx,
|
||
shape_id, f'Polyline {shape_id}',
|
||
off_x, off_y, w_emu, h_emu,
|
||
geom, '<a:noFill/>', stroke, xfrm_attr=xfrm_attr,
|
||
),
|
||
bounds_emu=bounds_emu,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# text
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_SERIF_WIDTH_FAMILIES = {
|
||
'book antiqua',
|
||
'cambria',
|
||
'fangsong',
|
||
'garamond',
|
||
'georgia',
|
||
'kaiti',
|
||
'palatino',
|
||
'palatino linotype',
|
||
'serif',
|
||
'simsun',
|
||
'songti',
|
||
'times',
|
||
'times new roman',
|
||
}
|
||
|
||
_TEXTBOX_PADDING_MIN_PX = 0.5
|
||
_TEXTBOX_PADDING_MAX_PX = 2.0
|
||
_TEXTBOX_PADDING_RATIO = 0.04
|
||
# Single-line auto-fit headroom interpolates between a low-caps base and an
|
||
# all-caps ceiling for each run. The crude per-char width estimate undercounts
|
||
# capitals most, so all-caps runs need the ceiling to keep wrap-ignoring
|
||
# renderers (LibreOffice) from folding. Applying headroom per run also prevents
|
||
# a short serif label from forcing a conservative serif multiplier onto an
|
||
# otherwise sans-serif line. Values are calibrated against LibreOffice renders
|
||
# of all-caps bold lines, with bases left above mixed-case and CJK render
|
||
# ratios; exact ratios shift with font substitution, so these carry deliberate
|
||
# margin rather than tracking one environment's numbers.
|
||
_TEXT_WIDTH_HEADROOM_BASE = 1.06
|
||
_TEXT_WIDTH_HEADROOM_CAPS = 1.12
|
||
_SERIF_TEXT_WIDTH_HEADROOM_BASE = 1.12
|
||
_SERIF_TEXT_WIDTH_HEADROOM_CAPS = 1.36
|
||
_TEXT_BULLET_MARKERS = {
|
||
'·': '•',
|
||
'•': '•',
|
||
'●': '●',
|
||
'▪': '▪',
|
||
'■': '■',
|
||
'◆': '◆',
|
||
'◇': '◇',
|
||
'◦': '◦',
|
||
'‣': '‣',
|
||
}
|
||
_TEXT_BULLET_RE = re.compile(
|
||
r'^(?P<prefix>\s*)(?P<marker>[·•●▪■◆◇◦‣])(?P<space>\s*)'
|
||
)
|
||
|
||
|
||
def _normalize_text_run_whitespace(
|
||
runs: list[dict[str, Any]],
|
||
) -> list[dict[str, Any]]:
|
||
"""Apply the shared whitespace contract without losing run ownership."""
|
||
normalized: list[dict[str, Any]] = []
|
||
segments = [
|
||
(str(run.get('_xml_space', 'default')), str(run.get('text', '')))
|
||
for run in runs
|
||
]
|
||
for index, text in normalize_project_text_segments(segments):
|
||
run = {**runs[index], 'text': text}
|
||
run.pop('_xml_space', None)
|
||
normalized.append(run)
|
||
return normalized
|
||
|
||
|
||
def _letter_spacing_to_drawingml_spc(letter_spacing_px: float) -> str:
|
||
"""Convert SVG px letter spacing into DrawingML rPr@spc."""
|
||
spacing = drawingml_letter_spacing(letter_spacing_px)
|
||
if spacing == 0:
|
||
return ''
|
||
return f' spc="{spacing}"'
|
||
|
||
|
||
def _is_serif_run(run: dict[str, Any]) -> bool:
|
||
"""Return whether a text run uses a serif-like family."""
|
||
for family in str(run.get('font_family', '')).split(','):
|
||
name = family.strip().strip("'\"").lower()
|
||
if not name or name in {'sans-serif', 'sans serif'}:
|
||
continue
|
||
if name in _SERIF_WIDTH_FAMILIES:
|
||
return True
|
||
if 'serif' in name and 'sans' not in name:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _estimate_run_text_width(run: dict[str, Any]) -> float:
|
||
"""Estimate one run using the metrics actually emitted to DrawingML."""
|
||
text = str(run.get('text', ''))
|
||
font_size_px = (
|
||
font_px_to_hpt(float(run.get('font_size', 16)))
|
||
/ FONT_PX_TO_HUNDREDTHS_PT
|
||
)
|
||
cluster_widths = estimate_text_cluster_widths(
|
||
text,
|
||
font_size_px,
|
||
str(run.get('font_weight', '400')),
|
||
)
|
||
letter_spacing_px = (
|
||
drawingml_letter_spacing(
|
||
float(run.get('letter_spacing', 0.0) or 0.0)
|
||
)
|
||
/ FONT_PX_TO_HUNDREDTHS_PT
|
||
)
|
||
return sum(cluster_widths) + letter_spacing_px * max(
|
||
len(cluster_widths) - 1,
|
||
0,
|
||
)
|
||
|
||
|
||
def validate_text_run_advances(runs: list[dict[str, Any]]) -> None:
|
||
"""Reject negative tracking that reverses or collapses one output run."""
|
||
for run in runs:
|
||
text = str(run.get('text', ''))
|
||
letter_spacing = float(run.get('letter_spacing', 0.0) or 0.0)
|
||
if len(split_project_text_clusters(text)) < 2 or letter_spacing >= 0:
|
||
continue
|
||
advance = _estimate_run_text_width(run)
|
||
if advance > 0:
|
||
continue
|
||
snippet = re.sub(r'\s+', ' ', text)
|
||
raise ValueError(
|
||
'negative letter-spacing produces a non-positive DrawingML '
|
||
f'text-run advance for {snippet!r} (advance={advance:g}px)'
|
||
)
|
||
|
||
|
||
def _uppercase_fraction(runs: list[dict[str, Any]]) -> float:
|
||
"""Fraction of cased letters across ``runs`` that are uppercase.
|
||
|
||
Caseless scripts (CJK, digits, punctuation) are ignored, so a Chinese or
|
||
numeric line reports 0.0 and takes the low-caps headroom base.
|
||
"""
|
||
upper = 0
|
||
cased = 0
|
||
for run in runs:
|
||
for ch in str(run.get('text', '')):
|
||
if ch.lower() != ch.upper():
|
||
cased += 1
|
||
if ch.isupper():
|
||
upper += 1
|
||
if not cased:
|
||
return 0.0
|
||
return upper / cased
|
||
|
||
|
||
def _estimate_text_runs_width(
|
||
runs: list[dict[str, Any]],
|
||
*,
|
||
include_headroom: bool = True,
|
||
) -> float:
|
||
"""Estimate a line of text runs.
|
||
|
||
``include_headroom`` is useful for single-line auto-fit boxes where a
|
||
renderer that measures text slightly wider would otherwise wrap. The
|
||
headroom scales independently with each run's family and uppercase
|
||
fraction. This keeps mixed-font lines from inheriting the most conservative
|
||
run's multiplier. Paragraph boxes use this value as a wrapping constraint,
|
||
so adding headroom there stretches the merged text frame beyond the
|
||
author's source line width.
|
||
"""
|
||
if not include_headroom:
|
||
return sum(_estimate_run_text_width(run) for run in runs)
|
||
|
||
width = 0.0
|
||
for run in runs:
|
||
if _is_serif_run(run):
|
||
base = _SERIF_TEXT_WIDTH_HEADROOM_BASE
|
||
ceiling = _SERIF_TEXT_WIDTH_HEADROOM_CAPS
|
||
else:
|
||
base = _TEXT_WIDTH_HEADROOM_BASE
|
||
ceiling = _TEXT_WIDTH_HEADROOM_CAPS
|
||
caps = _uppercase_fraction([run])
|
||
width += _estimate_run_text_width(run) * (
|
||
base + (ceiling - base) * caps
|
||
)
|
||
return width
|
||
|
||
|
||
def estimate_single_line_text_frame_width(
|
||
runs: list[dict[str, Any]],
|
||
*,
|
||
include_headroom: bool = True,
|
||
) -> float:
|
||
"""Estimate one DrawingML textbox width with optional safety headroom."""
|
||
content_runs, bullet = _extract_text_bullet(runs)
|
||
width = _estimate_text_runs_width(
|
||
content_runs,
|
||
include_headroom=include_headroom,
|
||
)
|
||
if bullet:
|
||
font_size = (
|
||
float(content_runs[0].get('font_size', 16))
|
||
if content_runs else 16.0
|
||
)
|
||
width += _bullet_margin_px(bullet, font_size)
|
||
return width
|
||
|
||
|
||
def validate_single_line_text_run_advances(
|
||
runs: list[dict[str, Any]],
|
||
) -> None:
|
||
"""Validate the runs that remain after single-line bullet promotion."""
|
||
content_runs, _bullet = _extract_text_bullet(runs)
|
||
validate_text_run_advances(content_runs)
|
||
|
||
|
||
def _first_nonspace_run(runs: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||
for run in runs:
|
||
if str(run.get('text', '')).strip():
|
||
return run
|
||
return None
|
||
|
||
|
||
def _strip_leading_chars_from_runs(
|
||
runs: list[dict[str, Any]],
|
||
char_count: int,
|
||
) -> list[dict[str, Any]]:
|
||
stripped: list[dict[str, Any]] = []
|
||
remaining = char_count
|
||
for run in runs:
|
||
if run.get('_line_break'):
|
||
if remaining == 0:
|
||
stripped.append(run)
|
||
continue
|
||
text = str(run.get('text', ''))
|
||
if remaining >= len(text):
|
||
remaining -= len(text)
|
||
continue
|
||
if remaining > 0:
|
||
text = text[remaining:]
|
||
remaining = 0
|
||
if text:
|
||
stripped.append({**run, 'text': text})
|
||
return stripped
|
||
|
||
|
||
def _take_leading_chars_from_runs(
|
||
runs: list[dict[str, Any]],
|
||
char_count: int,
|
||
) -> list[dict[str, Any]]:
|
||
taken: list[dict[str, Any]] = []
|
||
remaining = char_count
|
||
for run in runs:
|
||
if remaining <= 0:
|
||
break
|
||
text = str(run.get('text', ''))
|
||
if remaining >= len(text):
|
||
prefix = text
|
||
remaining -= len(text)
|
||
else:
|
||
prefix = text[:remaining]
|
||
remaining = 0
|
||
if prefix:
|
||
taken.append({**run, 'text': prefix})
|
||
return taken
|
||
|
||
|
||
def _extract_text_bullet(
|
||
runs: list[dict[str, Any]],
|
||
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
|
||
"""Convert a leading text bullet marker into paragraph metadata."""
|
||
full_text = ''.join(str(run.get('text', '')) for run in runs)
|
||
match = _TEXT_BULLET_RE.match(full_text)
|
||
if not match:
|
||
return runs, None
|
||
if not full_text[match.end():].strip():
|
||
return runs, None
|
||
|
||
marker = match.group('marker')
|
||
marker_run = _first_nonspace_run(runs) or {}
|
||
prefix_runs = _take_leading_chars_from_runs(runs, match.end())
|
||
replacement_prefix = _TEXT_BULLET_MARKERS.get(marker, marker) + (match.group('space') or ' ')
|
||
replacement_runs = [{**marker_run, 'text': replacement_prefix}] if marker_run else []
|
||
bullet = {
|
||
'char': _TEXT_BULLET_MARKERS.get(marker, marker),
|
||
'fill': marker_run.get('fill'),
|
||
'fill_raw': marker_run.get('fill_raw'),
|
||
'opacity': marker_run.get('opacity'),
|
||
'source_prefix_width_px': _estimate_text_runs_width(prefix_runs, include_headroom=False),
|
||
'margin_px': max(
|
||
_estimate_text_runs_width(replacement_runs, include_headroom=False),
|
||
8.0,
|
||
),
|
||
}
|
||
stripped = _strip_leading_chars_from_runs(runs, match.end())
|
||
return (stripped or runs), bullet
|
||
|
||
|
||
def _bullet_margin_px(bullet: dict[str, Any], font_size: float) -> float:
|
||
try:
|
||
return float(bullet.get('margin_px', 0.0))
|
||
except (TypeError, ValueError):
|
||
return max(font_size * 0.95, 12.0)
|
||
|
||
|
||
def _bullet_indent_px(bullet: dict[str, Any], font_size: float) -> float:
|
||
return -_bullet_margin_px(bullet, font_size)
|
||
|
||
|
||
def _build_bullet_xml(
|
||
bullet: dict[str, Any] | None,
|
||
ctx: ConvertContext | None,
|
||
) -> str:
|
||
if not bullet:
|
||
return ''
|
||
fill = bullet.get('fill')
|
||
fill_raw = bullet.get('fill_raw')
|
||
color, color_alpha = parse_svg_color(
|
||
fill_raw if isinstance(fill_raw, str) else ''
|
||
)
|
||
if color is None and isinstance(fill, str):
|
||
color = parse_hex_color(fill)
|
||
if color:
|
||
opacity = combine_opacity(bullet.get('opacity'), color_alpha)
|
||
alpha_xml = (
|
||
f'<a:alphaMod val="{quantize_ooxml_alpha(opacity)}"/>'
|
||
if opacity is not None else ''
|
||
)
|
||
theme_spec = ctx.theme_color_spec if ctx is not None else None
|
||
color_xml = (
|
||
f'<a:buClr>{color_node_xml(color, theme_spec, "text", alpha_xml)}</a:buClr>'
|
||
)
|
||
else:
|
||
color_xml = '<a:buClrTx/>'
|
||
return (
|
||
f'{color_xml}<a:buSzTx/><a:buFontTx/>'
|
||
f'<a:buChar char="{_xml_escape(str(bullet.get("char", "•")))}"/>'
|
||
)
|
||
|
||
|
||
def _paragraph_pr_xml(
|
||
*,
|
||
algn: str,
|
||
font_size: float,
|
||
body_xml: str = '',
|
||
bullet: dict[str, Any] | None = None,
|
||
ctx: ConvertContext | None = None,
|
||
rtl: bool = False,
|
||
) -> str:
|
||
attrs = f'algn="{algn}"'
|
||
if rtl:
|
||
attrs += ' rtl="1"'
|
||
if bullet:
|
||
margin = px_to_emu(_bullet_margin_px(bullet, font_size))
|
||
indent = px_to_emu(_bullet_indent_px(bullet, font_size))
|
||
attrs += f' marL="{margin}" indent="{indent}"'
|
||
return f'<a:pPr {attrs}>{body_xml}{_build_bullet_xml(bullet, ctx)}</a:pPr>'
|
||
|
||
|
||
def _estimate_bullet_line_width(
|
||
runs: list[dict[str, Any]],
|
||
default_fonts: dict[str, str],
|
||
ctx: ConvertContext,
|
||
) -> float:
|
||
line_runs, bullet = _extract_text_bullet(runs)
|
||
line_runs = _coalesce_text_runs(line_runs, default_fonts, ctx)
|
||
width = _estimate_text_runs_width(line_runs, include_headroom=False)
|
||
if bullet:
|
||
fs_px = float(line_runs[0].get('font_size', 16)) if line_runs else 16.0
|
||
width += _bullet_margin_px(bullet, fs_px)
|
||
return width
|
||
|
||
|
||
def _textbox_padding(font_size: float) -> float:
|
||
"""Return small text-frame slack without visibly lengthening the box."""
|
||
return max(
|
||
_TEXTBOX_PADDING_MIN_PX,
|
||
min(_TEXTBOX_PADDING_MAX_PX, font_size * _TEXTBOX_PADDING_RATIO),
|
||
)
|
||
|
||
|
||
def drawingml_text_frame_width_emu(
|
||
text_width: float,
|
||
font_size: float,
|
||
) -> int:
|
||
"""Return the exact horizontal extent used by a generated text frame."""
|
||
return px_to_emu(text_width + _textbox_padding(font_size) * 2)
|
||
|
||
|
||
def _text_opacity_ratio(value: str | None) -> float:
|
||
"""Parse a text opacity component and clamp it to the SVG ``0..1`` range."""
|
||
if value is None:
|
||
return 1.0
|
||
return parse_project_opacity(value)
|
||
|
||
|
||
def _override_run_attrs(
|
||
parent_attrs: dict[str, Any],
|
||
tspan: ET.Element,
|
||
ctx: ConvertContext,
|
||
) -> dict[str, Any]:
|
||
"""Layer a tspan's styling attributes over the inherited run attrs."""
|
||
run_attrs = dict(parent_attrs)
|
||
inline_style = parse_inline_style(tspan.get('style'))
|
||
|
||
def tspan_attr(name: str) -> str | None:
|
||
return inline_style.get(name) or tspan.get(name)
|
||
|
||
object_opacity = float(run_attrs.get('_object_opacity', 1.0))
|
||
fill_opacity = float(run_attrs.get('_fill_opacity', 1.0))
|
||
stroke_opacity = float(run_attrs.get('_stroke_opacity', 1.0))
|
||
if tspan_attr('opacity') is not None:
|
||
object_opacity *= _text_opacity_ratio(tspan_attr('opacity'))
|
||
if tspan_attr('fill-opacity') is not None:
|
||
fill_opacity = _text_opacity_ratio(tspan_attr('fill-opacity'))
|
||
if tspan_attr('stroke-opacity') is not None:
|
||
stroke_opacity = _text_opacity_ratio(tspan_attr('stroke-opacity'))
|
||
run_attrs['_object_opacity'] = object_opacity
|
||
run_attrs['_fill_opacity'] = fill_opacity
|
||
run_attrs['_stroke_opacity'] = stroke_opacity
|
||
effective_fill_opacity = object_opacity * fill_opacity
|
||
effective_stroke_opacity = object_opacity * stroke_opacity
|
||
run_attrs['opacity'] = (
|
||
effective_fill_opacity if effective_fill_opacity < 1.0 else None
|
||
)
|
||
run_attrs['stroke_opacity'] = (
|
||
effective_stroke_opacity if effective_stroke_opacity < 1.0 else None
|
||
)
|
||
|
||
if tspan_attr('font-weight'):
|
||
run_attrs['font_weight'] = parse_project_font_weight(
|
||
tspan_attr('font-weight')
|
||
).canonical
|
||
if tspan_attr('fill'):
|
||
child_fill = tspan_attr('fill')
|
||
run_attrs['fill_raw'] = child_fill
|
||
c = parse_hex_color(child_fill)
|
||
if c:
|
||
run_attrs['fill'] = c
|
||
if tspan_attr('stroke'):
|
||
run_attrs['stroke_raw'] = tspan_attr('stroke')
|
||
if tspan_attr('stroke-width'):
|
||
run_attrs['stroke_width'] = parse_svg_length(
|
||
tspan_attr('stroke-width'),
|
||
run_attrs.get('stroke_width', 1.0),
|
||
font_size=float(run_attrs.get('font_size', 16)),
|
||
)
|
||
resolved_font_size = ctx.text_font_sizes.get(id(tspan))
|
||
if resolved_font_size is not None:
|
||
run_attrs['font_size'] = resolved_font_size * ctx.scale_y
|
||
elif tspan_attr('font-size'):
|
||
run_attrs['font_size'] = parse_svg_length(
|
||
tspan_attr('font-size'),
|
||
run_attrs['font_size'],
|
||
font_size=float(run_attrs.get('font_size', 16)),
|
||
)
|
||
if tspan_attr('font-family'):
|
||
run_attrs['font_family'] = tspan_attr('font-family')
|
||
if tspan_attr('font-style'):
|
||
run_attrs['font_style'] = parse_project_font_style(
|
||
tspan_attr('font-style')
|
||
).canonical
|
||
if tspan_attr('text-decoration'):
|
||
run_attrs['text_decoration'] = parse_project_text_decoration(
|
||
tspan_attr('text-decoration')
|
||
).canonical
|
||
resolved_letter_spacing = ctx.text_letter_spacings.get(id(tspan))
|
||
if resolved_letter_spacing is not None:
|
||
run_attrs['letter_spacing'] = resolved_letter_spacing * ctx.scale_x
|
||
elif tspan_attr('letter-spacing'):
|
||
run_attrs['letter_spacing'] = parse_project_letter_spacing(
|
||
tspan_attr('letter-spacing'),
|
||
font_size=float(run_attrs.get('font_size', 16)),
|
||
scale_x=float(run_attrs.get('_scale_x', 1.0)),
|
||
).value
|
||
return run_attrs
|
||
|
||
|
||
def _collect_tspan_runs(
|
||
tspan: ET.Element,
|
||
inherited_attrs: dict[str, Any],
|
||
ctx: ConvertContext,
|
||
inherited_xml_space: str = 'default',
|
||
) -> list[dict[str, Any]]:
|
||
"""Recursively turn a tspan subtree into runs, propagating styling through nested tspans.
|
||
|
||
Order: tspan.text → (each nested child tspan's runs → that child's tail under THIS tspan's attrs).
|
||
"""
|
||
runs: list[dict[str, Any]] = []
|
||
own_attrs = _override_run_attrs(inherited_attrs, tspan, ctx)
|
||
own_xml_space = resolve_project_xml_space(tspan, inherited_xml_space)
|
||
|
||
if tspan.text:
|
||
runs.append({
|
||
**own_attrs,
|
||
'text': tspan.text,
|
||
'_xml_space': own_xml_space,
|
||
})
|
||
|
||
for child in tspan:
|
||
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||
if child_tag == 'tspan':
|
||
runs.extend(
|
||
_collect_tspan_runs(child, own_attrs, ctx, own_xml_space)
|
||
)
|
||
if child.tail:
|
||
runs.append({
|
||
**own_attrs,
|
||
'text': child.tail,
|
||
'_xml_space': own_xml_space,
|
||
})
|
||
|
||
return runs
|
||
|
||
|
||
def _build_text_runs(
|
||
elem: ET.Element,
|
||
parent_attrs: dict[str, Any],
|
||
ctx: ConvertContext,
|
||
) -> list[dict[str, Any]]:
|
||
"""Build a list of text runs from a <text> element, handling <tspan> children.
|
||
|
||
Each run is a dict with keys: text, fill, fill_raw, font_weight,
|
||
font_style, font_family, font_size, letter_spacing. Nested tspans are walked
|
||
recursively so inline format changes inside a tspan still produce distinct runs.
|
||
"""
|
||
runs: list[dict[str, Any]] = []
|
||
xml_space = resolve_project_xml_space(elem)
|
||
|
||
if elem.text:
|
||
runs.append({
|
||
**parent_attrs,
|
||
'text': elem.text,
|
||
'_xml_space': xml_space,
|
||
})
|
||
|
||
for child in elem:
|
||
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||
if child_tag == 'tspan':
|
||
runs.extend(
|
||
_collect_tspan_runs(child, parent_attrs, ctx, xml_space)
|
||
)
|
||
if child.tail:
|
||
runs.append({
|
||
**parent_attrs,
|
||
'text': child.tail,
|
||
'_xml_space': xml_space,
|
||
})
|
||
|
||
return _normalize_text_run_whitespace(runs)
|
||
|
||
|
||
def _build_text_fill_xml(
|
||
fill: str,
|
||
fill_raw: str,
|
||
opacity: float | None,
|
||
ctx: ConvertContext | None,
|
||
) -> str:
|
||
"""Build DrawingML fill XML for a text run."""
|
||
if fill_raw.strip().lower() in ('none', 'transparent'):
|
||
return '<a:noFill/>'
|
||
|
||
paint_id = resolve_url_id(fill_raw)
|
||
if paint_id and ctx and paint_id in ctx.defs:
|
||
paint = ctx.defs[paint_id]
|
||
paint_tag = paint.tag.rsplit('}', 1)[-1]
|
||
if paint_tag in {'linearGradient', 'radialGradient'}:
|
||
return build_gradient_fill(
|
||
paint,
|
||
opacity,
|
||
ctx.theme_color_spec,
|
||
"text",
|
||
)
|
||
if paint_tag == 'pattern':
|
||
mode, image = resolve_project_text_image_fill(paint)
|
||
source = load_project_image_source(image, ctx.svg_dir)
|
||
r_id = _register_image_media(
|
||
ctx,
|
||
source.img_format,
|
||
source.img_data,
|
||
reuse_text_fill=True,
|
||
)
|
||
blip_xml = _build_image_blip_xml(r_id, opacity)
|
||
if mode == 'stretch':
|
||
fill_mode_xml = '<a:stretch><a:fillRect/></a:stretch>'
|
||
else:
|
||
fill_mode_xml = '<a:tile/>'
|
||
return f'<a:blipFill>{blip_xml}{fill_mode_xml}</a:blipFill>'
|
||
|
||
parsed_color, color_alpha = parse_svg_color(fill_raw)
|
||
fill = parsed_color or fill
|
||
opacity = combine_opacity(opacity, color_alpha)
|
||
alpha_xml = ''
|
||
if opacity is not None:
|
||
alpha_xml = f'<a:alphaMod val="{quantize_ooxml_alpha(opacity)}"/>'
|
||
theme_spec = ctx.theme_color_spec if ctx is not None else None
|
||
return (
|
||
'<a:solidFill>'
|
||
f'{color_node_xml(fill, theme_spec, "text", alpha_xml)}'
|
||
'</a:solidFill>'
|
||
)
|
||
|
||
|
||
def _build_text_outline_xml(
|
||
run: dict[str, Any],
|
||
ctx: ConvertContext | None,
|
||
) -> str:
|
||
"""Build DrawingML outline XML for a text run from SVG stroke attributes."""
|
||
stroke_raw = run.get('stroke_raw')
|
||
if not stroke_raw or stroke_raw.strip().lower() in ('none', 'transparent'):
|
||
return ''
|
||
|
||
color, color_alpha = parse_svg_color(stroke_raw)
|
||
if not color:
|
||
return ''
|
||
|
||
stroke_width = _f(str(run.get('stroke_width', 1.0)), 1.0)
|
||
stroke_opacity = combine_opacity(run.get('stroke_opacity'), color_alpha)
|
||
alpha_xml = ''
|
||
if stroke_opacity is not None:
|
||
alpha_xml = (
|
||
f'<a:alphaMod val="{quantize_ooxml_alpha(stroke_opacity)}"/>'
|
||
)
|
||
|
||
theme_spec = ctx.theme_color_spec if ctx is not None else None
|
||
return (
|
||
f'<a:ln w="{px_to_emu(stroke_width)}">'
|
||
'<a:solidFill>'
|
||
f'{color_node_xml(color, theme_spec, "stroke", alpha_xml)}'
|
||
'</a:solidFill>'
|
||
'</a:ln>'
|
||
)
|
||
|
||
|
||
def _build_run_properties_xml(
|
||
run: dict[str, Any],
|
||
default_fonts: dict[str, str],
|
||
ctx: ConvertContext | None = None,
|
||
effect_xml: str = '',
|
||
) -> str:
|
||
"""Build the final ``a:rPr`` used to compare and emit one text run."""
|
||
text = str(run['text'])
|
||
fill = run.get('fill', '000000')
|
||
fill_raw = run.get('fill_raw', '')
|
||
fw = run.get('font_weight', '400')
|
||
fs_px = run.get('font_size', 16)
|
||
fstyle = run.get('font_style', '')
|
||
ff = run.get('font_family', '')
|
||
letter_spacing_px = float(run.get('letter_spacing', 0.0) or 0.0)
|
||
opacity = run.get('opacity')
|
||
|
||
text_dec = run.get('text_decoration', '')
|
||
|
||
# Exported font size = fs_px * FONT_PX_TO_HUNDREDTHS_PT hundredths-of-pt,
|
||
# rounded to **one decimal place of pt** (the nearest 10 hundredths). No 0.5pt
|
||
# / integer snapping — whatever the px works out to is the size, e.g.
|
||
# 18px -> 13.5pt, 24px -> 18.0pt, 42px -> 31.5pt.
|
||
sz = font_px_to_hpt(fs_px)
|
||
b_attr = ' b="1"' if parse_project_font_weight(fw).value else ''
|
||
i_attr = ' i="1"' if fstyle == 'italic' else ''
|
||
underline, strike = parse_project_text_decoration(
|
||
text_dec or 'none'
|
||
).value
|
||
u_attr = ' u="sng"' if underline else ''
|
||
strike_attr = ' strike="sngStrike"' if strike else ''
|
||
spc_attr = _letter_spacing_to_drawingml_spc(letter_spacing_px)
|
||
|
||
fonts = parse_font_family(ff) if ff else default_fonts
|
||
run_fonts = theme_font_tokens(
|
||
fonts,
|
||
ctx.theme_font_spec if ctx is not None else None,
|
||
) or resolve_text_run_fonts(text, fonts)
|
||
lang = detect_text_lang(
|
||
text,
|
||
ctx.primary_language if ctx is not None else None,
|
||
)
|
||
rtl_xml = (
|
||
'\n<a:rtl val="1"/>'
|
||
if text_has_rtl_characters(text)
|
||
else ''
|
||
)
|
||
|
||
fill_xml = _build_text_fill_xml(fill, fill_raw, opacity, ctx)
|
||
outline_xml = _build_text_outline_xml(run, ctx)
|
||
|
||
return f'''<a:rPr lang="{lang}" sz="{sz}"{b_attr}{i_attr}{u_attr}{strike_attr}{spc_attr} dirty="0">
|
||
{outline_xml}
|
||
{fill_xml}
|
||
{effect_xml}
|
||
<a:latin typeface="{_xml_escape(run_fonts['latin'])}"/>
|
||
<a:ea typeface="{_xml_escape(run_fonts['ea'])}"/>
|
||
<a:cs typeface="{_xml_escape(run_fonts['cs'])}"/>{rtl_xml}
|
||
</a:rPr>'''
|
||
|
||
|
||
def _coalesce_text_runs(
|
||
runs: list[dict[str, Any]],
|
||
default_fonts: dict[str, str],
|
||
ctx: ConvertContext | None,
|
||
) -> list[dict[str, Any]]:
|
||
"""Join adjacent runs that PowerPoint sees as one formatting run."""
|
||
merged: list[dict[str, Any]] = []
|
||
previous_properties: str | None = None
|
||
for run in runs:
|
||
if run.get('_line_break'):
|
||
merged.append({'_line_break': True})
|
||
previous_properties = None
|
||
continue
|
||
text = str(run.get('text', ''))
|
||
if not text:
|
||
continue
|
||
properties = _build_run_properties_xml(run, default_fonts, ctx)
|
||
if merged and properties == previous_properties:
|
||
candidate = {
|
||
**merged[-1],
|
||
'text': str(merged[-1].get('text', '')) + text,
|
||
}
|
||
candidate_properties = _build_run_properties_xml(
|
||
candidate,
|
||
default_fonts,
|
||
ctx,
|
||
)
|
||
if candidate_properties == previous_properties:
|
||
merged[-1] = candidate
|
||
previous_properties = candidate_properties
|
||
continue
|
||
merged.append({**run, 'text': text})
|
||
previous_properties = properties
|
||
return merged
|
||
|
||
|
||
def _text_axis_transform(ctx: ConvertContext) -> tuple[bool, bool, bool]:
|
||
"""Return whether text can consume the context matrix and its axis flips."""
|
||
if not ctx.use_transform_matrix:
|
||
return False, False, False
|
||
a, b, c, d, _e, _f = ctx.transform_matrix
|
||
if abs(b) > 1e-9 or abs(c) > 1e-9:
|
||
return False, False, False
|
||
return True, a < 0, d < 0
|
||
|
||
|
||
def _build_run_xml(
|
||
run: dict[str, Any],
|
||
default_fonts: dict[str, str],
|
||
ctx: ConvertContext | None = None,
|
||
effect_xml: str = '',
|
||
) -> str:
|
||
"""Build a single <a:r> XML from a run dict. Supports gradient fills on text."""
|
||
if run.get('_line_break'):
|
||
return '<a:br/>'
|
||
text = str(run['text'])
|
||
properties_xml = _build_run_properties_xml(
|
||
run,
|
||
default_fonts,
|
||
ctx,
|
||
effect_xml,
|
||
)
|
||
space_attr = ' xml:space="preserve"' if text != text.strip() or ' ' in text else ''
|
||
|
||
return f'''<a:r>
|
||
{properties_xml}
|
||
<a:t{space_attr}>{_xml_escape(text)}</a:t>
|
||
</a:r>'''
|
||
|
||
|
||
def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <text> to DrawingML text shape with multi-run support."""
|
||
raw_x = svg_length_x(elem.get('x'), ctx)
|
||
raw_y = svg_length_y(elem.get('y'), ctx)
|
||
use_axis_transform, reflect_x, reflect_y = _text_axis_transform(ctx)
|
||
if use_axis_transform:
|
||
x, y = transform_point(ctx.transform_matrix, raw_x, raw_y)
|
||
else:
|
||
x = ctx_x(raw_x, ctx)
|
||
y = ctx_y(raw_y, ctx)
|
||
resolved_font_size = ctx.text_font_sizes.get(id(elem))
|
||
font_size = (
|
||
resolved_font_size * ctx.scale_y
|
||
if resolved_font_size is not None
|
||
else parse_svg_length(
|
||
_get_attr(elem, 'font-size', ctx),
|
||
16,
|
||
font_size=16,
|
||
) * ctx.scale_y
|
||
)
|
||
font_weight = parse_project_font_weight(
|
||
_get_attr(elem, 'font-weight', ctx) or '400'
|
||
).canonical
|
||
font_family_str = _get_attr(elem, 'font-family', ctx) or ''
|
||
text_anchor = parse_project_text_anchor(
|
||
_get_attr(elem, 'text-anchor', ctx) or 'start'
|
||
).canonical
|
||
if reflect_x:
|
||
text_anchor = {
|
||
'start': 'end',
|
||
'end': 'start',
|
||
}.get(text_anchor, text_anchor)
|
||
fill_raw = _get_attr(elem, 'fill', ctx) or '#000000'
|
||
fill_color = parse_hex_color(fill_raw) or '000000'
|
||
opacity = get_fill_opacity(elem, ctx)
|
||
object_opacity = get_element_opacity(elem, ctx)
|
||
object_opacity = 1.0 if object_opacity is None else object_opacity
|
||
fill_opacity = _text_opacity_ratio(_get_attr(elem, 'fill-opacity', ctx))
|
||
stroke_raw = _get_attr(elem, 'stroke', ctx) or ''
|
||
stroke_width = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 1.0)
|
||
stroke_opacity = get_stroke_opacity(elem, ctx)
|
||
stroke_opacity_value = _text_opacity_ratio(_get_attr(elem, 'stroke-opacity', ctx))
|
||
font_style = parse_project_font_style(
|
||
_get_attr(elem, 'font-style', ctx) or 'normal'
|
||
).canonical
|
||
text_decoration = parse_project_text_decoration(
|
||
_get_attr(elem, 'text-decoration', ctx) or 'none'
|
||
).canonical
|
||
raw_letter_spacing = _get_attr(elem, 'letter-spacing', ctx)
|
||
resolved_letter_spacing = ctx.text_letter_spacings.get(id(elem))
|
||
if resolved_letter_spacing is not None:
|
||
letter_spacing_px = resolved_letter_spacing * ctx.scale_x
|
||
elif raw_letter_spacing is not None:
|
||
letter_spacing_px = parse_project_letter_spacing(
|
||
raw_letter_spacing,
|
||
font_size=font_size,
|
||
scale_x=ctx.scale_x or 1.0,
|
||
).value
|
||
else:
|
||
letter_spacing_px = 0.0
|
||
|
||
fonts = parse_font_family(font_family_str)
|
||
|
||
parent_attrs: dict[str, Any] = {
|
||
'fill': fill_color,
|
||
'fill_raw': fill_raw,
|
||
'font_weight': font_weight,
|
||
'font_size': font_size,
|
||
'font_family': font_family_str,
|
||
'font_style': font_style,
|
||
'text_decoration': text_decoration,
|
||
'letter_spacing': letter_spacing_px,
|
||
'_scale_x': ctx.scale_x or 1.0,
|
||
'_object_opacity': object_opacity,
|
||
'_fill_opacity': fill_opacity,
|
||
'_stroke_opacity': stroke_opacity_value,
|
||
'opacity': opacity,
|
||
'stroke_raw': stroke_raw,
|
||
'stroke_width': stroke_width,
|
||
'stroke_opacity': stroke_opacity,
|
||
}
|
||
|
||
# Single-frame modes annotate conservative dy-stacked text with one base
|
||
# line height. Semantic paragraphs become <a:p>; authored visual rows
|
||
# either become <a:br/> (preserve) or join for wrapping (reflow).
|
||
line_height_attr = (
|
||
elem.get('data-paragraph-line-height')
|
||
if ctx.text_flow != TEXT_FLOW_SPLIT
|
||
else None
|
||
)
|
||
line_height_px = _f(line_height_attr) if line_height_attr is not None else None
|
||
paragraph_runs: list[list[dict[str, Any]]] | None = None
|
||
paragraph_space_before: list[float] = []
|
||
paragraph_bullets: list[dict[str, Any] | None] = []
|
||
# Per-tspan widths (visual lines as the deck author drew them) regardless
|
||
# of how many merge into one <a:p>; used to size the textbox so PowerPoint
|
||
# has room to wrap text to the SVG's original line widths.
|
||
visual_line_widths: list[float] = []
|
||
if line_height_px is not None and line_height_px > 0:
|
||
xml_space = resolve_project_xml_space(elem)
|
||
paragraph_runs = []
|
||
for child in elem:
|
||
if child.tag != f'{{{SVG_NS}}}tspan':
|
||
continue
|
||
line_runs = _collect_tspan_runs(
|
||
child,
|
||
parent_attrs,
|
||
ctx,
|
||
xml_space,
|
||
)
|
||
line_runs = _normalize_text_run_whitespace(line_runs)
|
||
if not line_runs:
|
||
continue
|
||
visual_line_widths.append(
|
||
_estimate_bullet_line_width(line_runs, fonts, ctx)
|
||
)
|
||
soft_break = child.get('data-paragraph-soft-break') == '1'
|
||
line_break = child.get('data-paragraph-line-break') == '1'
|
||
if line_break and paragraph_runs:
|
||
paragraph_runs[-1].append({'_line_break': True})
|
||
paragraph_runs[-1].extend(line_runs)
|
||
elif soft_break and paragraph_runs:
|
||
# Append to the previous paragraph. A Latin line-wrap needs a
|
||
# space to keep two words apart (SVG used a dy break, not
|
||
# punctuation); CJK wraps mid-sentence with no inter-character
|
||
# space, so a joining space there is a spurious artifact.
|
||
prev = paragraph_runs[-1]
|
||
prev_text = prev[-1]['text'] if prev else ''
|
||
next_text = line_runs[0]['text']
|
||
boundary_is_cjk = (
|
||
(prev_text and is_cjk_char(prev_text[-1]))
|
||
or (next_text and is_cjk_char(next_text[0]))
|
||
)
|
||
if prev and not prev_text.endswith(' ') \
|
||
and not next_text.startswith(' ') \
|
||
and not boundary_is_cjk:
|
||
prev.append({
|
||
**prev[-1],
|
||
'text': ' ',
|
||
'letter_spacing': 0.0,
|
||
})
|
||
prev.extend(line_runs)
|
||
else:
|
||
paragraph_runs.append(line_runs)
|
||
sb_attr = child.get('data-paragraph-space-before')
|
||
paragraph_space_before.append(_f(sb_attr) if sb_attr else 0.0)
|
||
if not paragraph_runs:
|
||
paragraph_runs = None
|
||
paragraph_space_before = []
|
||
visual_line_widths = []
|
||
else:
|
||
stripped_paragraphs: list[list[dict[str, Any]]] = []
|
||
for line_runs in paragraph_runs:
|
||
stripped_runs, bullet = _extract_text_bullet(line_runs)
|
||
stripped_paragraphs.append(
|
||
_coalesce_text_runs(stripped_runs, fonts, ctx)
|
||
)
|
||
paragraph_bullets.append(bullet)
|
||
paragraph_runs = stripped_paragraphs
|
||
|
||
if paragraph_runs is not None:
|
||
runs = [r for line in paragraph_runs for r in line]
|
||
else:
|
||
runs = _build_text_runs(elem, parent_attrs, ctx)
|
||
runs, single_bullet = _extract_text_bullet(runs)
|
||
runs = _coalesce_text_runs(runs, fonts, ctx)
|
||
|
||
is_placeholder_carrier = (
|
||
(elem.get('data-pptx-carrier') or '').strip().lower() == 'true'
|
||
)
|
||
full_text = ''.join(str(r.get('text', '')) for r in runs) if runs else ''
|
||
if not full_text.strip():
|
||
if not is_placeholder_carrier:
|
||
return None
|
||
# A declared carrier must compile to one native text shape even when its
|
||
# authored visual is blank. U+200B is invisible but survives DrawingML.
|
||
runs = [{**parent_attrs, 'text': '\u200b'}]
|
||
paragraph_runs = None
|
||
paragraph_space_before = []
|
||
paragraph_bullets = []
|
||
visual_line_widths = []
|
||
single_bullet = None
|
||
|
||
# Estimate text dimensions
|
||
if paragraph_runs is not None:
|
||
# Use the widest authored visual line, not a reflow-joined paragraph.
|
||
text_width = max(visual_line_widths) if visual_line_widths else 0.0
|
||
# Keep the authored visual-line count as the source height contract.
|
||
text_height = (
|
||
line_height_px * (len(visual_line_widths) - 1)
|
||
+ sum(paragraph_space_before)
|
||
+ font_size * 1.5
|
||
)
|
||
else:
|
||
text_width = _estimate_text_runs_width(runs)
|
||
if single_bullet:
|
||
fs_px = float(runs[0].get('font_size', font_size)) if runs else font_size
|
||
text_width += _bullet_margin_px(single_bullet, fs_px)
|
||
text_height = font_size * 1.5
|
||
padding = _textbox_padding(font_size)
|
||
|
||
# Adjust position based on text-anchor. This first box follows the visible
|
||
# glyph baseline and remains useful for reconstructing imported text-body
|
||
# insets when data-pptx-frame supplies the owning PowerPoint shape frame.
|
||
if text_anchor == 'middle':
|
||
box_x = x - text_width / 2 - padding
|
||
elif text_anchor == 'end':
|
||
box_x = x - text_width - padding
|
||
else:
|
||
box_x = x - padding
|
||
|
||
box_y = y - font_size * 0.85
|
||
box_w = text_width + padding * 2
|
||
box_h = text_height + padding
|
||
if reflect_y:
|
||
box_y = 2 * y - box_y - box_h
|
||
|
||
visual_box_x = box_x
|
||
visual_box_y = box_y
|
||
exact_text_frame = None
|
||
exact_text_insets: tuple[float, float, float] | None = None
|
||
if elem.get('data-pptx-frame') is not None:
|
||
_preset, _guides, exact_text_frame = _parse_preset_geometry_metadata(elem)
|
||
if exact_text_frame is None:
|
||
raise ValueError('data-pptx-frame did not resolve to a text frame')
|
||
raw_frame_x, raw_frame_y, raw_frame_w, raw_frame_h = exact_text_frame
|
||
if use_axis_transform:
|
||
frame_x_1, frame_y_1 = transform_point(
|
||
ctx.transform_matrix,
|
||
raw_frame_x,
|
||
raw_frame_y,
|
||
)
|
||
frame_x_2, frame_y_2 = transform_point(
|
||
ctx.transform_matrix,
|
||
raw_frame_x + raw_frame_w,
|
||
raw_frame_y + raw_frame_h,
|
||
)
|
||
else:
|
||
frame_x_1 = ctx_x(raw_frame_x, ctx)
|
||
frame_x_2 = ctx_x(raw_frame_x + raw_frame_w, ctx)
|
||
frame_y_1 = ctx_y(raw_frame_y, ctx)
|
||
frame_y_2 = ctx_y(raw_frame_y + raw_frame_h, ctx)
|
||
box_x = min(frame_x_1, frame_x_2)
|
||
box_y = min(frame_y_1, frame_y_2)
|
||
box_w = abs(frame_x_2 - frame_x_1)
|
||
box_h = abs(frame_y_2 - frame_y_1)
|
||
top_inset = visual_box_y - box_y
|
||
if text_anchor == 'start':
|
||
left_inset = x - box_x
|
||
right_inset = 0.0
|
||
elif text_anchor == 'end':
|
||
left_inset = 0.0
|
||
right_inset = box_x + box_w - x
|
||
else:
|
||
center_delta = x - (box_x + box_w / 2)
|
||
left_inset = max(0.0, center_delta * 2)
|
||
right_inset = max(0.0, -center_delta * 2)
|
||
exact_text_insets = (left_inset, top_inset, right_inset)
|
||
|
||
text_transform = elem.get('transform', '')
|
||
text_operations = (
|
||
parse_transform_operations(text_transform)
|
||
if text_transform else ()
|
||
)
|
||
translate_only = bool(text_operations) and all(
|
||
name == 'translate' for name, _args in text_operations
|
||
)
|
||
rotate_only = (
|
||
len(text_operations) == 1
|
||
and text_operations[0][0] == 'rotate'
|
||
)
|
||
if text_operations and not (translate_only or rotate_only):
|
||
raise ValueError(
|
||
'Text transform must be a translate-only list or one rotate operation'
|
||
)
|
||
if translate_only and not ctx.use_transform_matrix:
|
||
a, b, c, d, e, f = parse_transform_matrix(text_transform)
|
||
# A pure-translate transform on a text element (hand-authored, or written
|
||
# by a live-preview move) was otherwise ignored here, drifting the text.
|
||
# Absorb the translation into the frame position.
|
||
if (
|
||
abs(a - 1.0) < 1e-9 and abs(b) < 1e-9
|
||
and abs(c) < 1e-9 and abs(d - 1.0) < 1e-9
|
||
):
|
||
sx = ctx.scale_x or 1.0
|
||
sy = ctx.scale_y or 1.0
|
||
raw_box_x = (box_x - ctx.translate_x) / sx
|
||
raw_box_y = (box_y - ctx.translate_y) / sy
|
||
box_x = ctx.translate_x + sx * (a * raw_box_x + e)
|
||
box_y = ctx.translate_y + sy * (d * raw_box_y + f)
|
||
elif translate_only and use_axis_transform:
|
||
_a, _b, _c, _d, e, f = parse_transform_matrix(text_transform)
|
||
matrix_a, matrix_b, matrix_c, matrix_d, _matrix_e, _matrix_f = (
|
||
ctx.transform_matrix
|
||
)
|
||
box_x += matrix_a * e + matrix_c * f
|
||
box_y += matrix_b * e + matrix_d * f
|
||
|
||
# Text rotation. SVG's rotate(angle [cx cy]) rotates around (cx, cy), but
|
||
# DrawingML's <a:xfrm rot="..."> rotates the shape around its own center.
|
||
# When a pivot is given (and differs from the box center), translate the
|
||
# box so its center lands where SVG would place the rotated visual center —
|
||
# otherwise rotated y-axis labels etc. drift to the wrong location.
|
||
text_rot = 0
|
||
if rotate_only:
|
||
rotate_args = text_operations[0][1]
|
||
angle_deg = rotate_args[0]
|
||
if reflect_x != reflect_y:
|
||
angle_deg = -angle_deg
|
||
text_rot = int(angle_deg * ANGLE_UNIT)
|
||
if len(rotate_args) == 3:
|
||
if use_axis_transform:
|
||
pivot_x, pivot_y = transform_point(
|
||
ctx.transform_matrix,
|
||
rotate_args[1],
|
||
rotate_args[2],
|
||
)
|
||
else:
|
||
pivot_x = ctx_x(rotate_args[1], ctx)
|
||
pivot_y = ctx_y(rotate_args[2], ctx)
|
||
cx_box = box_x + box_w / 2
|
||
cy_box = box_y + box_h / 2
|
||
rad = math.radians(angle_deg)
|
||
dx = cx_box - pivot_x
|
||
dy = cy_box - pivot_y
|
||
new_cx = pivot_x + dx * math.cos(rad) - dy * math.sin(rad)
|
||
new_cy = pivot_y + dx * math.sin(rad) + dy * math.cos(rad)
|
||
box_x = new_cx - box_w / 2
|
||
box_y = new_cy - box_h / 2
|
||
|
||
# Alignment
|
||
algn_map = {'start': 'l', 'middle': 'ctr', 'end': 'r'}
|
||
algn = algn_map.get(text_anchor, 'l')
|
||
|
||
# Shadow effect
|
||
shape_effect_xml = ''
|
||
text_effect_xml = ''
|
||
filt_id = get_effective_filter_id(elem, ctx)
|
||
if filt_id and filt_id in ctx.defs:
|
||
filter_elem = ctx.defs[filt_id]
|
||
effect_kind = classify_filter_effect(filter_elem)
|
||
if effect_kind == 'glow':
|
||
text_effect_xml = build_effect_xml(
|
||
filter_elem,
|
||
get_element_opacity(elem, ctx),
|
||
)
|
||
elif effect_kind == 'shadow':
|
||
shape_effect_xml = build_effect_xml(
|
||
filter_elem,
|
||
get_element_opacity(elem, ctx),
|
||
)
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
rot_attr = f' rot="{text_rot}"' if text_rot else ''
|
||
|
||
if paragraph_runs is not None:
|
||
# SVG dy(px) -> hundredths-of-a-point: dy_pt = dy_px * 0.75, then x100.
|
||
line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||
ln_spc_xml = f'<a:lnSpc><a:spcPts val="{line_spc_val}"/></a:lnSpc>'
|
||
paragraph_xml_chunks = []
|
||
for line, extra_px, bullet in zip(paragraph_runs, paragraph_space_before, paragraph_bullets):
|
||
spc_bef_xml = ''
|
||
if extra_px > 0:
|
||
spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||
spc_bef_xml = f'<a:spcBef><a:spcPts val="{spc_bef_val}"/></a:spcBef>'
|
||
runs_inner = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in line)
|
||
first_text_run = next(
|
||
(run for run in line if not run.get('_line_break')),
|
||
None,
|
||
)
|
||
p_pr_xml = _paragraph_pr_xml(
|
||
algn=algn,
|
||
font_size=(
|
||
float(first_text_run.get('font_size', font_size))
|
||
if first_text_run is not None
|
||
else font_size
|
||
),
|
||
body_xml=f'{ln_spc_xml}{spc_bef_xml}',
|
||
bullet=bullet,
|
||
ctx=ctx,
|
||
rtl=text_uses_rtl(
|
||
''.join(str(run.get('text', '')) for run in line),
|
||
ctx.primary_language,
|
||
),
|
||
)
|
||
paragraph_xml_chunks.append(
|
||
f'<a:p>\n{p_pr_xml}\n{runs_inner}\n</a:p>'
|
||
)
|
||
paragraphs_xml = '\n'.join(paragraph_xml_chunks)
|
||
else:
|
||
runs_xml = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in runs)
|
||
p_pr_xml = _paragraph_pr_xml(
|
||
algn=algn,
|
||
font_size=float(runs[0].get('font_size', font_size)) if runs else font_size,
|
||
bullet=single_bullet,
|
||
ctx=ctx,
|
||
rtl=text_uses_rtl(full_text, ctx.primary_language),
|
||
)
|
||
paragraphs_xml = f'<a:p>\n{p_pr_xml}\n{runs_xml}\n</a:p>'
|
||
|
||
off_x = px_to_emu(box_x)
|
||
off_y = px_to_emu(box_y)
|
||
ext_cx = (
|
||
px_to_emu(box_w)
|
||
if exact_text_frame is not None
|
||
else drawingml_text_frame_width_emu(text_width, font_size)
|
||
)
|
||
ext_cy = px_to_emu(box_h)
|
||
if ext_cx < 1 or ext_cy < 1:
|
||
raise ValueError(
|
||
'negative letter-spacing produces a non-positive DrawingML '
|
||
f'text-frame extent (cx={ext_cx}, cy={ext_cy})'
|
||
)
|
||
validate_ooxml_xfrm(off_x, off_y, ext_cx, ext_cy)
|
||
validate_text_run_advances(runs)
|
||
|
||
# Imported text carriers with data-pptx-frame retain the source shape frame
|
||
# instead of shrinking to glyph bounds. Reconstruct insets from the SVG
|
||
# anchor/baseline so the visible text stays at its imported position while
|
||
# remaining ordinary editable DrawingML text.
|
||
if exact_text_frame is not None:
|
||
if exact_text_insets is None:
|
||
raise ValueError('data-pptx-frame text insets were not resolved')
|
||
left_inset, top_inset, right_inset = exact_text_insets
|
||
exact_frame_wrap = (
|
||
'none' if ctx.text_flow == TEXT_FLOW_PRESERVE else 'square'
|
||
)
|
||
body_pr_xml = (
|
||
f'<a:bodyPr wrap="{exact_frame_wrap}" '
|
||
f'lIns="{px_to_emu(left_inset)}" '
|
||
f'tIns="{px_to_emu(top_inset)}" '
|
||
f'rIns="{px_to_emu(right_inset)}" bIns="0" '
|
||
'anchor="t" anchorCtr="0">\n<a:noAutofit/>\n</a:bodyPr>'
|
||
)
|
||
# Preserve mode keeps authored <a:br/> boundaries and lets an ordinary
|
||
# generated text box follow later manual edits, such as deleting a break.
|
||
# Reflow mode keeps the source width fixed as its wrapping constraint.
|
||
# Exact imported frames above and structured placeholder carriers remain
|
||
# fixed regardless of text-flow mode.
|
||
elif paragraph_runs is not None:
|
||
paragraph_wrap = (
|
||
'none' if ctx.text_flow == TEXT_FLOW_PRESERVE else 'square'
|
||
)
|
||
paragraph_autofit = (
|
||
'<a:spAutoFit/>'
|
||
if (
|
||
ctx.text_flow == TEXT_FLOW_PRESERVE
|
||
and not is_placeholder_carrier
|
||
)
|
||
else '<a:noAutofit/>'
|
||
)
|
||
body_pr_xml = (
|
||
f'<a:bodyPr wrap="{paragraph_wrap}" '
|
||
'lIns="0" tIns="0" rIns="0" bIns="0" '
|
||
f'anchor="t" anchorCtr="0">\n{paragraph_autofit}\n</a:bodyPr>'
|
||
)
|
||
else:
|
||
body_pr_xml = (
|
||
'<a:bodyPr wrap="none" lIns="0" tIns="0" rIns="0" bIns="0" '
|
||
'anchor="t" anchorCtr="0">\n<a:spAutoFit/>\n</a:bodyPr>'
|
||
)
|
||
|
||
return ShapeResult(xml=f'''<p:sp>
|
||
<p:nvSpPr>
|
||
<p:cNvPr id="{shape_id}" name="TextBox {shape_id}"/>
|
||
<p:cNvSpPr txBox="1"/><p:nvPr/>
|
||
</p:nvSpPr>
|
||
<p:spPr>
|
||
<a:xfrm{rot_attr}><a:off x="{off_x}" y="{off_y}"/>
|
||
<a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||
<a:noFill/>
|
||
<a:ln><a:noFill/></a:ln>
|
||
{shape_effect_xml}
|
||
</p:spPr>
|
||
<p:txBody>
|
||
{body_pr_xml}
|
||
<a:lstStyle/>
|
||
{paragraphs_xml}
|
||
</p:txBody>
|
||
</p:sp>''', bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# clipPath support (image clipping)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _clip_commands_to_geom(
|
||
commands: list[PathCommand],
|
||
img_x: float, img_y: float,
|
||
img_w: float, img_h: float,
|
||
object_bbox: bool,
|
||
) -> str:
|
||
"""Convert clip path commands to DrawingML custGeom XML.
|
||
|
||
Coordinates are transformed relative to the image bounding box so that
|
||
(img_x, img_y) maps to (0, 0) and (img_x+img_w, img_y+img_h) maps to
|
||
(w_emu, h_emu).
|
||
"""
|
||
w_emu = px_to_emu(img_w)
|
||
h_emu = px_to_emu(img_h)
|
||
|
||
if w_emu <= 0 or h_emu <= 0:
|
||
return '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
||
|
||
def _tx(x: float) -> int:
|
||
if object_bbox:
|
||
return int(x * w_emu)
|
||
return px_to_emu(x - img_x)
|
||
|
||
def _ty(y: float) -> int:
|
||
if object_bbox:
|
||
return int(y * h_emu)
|
||
return px_to_emu(y - img_y)
|
||
|
||
parts: list[str] = []
|
||
for cmd in commands:
|
||
if cmd.cmd == 'M':
|
||
parts.append(
|
||
f'<a:moveTo><a:pt x="{_tx(cmd.args[0])}" '
|
||
f'y="{_ty(cmd.args[1])}"/></a:moveTo>'
|
||
)
|
||
elif cmd.cmd == 'L':
|
||
parts.append(
|
||
f'<a:lnTo><a:pt x="{_tx(cmd.args[0])}" '
|
||
f'y="{_ty(cmd.args[1])}"/></a:lnTo>'
|
||
)
|
||
elif cmd.cmd == 'C':
|
||
pts = ''.join(
|
||
f'<a:pt x="{_tx(cmd.args[i])}" y="{_ty(cmd.args[i + 1])}"/>'
|
||
for i in range(0, 6, 2)
|
||
)
|
||
parts.append(f'<a:cubicBezTo>{pts}</a:cubicBezTo>')
|
||
elif cmd.cmd == 'Z':
|
||
parts.append('<a:close/>')
|
||
|
||
path_inner = '\n'.join(parts)
|
||
return f'''<a:custGeom>
|
||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||
<a:rect l="l" t="t" r="r" b="b"/>
|
||
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
|
||
{path_inner}
|
||
</a:path></a:pathLst>
|
||
</a:custGeom>'''
|
||
|
||
|
||
_CLIP_SHAPE_TAGS = frozenset({'circle', 'ellipse', 'rect', 'path', 'polygon'})
|
||
_CLIP_NON_VISUAL_ELEMENTS = frozenset({
|
||
f'{{{SVG_NS}}}{tag}' for tag in ('desc', 'metadata', 'style', 'title')
|
||
})
|
||
|
||
|
||
def _element_contract_label(elem: ET.Element) -> str:
|
||
tag = elem.tag.rsplit('}', 1)[-1]
|
||
elem_id = (elem.get('id') or '').strip()
|
||
return f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>'
|
||
|
||
|
||
def _unsupported_clip_rule_properties(elem: ET.Element) -> tuple[str, ...]:
|
||
style_values = parse_inline_style(elem.get('style'))
|
||
return tuple(
|
||
name for name in ('clip-rule', 'fill-rule')
|
||
if elem.get(name) is not None or name in style_values
|
||
)
|
||
|
||
|
||
def _effective_clip_geometry_length(
|
||
elem: ET.Element,
|
||
attribute: str,
|
||
*,
|
||
default: float | None = None,
|
||
) -> float:
|
||
style_values = parse_inline_style(elem.get('style'))
|
||
raw = style_values.get(attribute)
|
||
if raw is None:
|
||
raw = elem.get(attribute)
|
||
if raw is None:
|
||
if default is None:
|
||
raise ValueError(f'requires {attribute}')
|
||
return default
|
||
return parse_project_geometry_length(raw, attribute)
|
||
|
||
|
||
def _clip_preset_geometry_error(
|
||
target: ET.Element,
|
||
shape: ET.Element,
|
||
clip_units: str,
|
||
) -> str | None:
|
||
"""Reject primitive clips that cannot map to a full-frame preset."""
|
||
shape_tag = shape.tag.rsplit('}', 1)[-1].lower()
|
||
if shape_tag not in {'circle', 'ellipse', 'rect'}:
|
||
return None
|
||
target_label = _element_contract_label(target)
|
||
try:
|
||
target_x = _effective_clip_geometry_length(target, 'x', default=0.0)
|
||
target_y = _effective_clip_geometry_length(target, 'y', default=0.0)
|
||
target_w = _effective_clip_geometry_length(target, 'width')
|
||
target_h = _effective_clip_geometry_length(target, 'height')
|
||
except ValueError as exc:
|
||
return f'cannot validate {shape_tag} against {target_label}: {exc}'
|
||
if target_w <= 0 or target_h <= 0:
|
||
return (
|
||
f'cannot validate {shape_tag} against {target_label}: target '
|
||
'width and height must be positive'
|
||
)
|
||
|
||
object_bbox = clip_units == 'objectBoundingBox'
|
||
expected_x = 0.0 if object_bbox else target_x
|
||
expected_y = 0.0 if object_bbox else target_y
|
||
expected_w = 1.0 if object_bbox else target_w
|
||
expected_h = 1.0 if object_bbox else target_h
|
||
|
||
def close(actual: float, expected: float) -> bool:
|
||
return math.isclose(actual, expected, rel_tol=1e-9, abs_tol=1e-6)
|
||
|
||
try:
|
||
if shape_tag == 'circle':
|
||
cx = _effective_clip_geometry_length(shape, 'cx', default=0.0)
|
||
cy = _effective_clip_geometry_length(shape, 'cy', default=0.0)
|
||
radius = _effective_clip_geometry_length(shape, 'r', default=0.0)
|
||
fits = (
|
||
close(expected_w, expected_h)
|
||
and close(cx, expected_x + expected_w / 2.0)
|
||
and close(cy, expected_y + expected_h / 2.0)
|
||
and close(radius, expected_w / 2.0)
|
||
)
|
||
elif shape_tag == 'ellipse':
|
||
cx = _effective_clip_geometry_length(shape, 'cx', default=0.0)
|
||
cy = _effective_clip_geometry_length(shape, 'cy', default=0.0)
|
||
rx = _effective_clip_geometry_length(shape, 'rx', default=0.0)
|
||
ry = _effective_clip_geometry_length(shape, 'ry', default=0.0)
|
||
fits = (
|
||
close(cx, expected_x + expected_w / 2.0)
|
||
and close(cy, expected_y + expected_h / 2.0)
|
||
and close(rx, expected_w / 2.0)
|
||
and close(ry, expected_h / 2.0)
|
||
)
|
||
else:
|
||
rect_x = _effective_clip_geometry_length(shape, 'x', default=0.0)
|
||
rect_y = _effective_clip_geometry_length(shape, 'y', default=0.0)
|
||
rect_w = _effective_clip_geometry_length(shape, 'width', default=0.0)
|
||
rect_h = _effective_clip_geometry_length(shape, 'height', default=0.0)
|
||
fits = (
|
||
close(rect_x, expected_x)
|
||
and close(rect_y, expected_y)
|
||
and close(rect_w, expected_w)
|
||
and close(rect_h, expected_h)
|
||
)
|
||
if fits:
|
||
rx_raw = (
|
||
parse_inline_style(shape.get('style')).get('rx')
|
||
or shape.get('rx')
|
||
)
|
||
ry_raw = (
|
||
parse_inline_style(shape.get('style')).get('ry')
|
||
or shape.get('ry')
|
||
)
|
||
rx = (
|
||
parse_project_geometry_length(rx_raw, 'rx')
|
||
if rx_raw is not None else None
|
||
)
|
||
ry = (
|
||
parse_project_geometry_length(ry_raw, 'ry')
|
||
if ry_raw is not None else None
|
||
)
|
||
if rx is None and ry is not None:
|
||
rx = ry
|
||
elif ry is None and rx is not None:
|
||
ry = rx
|
||
rx = rx or 0.0
|
||
ry = ry or 0.0
|
||
if rx > 0 or ry > 0:
|
||
if object_bbox:
|
||
fits = close(rx * target_w, ry * target_h)
|
||
else:
|
||
fits = close(rx, ry)
|
||
except ValueError as exc:
|
||
return f'{shape_tag} geometry for {target_label} is invalid: {exc}'
|
||
|
||
if fits:
|
||
return None
|
||
return (
|
||
f'{shape_tag} geometry must cover the complete frame of {target_label} '
|
||
'for native preset mapping; use path or polygon for partial, offset, '
|
||
'or non-uniform clips'
|
||
)
|
||
|
||
|
||
def _nested_crop_clip_preset_geometry_error(
|
||
wrapper: ET.Element,
|
||
shape: ET.Element,
|
||
clip_units: str,
|
||
) -> str | None:
|
||
"""Validate an inner-image clip against the crop wrapper's viewBox."""
|
||
if clip_units != 'userSpaceOnUse':
|
||
return (
|
||
'inner <image> clip on a nested crop must use '
|
||
'clipPathUnits="userSpaceOnUse" so browser and PowerPoint '
|
||
'evaluate the visible viewBox region identically'
|
||
)
|
||
try:
|
||
crop = parse_project_nested_svg_crop(wrapper)
|
||
except ValueError as exc:
|
||
return f'cannot validate nested crop geometry: {exc}'
|
||
|
||
shape_tag = shape.tag.rsplit('}', 1)[-1].lower()
|
||
if shape_tag not in {'circle', 'ellipse', 'rect'}:
|
||
return None
|
||
expected_x = crop.view_box_x
|
||
expected_y = crop.view_box_y
|
||
expected_w = crop.view_box_width
|
||
expected_h = crop.view_box_height
|
||
|
||
def close(actual: float, expected: float) -> bool:
|
||
return math.isclose(actual, expected, rel_tol=1e-9, abs_tol=1e-6)
|
||
|
||
try:
|
||
if shape_tag == 'circle':
|
||
cx = _effective_clip_geometry_length(shape, 'cx', default=0.0)
|
||
cy = _effective_clip_geometry_length(shape, 'cy', default=0.0)
|
||
radius = _effective_clip_geometry_length(shape, 'r', default=0.0)
|
||
fits = (
|
||
close(expected_w, expected_h)
|
||
and close(cx, expected_x + expected_w / 2.0)
|
||
and close(cy, expected_y + expected_h / 2.0)
|
||
and close(radius, expected_w / 2.0)
|
||
)
|
||
elif shape_tag == 'ellipse':
|
||
cx = _effective_clip_geometry_length(shape, 'cx', default=0.0)
|
||
cy = _effective_clip_geometry_length(shape, 'cy', default=0.0)
|
||
rx = _effective_clip_geometry_length(shape, 'rx', default=0.0)
|
||
ry = _effective_clip_geometry_length(shape, 'ry', default=0.0)
|
||
fits = (
|
||
close(cx, expected_x + expected_w / 2.0)
|
||
and close(cy, expected_y + expected_h / 2.0)
|
||
and close(rx, expected_w / 2.0)
|
||
and close(ry, expected_h / 2.0)
|
||
)
|
||
else:
|
||
rect_x = _effective_clip_geometry_length(shape, 'x', default=0.0)
|
||
rect_y = _effective_clip_geometry_length(shape, 'y', default=0.0)
|
||
rect_w = _effective_clip_geometry_length(shape, 'width', default=0.0)
|
||
rect_h = _effective_clip_geometry_length(shape, 'height', default=0.0)
|
||
fits = (
|
||
close(rect_x, expected_x)
|
||
and close(rect_y, expected_y)
|
||
and close(rect_w, expected_w)
|
||
and close(rect_h, expected_h)
|
||
)
|
||
if fits:
|
||
rx_raw = (
|
||
parse_inline_style(shape.get('style')).get('rx')
|
||
or shape.get('rx')
|
||
)
|
||
ry_raw = (
|
||
parse_inline_style(shape.get('style')).get('ry')
|
||
or shape.get('ry')
|
||
)
|
||
rx = (
|
||
parse_project_geometry_length(rx_raw, 'rx')
|
||
if rx_raw is not None else None
|
||
)
|
||
ry = (
|
||
parse_project_geometry_length(ry_raw, 'ry')
|
||
if ry_raw is not None else None
|
||
)
|
||
if rx is None and ry is not None:
|
||
rx = ry
|
||
elif ry is None and rx is not None:
|
||
ry = rx
|
||
rx = rx or 0.0
|
||
ry = ry or 0.0
|
||
if rx > 0 or ry > 0:
|
||
physical_rx = rx * crop.width / expected_w
|
||
physical_ry = ry * crop.height / expected_h
|
||
fits = math.isclose(
|
||
physical_rx,
|
||
physical_ry,
|
||
rel_tol=1e-9,
|
||
abs_tol=1e-3,
|
||
)
|
||
except ValueError as exc:
|
||
return f'{shape_tag} geometry for nested crop is invalid: {exc}'
|
||
|
||
if fits:
|
||
return None
|
||
return (
|
||
f'{shape_tag} geometry must cover the nested crop viewBox and use '
|
||
'equal physical corner radii after viewport scaling'
|
||
)
|
||
|
||
|
||
def project_clip_path_errors(root: ET.Element) -> list[str]:
|
||
"""Return clip-path errors that would otherwise degrade picture geometry."""
|
||
definitions, duplicates = project_definition_index(root)
|
||
parent_by_id = {
|
||
id(child): parent
|
||
for parent in root.iter()
|
||
for child in list(parent)
|
||
}
|
||
errors: set[str] = set()
|
||
for elem in root.iter():
|
||
raw_ref = elem.get('clip-path')
|
||
if raw_ref is None or raw_ref.strip().lower() == 'none':
|
||
continue
|
||
label = _element_contract_label(elem)
|
||
is_svg_image = elem.tag == f'{{{SVG_NS}}}image'
|
||
parent = parent_by_id.get(id(elem))
|
||
is_nested_crop_image = (
|
||
is_svg_image
|
||
and parent is not None
|
||
and parent.tag == f'{{{SVG_NS}}}svg'
|
||
and parent is not root
|
||
and parent.get('data-pptx-crop') == '1'
|
||
)
|
||
is_imported_crop = (
|
||
elem.tag == f'{{{SVG_NS}}}svg'
|
||
and elem.get('data-pptx-crop') == '1'
|
||
)
|
||
if not is_svg_image and not is_imported_crop:
|
||
errors.add(
|
||
f'{label} clip-path is allowed only on <image> or an imported '
|
||
'data-pptx-crop="1" wrapper'
|
||
)
|
||
match = re.fullmatch(r'url\(#([^)]+)\)', raw_ref.strip())
|
||
if match is None:
|
||
errors.add(
|
||
f'{label} clip-path must be an exact local url(#id) '
|
||
f'reference; got {raw_ref!r}'
|
||
)
|
||
continue
|
||
clip_id = match.group(1)
|
||
if clip_id in duplicates:
|
||
errors.add(
|
||
f'{label} clip-path=url(#{clip_id}) is ambiguous because the '
|
||
'definition id is duplicated'
|
||
)
|
||
continue
|
||
clip = definitions.get(clip_id)
|
||
if clip is None or clip.tag != f'{{{SVG_NS}}}clipPath':
|
||
errors.add(
|
||
f'{label} clip-path=url(#{clip_id}) has no matching direct '
|
||
f'<defs><clipPath id="{clip_id}"> definition'
|
||
)
|
||
continue
|
||
clip_label = f'<clipPath id="{clip_id}">'
|
||
clip_units = clip.get('clipPathUnits', 'userSpaceOnUse')
|
||
if clip_units not in {'userSpaceOnUse', 'objectBoundingBox'}:
|
||
errors.add(
|
||
f'{clip_label} has unsupported clipPathUnits={clip_units!r}'
|
||
)
|
||
if clip.get('transform'):
|
||
errors.add(f'{clip_label} cannot use transform')
|
||
clip_rules = _unsupported_clip_rule_properties(clip)
|
||
if clip_rules:
|
||
errors.add(
|
||
f'{clip_label} cannot use {", ".join(clip_rules)}; native '
|
||
'picture geometry has no equivalent winding-rule control'
|
||
)
|
||
visual_children = [
|
||
child for child in list(clip)
|
||
if child.tag not in _CLIP_NON_VISUAL_ELEMENTS
|
||
]
|
||
if len(visual_children) != 1:
|
||
errors.add(
|
||
f'{clip_label} must contain exactly one direct supported shape'
|
||
)
|
||
continue
|
||
shape = visual_children[0]
|
||
shape_tag = shape.tag.rsplit('}', 1)[-1].lower()
|
||
if (
|
||
shape_tag not in _CLIP_SHAPE_TAGS
|
||
or shape.tag != f'{{{SVG_NS}}}{shape_tag}'
|
||
):
|
||
errors.add(
|
||
f'{clip_label} child <{shape_tag}> is unsupported; use '
|
||
'circle, ellipse, rect, path, or polygon'
|
||
)
|
||
continue
|
||
if shape.get('transform'):
|
||
errors.add(f'{clip_label} child <{shape_tag}> cannot use transform')
|
||
continue
|
||
shape_rules = _unsupported_clip_rule_properties(shape)
|
||
if shape_rules:
|
||
errors.add(
|
||
f'{clip_label} child <{shape_tag}> cannot use '
|
||
f'{", ".join(shape_rules)}; native picture geometry has no '
|
||
'equivalent winding-rule control'
|
||
)
|
||
continue
|
||
if clip_units in {'userSpaceOnUse', 'objectBoundingBox'}:
|
||
if is_nested_crop_image:
|
||
geometry_error = _nested_crop_clip_preset_geometry_error(
|
||
parent,
|
||
shape,
|
||
clip_units,
|
||
)
|
||
else:
|
||
geometry_error = _clip_preset_geometry_error(
|
||
elem,
|
||
shape,
|
||
clip_units,
|
||
)
|
||
if geometry_error is not None:
|
||
errors.add(f'{clip_label} {geometry_error}')
|
||
return sorted(errors)
|
||
|
||
|
||
def _resolve_clip_geometry(
|
||
elem: ET.Element,
|
||
ctx: ConvertContext,
|
||
raw_x: float, raw_y: float,
|
||
raw_w: float, raw_h: float,
|
||
) -> str:
|
||
"""Resolve clip-path on an image element to DrawingML geometry XML.
|
||
|
||
Supports:
|
||
- circle / ellipse → prstGeom ellipse
|
||
- rect with rx/ry → prstGeom roundRect
|
||
- path / polygon → custGeom
|
||
|
||
Args:
|
||
elem: SVG element bearing a clip-path attribute.
|
||
ctx: Conversion context (carries defs).
|
||
raw_x, raw_y: Image position in SVG space (pre-ctx-transform).
|
||
raw_w, raw_h: Image dimensions in SVG space (pre-ctx-transform).
|
||
|
||
Returns:
|
||
DrawingML geometry XML string.
|
||
"""
|
||
DEFAULT = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
||
|
||
clip_ref = elem.get('clip-path', '')
|
||
if not clip_ref or clip_ref == 'none':
|
||
return DEFAULT
|
||
|
||
clip_id = resolve_url_id(clip_ref)
|
||
if not clip_id or clip_id not in ctx.defs:
|
||
return DEFAULT
|
||
|
||
clip_elem = ctx.defs[clip_id]
|
||
clip_tag = clip_elem.tag.replace(f'{{{SVG_NS}}}', '')
|
||
if clip_tag != 'clipPath':
|
||
return DEFAULT
|
||
|
||
# Find the first shape child of the clipPath
|
||
shape = None
|
||
for child in clip_elem:
|
||
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||
if child_tag in ('circle', 'ellipse', 'rect', 'path', 'polygon'):
|
||
shape = child
|
||
break
|
||
|
||
if shape is None:
|
||
return DEFAULT
|
||
|
||
shape_tag = shape.tag.replace(f'{{{SVG_NS}}}', '')
|
||
is_obb = clip_elem.get('clipPathUnits') == 'objectBoundingBox'
|
||
|
||
# --- Circle / Ellipse → preset ellipse ---
|
||
if shape_tag in ('circle', 'ellipse'):
|
||
return '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
||
|
||
# --- Rect with rx/ry → preset roundRect ---
|
||
if shape_tag == 'rect':
|
||
rx_attr = shape.get('rx')
|
||
ry_attr = shape.get('ry')
|
||
rx = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0
|
||
ry = svg_length_y(ry_attr, ctx) if ry_attr is not None else rx
|
||
if rx <= 0 and ry <= 0:
|
||
return DEFAULT # plain rect clip is a no-op
|
||
r = max(rx, ry)
|
||
if is_obb:
|
||
r = r * min(raw_w, raw_h)
|
||
shorter = min(raw_w, raw_h)
|
||
if shorter <= 0:
|
||
return DEFAULT
|
||
adj = int(min(r / (shorter / 2), 1.0) * 50000)
|
||
return (
|
||
f'<a:prstGeom prst="roundRect"><a:avLst>'
|
||
f'<a:gd name="adj" fmla="val {adj}"/>'
|
||
f'</a:avLst></a:prstGeom>'
|
||
)
|
||
|
||
# --- Path → custGeom ---
|
||
if shape_tag == 'path':
|
||
d = shape.get('d', '')
|
||
if not d:
|
||
return DEFAULT
|
||
commands = parse_svg_path(d)
|
||
commands = svg_path_to_absolute(commands)
|
||
commands = normalize_path_commands(commands)
|
||
if not commands:
|
||
return DEFAULT
|
||
return _clip_commands_to_geom(
|
||
commands, raw_x, raw_y, raw_w, raw_h, is_obb,
|
||
)
|
||
|
||
# --- Polygon → custGeom ---
|
||
if shape_tag == 'polygon':
|
||
pts = parse_svg_points(shape.get('points', ''), min_points=3)
|
||
commands = [PathCommand('M', [pts[0][0], pts[0][1]])]
|
||
for px_, py_ in pts[1:]:
|
||
commands.append(PathCommand('L', [px_, py_]))
|
||
commands.append(PathCommand('Z', []))
|
||
return _clip_commands_to_geom(
|
||
commands, raw_x, raw_y, raw_w, raw_h, is_obb,
|
||
)
|
||
|
||
return DEFAULT
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# image
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _picture_xfrm_from_rect(
|
||
ctx: ConvertContext,
|
||
x: float,
|
||
y: float,
|
||
w: float,
|
||
h: float,
|
||
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||
"""Build DrawingML xfrm data for a picture rectangle.
|
||
|
||
Coordinates ``x``, ``y``, ``w``, ``h`` MUST already be in ctx-resolved
|
||
space (i.e. callers have applied ``ctx_x`` / ``ctx_w`` upstream). When
|
||
``ctx.use_transform_matrix`` is set, raw SVG-space coordinates are
|
||
expected and the matrix path applies the transform itself.
|
||
"""
|
||
if ctx.use_transform_matrix:
|
||
return rect_to_dml_xfrm(x, y, w, h, ctx.transform_matrix)
|
||
|
||
off_x = px_to_emu(x)
|
||
off_y = px_to_emu(y)
|
||
ext_cx = px_to_emu(w)
|
||
ext_cy = px_to_emu(h)
|
||
return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
|
||
|
||
|
||
def _picture_xfrm_from_svg_rect(
|
||
ctx: ConvertContext,
|
||
raw_x: float,
|
||
raw_y: float,
|
||
raw_w: float,
|
||
raw_h: float,
|
||
resolved_x: float,
|
||
resolved_y: float,
|
||
resolved_w: float,
|
||
resolved_h: float,
|
||
transform: str | None,
|
||
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||
"""Build picture xfrm data, honoring element-level SVG transforms.
|
||
|
||
``raw_*`` values stay in the element's source SVG coordinate space for
|
||
matrix decomposition; ``resolved_*`` values are the existing scalar path.
|
||
"""
|
||
if ctx.use_transform_matrix:
|
||
matrix = ctx.transform_matrix
|
||
if transform:
|
||
matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
|
||
return rect_to_dml_xfrm(raw_x, raw_y, raw_w, raw_h, matrix)
|
||
|
||
if transform:
|
||
context_matrix = (
|
||
ctx.scale_x, 0.0,
|
||
0.0, ctx.scale_y,
|
||
ctx.translate_x, ctx.translate_y,
|
||
)
|
||
matrix = matrix_multiply(context_matrix, parse_transform_matrix(transform))
|
||
return rect_to_dml_xfrm(raw_x, raw_y, raw_w, raw_h, matrix)
|
||
|
||
return _picture_xfrm_from_rect(ctx, resolved_x, resolved_y, resolved_w, resolved_h)
|
||
|
||
|
||
def _picture_rendered_frame_size(
|
||
ctx: ConvertContext,
|
||
raw_x: float,
|
||
raw_y: float,
|
||
raw_w: float,
|
||
raw_h: float,
|
||
resolved_x: float,
|
||
resolved_y: float,
|
||
resolved_w: float,
|
||
resolved_h: float,
|
||
transform: str | None,
|
||
) -> tuple[float, float]:
|
||
"""Return final DrawingML picture-axis lengths in rendered SVG pixels."""
|
||
_attr, _x, _y, ext_cx, ext_cy, _bounds = _picture_xfrm_from_svg_rect(
|
||
ctx,
|
||
raw_x,
|
||
raw_y,
|
||
raw_w,
|
||
raw_h,
|
||
resolved_x,
|
||
resolved_y,
|
||
resolved_w,
|
||
resolved_h,
|
||
transform,
|
||
)
|
||
emu_per_px = px_to_emu(1.0)
|
||
return ext_cx / emu_per_px, ext_cy / emu_per_px
|
||
|
||
|
||
def _read_svg_image_size(data: bytes) -> tuple[float, float] | None:
|
||
"""Read an SVG image's viewport ratio from root dimensions or viewBox."""
|
||
try:
|
||
root = ET.fromstring(data)
|
||
except ET.ParseError:
|
||
return None
|
||
if root.tag != f'{{{SVG_NS}}}svg':
|
||
return None
|
||
|
||
try:
|
||
width = parse_svg_length(root.get('width'))
|
||
height = parse_svg_length(root.get('height'))
|
||
except ValueError:
|
||
width = 0.0
|
||
height = 0.0
|
||
if (
|
||
width > 0
|
||
and height > 0
|
||
and math.isfinite(width)
|
||
and math.isfinite(height)
|
||
):
|
||
return width, height
|
||
|
||
view_box = root.get('viewBox')
|
||
if view_box:
|
||
parts = [
|
||
part for part in re.split(r'[\s,]+', view_box.strip())
|
||
if part
|
||
]
|
||
try:
|
||
values = [float(part) for part in parts]
|
||
except ValueError:
|
||
values = []
|
||
if (
|
||
len(values) == 4
|
||
and all(math.isfinite(value) for value in values)
|
||
and values[2] > 0
|
||
and values[3] > 0
|
||
):
|
||
return values[2], values[3]
|
||
return None
|
||
|
||
|
||
def _read_image_size(data: bytes) -> tuple[float | None, float | None]:
|
||
"""Read intrinsic image dimensions (width, height) from raw bytes.
|
||
|
||
Used by ``convert_image`` to translate SVG ``preserveAspectRatio`` into
|
||
DrawingML ``<a:srcRect>`` so the original image is preserved and remains
|
||
croppable inside PowerPoint.
|
||
|
||
SVG images use valid root ``width`` / ``height`` as the viewport ratio and
|
||
fall back to ``viewBox`` when either dimension is unavailable. Raster
|
||
images use EXIF-normalized pixel dimensions. Returns ``(None, None)`` on
|
||
any failure.
|
||
"""
|
||
svg_size = _read_svg_image_size(data)
|
||
if svg_size is not None:
|
||
return svg_size
|
||
|
||
try:
|
||
from PIL import Image, ImageOps, UnidentifiedImageError # type: ignore
|
||
except ImportError:
|
||
return (None, None)
|
||
try:
|
||
with Image.open(io.BytesIO(data)) as img:
|
||
oriented = ImageOps.exif_transpose(img)
|
||
try:
|
||
return oriented.size
|
||
finally:
|
||
if oriented is not img:
|
||
oriented.close()
|
||
except (
|
||
UnidentifiedImageError,
|
||
OSError,
|
||
SyntaxError,
|
||
ValueError,
|
||
ZeroDivisionError,
|
||
):
|
||
return (None, None)
|
||
|
||
|
||
def _image_has_alpha(img: Any) -> bool:
|
||
"""Return whether a PIL image carries useful transparency."""
|
||
if img.mode in ('RGBA', 'LA'):
|
||
return True
|
||
return 'transparency' in getattr(img, 'info', {})
|
||
|
||
|
||
def _prepare_raster_for_geometry(img: Any) -> Any:
|
||
"""Apply EXIF orientation and materialize palette/tRNS transparency."""
|
||
try:
|
||
from PIL import ImageOps # type: ignore
|
||
except ImportError:
|
||
return img
|
||
|
||
prepared = ImageOps.exif_transpose(img)
|
||
if prepared.mode == 'P':
|
||
prepared = prepared.convert(
|
||
'RGBA' if _image_has_alpha(prepared) else 'RGB'
|
||
)
|
||
elif (
|
||
'transparency' in getattr(prepared, 'info', {})
|
||
and prepared.mode not in {'RGBA', 'LA'}
|
||
):
|
||
prepared = prepared.convert('RGBA')
|
||
return prepared
|
||
|
||
|
||
def _has_exif_geometry_transform(img: Any) -> bool:
|
||
"""Return whether EXIF requires a physical mirror or rotation."""
|
||
try:
|
||
return int(img.getexif().get(274, 1)) in range(2, 9)
|
||
except (AttributeError, TypeError, ValueError):
|
||
return False
|
||
|
||
|
||
def _image_target_size(
|
||
display_w: float,
|
||
display_h: float,
|
||
*,
|
||
max_dimension: int | None,
|
||
scale: float,
|
||
) -> tuple[int, int]:
|
||
"""Resolve optimized pixel dimensions from rendered SVG dimensions."""
|
||
target_w = max(1, int(round(display_w * max(scale, 1.0))))
|
||
target_h = max(1, int(round(display_h * max(scale, 1.0))))
|
||
if max_dimension and max(target_w, target_h) > max_dimension:
|
||
ratio = max_dimension / max(target_w, target_h)
|
||
target_w = max(1, int(round(target_w * ratio)))
|
||
target_h = max(1, int(round(target_h * ratio)))
|
||
return target_w, target_h
|
||
|
||
|
||
def _visible_source_scale_floor(
|
||
img_w: int,
|
||
img_h: int,
|
||
display_w: float,
|
||
display_h: float,
|
||
visible_source_fraction: tuple[float, float],
|
||
) -> float:
|
||
"""Keep each rendered axis at or below its visible source pixels."""
|
||
fraction_w, fraction_h = visible_source_fraction
|
||
visible_w = img_w * fraction_w
|
||
visible_h = img_h * fraction_h
|
||
if visible_w <= 0 or visible_h <= 0:
|
||
return 1.0
|
||
return min(1.0, max(display_w / visible_w, display_h / visible_h))
|
||
|
||
|
||
def _fit_full_image_target(
|
||
img_w: int,
|
||
img_h: int,
|
||
display_w: float,
|
||
display_h: float,
|
||
*,
|
||
sizing: str,
|
||
max_dimension: int | None,
|
||
scale: float,
|
||
visible_source_fraction: tuple[float, float] | None = None,
|
||
) -> tuple[int, int]:
|
||
"""Size the full source image; never crop pixels.
|
||
|
||
``cap`` mode limits oversized sources unless that would leave a cropped
|
||
or stretched picture with fewer visible source pixels than its final
|
||
rendered frame. ``display`` mode targets the configured rendered-frame
|
||
scale while retaining the same one-source-pixel-per-display-pixel floor.
|
||
"""
|
||
if img_w <= 0 or img_h <= 0:
|
||
return (1, 1)
|
||
|
||
enforce_visible_floor = visible_source_fraction is not None
|
||
if enforce_visible_floor and (
|
||
any(
|
||
not math.isfinite(fraction) or fraction <= 0
|
||
for fraction in visible_source_fraction
|
||
)
|
||
):
|
||
return (img_w, img_h)
|
||
|
||
if sizing == 'cap':
|
||
ratio = 1.0
|
||
if max_dimension and max(img_w, img_h) > max_dimension:
|
||
ratio = max_dimension / max(img_w, img_h)
|
||
if enforce_visible_floor:
|
||
ratio = max(
|
||
ratio,
|
||
_visible_source_scale_floor(
|
||
img_w,
|
||
img_h,
|
||
display_w,
|
||
display_h,
|
||
visible_source_fraction,
|
||
),
|
||
)
|
||
ratio = min(1.0, ratio)
|
||
target_w = max(1, int(math.ceil(img_w * ratio)))
|
||
target_h = max(1, int(math.ceil(img_h * ratio)))
|
||
return target_w, target_h
|
||
|
||
target_display_w, target_display_h = _image_target_size(
|
||
display_w,
|
||
display_h,
|
||
max_dimension=None,
|
||
scale=scale,
|
||
)
|
||
|
||
if enforce_visible_floor:
|
||
fraction_w, fraction_h = visible_source_fraction
|
||
preferred_scale = min(
|
||
1.0,
|
||
max(
|
||
target_display_w / (img_w * fraction_w),
|
||
target_display_h / (img_h * fraction_h),
|
||
),
|
||
)
|
||
visible_floor = _visible_source_scale_floor(
|
||
img_w,
|
||
img_h,
|
||
display_w,
|
||
display_h,
|
||
visible_source_fraction,
|
||
)
|
||
resize_scale = preferred_scale
|
||
if max_dimension and max(img_w, img_h) * resize_scale > max_dimension:
|
||
resize_scale = max(
|
||
visible_floor,
|
||
max_dimension / max(img_w, img_h),
|
||
)
|
||
target_w = int(math.ceil(img_w * resize_scale))
|
||
target_h = int(math.ceil(img_h * resize_scale))
|
||
else:
|
||
ratio = min(
|
||
target_display_w / img_w,
|
||
target_display_h / img_h,
|
||
1.0,
|
||
)
|
||
target_w = int(round(img_w * ratio))
|
||
target_h = int(round(img_h * ratio))
|
||
|
||
target_w = max(1, target_w)
|
||
target_h = max(1, target_h)
|
||
if (
|
||
not enforce_visible_floor
|
||
and max_dimension
|
||
and max(target_w, target_h) > max_dimension
|
||
):
|
||
ratio = max_dimension / max(target_w, target_h)
|
||
target_w = max(1, int(round(target_w * ratio)))
|
||
target_h = max(1, int(round(target_h * ratio)))
|
||
return target_w, target_h
|
||
|
||
|
||
def _resize_for_target(img: Any, target_w: int, target_h: int) -> Any:
|
||
"""Downscale a PIL image to the target dimensions without upsampling."""
|
||
width, height = img.size
|
||
if target_w >= width and target_h >= height:
|
||
return img
|
||
ratio = min(target_w / width, target_h / height)
|
||
if ratio >= 1.0:
|
||
return img
|
||
try:
|
||
from PIL import Image # type: ignore
|
||
except ImportError:
|
||
return img
|
||
new_size = (
|
||
max(1, int(math.ceil(width * ratio))),
|
||
max(1, int(math.ceil(height * ratio))),
|
||
)
|
||
return img.resize(new_size, Image.Resampling.LANCZOS)
|
||
|
||
|
||
def _encode_optimized_image(img: Any, *, prefer_jpeg: bool, quality: int) -> tuple[bytes, str] | None:
|
||
"""Encode a PIL image for PPTX media."""
|
||
buf = io.BytesIO()
|
||
try:
|
||
if prefer_jpeg and not _image_has_alpha(img):
|
||
if img.mode != 'RGB':
|
||
img = img.convert('RGB')
|
||
img.save(buf, format='JPEG', quality=max(1, min(quality, 100)), optimize=True)
|
||
return buf.getvalue(), 'jpg'
|
||
if img.mode == 'P':
|
||
img = img.convert('RGBA' if _image_has_alpha(img) else 'RGB')
|
||
elif img.mode not in {'1', 'L', 'LA', 'I', 'I;16', 'RGB', 'RGBA'}:
|
||
img = img.convert('RGBA' if _image_has_alpha(img) else 'RGB')
|
||
img.save(buf, format='PNG', optimize=True)
|
||
return buf.getvalue(), 'png'
|
||
except (OSError, ValueError):
|
||
return None
|
||
|
||
|
||
def _optimize_image_for_pptx(
|
||
ctx: ConvertContext,
|
||
img_data: bytes,
|
||
img_format: str,
|
||
display_w: float,
|
||
display_h: float,
|
||
*,
|
||
visible_source_fraction: tuple[float, float] | None = None,
|
||
) -> tuple[bytes, str]:
|
||
"""Optimize full raster image bytes for native PPTX embedding."""
|
||
if not ctx.image_optimize:
|
||
return img_data, img_format
|
||
if img_format.lower() in {'svg', 'emf', 'wmf'}:
|
||
return img_data, img_format
|
||
|
||
try:
|
||
from PIL import Image, UnidentifiedImageError # type: ignore
|
||
except ImportError:
|
||
return img_data, img_format
|
||
|
||
try:
|
||
img = Image.open(io.BytesIO(img_data))
|
||
img.load()
|
||
except (UnidentifiedImageError, OSError, ValueError):
|
||
return img_data, img_format
|
||
|
||
# Multi-frame images (animated GIF / WebP / APNG): resize/re-encode
|
||
# below keeps frame 0 only, flattening the animation in the exported
|
||
# PPTX. Pass the original bytes through — animations are exempt from
|
||
# optimization and the size cap (before this optimizer existed, the
|
||
# native path embedded raster bytes verbatim and animations survived).
|
||
if getattr(img, 'is_animated', False):
|
||
return img_data, img_format
|
||
|
||
geometry_normalized = _has_exif_geometry_transform(img)
|
||
img = _prepare_raster_for_geometry(img)
|
||
target_w, target_h = _fit_full_image_target(
|
||
img.size[0],
|
||
img.size[1],
|
||
display_w,
|
||
display_h,
|
||
sizing=ctx.image_sizing,
|
||
max_dimension=ctx.image_max_dimension,
|
||
scale=ctx.image_scale,
|
||
visible_source_fraction=visible_source_fraction,
|
||
)
|
||
|
||
original_size = img.size
|
||
img = _resize_for_target(img, target_w, target_h)
|
||
resized = img.size != original_size
|
||
if (
|
||
ctx.image_sizing == 'cap'
|
||
and not geometry_normalized
|
||
and not resized
|
||
):
|
||
return img_data, img_format
|
||
# Preserve source semantics: only an original JPEG stays lossy. PNG and
|
||
# other static raster formats use lossless PNG after any resize.
|
||
prefer_jpeg = img_format.lower() in {'jpg', 'jpeg'}
|
||
encoded = _encode_optimized_image(img, prefer_jpeg=prefer_jpeg, quality=ctx.image_quality)
|
||
if encoded is None:
|
||
return img_data, img_format
|
||
|
||
optimized_data, optimized_format = encoded
|
||
if (
|
||
not geometry_normalized
|
||
and not resized
|
||
and len(optimized_data) >= len(img_data)
|
||
):
|
||
return img_data, img_format
|
||
|
||
return optimized_data, optimized_format
|
||
|
||
|
||
def _compute_slice_src_rect(
|
||
img_w: float, img_h: float,
|
||
box_w: float, box_h: float,
|
||
align: str,
|
||
) -> tuple[int, int, int, int] | None:
|
||
"""Compute DrawingML ``<a:srcRect>`` (l, t, r, b) for SVG slice mode.
|
||
|
||
SVG ``preserveAspectRatio="<align> slice"`` means: scale the image so it
|
||
fully covers the box (CSS object-fit: cover) and crop the overflow at the
|
||
given alignment anchor. DrawingML ``srcRect`` expresses the same intent
|
||
by specifying which sub-rectangle of the source image to display, in
|
||
units of 1/1000 of a percent (0–100000).
|
||
|
||
Returns ``None`` when no cropping is required (image and box already
|
||
match) or when inputs are degenerate.
|
||
"""
|
||
if img_w <= 0 or img_h <= 0 or box_w <= 0 or box_h <= 0:
|
||
return None
|
||
|
||
# Scale factor that makes the image cover the box (cover semantics).
|
||
scale = max(box_w / img_w, box_h / img_h)
|
||
visible_w = box_w / scale # ≤ img_w
|
||
visible_h = box_h / scale # ≤ img_h
|
||
|
||
if (
|
||
math.isclose(visible_w, img_w, rel_tol=1e-9, abs_tol=1e-9)
|
||
and math.isclose(visible_h, img_h, rel_tol=1e-9, abs_tol=1e-9)
|
||
):
|
||
return None # No crop needed
|
||
|
||
x_anchor, y_anchor = PROJECT_IMAGE_ASPECT_RATIO_ANCHORS[align]
|
||
crop_w_total = max(
|
||
0,
|
||
min(99999, int(round((1.0 - visible_w / img_w) * 100000))),
|
||
)
|
||
crop_h_total = max(
|
||
0,
|
||
min(99999, int(round((1.0 - visible_h / img_h) * 100000))),
|
||
)
|
||
l = max(0, min(crop_w_total, int(round(crop_w_total * x_anchor))))
|
||
t = max(0, min(crop_h_total, int(round(crop_h_total * y_anchor))))
|
||
r = crop_w_total - l
|
||
b = crop_h_total - t
|
||
if not any((l, t, r, b)):
|
||
return None
|
||
|
||
return (l, t, r, b)
|
||
|
||
|
||
def _resolve_image_src_rect_values(
|
||
elem: ET.Element,
|
||
img_data: bytes,
|
||
box_w: float, box_h: float,
|
||
) -> tuple[int, int, int, int] | None:
|
||
"""Resolve DrawingML source-crop values for an SVG slice image.
|
||
|
||
Slice mode is resolved into a srcRect so the original image is embedded
|
||
intact and PowerPoint's crop tool / "Reset Picture" continue to work.
|
||
Meet mode is handled separately by ``_resolve_image_meet_fit`` (which
|
||
shrinks the picture frame to match image aspect ratio); none mode keeps
|
||
the legacy stretch behaviour intentionally.
|
||
"""
|
||
align, mode = parse_project_image_aspect_ratio(
|
||
elem.get('preserveAspectRatio')
|
||
)
|
||
|
||
if align == 'none' or mode != 'slice':
|
||
return None
|
||
|
||
img_w, img_h = _read_image_size(img_data)
|
||
if img_w is None or img_h is None:
|
||
return None
|
||
|
||
return _compute_slice_src_rect(
|
||
float(img_w),
|
||
float(img_h),
|
||
box_w,
|
||
box_h,
|
||
align,
|
||
)
|
||
|
||
|
||
def _src_rect_xml(rect: tuple[int, int, int, int] | None) -> str:
|
||
"""Serialize an optional DrawingML source crop."""
|
||
if rect is None:
|
||
return ''
|
||
l, t, r, b = rect
|
||
return f'<a:srcRect l="{l}" t="{t}" r="{r}" b="{b}"/>'
|
||
|
||
|
||
def _resolve_image_meet_fit(
|
||
elem: ET.Element,
|
||
img_data: bytes,
|
||
box_w: float, box_h: float,
|
||
) -> tuple[float, float, float, float] | None:
|
||
"""For SVG ``preserveAspectRatio="<align> meet"``, compute the letterboxed
|
||
sub-rectangle ``(dx, dy, fit_w, fit_h)`` inside the original box that
|
||
matches the image's intrinsic aspect ratio.
|
||
|
||
PowerPoint has no native ``meet`` semantic — ``<a:stretch><a:fillRect/>``
|
||
fills the entire frame and would distort the image whenever the SVG
|
||
container ratio differs from the source image ratio. The fix is to shrink
|
||
the ``<p:pic>`` frame itself (off + ext) so the frame and image share an
|
||
aspect ratio; the stretch then fills a correctly-shaped frame.
|
||
|
||
Returns ``None`` when the adjustment is not applicable:
|
||
- mode is ``slice`` (handled by srcRect path)
|
||
- align is ``none`` (SVG spec says: stretch — do not adjust)
|
||
- intrinsic image dimensions cannot be read
|
||
- frame already matches image ratio (no-op)
|
||
"""
|
||
align, mode = parse_project_image_aspect_ratio(
|
||
elem.get('preserveAspectRatio')
|
||
)
|
||
|
||
if align == 'none' or mode == 'slice':
|
||
return None
|
||
|
||
img_w, img_h = _read_image_size(img_data)
|
||
if img_w is None or img_h is None or img_w <= 0 or img_h <= 0:
|
||
return None
|
||
if box_w <= 0 or box_h <= 0:
|
||
return None
|
||
|
||
scale = min(box_w / img_w, box_h / img_h)
|
||
fit_w = img_w * scale
|
||
fit_h = img_h * scale
|
||
|
||
if abs(fit_w - box_w) < 0.5 and abs(fit_h - box_h) < 0.5:
|
||
return None # already matches — no adjustment
|
||
|
||
x_anchor, y_anchor = PROJECT_IMAGE_ASPECT_RATIO_ANCHORS[align]
|
||
|
||
dx = (box_w - fit_w) * x_anchor
|
||
dy = (box_h - fit_h) * y_anchor
|
||
|
||
return (dx, dy, fit_w, fit_h)
|
||
|
||
|
||
def _build_image_blip_xml(r_id: str, opacity: float | None) -> str:
|
||
"""Build an image blip with native DrawingML transparency when requested."""
|
||
if opacity is None:
|
||
return f'<a:blip r:embed="{r_id}"/>'
|
||
alpha = quantize_ooxml_alpha(opacity)
|
||
return (
|
||
f'<a:blip r:embed="{r_id}">'
|
||
f'<a:alphaModFix amt="{alpha}"/>'
|
||
'</a:blip>'
|
||
)
|
||
|
||
|
||
def _register_image_media(
|
||
ctx: ConvertContext,
|
||
img_format: str,
|
||
img_data: bytes,
|
||
*,
|
||
reuse_text_fill: bool = False,
|
||
) -> str:
|
||
"""Register image bytes and return a DrawingML relationship id."""
|
||
cache_key: tuple[str, str] | None = None
|
||
if reuse_text_fill:
|
||
cache_key = (img_format, hashlib.sha256(img_data).hexdigest())
|
||
if cache_key in ctx.text_image_fill_cache:
|
||
return ctx.text_image_fill_cache[cache_key]
|
||
|
||
img_idx = len(ctx.media_files) + 1
|
||
img_filename = f's{ctx.slide_num}_img{img_idx}.{img_format}'
|
||
ctx.media_files[img_filename] = img_data
|
||
r_id = ctx.next_rel_id()
|
||
ctx.rel_entries.append({
|
||
'id': r_id,
|
||
'type': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
|
||
'target': f'../media/{img_filename}',
|
||
})
|
||
if cache_key is not None:
|
||
ctx.text_image_fill_cache[cache_key] = r_id
|
||
return r_id
|
||
|
||
|
||
def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <image> to DrawingML picture element.
|
||
|
||
Supports clip-path attribute: when present, the clipPath shape is mapped
|
||
to DrawingML picture geometry (prstGeom or custGeom) so the image is
|
||
natively clipped in PowerPoint.
|
||
"""
|
||
source = load_project_image_source(elem, ctx.svg_dir)
|
||
|
||
# Raw coordinates (pre-context-transform) for clip path calculations
|
||
raw_x = svg_length_x(elem.get('x'), ctx)
|
||
raw_y = svg_length_y(elem.get('y'), ctx)
|
||
raw_w = svg_length_x(elem.get('width'), ctx)
|
||
raw_h = svg_length_y(elem.get('height'), ctx)
|
||
|
||
if ctx.use_transform_matrix:
|
||
x = raw_x
|
||
y = raw_y
|
||
w = raw_w
|
||
h = raw_h
|
||
else:
|
||
x = ctx_x(raw_x, ctx)
|
||
y = ctx_y(raw_y, ctx)
|
||
w = ctx_w(raw_w, ctx)
|
||
h = ctx_h(raw_h, ctx)
|
||
|
||
if w <= 0 or h <= 0:
|
||
raise ValueError('image width and height must be positive')
|
||
|
||
img_format = source.img_format
|
||
img_data = source.img_data
|
||
|
||
transform = elem.get('transform')
|
||
rendered_w, rendered_h = _picture_rendered_frame_size(
|
||
ctx,
|
||
raw_x,
|
||
raw_y,
|
||
raw_w,
|
||
raw_h,
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
transform,
|
||
)
|
||
align, mode = parse_project_image_aspect_ratio(
|
||
elem.get('preserveAspectRatio')
|
||
)
|
||
src_rect = _resolve_image_src_rect_values(
|
||
elem,
|
||
img_data,
|
||
raw_w,
|
||
raw_h,
|
||
)
|
||
visible_source_fraction = None
|
||
if align == 'none':
|
||
visible_source_fraction = (1.0, 1.0)
|
||
elif mode == 'slice':
|
||
if src_rect is None:
|
||
visible_source_fraction = (1.0, 1.0)
|
||
else:
|
||
src_l, src_t, src_r, src_b = src_rect
|
||
visible_source_fraction = (
|
||
1.0 - (src_l + src_r) / 100000.0,
|
||
1.0 - (src_t + src_b) / 100000.0,
|
||
)
|
||
img_data, img_format = _optimize_image_for_pptx(
|
||
ctx,
|
||
img_data,
|
||
img_format,
|
||
rendered_w,
|
||
rendered_h,
|
||
visible_source_fraction=visible_source_fraction,
|
||
)
|
||
|
||
r_id = _register_image_media(ctx, img_format, img_data)
|
||
|
||
# Resolve clip-path → DrawingML geometry
|
||
clip_geom = _resolve_clip_geometry(elem, ctx, raw_x, raw_y, raw_w, raw_h)
|
||
effect_xml = ''
|
||
filter_id = get_effective_filter_id(elem, ctx)
|
||
if filter_id and filter_id in ctx.defs:
|
||
effect_xml = build_effect_xml(
|
||
ctx.defs[filter_id],
|
||
get_element_opacity(elem, ctx),
|
||
)
|
||
|
||
# Resolve preserveAspectRatio="<align> slice" as DrawingML crop metadata.
|
||
# Image optimization only downscales the full source image; it never crops
|
||
# pixels out of the embedded media.
|
||
src_rect_xml = _src_rect_xml(src_rect)
|
||
blip_xml = _build_image_blip_xml(r_id, get_element_opacity(elem, ctx))
|
||
|
||
# Resolve preserveAspectRatio="<align> meet" by shrinking the picture
|
||
# frame to match the image's aspect ratio. Skipped when a real clip-path
|
||
# produces non-trivial geometry: such clip rectangles are defined against
|
||
# the original box and would no longer line up after a frame shift.
|
||
# A clip-path that resolves back to the default rect geometry (e.g. plain
|
||
# <rect> without rx/ry) is a no-op and must not block meet adjustment.
|
||
clip_is_noop = clip_geom == '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
||
meet_fit = None if not clip_is_noop else _resolve_image_meet_fit(elem, img_data, w, h)
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
if meet_fit is not None:
|
||
dx, dy, fit_w, fit_h = meet_fit
|
||
if ctx.use_transform_matrix:
|
||
raw_fit_x = raw_x + dx
|
||
raw_fit_y = raw_y + dy
|
||
raw_fit_w = fit_w
|
||
raw_fit_h = fit_h
|
||
else:
|
||
raw_fit_x = raw_x + (dx / ctx.scale_x if ctx.scale_x else dx)
|
||
raw_fit_y = raw_y + (dy / ctx.scale_y if ctx.scale_y else dy)
|
||
raw_fit_w = fit_w / ctx.scale_x if ctx.scale_x else fit_w
|
||
raw_fit_h = fit_h / ctx.scale_y if ctx.scale_y else fit_h
|
||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect(
|
||
ctx,
|
||
raw_fit_x,
|
||
raw_fit_y,
|
||
raw_fit_w,
|
||
raw_fit_h,
|
||
x + dx,
|
||
y + dy,
|
||
fit_w,
|
||
fit_h,
|
||
transform,
|
||
)
|
||
else:
|
||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect(
|
||
ctx,
|
||
raw_x,
|
||
raw_y,
|
||
raw_w,
|
||
raw_h,
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
transform,
|
||
)
|
||
|
||
return ShapeResult(xml=f'''<p:pic>
|
||
<p:nvPicPr>
|
||
<p:cNvPr id="{shape_id}" name="Image {shape_id}"/>
|
||
<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
|
||
<p:nvPr/>
|
||
</p:nvPicPr>
|
||
<p:blipFill>
|
||
{blip_xml}
|
||
{src_rect_xml}<a:stretch><a:fillRect/></a:stretch>
|
||
</p:blipFill>
|
||
<p:spPr>
|
||
<a:xfrm{xfrm_attr}><a:off x="{off_x}" y="{off_y}"/>
|
||
<a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||
{clip_geom}
|
||
{effect_xml}
|
||
</p:spPr>
|
||
</p:pic>''', bounds_emu=bounds_emu)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ellipse
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <ellipse> to DrawingML ellipse shape."""
|
||
preset_geom = _build_preset_geom_from_meta(elem)
|
||
raw_cx = svg_length_x(elem.get('cx'), ctx)
|
||
raw_cy = svg_length_y(elem.get('cy'), ctx)
|
||
rx_attr = elem.get('rx')
|
||
ry_attr = elem.get('ry')
|
||
raw_rx = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0
|
||
raw_ry = svg_length_y(ry_attr, ctx) if ry_attr is not None else 0.0
|
||
if rx_attr is not None and ry_attr is None:
|
||
raw_ry = raw_rx
|
||
elif ry_attr is not None and rx_attr is None:
|
||
raw_rx = raw_ry
|
||
cx_ = ctx_x(raw_cx, ctx)
|
||
cy_ = ctx_y(raw_cy, ctx)
|
||
rx = raw_rx * ctx.scale_x
|
||
ry = raw_ry * ctx.scale_y
|
||
|
||
if rx <= 0 or ry <= 0:
|
||
return None
|
||
|
||
x = cx_ - rx
|
||
y = cy_ - ry
|
||
w = rx * 2
|
||
h = ry * 2
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
geom = preset_geom or '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
||
|
||
transform = elem.get('transform')
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
if preset_geom is not None:
|
||
xfrm = _shape_xfrm_from_preset_frame(
|
||
elem,
|
||
ctx,
|
||
(raw_cx - raw_rx, raw_cy - raw_ry, raw_rx * 2, raw_ry * 2),
|
||
(x, y, w, h),
|
||
transform,
|
||
)
|
||
else:
|
||
xfrm = _shape_xfrm_from_svg_rect(
|
||
ctx,
|
||
raw_cx - raw_rx,
|
||
raw_cy - raw_ry,
|
||
raw_rx * 2,
|
||
raw_ry * 2,
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
transform,
|
||
)
|
||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm
|
||
return ShapeResult(
|
||
xml=_wrap_geometry_object(
|
||
elem,
|
||
ctx,
|
||
shape_id, f'Ellipse {shape_id}',
|
||
off_x, off_y, ext_cx, ext_cy,
|
||
geom, fill, stroke, xfrm_attr=xfrm_attr,
|
||
),
|
||
bounds_emu=bounds_emu,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# nested <svg> sprite (template-import round-trip)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Inverse of pptx_to_svg/pic_to_svg.py:101-113 — that path writes a cropped
|
||
# DrawingML picture as an outer <svg viewBox> wrapping a unit-rectangle <image>.
|
||
# Without this converter, every cropped picture in a template-import SVG is
|
||
# silently dropped on re-export.
|
||
|
||
@dataclass(frozen=True)
|
||
class NestedSvgCropSpec:
|
||
"""Validated transport fields for one imported cropped picture."""
|
||
|
||
image: ET.Element
|
||
x: float
|
||
y: float
|
||
width: float
|
||
height: float
|
||
view_box_x: float
|
||
view_box_y: float
|
||
view_box_width: float
|
||
view_box_height: float
|
||
src_l: int
|
||
src_t: int
|
||
src_r: int
|
||
src_b: int
|
||
|
||
|
||
_NESTED_CROP_OUTER_ATTRIBUTES = frozenset({
|
||
'clip-path',
|
||
'data-pptx-crop',
|
||
'data-pptx-editable',
|
||
EFFECT_REASON_ATTR,
|
||
EFFECT_STATUS_ATTR,
|
||
'data-pptx-frame',
|
||
'data-pptx-layer',
|
||
'data-pptx-object',
|
||
'data-pptx-carrier',
|
||
'data-pptx-prst',
|
||
'data-pptx-shape-id',
|
||
'data-pptx-shape-name',
|
||
'data-pptx-shape-scope',
|
||
'id',
|
||
'overflow',
|
||
'preserveAspectRatio',
|
||
'transform',
|
||
'viewBox',
|
||
'x',
|
||
'y',
|
||
'width',
|
||
'height',
|
||
})
|
||
_NESTED_CROP_IMAGE_ATTRIBUTES = frozenset({
|
||
'clip-path',
|
||
'href',
|
||
f'{{{XLINK_NS}}}href',
|
||
'opacity',
|
||
'preserveAspectRatio',
|
||
'x',
|
||
'y',
|
||
'width',
|
||
'height',
|
||
})
|
||
_DRAWINGML_PERCENTAGE_MIN = -(2 ** 31)
|
||
_DRAWINGML_PERCENTAGE_MAX = 2 ** 31 - 1
|
||
|
||
|
||
def _unsupported_nested_crop_attributes(
|
||
elem: ET.Element,
|
||
allowed: frozenset[str],
|
||
) -> list[str]:
|
||
unsupported = []
|
||
for name in elem.attrib:
|
||
if name in allowed:
|
||
continue
|
||
unsupported.append(name.rsplit('}', 1)[-1])
|
||
return sorted(unsupported)
|
||
|
||
|
||
def parse_project_nested_svg_crop(elem: ET.Element) -> NestedSvgCropSpec:
|
||
"""Parse the closed nested-SVG transport written by ``pptx_to_svg``."""
|
||
if elem.tag != f'{{{SVG_NS}}}svg':
|
||
raise ValueError('expected an SVG-namespace nested <svg> crop wrapper')
|
||
|
||
unsupported = _unsupported_nested_crop_attributes(
|
||
elem,
|
||
_NESTED_CROP_OUTER_ATTRIBUTES,
|
||
)
|
||
if unsupported:
|
||
raise ValueError(
|
||
'nested crop <svg> has unsupported attribute(s): '
|
||
+ ', '.join(unsupported)
|
||
)
|
||
crop_marker = elem.get('data-pptx-crop')
|
||
overflow = elem.get('overflow')
|
||
if overflow is not None and overflow != 'hidden':
|
||
raise ValueError(
|
||
'nested crop overflow must be exactly "hidden" when present'
|
||
)
|
||
if elem.text and elem.text.strip():
|
||
raise ValueError(
|
||
'nested crop <svg> cannot contain non-whitespace character data'
|
||
)
|
||
|
||
children = list(elem)
|
||
if (
|
||
len(children) != 1
|
||
or children[0].tag != f'{{{SVG_NS}}}image'
|
||
):
|
||
raise ValueError(
|
||
'nested <svg> is reserved for imported picture crops; expected '
|
||
'exactly one direct SVG-namespace <image> child'
|
||
)
|
||
image_elem = children[0]
|
||
if image_elem.tail and image_elem.tail.strip():
|
||
raise ValueError(
|
||
'nested crop <svg> cannot contain non-whitespace character data'
|
||
)
|
||
if list(image_elem) or (image_elem.text and image_elem.text.strip()):
|
||
raise ValueError('nested crop <image> must be an empty element')
|
||
|
||
unsupported = _unsupported_nested_crop_attributes(
|
||
image_elem,
|
||
_NESTED_CROP_IMAGE_ATTRIBUTES,
|
||
)
|
||
if unsupported:
|
||
raise ValueError(
|
||
'nested crop <image> has unsupported attribute(s): '
|
||
+ ', '.join(unsupported)
|
||
)
|
||
|
||
outer_clip_path = elem.get('clip-path')
|
||
inner_clip_path = image_elem.get('clip-path')
|
||
if outer_clip_path is not None and inner_clip_path is not None:
|
||
raise ValueError(
|
||
'nested crop clip-path must occur on either the outer <svg> or '
|
||
'the inner <image>, not both'
|
||
)
|
||
clip_path = inner_clip_path or outer_clip_path
|
||
if crop_marker is not None and crop_marker != '1':
|
||
raise ValueError('nested crop data-pptx-crop must be exactly "1"')
|
||
if clip_path is None:
|
||
if crop_marker is not None:
|
||
raise ValueError(
|
||
'nested crop data-pptx-crop="1" requires clip-path'
|
||
)
|
||
elif clip_path.strip().lower() == 'none':
|
||
raise ValueError('nested crop clip-path cannot be "none"')
|
||
elif crop_marker != '1':
|
||
raise ValueError(
|
||
'nested crop clip-path requires data-pptx-crop="1"'
|
||
)
|
||
if inner_clip_path is not None and overflow != 'hidden':
|
||
raise ValueError(
|
||
'nested crop with inner <image> clip-path requires '
|
||
'overflow="hidden" on the outer <svg>'
|
||
)
|
||
|
||
try:
|
||
_project_image_href(image_elem)
|
||
except ValueError as exc:
|
||
raise ValueError(f'nested crop <image> {exc}') from exc
|
||
|
||
required_outer = (
|
||
'x',
|
||
'y',
|
||
'width',
|
||
'height',
|
||
'viewBox',
|
||
'preserveAspectRatio',
|
||
)
|
||
missing = [name for name in required_outer if elem.get(name) is None]
|
||
if missing:
|
||
raise ValueError(
|
||
'nested crop <svg> requires explicit x, y, width, height, '
|
||
'viewBox, and preserveAspectRatio="none"; missing '
|
||
+ ', '.join(missing)
|
||
)
|
||
if elem.get('preserveAspectRatio') != 'none':
|
||
raise ValueError(
|
||
'nested crop <svg> preserveAspectRatio must be exactly "none"'
|
||
)
|
||
|
||
frame_values: dict[str, float] = {}
|
||
for name in ('x', 'y', 'width', 'height'):
|
||
raw = elem.get(name)
|
||
assert raw is not None
|
||
try:
|
||
frame_values[name] = parse_project_geometry_length(raw, name)
|
||
except ValueError as exc:
|
||
raise ValueError(
|
||
f'nested crop <svg> {name}={raw!r}: {exc}'
|
||
) from exc
|
||
if frame_values['width'] <= 0 or frame_values['height'] <= 0:
|
||
raise ValueError('nested crop <svg> width and height must be positive')
|
||
|
||
view_box = elem.get('viewBox') or ''
|
||
view_box_tokens = view_box.strip().split()
|
||
if (
|
||
len(view_box_tokens) != 4
|
||
or any(
|
||
not is_canonical_project_geometry_length(token)
|
||
for token in view_box_tokens
|
||
)
|
||
):
|
||
raise ValueError(
|
||
'nested crop viewBox must contain four finite unitless ordinary '
|
||
'decimals separated by whitespace'
|
||
)
|
||
vb_x, vb_y, vb_w, vb_h = (
|
||
parse_project_geometry_length(token, 'x')
|
||
for token in view_box_tokens
|
||
)
|
||
if vb_w <= 0 or vb_h <= 0:
|
||
raise ValueError('nested crop viewBox width and height must be positive')
|
||
src_l = round(vb_x * 100000)
|
||
src_t = round(vb_y * 100000)
|
||
src_r = round((1.0 - vb_x - vb_w) * 100000)
|
||
src_b = round((1.0 - vb_y - vb_h) * 100000)
|
||
src_values = (src_l, src_t, src_r, src_b)
|
||
if (
|
||
any(
|
||
value < _DRAWINGML_PERCENTAGE_MIN
|
||
or value > _DRAWINGML_PERCENTAGE_MAX
|
||
for value in src_values
|
||
)
|
||
or src_l + src_r >= 100000
|
||
or src_t + src_b >= 100000
|
||
):
|
||
raise ValueError(
|
||
'nested crop viewBox cannot be represented as a DrawingML '
|
||
'srcRect with a positive visible region within the signed '
|
||
'percentage range'
|
||
)
|
||
if not any(src_values):
|
||
raise ValueError(
|
||
'nested crop viewBox="0 0 1 1" is redundant; use a plain <image>'
|
||
)
|
||
|
||
required_image_values = {
|
||
'x': '0',
|
||
'y': '0',
|
||
'width': '1',
|
||
'height': '1',
|
||
'preserveAspectRatio': 'none',
|
||
}
|
||
invalid_image_values = [
|
||
f'{name}={image_elem.get(name)!r}'
|
||
for name, expected in required_image_values.items()
|
||
if image_elem.get(name) != expected
|
||
]
|
||
if invalid_image_values:
|
||
raise ValueError(
|
||
'nested crop <image> must use x="0", y="0", width="1", '
|
||
'height="1", and preserveAspectRatio="none"; got '
|
||
+ ', '.join(invalid_image_values)
|
||
)
|
||
|
||
return NestedSvgCropSpec(
|
||
image=image_elem,
|
||
x=frame_values['x'],
|
||
y=frame_values['y'],
|
||
width=frame_values['width'],
|
||
height=frame_values['height'],
|
||
view_box_x=vb_x,
|
||
view_box_y=vb_y,
|
||
view_box_width=vb_w,
|
||
view_box_height=vb_h,
|
||
src_l=src_l,
|
||
src_t=src_t,
|
||
src_r=src_r,
|
||
src_b=src_b,
|
||
)
|
||
|
||
|
||
def project_nested_svg_crop_errors(root: ET.Element) -> list[str]:
|
||
"""Return contract errors for every nested SVG transport wrapper."""
|
||
errors: list[str] = []
|
||
parent_by_id = {
|
||
id(child): parent
|
||
for parent in root.iter()
|
||
for child in list(parent)
|
||
}
|
||
for elem in root.iter():
|
||
if elem is root or elem.tag.rsplit('}', 1)[-1] != 'svg':
|
||
continue
|
||
elem_id = (elem.get('id') or '').strip()
|
||
label = f'<svg id="{elem_id}">' if elem_id else '<svg>'
|
||
ancestor = parent_by_id.get(id(elem))
|
||
invalid_ancestor: ET.Element | None = None
|
||
while ancestor is not None and ancestor is not root:
|
||
if (
|
||
ancestor.tag != f'{{{SVG_NS}}}g'
|
||
or ancestor.get('data-pptx-part') is not None
|
||
):
|
||
invalid_ancestor = ancestor
|
||
break
|
||
ancestor = parent_by_id.get(id(ancestor))
|
||
if invalid_ancestor is not None:
|
||
errors.append(
|
||
f'{label} invalid imported crop wrapper: visual ancestor chain '
|
||
'may contain only ordinary <g> elements'
|
||
)
|
||
continue
|
||
try:
|
||
parse_project_nested_svg_crop(elem)
|
||
except ValueError as exc:
|
||
errors.append(f'{label} invalid imported crop wrapper: {exc}')
|
||
return sorted(errors)
|
||
|
||
|
||
def _resolve_nested_svg_clip_geometry(
|
||
crop: NestedSvgCropSpec,
|
||
image_elem: ET.Element,
|
||
ctx: ConvertContext,
|
||
) -> str:
|
||
"""Resolve a preview-safe inner-image clip into picture geometry."""
|
||
default = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
||
clip_id = resolve_url_id(image_elem.get('clip-path', ''))
|
||
if not clip_id or clip_id not in ctx.defs:
|
||
return default
|
||
clip_elem = ctx.defs[clip_id]
|
||
shape = next(
|
||
(
|
||
child
|
||
for child in clip_elem
|
||
if child.tag.rsplit('}', 1)[-1]
|
||
in {'circle', 'ellipse', 'rect', 'path', 'polygon'}
|
||
),
|
||
None,
|
||
)
|
||
if shape is None:
|
||
return default
|
||
if (
|
||
clip_elem.get('clipPathUnits', 'userSpaceOnUse')
|
||
!= 'userSpaceOnUse'
|
||
):
|
||
return _resolve_clip_geometry(
|
||
image_elem,
|
||
ctx,
|
||
crop.x,
|
||
crop.y,
|
||
crop.width,
|
||
crop.height,
|
||
)
|
||
|
||
shape_tag = shape.tag.rsplit('}', 1)[-1]
|
||
if shape_tag == 'rect':
|
||
style_values = parse_inline_style(shape.get('style'))
|
||
rx_raw = style_values.get('rx') or shape.get('rx')
|
||
ry_raw = style_values.get('ry') or shape.get('ry')
|
||
rx = (
|
||
parse_project_geometry_length(rx_raw, 'rx')
|
||
if rx_raw is not None else None
|
||
)
|
||
ry = (
|
||
parse_project_geometry_length(ry_raw, 'ry')
|
||
if ry_raw is not None else None
|
||
)
|
||
if rx is None and ry is not None:
|
||
rx = ry
|
||
elif ry is None and rx is not None:
|
||
ry = rx
|
||
rx = rx or 0.0
|
||
ry = ry or 0.0
|
||
if rx <= 0 and ry <= 0:
|
||
return default
|
||
physical_rx = rx * crop.width / crop.view_box_width
|
||
physical_ry = ry * crop.height / crop.view_box_height
|
||
radius = (physical_rx + physical_ry) / 2.0
|
||
shorter = min(crop.width, crop.height)
|
||
if shorter <= 0:
|
||
return default
|
||
adj = int(min(radius / (shorter / 2.0), 1.0) * 50000)
|
||
return (
|
||
f'<a:prstGeom prst="roundRect"><a:avLst>'
|
||
f'<a:gd name="adj" fmla="val {adj}"/>'
|
||
f'</a:avLst></a:prstGeom>'
|
||
)
|
||
|
||
return _resolve_clip_geometry(
|
||
image_elem,
|
||
ctx,
|
||
crop.view_box_x,
|
||
crop.view_box_y,
|
||
crop.view_box_width,
|
||
crop.view_box_height,
|
||
)
|
||
|
||
|
||
def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult:
|
||
"""Convert a nested <svg> sprite-crop wrapper to a DrawingML picture.
|
||
|
||
Pattern produced by pptx_to_svg::
|
||
|
||
<svg x="10" y="20" width="200" height="300" viewBox="0.5 0.3 0.5 0.7">
|
||
<image href="..." x="0" y="0" width="1" height="1" preserveAspectRatio="none"/>
|
||
</svg>
|
||
|
||
The viewBox crops the unit-rectangle inner image; that crop is mapped to a
|
||
DrawingML <a:srcRect> so PowerPoint can re-crop / "Reset Picture".
|
||
"""
|
||
crop = parse_project_nested_svg_crop(elem)
|
||
image_elem = crop.image
|
||
source = load_project_image_source(image_elem, ctx.svg_dir)
|
||
|
||
svg_x = crop.x
|
||
svg_y = crop.y
|
||
svg_w = crop.width
|
||
svg_h = crop.height
|
||
|
||
if ctx.use_transform_matrix:
|
||
x = svg_x
|
||
y = svg_y
|
||
w = svg_w
|
||
h = svg_h
|
||
else:
|
||
x = ctx_x(svg_x, ctx)
|
||
y = ctx_y(svg_y, ctx)
|
||
w = ctx_w(svg_w, ctx)
|
||
h = ctx_h(svg_h, ctx)
|
||
|
||
src_rect_xml = (
|
||
f'<a:srcRect l="{crop.src_l}" t="{crop.src_t}" '
|
||
f'r="{crop.src_r}" b="{crop.src_b}"/>'
|
||
)
|
||
|
||
img_format = source.img_format
|
||
img_data = source.img_data
|
||
|
||
transform = elem.get('transform')
|
||
rendered_w, rendered_h = _picture_rendered_frame_size(
|
||
ctx,
|
||
svg_x,
|
||
svg_y,
|
||
svg_w,
|
||
svg_h,
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
transform,
|
||
)
|
||
img_data, img_format = _optimize_image_for_pptx(
|
||
ctx,
|
||
img_data,
|
||
img_format,
|
||
rendered_w,
|
||
rendered_h,
|
||
visible_source_fraction=(
|
||
1.0 - (crop.src_l + crop.src_r) / 100000.0,
|
||
1.0 - (crop.src_t + crop.src_b) / 100000.0,
|
||
),
|
||
)
|
||
|
||
r_id = _register_image_media(ctx, img_format, img_data)
|
||
|
||
shape_id = _claim_element_shape_id(elem, ctx)
|
||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect(
|
||
ctx,
|
||
svg_x,
|
||
svg_y,
|
||
svg_w,
|
||
svg_h,
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
transform,
|
||
)
|
||
if image_elem.get('clip-path') is not None:
|
||
clip_geom = _resolve_nested_svg_clip_geometry(crop, image_elem, ctx)
|
||
else:
|
||
clip_geom = _resolve_clip_geometry(
|
||
elem,
|
||
ctx,
|
||
svg_x,
|
||
svg_y,
|
||
svg_w,
|
||
svg_h,
|
||
)
|
||
effect_xml = ''
|
||
filter_id = get_effective_filter_id(elem, ctx)
|
||
if filter_id and filter_id in ctx.defs:
|
||
effect_xml = build_effect_xml(
|
||
ctx.defs[filter_id],
|
||
get_element_opacity(elem, ctx),
|
||
)
|
||
blip_xml = _build_image_blip_xml(
|
||
r_id,
|
||
get_element_opacity(image_elem, ctx),
|
||
)
|
||
|
||
return ShapeResult(xml=f'''<p:pic>
|
||
<p:nvPicPr>
|
||
<p:cNvPr id="{shape_id}" name="Image {shape_id}"/>
|
||
<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
|
||
<p:nvPr/>
|
||
</p:nvPicPr>
|
||
<p:blipFill>
|
||
{blip_xml}
|
||
{src_rect_xml}<a:stretch><a:fillRect/></a:stretch>
|
||
</p:blipFill>
|
||
<p:spPr>
|
||
<a:xfrm{xfrm_attr}><a:off x="{off_x}" y="{off_y}"/>
|
||
<a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||
{clip_geom}
|
||
{effect_xml}
|
||
</p:spPr>
|
||
</p:pic>''', bounds_emu=bounds_emu)
|