| 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 (") 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""{name}"" |
| 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("&", "&") |
| 1465 | .replace("<", "<") |
| 1466 | .replace(">", ">") |
| 1467 | .replace('"', """)) |
| 1468 |