返回 ppt-master
txbody_to_svg.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / txbody_to_svg.py
1 """DrawingML <p:txBody> -> SVG <text> conversion.
2
3 Reverse of svg_to_pptx/drawingml/elements.py convert_text.
4
5 Strategy (v1):
6 - Each <a:p> paragraph emits one <text> element (one line of baseline).
7 Multiple <a:r> runs in one paragraph become <tspan>s sharing the text
8 element's x.
9 - Vertical layout: y of first paragraph is determined by anchor (t/ctr/b)
10 and tIns/bIns. Subsequent paragraphs stack downward with line height
11 derived from the largest font in the paragraph * 1.2 (default leading).
12 - Horizontal layout: text-anchor follows pPr@algn. x is computed from the
13 text frame plus lIns/rIns and the alignment.
14 - No automatic word wrap (PPT's wrap is layout-time; v1 trusts the existing
15 text frame width and emits text as-is). a:br produces an explicit linebreak.
16 - Bullet points (a:buChar / a:buAutoNum) are rendered as literal prefixes
17 so the visual lands without relying on PowerPoint list semantics.
18
19 Color / font / size attributes propagate from a:rPr; missing attributes fall
20 back to paragraph/list defaults, endParaRPr, or spec-default values.
21 """
22
23 from __future__ import annotations
24
25 from dataclasses import dataclass, field
26 from typing import Callable
27 from xml.etree import ElementTree as ET
28
29 from svg_to_pptx.drawingml.utils import detect_text_lang, is_cjk_char
30
31 from .color_resolver import ColorPalette, find_color_elem, resolve_color
32 from .emu_units import (
33 NS, Xfrm, fmt_num, emu_to_px, format_ooxml_alpha,
34 hundredths_pt_to_px,
35 )
36 from .fill_to_svg import resolve_fill
37
38
39 # ---------------------------------------------------------------------------
40 # Defaults (matches DrawingML spec)
41 # ---------------------------------------------------------------------------
42
43 # Default body insets when bodyPr omits them: 0.1 inch left/right, 0.05 top/bot.
44 DEFAULT_INSETS_EMU = {"l": 91440, "t": 45720, "r": 91440, "b": 45720}
45
46 # Default font size = 1800 (= 18 pt = 24 px). Spec is actually 1800 (18pt).
47 DEFAULT_FONT_SIZE_PX = 24.0
48 DEFAULT_LINE_HEIGHT_RATIO = 1.2 # leading multiplier
49 DEFAULT_FILL_HEX = "#000000"
50
51
52 @dataclass
53 class TextRun:
54 """A single run with resolved style + text."""
55
56 text: str
57 font_size_px: float
58 font_family: str # full font-family stack (latin, ea fallback joined)
59 fill: str
60 fill_opacity: float = 1.0
61 defs: list[str] = field(default_factory=list)
62 bold: bool = False
63 italic: bool = False
64 underline: bool = False
65 strikethrough: bool = False
66 letter_spacing_px: float = 0.0
67 is_break: bool = False # marks an a:br within a paragraph
68 hyperlink_href: str | None = None
69 formula_latex: str | None = None
70
71
72 HyperlinkResolver = Callable[[str, str], str | None]
73 InlineFormulaResolver = Callable[[ET.Element], tuple[str | None, str]]
74 TextDiagnosticSink = Callable[[str, str, str], None]
75
76
77 class TextImportError(ValueError):
78 """Report malformed DrawingML text in strict import mode."""
79
80
81 @dataclass
82 class TextParagraph:
83 """One <a:p>: a list of runs sharing alignment + level."""
84
85 runs: list[TextRun] = field(default_factory=list)
86 align: str = "l" # l / ctr / r / just / dist
87 level: int = 0
88 indent_px: float = 0.0
89 margin_left_px: float = 0.0
90 line_height_ratio: float = DEFAULT_LINE_HEIGHT_RATIO
91 space_before_px: float = 0.0
92 space_after_px: float = 0.0
93 empty_line_font_size_px: float = DEFAULT_FONT_SIZE_PX
94 bullet_prefix: str = "" # rendered prefix like '• ' or '1. '
95
96
97 @dataclass
98 class TextResult:
99 """Resolved text body ready for SVG emission.
100
101 `svg` is one or more <text> elements, already absolutely positioned
102 inside the slide coordinate system. `defs` holds text gradient fills.
103 """
104
105 svg: str = ""
106 defs: list[str] = field(default_factory=list)
107 contains_inline_formula: bool = False
108
109
110 VERTICAL_TEXT_MODES = {"eaVert", "vert", "wordArtVert", "wordArtVertRtl"}
111
112
113 # ---------------------------------------------------------------------------
114 # Public API
115 # ---------------------------------------------------------------------------
116
117 def convert_txbody(
118 tx_body: ET.Element | None,
119 xfrm: Xfrm,
120 palette: ColorPalette | None,
121 *,
122 theme_fonts: dict[str, str] | None = None,
123 slide_number: int | None = None,
124 default_fill: str = DEFAULT_FILL_HEX,
125 default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
126 fallback_lst_styles: tuple[ET.Element, ...] = (),
127 fallback_run_props: tuple[ET.Element, ...] = (),
128 id_prefix: str = "txt",
129 id_seq: list[int] | None = None,
130 hyperlink_resolver: HyperlinkResolver | None = None,
131 inline_formula_resolver: InlineFormulaResolver | None = None,
132 strict: bool = False,
133 diagnostic_sink: TextDiagnosticSink | None = None,
134 ) -> TextResult:
135 """Convert <p:txBody> under the given shape geometry to SVG <text>(s)."""
136 if tx_body is None:
137 return TextResult()
138
139 body_pr = tx_body.find("a:bodyPr", NS)
140 paragraphs = _parse_paragraphs(
141 tx_body, palette, theme_fonts or {}, default_fill=default_fill,
142 default_font_size_px=default_font_size_px,
143 fallback_lst_styles=fallback_lst_styles,
144 fallback_run_props=fallback_run_props,
145 slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
146 hyperlink_resolver=hyperlink_resolver,
147 inline_formula_resolver=inline_formula_resolver,
148 strict=strict,
149 diagnostic_sink=diagnostic_sink,
150 )
151 if not paragraphs or not _has_visible_text(paragraphs):
152 return TextResult()
153
154 # Insets + anchor + wrap
155 lins = _read_emu_attr(body_pr, "lIns", DEFAULT_INSETS_EMU["l"])
156 tins = _read_emu_attr(body_pr, "tIns", DEFAULT_INSETS_EMU["t"])
157 rins = _read_emu_attr(body_pr, "rIns", DEFAULT_INSETS_EMU["r"])
158 bins = _read_emu_attr(body_pr, "bIns", DEFAULT_INSETS_EMU["b"])
159 anchor = body_pr.attrib.get("anchor", "t") if body_pr is not None else "t"
160 wrap_mode = body_pr.attrib.get("wrap", "square") if body_pr is not None else "square"
161 respect_edge_spacing = (
162 body_pr is not None
163 and body_pr.attrib.get("spcFirstLastPara") in {"1", "true"}
164 )
165
166 inner_x = xfrm.x + lins
167 inner_y = xfrm.y + tins
168 inner_w = max(xfrm.w - lins - rins, 1.0)
169 inner_h = max(xfrm.h - tins - bins, 1.0)
170
171 # Pre-wrap each paragraph into concrete display lines.
172 wrap_width = inner_w if wrap_mode == "square" else float("inf")
173 para_lines: list[list[list[TextRun]]] = [
174 _wrap_paragraph_into_lines(p, wrap_width) for p in paragraphs
175 ]
176
177 # Pre-compute heights to support anchor=ctr / b
178 para_heights = [
179 _paragraph_height_from_lines(p, lines)
180 for p, lines in zip(paragraphs, para_lines)
181 ]
182 space_before = [paragraph.space_before_px for paragraph in paragraphs]
183 space_after = [paragraph.space_after_px for paragraph in paragraphs]
184 if not respect_edge_spacing:
185 space_before[0] = 0.0
186 space_after[-1] = 0.0
187 total_h = sum(
188 before + height + after
189 for before, height, after in zip(
190 space_before,
191 para_heights,
192 space_after,
193 )
194 )
195 if anchor == "ctr":
196 cursor_y = inner_y + max(0.0, (inner_h - total_h) / 2.0)
197 elif anchor == "b":
198 cursor_y = inner_y + max(0.0, inner_h - total_h)
199 else:
200 cursor_y = inner_y
201
202 bottom_y = inner_y + inner_h
203 text_blocks: list[str] = []
204 for para, lines, height, before, after in zip(
205 paragraphs,
206 para_lines,
207 para_heights,
208 space_before,
209 space_after,
210 ):
211 cursor_y += before
212 visible_lines = _clip_lines_to_bottom(para, lines, cursor_y, bottom_y)
213 if visible_lines:
214 text_blocks.append(
215 _emit_paragraph(para, visible_lines, inner_x, inner_w, cursor_y)
216 )
217 cursor_y += height + after
218 if cursor_y >= bottom_y:
219 break
220
221 svg = "\n".join(text_blocks)
222 return TextResult(
223 svg=svg,
224 defs=_collect_text_defs(paragraphs),
225 contains_inline_formula="data-pptx-inline-formula=" in svg,
226 )
227
228
229 def is_vertical_txbody(tx_body: ET.Element | None, xfrm: Xfrm | None = None) -> bool:
230 if tx_body is None:
231 return False
232 body_pr = tx_body.find("a:bodyPr", NS)
233 if body_pr is None:
234 return False
235 if body_pr.attrib.get("vert") in VERTICAL_TEXT_MODES:
236 return True
237 return _looks_like_auto_stacked_cjk(tx_body, body_pr, xfrm)
238
239
240 def convert_vertical_txbody(
241 tx_body: ET.Element | None,
242 xfrm: Xfrm,
243 palette: ColorPalette | None,
244 *,
245 theme_fonts: dict[str, str] | None = None,
246 slide_number: int | None = None,
247 default_fill: str = DEFAULT_FILL_HEX,
248 default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
249 fallback_lst_styles: tuple[ET.Element, ...] = (),
250 fallback_run_props: tuple[ET.Element, ...] = (),
251 id_prefix: str = "txt",
252 id_seq: list[int] | None = None,
253 hyperlink_resolver: HyperlinkResolver | None = None,
254 inline_formula_resolver: InlineFormulaResolver | None = None,
255 strict: bool = False,
256 diagnostic_sink: TextDiagnosticSink | None = None,
257 ) -> TextResult:
258 """Render East Asian vertical text as upright stacked glyphs.
259
260 PowerPoint often combines ``bodyPr@vert=eaVert`` with a rotated text box.
261 Rendering the text inside the rotated shape group makes Chinese glyphs lie
262 sideways. This helper computes the final rotated box and places glyphs
263 upright in slide coordinates.
264 """
265 if tx_body is None:
266 return TextResult()
267
268 paragraphs = _parse_paragraphs(
269 tx_body, palette, theme_fonts or {}, default_fill=default_fill,
270 default_font_size_px=default_font_size_px,
271 fallback_lst_styles=fallback_lst_styles,
272 fallback_run_props=fallback_run_props,
273 slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
274 hyperlink_resolver=hyperlink_resolver,
275 inline_formula_resolver=inline_formula_resolver,
276 strict=strict,
277 diagnostic_sink=diagnostic_sink,
278 )
279 runs = [
280 run
281 for para in paragraphs
282 for run in para.runs
283 if not run.is_break and run.text
284 ]
285 if not runs:
286 return TextResult()
287
288 box_x, box_y, box_w, box_h = _rotated_bbox(xfrm)
289 center_x = box_x + box_w / 2.0
290
291 glyphs: list[tuple[str, TextRun]] = []
292 for run in runs:
293 for char in run.text:
294 glyphs.append((" " if char in "\t\r\n" else char, run))
295
296 if not glyphs:
297 return TextResult()
298
299 advances = [glyph_run.font_size_px * 1.05 for _, glyph_run in glyphs]
300 total_h = sum(advances)
301 top_y = box_y + max(0.0, (box_h - total_h) / 2.0)
302
303 bottom_y = box_y + box_h
304 spans: list[str] = []
305 cursor_y = top_y
306 first_run: TextRun | None = None
307 first_baseline: float | None = None
308 previous_baseline: float | None = None
309 for (char, run), advance in zip(glyphs, advances):
310 if cursor_y + advance > bottom_y:
311 break
312 baseline_y = cursor_y + run.font_size_px * 0.85
313 tspan_attrs = _run_tspan_attrs(run)
314 if first_run is None:
315 first_run = run
316 first_baseline = baseline_y
317 run_span = f"<tspan{tspan_attrs}>{_xml_escape(char)}</tspan>"
318 spans.append(_wrap_run_hyperlink(run_span, run))
319 else:
320 dy = baseline_y - (previous_baseline or baseline_y)
321 run_span = f"<tspan{tspan_attrs}>{_xml_escape(char)}</tspan>"
322 linked_span = _wrap_run_hyperlink(run_span, run)
323 spans.append(
324 f'<tspan x="{fmt_num(center_x)}" dy="{fmt_num(dy)}">'
325 f"{linked_span}</tspan>"
326 )
327 previous_baseline = baseline_y
328 cursor_y += advance
329
330 if first_run is None or first_baseline is None:
331 return TextResult()
332
333 attrs = _text_base_attrs(first_run, center_x, first_baseline, "middle")
334 return TextResult(
335 svg=f"<text{attrs}>{''.join(spans)}</text>",
336 defs=_collect_text_defs(paragraphs),
337 contains_inline_formula=False,
338 )
339
340
341 def _rotated_bbox(xfrm: Xfrm) -> tuple[float, float, float, float]:
342 rot = round(xfrm.rot) % 360
343 cx = xfrm.x + xfrm.w / 2.0
344 cy = xfrm.y + xfrm.h / 2.0
345 if rot in (90, 270):
346 return cx - xfrm.h / 2.0, cy - xfrm.w / 2.0, xfrm.h, xfrm.w
347 return xfrm.x, xfrm.y, xfrm.w, xfrm.h
348
349
350 def _looks_like_auto_stacked_cjk(
351 tx_body: ET.Element,
352 body_pr: ET.Element,
353 xfrm: Xfrm | None,
354 ) -> bool:
355 """Detect PowerPoint's narrow-box CJK vertical layout without vert=eaVert."""
356 if xfrm is None or xfrm.w <= 0 or xfrm.h <= 0:
357 return False
358 if body_pr.attrib.get("wrap", "square") != "square":
359 return False
360 if xfrm.w > 64 or xfrm.h < xfrm.w * 2.4:
361 return False
362
363 text = _plain_text(tx_body)
364 chars = [ch for ch in text if not ch.isspace()]
365 if len(chars) < 3 or len(chars) > 16:
366 return False
367 cjk_count = sum(1 for ch in chars if _is_cjk(ch))
368 if cjk_count / len(chars) < 0.8:
369 return False
370
371 lins = _read_emu_attr(body_pr, "lIns", DEFAULT_INSETS_EMU["l"])
372 rins = _read_emu_attr(body_pr, "rIns", DEFAULT_INSETS_EMU["r"])
373 inner_w = max(xfrm.w - lins - rins, 1.0)
374 return inner_w <= DEFAULT_FONT_SIZE_PX
375
376
377 def _plain_text(tx_body: ET.Element) -> str:
378 """Return concatenated literal text for layout heuristics."""
379 parts: list[str] = []
380 for text_elem in tx_body.findall(".//a:t", NS):
381 if text_elem.text:
382 parts.append(text_elem.text)
383 return "".join(parts)
384
385
386 # ---------------------------------------------------------------------------
387 # Parsing helpers
388 # ---------------------------------------------------------------------------
389
390 def _read_emu_attr(elem: ET.Element | None, attr: str, default_emu: int) -> float:
391 """Read an EMU integer attribute and return px."""
392 if elem is None:
393 return emu_to_px(default_emu)
394 val = elem.attrib.get(attr)
395 if val is None:
396 return emu_to_px(default_emu)
397 try:
398 return emu_to_px(int(val))
399 except ValueError:
400 return emu_to_px(default_emu)
401
402
403 def _parse_paragraphs(
404 tx_body: ET.Element,
405 palette: ColorPalette | None,
406 theme_fonts: dict[str, str],
407 *,
408 default_fill: str = DEFAULT_FILL_HEX,
409 default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
410 fallback_lst_styles: tuple[ET.Element, ...] = (),
411 fallback_run_props: tuple[ET.Element, ...] = (),
412 slide_number: int | None = None,
413 id_prefix: str = "txt",
414 id_seq: list[int] | None = None,
415 hyperlink_resolver: HyperlinkResolver | None = None,
416 inline_formula_resolver: InlineFormulaResolver | None = None,
417 strict: bool = False,
418 diagnostic_sink: TextDiagnosticSink | None = None,
419 ) -> list[TextParagraph]:
420 """Walk <a:p> children producing TextParagraph objects."""
421 paragraphs: list[TextParagraph] = []
422 autonum_state: dict[int, int] = {}
423 lst_style = tx_body.find("a:lstStyle", NS)
424 lst_styles = (
425 (lst_style,) + fallback_lst_styles
426 if lst_style is not None else fallback_lst_styles
427 )
428
429 for p_elem in tx_body.findall("a:p", NS):
430 para = _parse_paragraph(
431 p_elem, palette, theme_fonts, autonum_state,
432 lst_styles=lst_styles,
433 fallback_run_props=fallback_run_props,
434 default_fill=default_fill,
435 default_font_size_px=default_font_size_px,
436 slide_number=slide_number,
437 id_prefix=id_prefix, id_seq=id_seq,
438 hyperlink_resolver=hyperlink_resolver,
439 inline_formula_resolver=inline_formula_resolver,
440 strict=strict,
441 diagnostic_sink=diagnostic_sink,
442 )
443 paragraphs.append(para)
444
445 return paragraphs
446
447
448 def _parse_paragraph(
449 p_elem: ET.Element,
450 palette: ColorPalette | None,
451 theme_fonts: dict[str, str],
452 autonum_state: dict[int, int],
453 *,
454 lst_styles: tuple[ET.Element, ...] = (),
455 fallback_run_props: tuple[ET.Element, ...] = (),
456 default_fill: str = DEFAULT_FILL_HEX,
457 default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
458 slide_number: int | None = None,
459 id_prefix: str = "txt",
460 id_seq: list[int] | None = None,
461 hyperlink_resolver: HyperlinkResolver | None = None,
462 inline_formula_resolver: InlineFormulaResolver | None = None,
463 strict: bool = False,
464 diagnostic_sink: TextDiagnosticSink | None = None,
465 ) -> TextParagraph:
466 para = TextParagraph()
467
468 p_pr = p_elem.find("a:pPr", NS)
469 if p_pr is not None:
470 try:
471 para.level = int(p_pr.attrib.get("lvl", "0"))
472 except ValueError:
473 para.level = 0
474
475 para_style_chain = (p_pr,) + _lst_style_level_prs(lst_styles, para.level)
476 para.align = _attr_chain(para_style_chain, "algn") or "l"
477 para.margin_left_px = _emu_px_attr_chain(para_style_chain, "marL", 0.0)
478 para.indent_px = _emu_px_attr_chain(para_style_chain, "indent", 0.0)
479 para.line_height_ratio = _line_height_ratio(para_style_chain)
480 para.space_before_px = _spacing_points_px(para_style_chain, "a:spcBef/a:spcPts")
481 para.space_after_px = _spacing_points_px(para_style_chain, "a:spcAft/a:spcPts")
482 para.bullet_prefix = _resolve_bullet_prefix(
483 para_style_chain, para.level, autonum_state,
484 )
485
486 # Default endParaRPr style (applies if a run has no rPr)
487 end_rpr = p_elem.find("a:endParaRPr", NS)
488 # defRPr from pPr and txBody/lstStyle, both optional.
489 def_rpr = p_pr.find("a:defRPr", NS) if p_pr is not None else None
490 list_def_rpr = _child_chain(para_style_chain[1:], "a:defRPr")
491 para.empty_line_font_size_px = _font_size_px(
492 (end_rpr, def_rpr, list_def_rpr) + fallback_run_props,
493 default_font_size_px,
494 )
495
496 def resolved_run(text: str, rpr: ET.Element | None) -> TextRun:
497 return _build_run(
498 text, rpr, end_rpr, palette, theme_fonts,
499 def_rpr=def_rpr,
500 list_def_rpr=list_def_rpr,
501 fallback_run_props=fallback_run_props,
502 default_fill=default_fill,
503 default_font_size_px=default_font_size_px,
504 id_prefix=id_prefix, id_seq=id_seq,
505 hyperlink_resolver=hyperlink_resolver,
506 strict=strict,
507 diagnostic_sink=diagnostic_sink,
508 )
509
510 for child in list(p_elem):
511 if not isinstance(child.tag, str):
512 continue
513 local = child.tag.split("}", 1)[-1]
514 if local == "r":
515 rpr = child.find("a:rPr", NS)
516 text_elem = child.find("a:t", NS)
517 text = text_elem.text or "" if text_elem is not None else ""
518 para.runs.append(resolved_run(text, rpr))
519 elif local == "br":
520 break_rpr = child.find("a:rPr", NS)
521 para.runs.append(TextRun(
522 text="",
523 font_size_px=_font_size_px(
524 (break_rpr, def_rpr, list_def_rpr, end_rpr)
525 + fallback_run_props,
526 default_font_size_px,
527 ),
528 font_family="sans-serif",
529 fill=default_fill,
530 is_break=True,
531 ))
532 elif local == "fld":
533 # Slide SVGs have a concrete page context, so resolve slide-number
534 # fields there. Standalone master/layout renders keep the literal
535 # fallback because one shared part can serve many slide numbers.
536 rpr = child.find("a:rPr", NS)
537 text_elem = child.find("a:t", NS)
538 text = text_elem.text or "" if text_elem is not None else ""
539 field_type = child.attrib.get("type", "").strip().lower()
540 if field_type == "slidenum" and slide_number is not None:
541 text = str(slide_number)
542 if text:
543 para.runs.append(resolved_run(text, rpr))
544 elif (
545 child.tag
546 == "{http://schemas.microsoft.com/office/drawing/2010/main}m"
547 and inline_formula_resolver is not None
548 ):
549 latex, preview = inline_formula_resolver(child)
550 if preview:
551 formula_rpr = next(
552 child.iter(f"{{{NS['a']}}}rPr"),
553 None,
554 )
555 run = resolved_run(preview, formula_rpr)
556 run.formula_latex = latex
557 para.runs.append(run)
558
559 return para
560
561
562 def _font_size_px(
563 sources: tuple[ET.Element | None, ...],
564 default_font_size_px: float,
565 ) -> float:
566 """Resolve one effective DrawingML run size into SVG pixels."""
567 return hundredths_pt_to_px(
568 _attr_chain(sources, "sz"),
569 default_font_size_px,
570 )
571
572
573 def _build_run(
574 text: str,
575 rpr: ET.Element | None,
576 end_rpr: ET.Element | None,
577 palette: ColorPalette | None,
578 theme_fonts: dict[str, str],
579 *,
580 def_rpr: ET.Element | None = None,
581 list_def_rpr: ET.Element | None = None,
582 fallback_run_props: tuple[ET.Element, ...] = (),
583 default_fill: str = DEFAULT_FILL_HEX,
584 default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
585 id_prefix: str = "txt",
586 id_seq: list[int] | None = None,
587 hyperlink_resolver: HyperlinkResolver | None = None,
588 strict: bool = False,
589 diagnostic_sink: TextDiagnosticSink | None = None,
590 ) -> TextRun:
591 """Resolve a single <a:r> run from its rPr and fallback run properties."""
592 style_chain = (
593 rpr, def_rpr, list_def_rpr, end_rpr,
594 ) + fallback_run_props
595 # font-size: rPr > pPr/defRPr > lstStyle/lvlNpPr/defRPr > endParaRPr > default
596 font_size_px = _font_size_px(style_chain, default_font_size_px)
597 # Bold / italic
598 bold = _attr_chain(style_chain, "b") == "1"
599 italic = _attr_chain(style_chain, "i") == "1"
600 # Underline / strike
601 u_val = _attr_chain(style_chain, "u")
602 underline = u_val not in (None, "", "none")
603 strike_val = _attr_chain(style_chain, "strike")
604 strikethrough = strike_val in ("sngStrike", "dblStrike")
605
606 # Letter spacing (rPr@spc, in 1/100 pt)
607 spc = _attr_chain(style_chain, "spc")
608 letter_spacing_px = 0.0
609 if spc is not None:
610 try:
611 letter_spacing_px = float(spc) / 100.0 * 4.0 / 3.0 # pt -> px
612 except ValueError as exc:
613 message = (
614 f"Invalid DrawingML a:rPr@spc value {spc!r}; expected a numeric "
615 "hundredths-of-a-point value"
616 )
617 if strict:
618 raise TextImportError(message) from exc
619 if diagnostic_sink is not None:
620 diagnostic_sink(
621 "text-letter-spacing-normalized",
622 message,
623 "use zero letter spacing for this run",
624 )
625
626 # Color
627 fill = default_fill
628 fill_opacity = 1.0
629 defs: list[str] = []
630 color_source = None
631 for src in style_chain:
632 if src is None:
633 continue
634 grad = src.find("a:gradFill", NS)
635 if grad is not None:
636 grad_fill = resolve_fill(
637 grad, palette,
638 id_prefix=id_prefix,
639 id_seq=id_seq,
640 )
641 if grad_fill.attrs.get("fill"):
642 fill = grad_fill.attrs["fill"]
643 fill_opacity = 1.0
644 defs.extend(grad_fill.defs)
645 color_source = None
646 break
647 solid = src.find("a:solidFill", NS)
648 if solid is not None:
649 color_source = solid
650 break
651 if color_source is not None:
652 color_elem = find_color_elem(color_source)
653 hex_, alpha = resolve_color(color_elem, palette)
654 if hex_:
655 fill = hex_
656 fill_opacity = alpha
657
658 # Font typeface
659 latin_face = _typeface_chain(style_chain, "latin")
660 ea_face = _typeface_chain(style_chain, "ea")
661 cs_face = _typeface_chain(style_chain, "cs")
662 lang = _attr_chain(style_chain, "lang")
663 alt_lang = _attr_chain(style_chain, "altLang")
664
665 # Resolve theme refs (e.g. typeface="+mn-lt" / "+mj-ea")
666 latin_face = _resolve_theme_typeface(
667 latin_face,
668 theme_fonts,
669 text=text,
670 lang=lang,
671 alt_lang=alt_lang,
672 )
673 ea_face = _resolve_theme_typeface(
674 ea_face,
675 theme_fonts,
676 text=text,
677 lang=lang,
678 alt_lang=alt_lang,
679 )
680 cs_face = _resolve_theme_typeface(
681 cs_face,
682 theme_fonts,
683 text=text,
684 lang=lang,
685 alt_lang=alt_lang,
686 )
687
688 font_family = _build_font_stack(latin_face, ea_face, cs_face)
689 hyperlink_href: str | None = None
690 if rpr is not None and hyperlink_resolver is not None:
691 hyperlink = rpr.find("a:hlinkClick", NS)
692 if hyperlink is not None:
693 hyperlink_href = hyperlink_resolver(
694 hyperlink.attrib.get(f"{{{NS['r']}}}id", ""),
695 hyperlink.attrib.get("action", ""),
696 )
697
698 return TextRun(
699 text=text,
700 font_size_px=font_size_px,
701 font_family=font_family,
702 fill=fill,
703 fill_opacity=fill_opacity,
704 defs=defs,
705 bold=bold,
706 italic=italic,
707 underline=underline,
708 strikethrough=strikethrough,
709 letter_spacing_px=letter_spacing_px,
710 hyperlink_href=hyperlink_href,
711 )
712
713
714 def _lst_style_level_prs(
715 lst_styles: tuple[ET.Element, ...],
716 level: int,
717 ) -> tuple[ET.Element, ...]:
718 """Return txBody/lstStyle paragraph properties for a paragraph level."""
719 level_idx = min(max(level, 0), 8) + 1
720 level_prs: list[ET.Element] = []
721 for lst_style in lst_styles:
722 lvl_pr = lst_style.find(f"a:lvl{level_idx}pPr", NS)
723 if lvl_pr is not None:
724 level_prs.append(lvl_pr)
725 return tuple(level_prs)
726
727
728 def _child_chain(
729 sources: tuple[ET.Element | None, ...],
730 path: str,
731 ) -> ET.Element | None:
732 for src in sources:
733 if src is None:
734 continue
735 child = src.find(path, NS)
736 if child is not None:
737 return child
738 return None
739
740
741 def _emu_px_attr_chain(
742 sources: tuple[ET.Element | None, ...],
743 attr: str,
744 default: float,
745 ) -> float:
746 value = _attr_chain(sources, attr)
747 if value is None:
748 return default
749 try:
750 return emu_to_px(int(value))
751 except ValueError:
752 return default
753
754
755 def _line_height_ratio(sources: tuple[ET.Element | None, ...]) -> float:
756 ln_spc = _child_chain(sources, "a:lnSpc")
757 if ln_spc is None:
758 return DEFAULT_LINE_HEIGHT_RATIO
759 spc_pct = ln_spc.find("a:spcPct", NS)
760 if spc_pct is None:
761 return DEFAULT_LINE_HEIGHT_RATIO
762 try:
763 return float(spc_pct.attrib.get("val", "100000")) / 100000.0
764 except ValueError:
765 return DEFAULT_LINE_HEIGHT_RATIO
766
767
768 def _spacing_points_px(
769 sources: tuple[ET.Element | None, ...],
770 path: str,
771 ) -> float:
772 spacing = _child_chain(sources, path)
773 if spacing is None:
774 return 0.0
775 try:
776 return hundredths_pt_to_px(int(spacing.attrib.get("val", "0")))
777 except ValueError:
778 return 0.0
779
780
781 def _attr_chain(sources: tuple[ET.Element | None, ...], attr: str) -> str | None:
782 """Return the first non-empty value of `attr` from any source element."""
783 for src in sources:
784 if src is None:
785 continue
786 v = src.attrib.get(attr)
787 if v is not None:
788 return v
789 return None
790
791
792 def _typeface(rpr: ET.Element | None, child_tag: str) -> str | None:
793 if rpr is None:
794 return None
795 elem = rpr.find(f"a:{child_tag}", NS)
796 if elem is None:
797 return None
798 val = elem.attrib.get("typeface")
799 return val or None
800
801
802 def _typeface_chain(
803 sources: tuple[ET.Element | None, ...],
804 child_tag: str,
805 ) -> str | None:
806 for src in sources:
807 face = _typeface(src, child_tag)
808 if face:
809 return face
810 return None
811
812
813 def _theme_script_from_lang(lang: str | None) -> str | None:
814 """Map a DrawingML language tag to one theme supplemental-script key."""
815 if not lang:
816 return None
817 normalized = lang.strip().replace("_", "-").lower()
818 if not normalized:
819 return None
820 parts = normalized.split("-")
821 primary = parts[0]
822 if primary == "ja":
823 return "Jpan"
824 if primary == "ko":
825 return "Hang"
826 if primary != "zh":
827 return None
828 if any(part in {"hant", "cht", "tw", "hk", "mo"} for part in parts[1:]):
829 return "Hant"
830 return "Hans"
831
832
833 def _theme_script_from_text(text: str) -> str | None:
834 """Infer a CJK theme script from glyph ranges, defaulting plain Han to Hans."""
835 if any(
836 0x3100 <= ord(char) <= 0x312F
837 or 0x31A0 <= ord(char) <= 0x31BF
838 for char in text
839 ):
840 return "Hant"
841 return {
842 "ko-KR": "Hang",
843 "ja-JP": "Jpan",
844 "zh-CN": "Hans",
845 }.get(detect_text_lang(text))
846
847
848 def _run_theme_script(
849 text: str,
850 lang: str | None,
851 alt_lang: str | None,
852 ) -> str | None:
853 """Resolve EA script from run language first, then alternate language/text."""
854 return (
855 _theme_script_from_lang(lang)
856 or _theme_script_from_lang(alt_lang)
857 or _theme_script_from_text(text)
858 )
859
860
861 def _resolve_theme_typeface(
862 face: str | None,
863 theme_fonts: dict[str, str],
864 *,
865 text: str = "",
866 lang: str | None = None,
867 alt_lang: str | None = None,
868 ) -> str | None:
869 """Resolve DrawingML major/minor Latin, EA, and complex-script tokens."""
870 if not face or not face.startswith("+"):
871 return face
872 code = face[1:]
873 if code == "mj-lt":
874 return theme_fonts.get("majorLatin") or face
875 if code == "mn-lt":
876 return theme_fonts.get("minorLatin") or face
877 if code in {"mj-ea", "mn-ea"}:
878 prefix = "major" if code.startswith("mj") else "minor"
879 script = _run_theme_script(text, lang, alt_lang)
880 script_face = (
881 theme_fonts.get(f"{prefix}Script{script}")
882 if script is not None else None
883 )
884 return (
885 theme_fonts.get(f"{prefix}EastAsia")
886 or script_face
887 or theme_fonts.get(f"{prefix}Latin")
888 or face
889 )
890 if code == "mj-cs":
891 return (
892 theme_fonts.get("majorComplexScript")
893 or theme_fonts.get("majorLatin")
894 or face
895 )
896 if code == "mn-cs":
897 return (
898 theme_fonts.get("minorComplexScript")
899 or theme_fonts.get("minorLatin")
900 or face
901 )
902 return face
903
904
905 def _build_font_stack(latin: str | None, ea: str | None, cs: str | None) -> str:
906 """Build a CSS font-family stack: original PPT names first, then fallbacks."""
907 parts: list[str] = []
908 seen: set[str] = set()
909 for face in (latin, ea, cs):
910 if face and face not in seen:
911 parts.append(_quote_font(face))
912 seen.add(face)
913 # Generic fallback so the browser can render even if PPT fonts are absent.
914 parts.append("sans-serif")
915 return ", ".join(parts)
916
917
918 def _quote_font(name: str) -> str:
919 """Quote a font name if it contains spaces or non-ASCII chars.
920
921 Uses XML entity-escaped double quotes (&quot;) so the resulting CSS string
922 survives being embedded inside an SVG attribute that itself uses double
923 quotes. CSS parsers accept the unescaped form after attribute parsing.
924 """
925 if any(c.isspace() or ord(c) > 127 for c in name):
926 return f"&quot;{name}&quot;"
927 return name
928
929
930 def _resolve_bullet_prefix(
931 sources: tuple[ET.Element | None, ...],
932 level: int,
933 autonum_state: dict[int, int],
934 ) -> str:
935 """Render bullet glyphs / numbering as a literal text prefix."""
936 bu_none = _child_chain(sources, "a:buNone")
937 if bu_none is not None:
938 autonum_state.pop(level, None)
939 return ""
940 bu_char = _child_chain(sources, "a:buChar")
941 if bu_char is not None:
942 ch = bu_char.attrib.get("char", "•")
943 return f"{ch} "
944 bu_auto = _child_chain(sources, "a:buAutoNum")
945 if bu_auto is not None:
946 start_at = bu_auto.attrib.get("startAt")
947 if start_at is not None:
948 try:
949 autonum_state[level] = int(start_at)
950 except ValueError:
951 autonum_state[level] = 1
952 else:
953 autonum_state[level] = autonum_state.get(level, 0) + 1
954 return _format_auto_number(
955 autonum_state[level],
956 bu_auto.attrib.get("type", "arabicPeriod"),
957 )
958 return ""
959
960
961 def _format_auto_number(value: int, kind: str) -> str:
962 lower = kind.lower()
963 if "alphalc" in lower:
964 token = _alpha_number(value, uppercase=False)
965 elif "alphauc" in lower:
966 token = _alpha_number(value, uppercase=True)
967 elif "romanlc" in lower:
968 token = _roman_number(value).lower()
969 elif "romanuc" in lower:
970 token = _roman_number(value).upper()
971 else:
972 token = str(value)
973
974 if "parenboth" in lower:
975 return f"({token}) "
976 if "parenr" in lower:
977 return f"{token}) "
978 if "period" in lower:
979 return f"{token}. "
980 return f"{token} "
981
982
983 def _alpha_number(value: int, *, uppercase: bool) -> str:
984 value = max(1, value)
985 chars: list[str] = []
986 while value:
987 value -= 1
988 chars.append(chr(ord("A") + (value % 26)))
989 value //= 26
990 text = "".join(reversed(chars))
991 return text if uppercase else text.lower()
992
993
994 def _roman_number(value: int) -> str:
995 value = max(1, min(value, 3999))
996 parts: list[str] = []
997 for n, token in (
998 (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
999 (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
1000 (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
1001 ):
1002 while value >= n:
1003 parts.append(token)
1004 value -= n
1005 return "".join(parts)
1006
1007
1008 # ---------------------------------------------------------------------------
1009 # Layout / emission
1010 # ---------------------------------------------------------------------------
1011
1012 def _has_visible_text(paragraphs: list[TextParagraph]) -> bool:
1013 for p in paragraphs:
1014 for r in p.runs:
1015 if r.text.strip():
1016 return True
1017 return False
1018
1019
1020 def _collect_text_defs(paragraphs: list[TextParagraph]) -> list[str]:
1021 """Return unique text fill defs referenced by parsed runs."""
1022 defs: list[str] = []
1023 seen: set[str] = set()
1024 for para in paragraphs:
1025 for run in para.runs:
1026 for item in run.defs:
1027 if item not in seen:
1028 defs.append(item)
1029 seen.add(item)
1030 return defs
1031
1032
1033 # ---------------------------------------------------------------------------
1034 # Word-wrap / text measurement
1035 # ---------------------------------------------------------------------------
1036
1037 def _is_cjk(ch: str) -> bool:
1038 """Check if a character is CJK (Chinese/Japanese/Korean) or full-width."""
1039 return is_cjk_char(ch)
1040
1041
1042 def _char_width(ch: str, font_size: float, bold: bool) -> float:
1043 """Estimate a single character's rendered width in pixels.
1044
1045 Mirrors svg_to_pptx/drawingml/utils.py estimate_text_width so wrapping breaks
1046 align with the same heuristic used to estimate text-box sizes elsewhere.
1047 """
1048 if _is_cjk(ch):
1049 w = font_size # CJK is approximately 1em per glyph
1050 elif ch == ' ':
1051 w = font_size * 0.3
1052 elif ch in 'mMwWOQ%':
1053 w = font_size * 0.75
1054 elif ch in 'iIlj!|':
1055 w = font_size * 0.3
1056 elif ch.isdigit():
1057 # digits are tabular (uniform ~0.55em) in most UI fonts, including
1058 # '1' — classing it with 'il|' under-sizes the width and makes
1059 # renderers that ignore wrap="none" (LibreOffice) wrap the line
1060 w = font_size * 0.55
1061 else:
1062 w = font_size * 0.55
1063 # Bold Latin generally expands a little. CJK glyphs keep their em advance
1064 # in common PPT fonts; applying the bold multiplier causes short Chinese
1065 # titles such as "少年强国说" to wrap even though PowerPoint keeps them on
1066 # one line.
1067 if bold and not _is_cjk(ch):
1068 w *= 1.05
1069 return w
1070
1071
1072 def _estimate_run_width(text: str, run: TextRun) -> float:
1073 glyph_width = sum(_char_width(c, run.font_size_px, run.bold) for c in text)
1074 tracking_width = run.letter_spacing_px * max(len(text) - 1, 0)
1075 return (glyph_width + tracking_width) * 1.05
1076
1077
1078 def _advance_width(ch: str, index_in_segment: int, run: TextRun) -> float:
1079 """Return the width added by one character inside a measured line segment."""
1080 tracking = run.letter_spacing_px if index_in_segment > 0 else 0.0
1081 return _char_width(ch, run.font_size_px, run.bold) + tracking
1082
1083
1084 def _find_break_point(
1085 text: str, start: int, max_width: float, run: TextRun,
1086 ) -> tuple[int, float]:
1087 """Find the longest prefix of text[start:] that fits in max_width.
1088
1089 Returns (end_index, used_width). Prefers breaking after whitespace, after
1090 CJK characters, or after hyphens. If even the first character doesn't fit,
1091 returns (start, 0.0) — the caller should flush the current line first.
1092 """
1093 cur_w = 0.0
1094 last_break = start
1095 last_break_w = 0.0
1096
1097 for i in range(start, len(text)):
1098 ch = text[i]
1099 ch_w = _advance_width(ch, i - start, run)
1100 if cur_w + ch_w > max_width:
1101 if last_break > start:
1102 return last_break, last_break_w
1103 return start, 0.0
1104 cur_w += ch_w
1105 # Update last_break point
1106 if ch.isspace() or _is_cjk(ch) or ch in "-—、,。!?:;":
1107 last_break = i + 1
1108 last_break_w = cur_w
1109 # Whole rest fits
1110 return len(text), cur_w
1111
1112
1113 def _wrap_paragraph_into_lines(
1114 para: TextParagraph,
1115 max_width: float,
1116 ) -> list[list[TextRun]]:
1117 """Split a paragraph's runs into display lines respecting `max_width`.
1118
1119 Each line is a list of (possibly truncated) TextRuns. Explicit a:br runs
1120 force a new line. When max_width is +inf the original runs are returned
1121 unchanged (one logical line per a:br segment).
1122
1123 Bullet prefix is prepended to the first non-empty run if present.
1124 """
1125 lines: list[list[TextRun]] = [[]]
1126 cur_w = 0.0
1127
1128 if _should_keep_single_line(para, max_width):
1129 return [[_copy_run(run, text=run.text) for run in para.runs if not run.is_break and run.text]]
1130
1131 if para.bullet_prefix and para.runs:
1132 first_run = next((r for r in para.runs if not r.is_break), None)
1133 if first_run is not None:
1134 bullet_run = _copy_run(first_run, text=para.bullet_prefix)
1135 lines[-1].append(bullet_run)
1136 cur_w = _estimate_run_width(para.bullet_prefix, bullet_run)
1137
1138 for run in para.runs:
1139 if run.is_break:
1140 # Keep the break on the line it terminates so consecutive breaks
1141 # form a break-only empty line. Visible text owns a non-empty
1142 # line's height; the break rPr owns only that empty line.
1143 lines[-1].append(run)
1144 lines.append([])
1145 cur_w = 0.0
1146 continue
1147 if not run.text:
1148 continue
1149 if run.formula_latex is not None:
1150 width = _estimate_run_width(run.text, run)
1151 if lines[-1] and cur_w + width > max_width:
1152 lines.append([])
1153 cur_w = 0.0
1154 lines[-1].append(_copy_run(run, text=run.text))
1155 cur_w += width
1156 continue
1157 text = run.text
1158 i = 0
1159 while i < len(text):
1160 avail = max_width - cur_w
1161 if avail <= 0 and lines[-1]:
1162 # Line is full; start a new one
1163 lines.append([])
1164 cur_w = 0.0
1165 avail = max_width
1166
1167 end, used = _find_break_point(text, i, avail, run)
1168 if end == i:
1169 # Nothing fits even from a fresh line — force one char to avoid
1170 # an infinite loop.
1171 if lines[-1]:
1172 lines.append([])
1173 cur_w = 0.0
1174 continue
1175 end = i + 1
1176 used = _advance_width(text[i], 0, run)
1177
1178 chunk = text[i:end]
1179 lines[-1].append(_copy_run(run, text=chunk))
1180 cur_w += used
1181 i = end
1182
1183 if i < len(text):
1184 # More to render — wrap to next line
1185 lines.append([])
1186 cur_w = 0.0
1187
1188 return lines
1189
1190
1191 def _should_keep_single_line(para: TextParagraph, max_width: float) -> bool:
1192 if max_width == float("inf") or para.bullet_prefix:
1193 return False
1194 if any(run.is_break for run in para.runs):
1195 return False
1196
1197 text_runs = [run for run in para.runs if run.text]
1198 if not text_runs:
1199 return False
1200 text = "".join(run.text for run in text_runs)
1201 non_space_count = sum(1 for ch in text if not ch.isspace())
1202
1203 # Short labels/titles are usually intentionally single-line in PPT. Let
1204 # them overflow slightly rather than inventing a line break from imperfect
1205 # font metrics or alignment spaces.
1206 if non_space_count <= 18:
1207 return True
1208
1209 estimated = sum(_estimate_run_width(run.text, run) for run in text_runs)
1210 return estimated <= max_width * 1.12
1211
1212
1213 def _copy_run(run: TextRun, *, text: str) -> TextRun:
1214 return TextRun(
1215 text=text,
1216 font_size_px=run.font_size_px,
1217 font_family=run.font_family,
1218 fill=run.fill,
1219 fill_opacity=run.fill_opacity,
1220 defs=list(run.defs),
1221 bold=run.bold,
1222 italic=run.italic,
1223 underline=run.underline,
1224 strikethrough=run.strikethrough,
1225 letter_spacing_px=run.letter_spacing_px,
1226 hyperlink_href=run.hyperlink_href,
1227 formula_latex=run.formula_latex,
1228 )
1229
1230
1231 def _paragraph_height_from_lines(p: TextParagraph,
1232 lines: list[list[TextRun]]) -> float:
1233 """Total px height after wrapping. Each line uses its own max font size."""
1234 if not lines:
1235 return p.empty_line_font_size_px * p.line_height_ratio
1236 height = 0.0
1237 for line in lines:
1238 height += _line_height(p, line)
1239 return height
1240
1241
1242 def _line_height(p: TextParagraph, line: list[TextRun]) -> float:
1243 return _line_font_size(p, line) * p.line_height_ratio
1244
1245
1246 def _line_font_size(p: TextParagraph, line: list[TextRun]) -> float:
1247 visible_sizes = [
1248 run.font_size_px
1249 for run in line
1250 if not run.is_break and run.text
1251 ]
1252 if visible_sizes:
1253 return max(visible_sizes)
1254 break_sizes = [run.font_size_px for run in line if run.is_break]
1255 return max(break_sizes, default=p.empty_line_font_size_px)
1256
1257
1258 def _clip_lines_to_bottom(
1259 para: TextParagraph,
1260 lines: list[list[TextRun]],
1261 top_y: float,
1262 bottom_y: float,
1263 ) -> list[list[TextRun]]:
1264 """Return the leading display lines whose line boxes fit in the text frame."""
1265 visible: list[list[TextRun]] = []
1266 cursor_y = top_y
1267 for line in lines:
1268 line_h = _line_height(para, line)
1269 # PowerPoint lets the first line that starts within the box render even
1270 # when it slightly exceeds the bottom — only suppress lines whose top
1271 # is already at/below the bottom edge.
1272 if cursor_y >= bottom_y:
1273 break
1274 visible.append(line)
1275 cursor_y += line_h
1276 return visible
1277
1278
1279 def _paragraph_height(p: TextParagraph) -> float:
1280 """Legacy helper kept for callers that don't pre-wrap (currently unused)."""
1281 lines = _wrap_paragraph_into_lines(p, float("inf"))
1282 return _paragraph_height_from_lines(p, lines)
1283
1284
1285 def _emit_paragraph(
1286 para: TextParagraph,
1287 lines: list[list[TextRun]],
1288 inner_x: float, inner_w: float,
1289 top_y: float,
1290 ) -> str:
1291 """Render a paragraph (already split into lines) as one <text> element.
1292
1293 Each pre-wrapped display line becomes a sequence of <tspan>s: the first
1294 tspan on a line carries the explicit x and dy (line-height advance);
1295 subsequent tspans on the same line inherit x.
1296 """
1297 align = para.align
1298 if align == "ctr":
1299 anchor_x = inner_x + inner_w / 2.0
1300 text_anchor = "middle"
1301 elif align == "r":
1302 anchor_x = inner_x + inner_w
1303 text_anchor = "end"
1304 else: # 'l' / 'just' / 'dist' / unknown
1305 anchor_x = inner_x + para.indent_px + para.margin_left_px
1306 text_anchor = "start"
1307
1308 if not lines:
1309 return ""
1310
1311 visible_lines = [
1312 [run for run in line if not run.is_break and run.text]
1313 for line in lines
1314 ]
1315 first_line_idx = next(
1316 (index for index, line in enumerate(visible_lines) if line),
1317 None,
1318 )
1319 if first_line_idx is None:
1320 return ""
1321
1322 first_run = visible_lines[first_line_idx][0]
1323 first_baseline = top_y + 0.85 * first_run.font_size_px
1324
1325 spans: list[str] = []
1326 for line_idx, line in enumerate(visible_lines):
1327 line_advance = None
1328 if line_idx > 0:
1329 height_line = (
1330 lines[line_idx - 1]
1331 if line_idx <= first_line_idx else lines[line_idx]
1332 )
1333 line_advance = _line_height(para, height_line)
1334 if not line:
1335 if line_advance is not None:
1336 spans.append(
1337 f'<tspan x="{fmt_num(anchor_x)}" '
1338 f'dy="{fmt_num(line_advance)}"></tspan>'
1339 )
1340 continue
1341 line_has_hyperlink = any(run.hyperlink_href for run in line)
1342 if line_has_hyperlink:
1343 run_spans = ''.join(
1344 _wrap_run_hyperlink(
1345 _run_tspan_markup(run),
1346 run,
1347 )
1348 for run in line
1349 )
1350 position_attrs = (
1351 f' x="{fmt_num(anchor_x)}" dy="{fmt_num(line_advance)}"'
1352 if line_advance is not None
1353 else ''
1354 )
1355 spans.append(f'<tspan{position_attrs}>{run_spans}</tspan>')
1356 continue
1357 for run_idx, run in enumerate(line):
1358 attrs = _run_tspan_attrs(run)
1359 if run_idx == 0 and line_advance is not None:
1360 if run.formula_latex is not None:
1361 spans.append(
1362 f'<tspan x="{fmt_num(anchor_x)}" '
1363 f'dy="{fmt_num(line_advance)}">'
1364 f'{_run_tspan_markup(run)}</tspan>'
1365 )
1366 else:
1367 spans.append(
1368 f'<tspan x="{fmt_num(anchor_x)}" '
1369 f'dy="{fmt_num(line_advance)}"'
1370 f'{attrs}>{_xml_escape(run.text)}</tspan>'
1371 )
1372 else:
1373 spans.append(_run_tspan_markup(run))
1374
1375 base_attrs = _text_base_attrs(first_run, anchor_x, first_baseline, text_anchor)
1376 return f"<text{base_attrs}>{''.join(spans)}</text>"
1377
1378
1379 def _text_base_attrs(run: TextRun | None, x: float, y: float,
1380 text_anchor: str) -> str:
1381 parts = [
1382 f'x="{fmt_num(x)}"',
1383 f'y="{fmt_num(y)}"',
1384 f'text-anchor="{text_anchor}"',
1385 'xml:space="preserve"',
1386 ]
1387 if run is None:
1388 return " " + " ".join(parts)
1389 parts.append(f'font-family="{run.font_family}"')
1390 parts.append(f'font-size="{fmt_num(run.font_size_px)}"')
1391 parts.append(f'fill="{run.fill}"')
1392 if run.fill_opacity < 1.0:
1393 parts.append(
1394 f'fill-opacity="{format_ooxml_alpha(run.fill_opacity)}"'
1395 )
1396 if run.bold:
1397 parts.append('font-weight="bold"')
1398 if run.italic:
1399 parts.append('font-style="italic"')
1400 if run.underline and run.strikethrough:
1401 parts.append('text-decoration="underline line-through"')
1402 elif run.underline:
1403 parts.append('text-decoration="underline"')
1404 elif run.strikethrough:
1405 parts.append('text-decoration="line-through"')
1406 if run.letter_spacing_px:
1407 parts.append(f'letter-spacing="{fmt_num(run.letter_spacing_px)}"')
1408 return " " + " ".join(parts)
1409
1410
1411 def _run_tspan_attrs(run: TextRun) -> str:
1412 """Per-run overrides on a <tspan>. Only emit attributes that differ from
1413 the run that drove the parent <text> (we keep things simple: emit only
1414 overrides that can plausibly change run-to-run, never re-emit common
1415 defaults). For v1 we always emit fill, font family, and font size so each
1416 imported run keeps its resolved typeface even when adjacent runs differ.
1417 """
1418 parts = [
1419 f'fill="{run.fill}"',
1420 f'font-family="{run.font_family}"',
1421 f'font-size="{fmt_num(run.font_size_px)}"',
1422 ]
1423 if run.fill_opacity < 1.0:
1424 parts.append(
1425 f'fill-opacity="{format_ooxml_alpha(run.fill_opacity)}"'
1426 )
1427 if run.bold:
1428 parts.append('font-weight="bold"')
1429 if run.italic:
1430 parts.append('font-style="italic"')
1431 if run.underline and run.strikethrough:
1432 parts.append('text-decoration="underline line-through"')
1433 elif run.underline:
1434 parts.append('text-decoration="underline"')
1435 elif run.strikethrough:
1436 parts.append('text-decoration="line-through"')
1437 if run.letter_spacing_px:
1438 parts.append(f'letter-spacing="{fmt_num(run.letter_spacing_px)}"')
1439 return " " + " ".join(parts)
1440
1441
1442 def _run_tspan_markup(run: TextRun) -> str:
1443 formula_attr = ""
1444 if run.formula_latex is not None:
1445 formula_attr = (
1446 ' data-pptx-inline-formula="'
1447 + _xml_escape(run.formula_latex)
1448 + '"'
1449 )
1450 return (
1451 f"<tspan{_run_tspan_attrs(run)}{formula_attr}>"
1452 f"{_xml_escape(run.text)}</tspan>"
1453 )
1454
1455
1456 def _wrap_run_hyperlink(markup: str, run: TextRun) -> str:
1457 """Wrap one visible SVG run in the canonical hyperlink carrier."""
1458 if not run.hyperlink_href:
1459 return markup
1460 return f'<a href="{_xml_escape(run.hyperlink_href)}">{markup}</a>'
1461
1462
1463 def _xml_escape(text: str) -> str:
1464 return (text.replace("&", "&amp;")
1465 .replace("<", "&lt;")
1466 .replace(">", "&gt;")
1467 .replace('"', "&quot;"))
1468
1468 lines PYTHON