1326 lines
43 KiB
Python
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 (") 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""{name}""
|
|
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("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """))
|