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