| 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 (") 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""{name}"" |
| 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("&", "&") |
| 1323 | .replace("<", "<") |
| 1324 | .replace(">", ">") |
| 1325 | .replace('"', """)) |
| 1326 |