Files
Templated-PPT-Master/runtime/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py
T

1326 lines
43 KiB
Python

"""DrawingML <p:txBody> -> SVG <text> conversion.
Reverse of svg_to_pptx/drawingml/elements.py convert_text.
Strategy (v1):
- Each <a:p> paragraph emits one <text> element (one line of baseline).
Multiple <a:r> runs in one paragraph become <tspan>s sharing the text
element's x.
- Vertical layout: y of first paragraph is determined by anchor (t/ctr/b)
and tIns/bIns. Subsequent paragraphs stack downward with line height
derived from the largest font in the paragraph * 1.2 (default leading).
- Horizontal layout: text-anchor follows pPr@algn. x is computed from the
text frame plus lIns/rIns and the alignment.
- No automatic word wrap (PPT's wrap is layout-time; v1 trusts the existing
text frame width and emits text as-is). a:br produces an explicit linebreak.
- Bullet points (a:buChar / a:buAutoNum) are rendered as literal prefixes
so the visual lands without relying on PowerPoint list semantics.
Color / font / size attributes propagate from a:rPr; missing attributes fall
back to paragraph/list defaults, endParaRPr, or spec-default values.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
from svg_to_pptx.drawingml.utils import detect_text_lang, is_cjk_char
from .color_resolver import ColorPalette, find_color_elem, resolve_color
from .emu_units import (
NS, Xfrm, fmt_num, emu_to_px, format_ooxml_alpha,
hundredths_pt_to_px,
)
from .fill_to_svg import resolve_fill
# ---------------------------------------------------------------------------
# Defaults (matches DrawingML spec)
# ---------------------------------------------------------------------------
# Default body insets when bodyPr omits them: 0.1 inch left/right, 0.05 top/bot.
DEFAULT_INSETS_EMU = {"l": 91440, "t": 45720, "r": 91440, "b": 45720}
# Default font size = 1800 (= 18 pt = 24 px). Spec is actually 1800 (18pt).
DEFAULT_FONT_SIZE_PX = 24.0
DEFAULT_LINE_HEIGHT_RATIO = 1.2 # leading multiplier
DEFAULT_FILL_HEX = "#000000"
@dataclass
class TextRun:
"""A single run with resolved style + text."""
text: str
font_size_px: float
font_family: str # full font-family stack (latin, ea fallback joined)
fill: str
fill_opacity: float = 1.0
defs: list[str] = field(default_factory=list)
bold: bool = False
italic: bool = False
underline: bool = False
strikethrough: bool = False
letter_spacing_px: float = 0.0
is_break: bool = False # marks an a:br within a paragraph
@dataclass
class TextParagraph:
"""One <a:p>: a list of runs sharing alignment + level."""
runs: list[TextRun] = field(default_factory=list)
align: str = "l" # l / ctr / r / just / dist
level: int = 0
indent_px: float = 0.0
margin_left_px: float = 0.0
line_height_ratio: float = DEFAULT_LINE_HEIGHT_RATIO
space_before_px: float = 0.0
space_after_px: float = 0.0
empty_line_font_size_px: float = DEFAULT_FONT_SIZE_PX
bullet_prefix: str = "" # rendered prefix like '• ' or '1. '
@dataclass
class TextResult:
"""Resolved text body ready for SVG emission.
`svg` is one or more <text> elements, already absolutely positioned
inside the slide coordinate system. `defs` holds text gradient fills.
"""
svg: str = ""
defs: list[str] = field(default_factory=list)
VERTICAL_TEXT_MODES = {"eaVert", "vert", "wordArtVert", "wordArtVertRtl"}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def convert_txbody(
tx_body: ET.Element | None,
xfrm: Xfrm,
palette: ColorPalette | None,
*,
theme_fonts: dict[str, str] | None = None,
slide_number: int | None = None,
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
fallback_lst_styles: tuple[ET.Element, ...] = (),
fallback_run_props: tuple[ET.Element, ...] = (),
id_prefix: str = "txt",
id_seq: list[int] | None = None,
) -> TextResult:
"""Convert <p:txBody> under the given shape geometry to SVG <text>(s)."""
if tx_body is None:
return TextResult()
body_pr = tx_body.find("a:bodyPr", NS)
paragraphs = _parse_paragraphs(
tx_body, palette, theme_fonts or {}, default_fill=default_fill,
default_font_size_px=default_font_size_px,
fallback_lst_styles=fallback_lst_styles,
fallback_run_props=fallback_run_props,
slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
)
if not paragraphs or not _has_visible_text(paragraphs):
return TextResult()
# Insets + anchor + wrap
lins = _read_emu_attr(body_pr, "lIns", DEFAULT_INSETS_EMU["l"])
tins = _read_emu_attr(body_pr, "tIns", DEFAULT_INSETS_EMU["t"])
rins = _read_emu_attr(body_pr, "rIns", DEFAULT_INSETS_EMU["r"])
bins = _read_emu_attr(body_pr, "bIns", DEFAULT_INSETS_EMU["b"])
anchor = body_pr.attrib.get("anchor", "t") if body_pr is not None else "t"
wrap_mode = body_pr.attrib.get("wrap", "square") if body_pr is not None else "square"
respect_edge_spacing = (
body_pr is not None
and body_pr.attrib.get("spcFirstLastPara") in {"1", "true"}
)
inner_x = xfrm.x + lins
inner_y = xfrm.y + tins
inner_w = max(xfrm.w - lins - rins, 1.0)
inner_h = max(xfrm.h - tins - bins, 1.0)
# Pre-wrap each paragraph into concrete display lines.
wrap_width = inner_w if wrap_mode == "square" else float("inf")
para_lines: list[list[list[TextRun]]] = [
_wrap_paragraph_into_lines(p, wrap_width) for p in paragraphs
]
# Pre-compute heights to support anchor=ctr / b
para_heights = [
_paragraph_height_from_lines(p, lines)
for p, lines in zip(paragraphs, para_lines)
]
space_before = [paragraph.space_before_px for paragraph in paragraphs]
space_after = [paragraph.space_after_px for paragraph in paragraphs]
if not respect_edge_spacing:
space_before[0] = 0.0
space_after[-1] = 0.0
total_h = sum(
before + height + after
for before, height, after in zip(
space_before,
para_heights,
space_after,
)
)
if anchor == "ctr":
cursor_y = inner_y + max(0.0, (inner_h - total_h) / 2.0)
elif anchor == "b":
cursor_y = inner_y + max(0.0, inner_h - total_h)
else:
cursor_y = inner_y
bottom_y = inner_y + inner_h
text_blocks: list[str] = []
for para, lines, height, before, after in zip(
paragraphs,
para_lines,
para_heights,
space_before,
space_after,
):
cursor_y += before
visible_lines = _clip_lines_to_bottom(para, lines, cursor_y, bottom_y)
if visible_lines:
text_blocks.append(
_emit_paragraph(para, visible_lines, inner_x, inner_w, cursor_y)
)
cursor_y += height + after
if cursor_y >= bottom_y:
break
return TextResult(svg="\n".join(text_blocks), defs=_collect_text_defs(paragraphs))
def is_vertical_txbody(tx_body: ET.Element | None, xfrm: Xfrm | None = None) -> bool:
if tx_body is None:
return False
body_pr = tx_body.find("a:bodyPr", NS)
if body_pr is None:
return False
if body_pr.attrib.get("vert") in VERTICAL_TEXT_MODES:
return True
return _looks_like_auto_stacked_cjk(tx_body, body_pr, xfrm)
def convert_vertical_txbody(
tx_body: ET.Element | None,
xfrm: Xfrm,
palette: ColorPalette | None,
*,
theme_fonts: dict[str, str] | None = None,
slide_number: int | None = None,
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
fallback_lst_styles: tuple[ET.Element, ...] = (),
fallback_run_props: tuple[ET.Element, ...] = (),
id_prefix: str = "txt",
id_seq: list[int] | None = None,
) -> TextResult:
"""Render East Asian vertical text as upright stacked glyphs.
PowerPoint often combines ``bodyPr@vert=eaVert`` with a rotated text box.
Rendering the text inside the rotated shape group makes Chinese glyphs lie
sideways. This helper computes the final rotated box and places glyphs
upright in slide coordinates.
"""
if tx_body is None:
return TextResult()
paragraphs = _parse_paragraphs(
tx_body, palette, theme_fonts or {}, default_fill=default_fill,
default_font_size_px=default_font_size_px,
fallback_lst_styles=fallback_lst_styles,
fallback_run_props=fallback_run_props,
slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
)
runs = [
run
for para in paragraphs
for run in para.runs
if not run.is_break and run.text
]
if not runs:
return TextResult()
box_x, box_y, box_w, box_h = _rotated_bbox(xfrm)
center_x = box_x + box_w / 2.0
glyphs: list[tuple[str, TextRun]] = []
for run in runs:
for char in run.text:
glyphs.append((" " if char in "\t\r\n" else char, run))
if not glyphs:
return TextResult()
advances = [glyph_run.font_size_px * 1.05 for _, glyph_run in glyphs]
total_h = sum(advances)
top_y = box_y + max(0.0, (box_h - total_h) / 2.0)
bottom_y = box_y + box_h
spans: list[str] = []
cursor_y = top_y
first_run: TextRun | None = None
first_baseline: float | None = None
previous_baseline: float | None = None
for (char, run), advance in zip(glyphs, advances):
if cursor_y + advance > bottom_y:
break
baseline_y = cursor_y + run.font_size_px * 0.85
tspan_attrs = _run_tspan_attrs(run)
if first_run is None:
first_run = run
first_baseline = baseline_y
spans.append(f"<tspan{tspan_attrs}>{_xml_escape(char)}</tspan>")
else:
dy = baseline_y - (previous_baseline or baseline_y)
spans.append(
f'<tspan x="{fmt_num(center_x)}" dy="{fmt_num(dy)}"'
f"{tspan_attrs}>{_xml_escape(char)}</tspan>"
)
previous_baseline = baseline_y
cursor_y += advance
if first_run is None or first_baseline is None:
return TextResult()
attrs = _text_base_attrs(first_run, center_x, first_baseline, "middle")
return TextResult(
svg=f"<text{attrs}>{''.join(spans)}</text>",
defs=_collect_text_defs(paragraphs),
)
def _rotated_bbox(xfrm: Xfrm) -> tuple[float, float, float, float]:
rot = round(xfrm.rot) % 360
cx = xfrm.x + xfrm.w / 2.0
cy = xfrm.y + xfrm.h / 2.0
if rot in (90, 270):
return cx - xfrm.h / 2.0, cy - xfrm.w / 2.0, xfrm.h, xfrm.w
return xfrm.x, xfrm.y, xfrm.w, xfrm.h
def _looks_like_auto_stacked_cjk(
tx_body: ET.Element,
body_pr: ET.Element,
xfrm: Xfrm | None,
) -> bool:
"""Detect PowerPoint's narrow-box CJK vertical layout without vert=eaVert."""
if xfrm is None or xfrm.w <= 0 or xfrm.h <= 0:
return False
if body_pr.attrib.get("wrap", "square") != "square":
return False
if xfrm.w > 64 or xfrm.h < xfrm.w * 2.4:
return False
text = _plain_text(tx_body)
chars = [ch for ch in text if not ch.isspace()]
if len(chars) < 3 or len(chars) > 16:
return False
cjk_count = sum(1 for ch in chars if _is_cjk(ch))
if cjk_count / len(chars) < 0.8:
return False
lins = _read_emu_attr(body_pr, "lIns", DEFAULT_INSETS_EMU["l"])
rins = _read_emu_attr(body_pr, "rIns", DEFAULT_INSETS_EMU["r"])
inner_w = max(xfrm.w - lins - rins, 1.0)
return inner_w <= DEFAULT_FONT_SIZE_PX
def _plain_text(tx_body: ET.Element) -> str:
"""Return concatenated literal text for layout heuristics."""
parts: list[str] = []
for text_elem in tx_body.findall(".//a:t", NS):
if text_elem.text:
parts.append(text_elem.text)
return "".join(parts)
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
def _read_emu_attr(elem: ET.Element | None, attr: str, default_emu: int) -> float:
"""Read an EMU integer attribute and return px."""
if elem is None:
return emu_to_px(default_emu)
val = elem.attrib.get(attr)
if val is None:
return emu_to_px(default_emu)
try:
return emu_to_px(int(val))
except ValueError:
return emu_to_px(default_emu)
def _parse_paragraphs(
tx_body: ET.Element,
palette: ColorPalette | None,
theme_fonts: dict[str, str],
*,
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
fallback_lst_styles: tuple[ET.Element, ...] = (),
fallback_run_props: tuple[ET.Element, ...] = (),
slide_number: int | None = None,
id_prefix: str = "txt",
id_seq: list[int] | None = None,
) -> list[TextParagraph]:
"""Walk <a:p> children producing TextParagraph objects."""
paragraphs: list[TextParagraph] = []
autonum_state: dict[int, int] = {}
lst_style = tx_body.find("a:lstStyle", NS)
lst_styles = (
(lst_style,) + fallback_lst_styles
if lst_style is not None else fallback_lst_styles
)
for p_elem in tx_body.findall("a:p", NS):
para = _parse_paragraph(
p_elem, palette, theme_fonts, autonum_state,
lst_styles=lst_styles,
fallback_run_props=fallback_run_props,
default_fill=default_fill,
default_font_size_px=default_font_size_px,
slide_number=slide_number,
id_prefix=id_prefix, id_seq=id_seq,
)
paragraphs.append(para)
return paragraphs
def _parse_paragraph(
p_elem: ET.Element,
palette: ColorPalette | None,
theme_fonts: dict[str, str],
autonum_state: dict[int, int],
*,
lst_styles: tuple[ET.Element, ...] = (),
fallback_run_props: tuple[ET.Element, ...] = (),
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
slide_number: int | None = None,
id_prefix: str = "txt",
id_seq: list[int] | None = None,
) -> TextParagraph:
para = TextParagraph()
p_pr = p_elem.find("a:pPr", NS)
if p_pr is not None:
try:
para.level = int(p_pr.attrib.get("lvl", "0"))
except ValueError:
para.level = 0
para_style_chain = (p_pr,) + _lst_style_level_prs(lst_styles, para.level)
para.align = _attr_chain(para_style_chain, "algn") or "l"
para.margin_left_px = _emu_px_attr_chain(para_style_chain, "marL", 0.0)
para.indent_px = _emu_px_attr_chain(para_style_chain, "indent", 0.0)
para.line_height_ratio = _line_height_ratio(para_style_chain)
para.space_before_px = _spacing_points_px(para_style_chain, "a:spcBef/a:spcPts")
para.space_after_px = _spacing_points_px(para_style_chain, "a:spcAft/a:spcPts")
para.bullet_prefix = _resolve_bullet_prefix(
para_style_chain, para.level, autonum_state,
)
# Default endParaRPr style (applies if a run has no rPr)
end_rpr = p_elem.find("a:endParaRPr", NS)
# defRPr from pPr and txBody/lstStyle, both optional.
def_rpr = p_pr.find("a:defRPr", NS) if p_pr is not None else None
list_def_rpr = _child_chain(para_style_chain[1:], "a:defRPr")
para.empty_line_font_size_px = _font_size_px(
(end_rpr, def_rpr, list_def_rpr) + fallback_run_props,
default_font_size_px,
)
def resolved_run(text: str, rpr: ET.Element | None) -> TextRun:
return _build_run(
text, rpr, end_rpr, palette, theme_fonts,
def_rpr=def_rpr,
list_def_rpr=list_def_rpr,
fallback_run_props=fallback_run_props,
default_fill=default_fill,
default_font_size_px=default_font_size_px,
id_prefix=id_prefix, id_seq=id_seq,
)
for child in list(p_elem):
if not isinstance(child.tag, str):
continue
local = child.tag.split("}", 1)[-1]
if local == "r":
rpr = child.find("a:rPr", NS)
text_elem = child.find("a:t", NS)
text = text_elem.text or "" if text_elem is not None else ""
para.runs.append(resolved_run(text, rpr))
elif local == "br":
break_rpr = child.find("a:rPr", NS)
para.runs.append(TextRun(
text="",
font_size_px=_font_size_px(
(break_rpr, def_rpr, list_def_rpr, end_rpr)
+ fallback_run_props,
default_font_size_px,
),
font_family="sans-serif",
fill=default_fill,
is_break=True,
))
elif local == "fld":
# Slide SVGs have a concrete page context, so resolve slide-number
# fields there. Standalone master/layout renders keep the literal
# fallback because one shared part can serve many slide numbers.
rpr = child.find("a:rPr", NS)
text_elem = child.find("a:t", NS)
text = text_elem.text or "" if text_elem is not None else ""
field_type = child.attrib.get("type", "").strip().lower()
if field_type == "slidenum" and slide_number is not None:
text = str(slide_number)
if text:
para.runs.append(resolved_run(text, rpr))
return para
def _font_size_px(
sources: tuple[ET.Element | None, ...],
default_font_size_px: float,
) -> float:
"""Resolve one effective DrawingML run size into SVG pixels."""
return hundredths_pt_to_px(
_attr_chain(sources, "sz"),
default_font_size_px,
)
def _build_run(
text: str,
rpr: ET.Element | None,
end_rpr: ET.Element | None,
palette: ColorPalette | None,
theme_fonts: dict[str, str],
*,
def_rpr: ET.Element | None = None,
list_def_rpr: ET.Element | None = None,
fallback_run_props: tuple[ET.Element, ...] = (),
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
id_prefix: str = "txt",
id_seq: list[int] | None = None,
) -> TextRun:
"""Resolve a single <a:r> run from its rPr and fallback run properties."""
style_chain = (
rpr, def_rpr, list_def_rpr, end_rpr,
) + fallback_run_props
# font-size: rPr > pPr/defRPr > lstStyle/lvlNpPr/defRPr > endParaRPr > default
font_size_px = _font_size_px(style_chain, default_font_size_px)
# Bold / italic
bold = _attr_chain(style_chain, "b") == "1"
italic = _attr_chain(style_chain, "i") == "1"
# Underline / strike
u_val = _attr_chain(style_chain, "u")
underline = u_val not in (None, "", "none")
strike_val = _attr_chain(style_chain, "strike")
strikethrough = strike_val in ("sngStrike", "dblStrike")
# Letter spacing (rPr@spc, in 1/100 pt)
spc = _attr_chain(style_chain, "spc")
letter_spacing_px = 0.0
if spc is not None:
try:
letter_spacing_px = float(spc) / 100.0 * 4.0 / 3.0 # pt -> px
except ValueError:
pass
# Color
fill = default_fill
fill_opacity = 1.0
defs: list[str] = []
color_source = None
for src in style_chain:
if src is None:
continue
grad = src.find("a:gradFill", NS)
if grad is not None:
grad_fill = resolve_fill(
grad, palette,
id_prefix=id_prefix,
id_seq=id_seq,
)
if grad_fill.attrs.get("fill"):
fill = grad_fill.attrs["fill"]
fill_opacity = 1.0
defs.extend(grad_fill.defs)
color_source = None
break
solid = src.find("a:solidFill", NS)
if solid is not None:
color_source = solid
break
if color_source is not None:
color_elem = find_color_elem(color_source)
hex_, alpha = resolve_color(color_elem, palette)
if hex_:
fill = hex_
fill_opacity = alpha
# Font typeface
latin_face = _typeface_chain(style_chain, "latin")
ea_face = _typeface_chain(style_chain, "ea")
cs_face = _typeface_chain(style_chain, "cs")
lang = _attr_chain(style_chain, "lang")
alt_lang = _attr_chain(style_chain, "altLang")
# Resolve theme refs (e.g. typeface="+mn-lt" / "+mj-ea")
latin_face = _resolve_theme_typeface(
latin_face,
theme_fonts,
text=text,
lang=lang,
alt_lang=alt_lang,
)
ea_face = _resolve_theme_typeface(
ea_face,
theme_fonts,
text=text,
lang=lang,
alt_lang=alt_lang,
)
cs_face = _resolve_theme_typeface(
cs_face,
theme_fonts,
text=text,
lang=lang,
alt_lang=alt_lang,
)
font_family = _build_font_stack(latin_face, ea_face, cs_face)
return TextRun(
text=text,
font_size_px=font_size_px,
font_family=font_family,
fill=fill,
fill_opacity=fill_opacity,
defs=defs,
bold=bold,
italic=italic,
underline=underline,
strikethrough=strikethrough,
letter_spacing_px=letter_spacing_px,
)
def _lst_style_level_prs(
lst_styles: tuple[ET.Element, ...],
level: int,
) -> tuple[ET.Element, ...]:
"""Return txBody/lstStyle paragraph properties for a paragraph level."""
level_idx = min(max(level, 0), 8) + 1
level_prs: list[ET.Element] = []
for lst_style in lst_styles:
lvl_pr = lst_style.find(f"a:lvl{level_idx}pPr", NS)
if lvl_pr is not None:
level_prs.append(lvl_pr)
return tuple(level_prs)
def _child_chain(
sources: tuple[ET.Element | None, ...],
path: str,
) -> ET.Element | None:
for src in sources:
if src is None:
continue
child = src.find(path, NS)
if child is not None:
return child
return None
def _emu_px_attr_chain(
sources: tuple[ET.Element | None, ...],
attr: str,
default: float,
) -> float:
value = _attr_chain(sources, attr)
if value is None:
return default
try:
return emu_to_px(int(value))
except ValueError:
return default
def _line_height_ratio(sources: tuple[ET.Element | None, ...]) -> float:
ln_spc = _child_chain(sources, "a:lnSpc")
if ln_spc is None:
return DEFAULT_LINE_HEIGHT_RATIO
spc_pct = ln_spc.find("a:spcPct", NS)
if spc_pct is None:
return DEFAULT_LINE_HEIGHT_RATIO
try:
return float(spc_pct.attrib.get("val", "100000")) / 100000.0
except ValueError:
return DEFAULT_LINE_HEIGHT_RATIO
def _spacing_points_px(
sources: tuple[ET.Element | None, ...],
path: str,
) -> float:
spacing = _child_chain(sources, path)
if spacing is None:
return 0.0
try:
return hundredths_pt_to_px(int(spacing.attrib.get("val", "0")))
except ValueError:
return 0.0
def _attr_chain(sources: tuple[ET.Element | None, ...], attr: str) -> str | None:
"""Return the first non-empty value of `attr` from any source element."""
for src in sources:
if src is None:
continue
v = src.attrib.get(attr)
if v is not None:
return v
return None
def _typeface(rpr: ET.Element | None, child_tag: str) -> str | None:
if rpr is None:
return None
elem = rpr.find(f"a:{child_tag}", NS)
if elem is None:
return None
val = elem.attrib.get("typeface")
return val or None
def _typeface_chain(
sources: tuple[ET.Element | None, ...],
child_tag: str,
) -> str | None:
for src in sources:
face = _typeface(src, child_tag)
if face:
return face
return None
def _theme_script_from_lang(lang: str | None) -> str | None:
"""Map a DrawingML language tag to one theme supplemental-script key."""
if not lang:
return None
normalized = lang.strip().replace("_", "-").lower()
if not normalized:
return None
parts = normalized.split("-")
primary = parts[0]
if primary == "ja":
return "Jpan"
if primary == "ko":
return "Hang"
if primary != "zh":
return None
if any(part in {"hant", "cht", "tw", "hk", "mo"} for part in parts[1:]):
return "Hant"
return "Hans"
def _theme_script_from_text(text: str) -> str | None:
"""Infer a CJK theme script from glyph ranges, defaulting plain Han to Hans."""
if any(
0x3100 <= ord(char) <= 0x312F
or 0x31A0 <= ord(char) <= 0x31BF
for char in text
):
return "Hant"
return {
"ko-KR": "Hang",
"ja-JP": "Jpan",
"zh-CN": "Hans",
}.get(detect_text_lang(text))
def _run_theme_script(
text: str,
lang: str | None,
alt_lang: str | None,
) -> str | None:
"""Resolve EA script from run language first, then alternate language/text."""
return (
_theme_script_from_lang(lang)
or _theme_script_from_lang(alt_lang)
or _theme_script_from_text(text)
)
def _resolve_theme_typeface(
face: str | None,
theme_fonts: dict[str, str],
*,
text: str = "",
lang: str | None = None,
alt_lang: str | None = None,
) -> str | None:
"""Resolve DrawingML major/minor Latin, EA, and complex-script tokens."""
if not face or not face.startswith("+"):
return face
code = face[1:]
if code == "mj-lt":
return theme_fonts.get("majorLatin") or face
if code == "mn-lt":
return theme_fonts.get("minorLatin") or face
if code in {"mj-ea", "mn-ea"}:
prefix = "major" if code.startswith("mj") else "minor"
script = _run_theme_script(text, lang, alt_lang)
script_face = (
theme_fonts.get(f"{prefix}Script{script}")
if script is not None else None
)
return (
theme_fonts.get(f"{prefix}EastAsia")
or script_face
or theme_fonts.get(f"{prefix}Latin")
or face
)
if code == "mj-cs":
return (
theme_fonts.get("majorComplexScript")
or theme_fonts.get("majorLatin")
or face
)
if code == "mn-cs":
return (
theme_fonts.get("minorComplexScript")
or theme_fonts.get("minorLatin")
or face
)
return face
def _build_font_stack(latin: str | None, ea: str | None, cs: str | None) -> str:
"""Build a CSS font-family stack: original PPT names first, then fallbacks."""
parts: list[str] = []
seen: set[str] = set()
for face in (latin, ea, cs):
if face and face not in seen:
parts.append(_quote_font(face))
seen.add(face)
# Generic fallback so the browser can render even if PPT fonts are absent.
parts.append("sans-serif")
return ", ".join(parts)
def _quote_font(name: str) -> str:
"""Quote a font name if it contains spaces or non-ASCII chars.
Uses XML entity-escaped double quotes (&quot;) so the resulting CSS string
survives being embedded inside an SVG attribute that itself uses double
quotes. CSS parsers accept the unescaped form after attribute parsing.
"""
if any(c.isspace() or ord(c) > 127 for c in name):
return f"&quot;{name}&quot;"
return name
def _resolve_bullet_prefix(
sources: tuple[ET.Element | None, ...],
level: int,
autonum_state: dict[int, int],
) -> str:
"""Render bullet glyphs / numbering as a literal text prefix."""
bu_none = _child_chain(sources, "a:buNone")
if bu_none is not None:
autonum_state.pop(level, None)
return ""
bu_char = _child_chain(sources, "a:buChar")
if bu_char is not None:
ch = bu_char.attrib.get("char", "•")
return f"{ch} "
bu_auto = _child_chain(sources, "a:buAutoNum")
if bu_auto is not None:
start_at = bu_auto.attrib.get("startAt")
if start_at is not None:
try:
autonum_state[level] = int(start_at)
except ValueError:
autonum_state[level] = 1
else:
autonum_state[level] = autonum_state.get(level, 0) + 1
return _format_auto_number(
autonum_state[level],
bu_auto.attrib.get("type", "arabicPeriod"),
)
return ""
def _format_auto_number(value: int, kind: str) -> str:
lower = kind.lower()
if "alphalc" in lower:
token = _alpha_number(value, uppercase=False)
elif "alphauc" in lower:
token = _alpha_number(value, uppercase=True)
elif "romanlc" in lower:
token = _roman_number(value).lower()
elif "romanuc" in lower:
token = _roman_number(value).upper()
else:
token = str(value)
if "parenboth" in lower:
return f"({token}) "
if "parenr" in lower:
return f"{token}) "
if "period" in lower:
return f"{token}. "
return f"{token} "
def _alpha_number(value: int, *, uppercase: bool) -> str:
value = max(1, value)
chars: list[str] = []
while value:
value -= 1
chars.append(chr(ord("A") + (value % 26)))
value //= 26
text = "".join(reversed(chars))
return text if uppercase else text.lower()
def _roman_number(value: int) -> str:
value = max(1, min(value, 3999))
parts: list[str] = []
for n, token in (
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
):
while value >= n:
parts.append(token)
value -= n
return "".join(parts)
# ---------------------------------------------------------------------------
# Layout / emission
# ---------------------------------------------------------------------------
def _has_visible_text(paragraphs: list[TextParagraph]) -> bool:
for p in paragraphs:
for r in p.runs:
if r.text.strip():
return True
return False
def _collect_text_defs(paragraphs: list[TextParagraph]) -> list[str]:
"""Return unique text fill defs referenced by parsed runs."""
defs: list[str] = []
seen: set[str] = set()
for para in paragraphs:
for run in para.runs:
for item in run.defs:
if item not in seen:
defs.append(item)
seen.add(item)
return defs
# ---------------------------------------------------------------------------
# Word-wrap / text measurement
# ---------------------------------------------------------------------------
def _is_cjk(ch: str) -> bool:
"""Check if a character is CJK (Chinese/Japanese/Korean) or full-width."""
return is_cjk_char(ch)
def _char_width(ch: str, font_size: float, bold: bool) -> float:
"""Estimate a single character's rendered width in pixels.
Mirrors svg_to_pptx/drawingml/utils.py estimate_text_width so wrapping breaks
align with the same heuristic used to estimate text-box sizes elsewhere.
"""
if _is_cjk(ch):
w = font_size # CJK is approximately 1em per glyph
elif ch == ' ':
w = font_size * 0.3
elif ch in 'mMwWOQ%':
w = font_size * 0.75
elif ch in 'iIlj!|':
w = font_size * 0.3
elif ch.isdigit():
# digits are tabular (uniform ~0.55em) in most UI fonts, including
# '1' — classing it with 'il|' under-sizes the width and makes
# renderers that ignore wrap="none" (LibreOffice) wrap the line
w = font_size * 0.55
else:
w = font_size * 0.55
# Bold Latin generally expands a little. CJK glyphs keep their em advance
# in common PPT fonts; applying the bold multiplier causes short Chinese
# titles such as "少年强国说" to wrap even though PowerPoint keeps them on
# one line.
if bold and not _is_cjk(ch):
w *= 1.05
return w
def _estimate_run_width(text: str, run: TextRun) -> float:
glyph_width = sum(_char_width(c, run.font_size_px, run.bold) for c in text)
tracking_width = run.letter_spacing_px * max(len(text) - 1, 0)
return (glyph_width + tracking_width) * 1.05
def _advance_width(ch: str, index_in_segment: int, run: TextRun) -> float:
"""Return the width added by one character inside a measured line segment."""
tracking = run.letter_spacing_px if index_in_segment > 0 else 0.0
return _char_width(ch, run.font_size_px, run.bold) + tracking
def _find_break_point(
text: str, start: int, max_width: float, run: TextRun,
) -> tuple[int, float]:
"""Find the longest prefix of text[start:] that fits in max_width.
Returns (end_index, used_width). Prefers breaking after whitespace, after
CJK characters, or after hyphens. If even the first character doesn't fit,
returns (start, 0.0) — the caller should flush the current line first.
"""
cur_w = 0.0
last_break = start
last_break_w = 0.0
for i in range(start, len(text)):
ch = text[i]
ch_w = _advance_width(ch, i - start, run)
if cur_w + ch_w > max_width:
if last_break > start:
return last_break, last_break_w
return start, 0.0
cur_w += ch_w
# Update last_break point
if ch.isspace() or _is_cjk(ch) or ch in "-—、,。!?:;":
last_break = i + 1
last_break_w = cur_w
# Whole rest fits
return len(text), cur_w
def _wrap_paragraph_into_lines(
para: TextParagraph,
max_width: float,
) -> list[list[TextRun]]:
"""Split a paragraph's runs into display lines respecting `max_width`.
Each line is a list of (possibly truncated) TextRuns. Explicit a:br runs
force a new line. When max_width is +inf the original runs are returned
unchanged (one logical line per a:br segment).
Bullet prefix is prepended to the first non-empty run if present.
"""
lines: list[list[TextRun]] = [[]]
cur_w = 0.0
if _should_keep_single_line(para, max_width):
return [[_copy_run(run, text=run.text) for run in para.runs if not run.is_break and run.text]]
if para.bullet_prefix and para.runs:
first_run = next((r for r in para.runs if not r.is_break), None)
if first_run is not None:
bullet_run = _copy_run(first_run, text=para.bullet_prefix)
lines[-1].append(bullet_run)
cur_w = _estimate_run_width(para.bullet_prefix, bullet_run)
for run in para.runs:
if run.is_break:
# Keep the break on the line it terminates so consecutive breaks
# form a break-only empty line. Visible text owns a non-empty
# line's height; the break rPr owns only that empty line.
lines[-1].append(run)
lines.append([])
cur_w = 0.0
continue
if not run.text:
continue
text = run.text
i = 0
while i < len(text):
avail = max_width - cur_w
if avail <= 0 and lines[-1]:
# Line is full; start a new one
lines.append([])
cur_w = 0.0
avail = max_width
end, used = _find_break_point(text, i, avail, run)
if end == i:
# Nothing fits even from a fresh line — force one char to avoid
# an infinite loop.
if lines[-1]:
lines.append([])
cur_w = 0.0
continue
end = i + 1
used = _advance_width(text[i], 0, run)
chunk = text[i:end]
lines[-1].append(_copy_run(run, text=chunk))
cur_w += used
i = end
if i < len(text):
# More to render — wrap to next line
lines.append([])
cur_w = 0.0
return lines
def _should_keep_single_line(para: TextParagraph, max_width: float) -> bool:
if max_width == float("inf") or para.bullet_prefix:
return False
if any(run.is_break for run in para.runs):
return False
text_runs = [run for run in para.runs if run.text]
if not text_runs:
return False
text = "".join(run.text for run in text_runs)
non_space_count = sum(1 for ch in text if not ch.isspace())
# Short labels/titles are usually intentionally single-line in PPT. Let
# them overflow slightly rather than inventing a line break from imperfect
# font metrics or alignment spaces.
if non_space_count <= 18:
return True
estimated = sum(_estimate_run_width(run.text, run) for run in text_runs)
return estimated <= max_width * 1.12
def _copy_run(run: TextRun, *, text: str) -> TextRun:
return TextRun(
text=text,
font_size_px=run.font_size_px,
font_family=run.font_family,
fill=run.fill,
fill_opacity=run.fill_opacity,
defs=list(run.defs),
bold=run.bold,
italic=run.italic,
underline=run.underline,
strikethrough=run.strikethrough,
letter_spacing_px=run.letter_spacing_px,
)
def _paragraph_height_from_lines(p: TextParagraph,
lines: list[list[TextRun]]) -> float:
"""Total px height after wrapping. Each line uses its own max font size."""
if not lines:
return p.empty_line_font_size_px * p.line_height_ratio
height = 0.0
for line in lines:
height += _line_height(p, line)
return height
def _line_height(p: TextParagraph, line: list[TextRun]) -> float:
return _line_font_size(p, line) * p.line_height_ratio
def _line_font_size(p: TextParagraph, line: list[TextRun]) -> float:
visible_sizes = [
run.font_size_px
for run in line
if not run.is_break and run.text
]
if visible_sizes:
return max(visible_sizes)
break_sizes = [run.font_size_px for run in line if run.is_break]
return max(break_sizes, default=p.empty_line_font_size_px)
def _clip_lines_to_bottom(
para: TextParagraph,
lines: list[list[TextRun]],
top_y: float,
bottom_y: float,
) -> list[list[TextRun]]:
"""Return the leading display lines whose line boxes fit in the text frame."""
visible: list[list[TextRun]] = []
cursor_y = top_y
for line in lines:
line_h = _line_height(para, line)
# PowerPoint lets the first line that starts within the box render even
# when it slightly exceeds the bottom — only suppress lines whose top
# is already at/below the bottom edge.
if cursor_y >= bottom_y:
break
visible.append(line)
cursor_y += line_h
return visible
def _paragraph_height(p: TextParagraph) -> float:
"""Legacy helper kept for callers that don't pre-wrap (currently unused)."""
lines = _wrap_paragraph_into_lines(p, float("inf"))
return _paragraph_height_from_lines(p, lines)
def _emit_paragraph(
para: TextParagraph,
lines: list[list[TextRun]],
inner_x: float, inner_w: float,
top_y: float,
) -> str:
"""Render a paragraph (already split into lines) as one <text> element.
Each pre-wrapped display line becomes a sequence of <tspan>s: the first
tspan on a line carries the explicit x and dy (line-height advance);
subsequent tspans on the same line inherit x.
"""
align = para.align
if align == "ctr":
anchor_x = inner_x + inner_w / 2.0
text_anchor = "middle"
elif align == "r":
anchor_x = inner_x + inner_w
text_anchor = "end"
else: # 'l' / 'just' / 'dist' / unknown
anchor_x = inner_x + para.indent_px + para.margin_left_px
text_anchor = "start"
if not lines:
return ""
visible_lines = [
[run for run in line if not run.is_break and run.text]
for line in lines
]
first_line_idx = next(
(index for index, line in enumerate(visible_lines) if line),
None,
)
if first_line_idx is None:
return ""
first_run = visible_lines[first_line_idx][0]
first_baseline = top_y + 0.85 * first_run.font_size_px
spans: list[str] = []
for line_idx, line in enumerate(visible_lines):
line_advance = None
if line_idx > 0:
height_line = (
lines[line_idx - 1]
if line_idx <= first_line_idx else lines[line_idx]
)
line_advance = _line_height(para, height_line)
if not line:
if line_advance is not None:
spans.append(
f'<tspan x="{fmt_num(anchor_x)}" '
f'dy="{fmt_num(line_advance)}"></tspan>'
)
continue
for run_idx, run in enumerate(line):
attrs = _run_tspan_attrs(run)
if run_idx == 0 and line_advance is not None:
spans.append(
f'<tspan x="{fmt_num(anchor_x)}" '
f'dy="{fmt_num(line_advance)}"'
f'{attrs}>{_xml_escape(run.text)}</tspan>'
)
else:
spans.append(
f"<tspan{attrs}>{_xml_escape(run.text)}</tspan>"
)
base_attrs = _text_base_attrs(first_run, anchor_x, first_baseline, text_anchor)
return f"<text{base_attrs}>{''.join(spans)}</text>"
def _text_base_attrs(run: TextRun | None, x: float, y: float,
text_anchor: str) -> str:
parts = [
f'x="{fmt_num(x)}"',
f'y="{fmt_num(y)}"',
f'text-anchor="{text_anchor}"',
'xml:space="preserve"',
]
if run is None:
return " " + " ".join(parts)
parts.append(f'font-family="{run.font_family}"')
parts.append(f'font-size="{fmt_num(run.font_size_px)}"')
parts.append(f'fill="{run.fill}"')
if run.fill_opacity < 1.0:
parts.append(
f'fill-opacity="{format_ooxml_alpha(run.fill_opacity)}"'
)
if run.bold:
parts.append('font-weight="bold"')
if run.italic:
parts.append('font-style="italic"')
if run.underline and run.strikethrough:
parts.append('text-decoration="underline line-through"')
elif run.underline:
parts.append('text-decoration="underline"')
elif run.strikethrough:
parts.append('text-decoration="line-through"')
if run.letter_spacing_px:
parts.append(f'letter-spacing="{fmt_num(run.letter_spacing_px)}"')
return " " + " ".join(parts)
def _run_tspan_attrs(run: TextRun) -> str:
"""Per-run overrides on a <tspan>. Only emit attributes that differ from
the run that drove the parent <text> (we keep things simple: emit only
overrides that can plausibly change run-to-run, never re-emit common
defaults). For v1 we always emit fill, font family, and font size so each
imported run keeps its resolved typeface even when adjacent runs differ.
"""
parts = [
f'fill="{run.fill}"',
f'font-family="{run.font_family}"',
f'font-size="{fmt_num(run.font_size_px)}"',
]
if run.fill_opacity < 1.0:
parts.append(
f'fill-opacity="{format_ooxml_alpha(run.fill_opacity)}"'
)
if run.bold:
parts.append('font-weight="bold"')
if run.italic:
parts.append('font-style="italic"')
if run.underline and run.strikethrough:
parts.append('text-decoration="underline line-through"')
elif run.underline:
parts.append('text-decoration="underline"')
elif run.strikethrough:
parts.append('text-decoration="line-through"')
if run.letter_spacing_px:
parts.append(f'letter-spacing="{fmt_num(run.letter_spacing_px)}"')
return " " + " ".join(parts)
def _xml_escape(text: str) -> str:
return (text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))