"""DrawingML -> SVG conversion. Reverse of svg_to_pptx/drawingml/elements.py convert_text. Strategy (v1): - Each paragraph emits one element (one line of baseline). Multiple runs in one paragraph become s sharing the text element's x. - Vertical layout: y of first paragraph is determined by anchor (t/ctr/b) and tIns/bIns. Subsequent paragraphs stack downward with line height derived from the largest font in the paragraph * 1.2 (default leading). - Horizontal layout: text-anchor follows pPr@algn. x is computed from the text frame plus lIns/rIns and the alignment. - No automatic word wrap (PPT's wrap is layout-time; v1 trusts the existing text frame width and emits text as-is). a:br produces an explicit linebreak. - Bullet points (a:buChar / a:buAutoNum) are rendered as literal prefixes so the visual lands without relying on PowerPoint list semantics. Color / font / size attributes propagate from a:rPr; missing attributes fall back to paragraph/list defaults, endParaRPr, or spec-default values. """ from __future__ import annotations from dataclasses import dataclass, field from xml.etree import ElementTree as ET from svg_to_pptx.drawingml.utils import detect_text_lang, is_cjk_char from .color_resolver import ColorPalette, find_color_elem, resolve_color from .emu_units import ( NS, Xfrm, fmt_num, emu_to_px, format_ooxml_alpha, hundredths_pt_to_px, ) from .fill_to_svg import resolve_fill # --------------------------------------------------------------------------- # Defaults (matches DrawingML spec) # --------------------------------------------------------------------------- # Default body insets when bodyPr omits them: 0.1 inch left/right, 0.05 top/bot. DEFAULT_INSETS_EMU = {"l": 91440, "t": 45720, "r": 91440, "b": 45720} # Default font size = 1800 (= 18 pt = 24 px). Spec is actually 1800 (18pt). DEFAULT_FONT_SIZE_PX = 24.0 DEFAULT_LINE_HEIGHT_RATIO = 1.2 # leading multiplier DEFAULT_FILL_HEX = "#000000" @dataclass class TextRun: """A single run with resolved style + text.""" text: str font_size_px: float font_family: str # full font-family stack (latin, ea fallback joined) fill: str fill_opacity: float = 1.0 defs: list[str] = field(default_factory=list) bold: bool = False italic: bool = False underline: bool = False strikethrough: bool = False letter_spacing_px: float = 0.0 is_break: bool = False # marks an a:br within a paragraph @dataclass class TextParagraph: """One : a list of runs sharing alignment + level.""" runs: list[TextRun] = field(default_factory=list) align: str = "l" # l / ctr / r / just / dist level: int = 0 indent_px: float = 0.0 margin_left_px: float = 0.0 line_height_ratio: float = DEFAULT_LINE_HEIGHT_RATIO space_before_px: float = 0.0 space_after_px: float = 0.0 empty_line_font_size_px: float = DEFAULT_FONT_SIZE_PX bullet_prefix: str = "" # rendered prefix like '• ' or '1. ' @dataclass class TextResult: """Resolved text body ready for SVG emission. `svg` is one or more elements, already absolutely positioned inside the slide coordinate system. `defs` holds text gradient fills. """ svg: str = "" defs: list[str] = field(default_factory=list) VERTICAL_TEXT_MODES = {"eaVert", "vert", "wordArtVert", "wordArtVertRtl"} # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def convert_txbody( tx_body: ET.Element | None, xfrm: Xfrm, palette: ColorPalette | None, *, theme_fonts: dict[str, str] | None = None, slide_number: int | None = None, default_fill: str = DEFAULT_FILL_HEX, default_font_size_px: float = DEFAULT_FONT_SIZE_PX, fallback_lst_styles: tuple[ET.Element, ...] = (), fallback_run_props: tuple[ET.Element, ...] = (), id_prefix: str = "txt", id_seq: list[int] | None = None, ) -> TextResult: """Convert under the given shape geometry to SVG (s).""" if tx_body is None: return TextResult() body_pr = tx_body.find("a:bodyPr", NS) paragraphs = _parse_paragraphs( tx_body, palette, theme_fonts or {}, default_fill=default_fill, default_font_size_px=default_font_size_px, fallback_lst_styles=fallback_lst_styles, fallback_run_props=fallback_run_props, slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq, ) if not paragraphs or not _has_visible_text(paragraphs): return TextResult() # Insets + anchor + wrap lins = _read_emu_attr(body_pr, "lIns", DEFAULT_INSETS_EMU["l"]) tins = _read_emu_attr(body_pr, "tIns", DEFAULT_INSETS_EMU["t"]) rins = _read_emu_attr(body_pr, "rIns", DEFAULT_INSETS_EMU["r"]) bins = _read_emu_attr(body_pr, "bIns", DEFAULT_INSETS_EMU["b"]) anchor = body_pr.attrib.get("anchor", "t") if body_pr is not None else "t" wrap_mode = body_pr.attrib.get("wrap", "square") if body_pr is not None else "square" respect_edge_spacing = ( body_pr is not None and body_pr.attrib.get("spcFirstLastPara") in {"1", "true"} ) inner_x = xfrm.x + lins inner_y = xfrm.y + tins inner_w = max(xfrm.w - lins - rins, 1.0) inner_h = max(xfrm.h - tins - bins, 1.0) # Pre-wrap each paragraph into concrete display lines. wrap_width = inner_w if wrap_mode == "square" else float("inf") para_lines: list[list[list[TextRun]]] = [ _wrap_paragraph_into_lines(p, wrap_width) for p in paragraphs ] # Pre-compute heights to support anchor=ctr / b para_heights = [ _paragraph_height_from_lines(p, lines) for p, lines in zip(paragraphs, para_lines) ] space_before = [paragraph.space_before_px for paragraph in paragraphs] space_after = [paragraph.space_after_px for paragraph in paragraphs] if not respect_edge_spacing: space_before[0] = 0.0 space_after[-1] = 0.0 total_h = sum( before + height + after for before, height, after in zip( space_before, para_heights, space_after, ) ) if anchor == "ctr": cursor_y = inner_y + max(0.0, (inner_h - total_h) / 2.0) elif anchor == "b": cursor_y = inner_y + max(0.0, inner_h - total_h) else: cursor_y = inner_y bottom_y = inner_y + inner_h text_blocks: list[str] = [] for para, lines, height, before, after in zip( paragraphs, para_lines, para_heights, space_before, space_after, ): cursor_y += before visible_lines = _clip_lines_to_bottom(para, lines, cursor_y, bottom_y) if visible_lines: text_blocks.append( _emit_paragraph(para, visible_lines, inner_x, inner_w, cursor_y) ) cursor_y += height + after if cursor_y >= bottom_y: break return TextResult(svg="\n".join(text_blocks), defs=_collect_text_defs(paragraphs)) def is_vertical_txbody(tx_body: ET.Element | None, xfrm: Xfrm | None = None) -> bool: if tx_body is None: return False body_pr = tx_body.find("a:bodyPr", NS) if body_pr is None: return False if body_pr.attrib.get("vert") in VERTICAL_TEXT_MODES: return True return _looks_like_auto_stacked_cjk(tx_body, body_pr, xfrm) def convert_vertical_txbody( tx_body: ET.Element | None, xfrm: Xfrm, palette: ColorPalette | None, *, theme_fonts: dict[str, str] | None = None, slide_number: int | None = None, default_fill: str = DEFAULT_FILL_HEX, default_font_size_px: float = DEFAULT_FONT_SIZE_PX, fallback_lst_styles: tuple[ET.Element, ...] = (), fallback_run_props: tuple[ET.Element, ...] = (), id_prefix: str = "txt", id_seq: list[int] | None = None, ) -> TextResult: """Render East Asian vertical text as upright stacked glyphs. PowerPoint often combines ``bodyPr@vert=eaVert`` with a rotated text box. Rendering the text inside the rotated shape group makes Chinese glyphs lie sideways. This helper computes the final rotated box and places glyphs upright in slide coordinates. """ if tx_body is None: return TextResult() paragraphs = _parse_paragraphs( tx_body, palette, theme_fonts or {}, default_fill=default_fill, default_font_size_px=default_font_size_px, fallback_lst_styles=fallback_lst_styles, fallback_run_props=fallback_run_props, slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq, ) runs = [ run for para in paragraphs for run in para.runs if not run.is_break and run.text ] if not runs: return TextResult() box_x, box_y, box_w, box_h = _rotated_bbox(xfrm) center_x = box_x + box_w / 2.0 glyphs: list[tuple[str, TextRun]] = [] for run in runs: for char in run.text: glyphs.append((" " if char in "\t\r\n" else char, run)) if not glyphs: return TextResult() advances = [glyph_run.font_size_px * 1.05 for _, glyph_run in glyphs] total_h = sum(advances) top_y = box_y + max(0.0, (box_h - total_h) / 2.0) bottom_y = box_y + box_h spans: list[str] = [] cursor_y = top_y first_run: TextRun | None = None first_baseline: float | None = None previous_baseline: float | None = None for (char, run), advance in zip(glyphs, advances): if cursor_y + advance > bottom_y: break baseline_y = cursor_y + run.font_size_px * 0.85 tspan_attrs = _run_tspan_attrs(run) if first_run is None: first_run = run first_baseline = baseline_y spans.append(f"{_xml_escape(char)}") else: dy = baseline_y - (previous_baseline or baseline_y) spans.append( f'{_xml_escape(char)}" ) previous_baseline = baseline_y cursor_y += advance if first_run is None or first_baseline is None: return TextResult() attrs = _text_base_attrs(first_run, center_x, first_baseline, "middle") return TextResult( svg=f"{''.join(spans)}", defs=_collect_text_defs(paragraphs), ) def _rotated_bbox(xfrm: Xfrm) -> tuple[float, float, float, float]: rot = round(xfrm.rot) % 360 cx = xfrm.x + xfrm.w / 2.0 cy = xfrm.y + xfrm.h / 2.0 if rot in (90, 270): return cx - xfrm.h / 2.0, cy - xfrm.w / 2.0, xfrm.h, xfrm.w return xfrm.x, xfrm.y, xfrm.w, xfrm.h def _looks_like_auto_stacked_cjk( tx_body: ET.Element, body_pr: ET.Element, xfrm: Xfrm | None, ) -> bool: """Detect PowerPoint's narrow-box CJK vertical layout without vert=eaVert.""" if xfrm is None or xfrm.w <= 0 or xfrm.h <= 0: return False if body_pr.attrib.get("wrap", "square") != "square": return False if xfrm.w > 64 or xfrm.h < xfrm.w * 2.4: return False text = _plain_text(tx_body) chars = [ch for ch in text if not ch.isspace()] if len(chars) < 3 or len(chars) > 16: return False cjk_count = sum(1 for ch in chars if _is_cjk(ch)) if cjk_count / len(chars) < 0.8: return False lins = _read_emu_attr(body_pr, "lIns", DEFAULT_INSETS_EMU["l"]) rins = _read_emu_attr(body_pr, "rIns", DEFAULT_INSETS_EMU["r"]) inner_w = max(xfrm.w - lins - rins, 1.0) return inner_w <= DEFAULT_FONT_SIZE_PX def _plain_text(tx_body: ET.Element) -> str: """Return concatenated literal text for layout heuristics.""" parts: list[str] = [] for text_elem in tx_body.findall(".//a:t", NS): if text_elem.text: parts.append(text_elem.text) return "".join(parts) # --------------------------------------------------------------------------- # Parsing helpers # --------------------------------------------------------------------------- def _read_emu_attr(elem: ET.Element | None, attr: str, default_emu: int) -> float: """Read an EMU integer attribute and return px.""" if elem is None: return emu_to_px(default_emu) val = elem.attrib.get(attr) if val is None: return emu_to_px(default_emu) try: return emu_to_px(int(val)) except ValueError: return emu_to_px(default_emu) def _parse_paragraphs( tx_body: ET.Element, palette: ColorPalette | None, theme_fonts: dict[str, str], *, default_fill: str = DEFAULT_FILL_HEX, default_font_size_px: float = DEFAULT_FONT_SIZE_PX, fallback_lst_styles: tuple[ET.Element, ...] = (), fallback_run_props: tuple[ET.Element, ...] = (), slide_number: int | None = None, id_prefix: str = "txt", id_seq: list[int] | None = None, ) -> list[TextParagraph]: """Walk children producing TextParagraph objects.""" paragraphs: list[TextParagraph] = [] autonum_state: dict[int, int] = {} lst_style = tx_body.find("a:lstStyle", NS) lst_styles = ( (lst_style,) + fallback_lst_styles if lst_style is not None else fallback_lst_styles ) for p_elem in tx_body.findall("a:p", NS): para = _parse_paragraph( p_elem, palette, theme_fonts, autonum_state, lst_styles=lst_styles, fallback_run_props=fallback_run_props, default_fill=default_fill, default_font_size_px=default_font_size_px, slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq, ) paragraphs.append(para) return paragraphs def _parse_paragraph( p_elem: ET.Element, palette: ColorPalette | None, theme_fonts: dict[str, str], autonum_state: dict[int, int], *, lst_styles: tuple[ET.Element, ...] = (), fallback_run_props: tuple[ET.Element, ...] = (), default_fill: str = DEFAULT_FILL_HEX, default_font_size_px: float = DEFAULT_FONT_SIZE_PX, slide_number: int | None = None, id_prefix: str = "txt", id_seq: list[int] | None = None, ) -> TextParagraph: para = TextParagraph() p_pr = p_elem.find("a:pPr", NS) if p_pr is not None: try: para.level = int(p_pr.attrib.get("lvl", "0")) except ValueError: para.level = 0 para_style_chain = (p_pr,) + _lst_style_level_prs(lst_styles, para.level) para.align = _attr_chain(para_style_chain, "algn") or "l" para.margin_left_px = _emu_px_attr_chain(para_style_chain, "marL", 0.0) para.indent_px = _emu_px_attr_chain(para_style_chain, "indent", 0.0) para.line_height_ratio = _line_height_ratio(para_style_chain) para.space_before_px = _spacing_points_px(para_style_chain, "a:spcBef/a:spcPts") para.space_after_px = _spacing_points_px(para_style_chain, "a:spcAft/a:spcPts") para.bullet_prefix = _resolve_bullet_prefix( para_style_chain, para.level, autonum_state, ) # Default endParaRPr style (applies if a run has no rPr) end_rpr = p_elem.find("a:endParaRPr", NS) # defRPr from pPr and txBody/lstStyle, both optional. def_rpr = p_pr.find("a:defRPr", NS) if p_pr is not None else None list_def_rpr = _child_chain(para_style_chain[1:], "a:defRPr") para.empty_line_font_size_px = _font_size_px( (end_rpr, def_rpr, list_def_rpr) + fallback_run_props, default_font_size_px, ) def resolved_run(text: str, rpr: ET.Element | None) -> TextRun: return _build_run( text, rpr, end_rpr, palette, theme_fonts, def_rpr=def_rpr, list_def_rpr=list_def_rpr, fallback_run_props=fallback_run_props, default_fill=default_fill, default_font_size_px=default_font_size_px, id_prefix=id_prefix, id_seq=id_seq, ) for child in list(p_elem): if not isinstance(child.tag, str): continue local = child.tag.split("}", 1)[-1] if local == "r": rpr = child.find("a:rPr", NS) text_elem = child.find("a:t", NS) text = text_elem.text or "" if text_elem is not None else "" para.runs.append(resolved_run(text, rpr)) elif local == "br": break_rpr = child.find("a:rPr", NS) para.runs.append(TextRun( text="", font_size_px=_font_size_px( (break_rpr, def_rpr, list_def_rpr, end_rpr) + fallback_run_props, default_font_size_px, ), font_family="sans-serif", fill=default_fill, is_break=True, )) elif local == "fld": # Slide SVGs have a concrete page context, so resolve slide-number # fields there. Standalone master/layout renders keep the literal # fallback because one shared part can serve many slide numbers. rpr = child.find("a:rPr", NS) text_elem = child.find("a:t", NS) text = text_elem.text or "" if text_elem is not None else "" field_type = child.attrib.get("type", "").strip().lower() if field_type == "slidenum" and slide_number is not None: text = str(slide_number) if text: para.runs.append(resolved_run(text, rpr)) return para def _font_size_px( sources: tuple[ET.Element | None, ...], default_font_size_px: float, ) -> float: """Resolve one effective DrawingML run size into SVG pixels.""" return hundredths_pt_to_px( _attr_chain(sources, "sz"), default_font_size_px, ) def _build_run( text: str, rpr: ET.Element | None, end_rpr: ET.Element | None, palette: ColorPalette | None, theme_fonts: dict[str, str], *, def_rpr: ET.Element | None = None, list_def_rpr: ET.Element | None = None, fallback_run_props: tuple[ET.Element, ...] = (), default_fill: str = DEFAULT_FILL_HEX, default_font_size_px: float = DEFAULT_FONT_SIZE_PX, id_prefix: str = "txt", id_seq: list[int] | None = None, ) -> TextRun: """Resolve a single run from its rPr and fallback run properties.""" style_chain = ( rpr, def_rpr, list_def_rpr, end_rpr, ) + fallback_run_props # font-size: rPr > pPr/defRPr > lstStyle/lvlNpPr/defRPr > endParaRPr > default font_size_px = _font_size_px(style_chain, default_font_size_px) # Bold / italic bold = _attr_chain(style_chain, "b") == "1" italic = _attr_chain(style_chain, "i") == "1" # Underline / strike u_val = _attr_chain(style_chain, "u") underline = u_val not in (None, "", "none") strike_val = _attr_chain(style_chain, "strike") strikethrough = strike_val in ("sngStrike", "dblStrike") # Letter spacing (rPr@spc, in 1/100 pt) spc = _attr_chain(style_chain, "spc") letter_spacing_px = 0.0 if spc is not None: try: letter_spacing_px = float(spc) / 100.0 * 4.0 / 3.0 # pt -> px except ValueError: pass # Color fill = default_fill fill_opacity = 1.0 defs: list[str] = [] color_source = None for src in style_chain: if src is None: continue grad = src.find("a:gradFill", NS) if grad is not None: grad_fill = resolve_fill( grad, palette, id_prefix=id_prefix, id_seq=id_seq, ) if grad_fill.attrs.get("fill"): fill = grad_fill.attrs["fill"] fill_opacity = 1.0 defs.extend(grad_fill.defs) color_source = None break solid = src.find("a:solidFill", NS) if solid is not None: color_source = solid break if color_source is not None: color_elem = find_color_elem(color_source) hex_, alpha = resolve_color(color_elem, palette) if hex_: fill = hex_ fill_opacity = alpha # Font typeface latin_face = _typeface_chain(style_chain, "latin") ea_face = _typeface_chain(style_chain, "ea") cs_face = _typeface_chain(style_chain, "cs") lang = _attr_chain(style_chain, "lang") alt_lang = _attr_chain(style_chain, "altLang") # Resolve theme refs (e.g. typeface="+mn-lt" / "+mj-ea") latin_face = _resolve_theme_typeface( latin_face, theme_fonts, text=text, lang=lang, alt_lang=alt_lang, ) ea_face = _resolve_theme_typeface( ea_face, theme_fonts, text=text, lang=lang, alt_lang=alt_lang, ) cs_face = _resolve_theme_typeface( cs_face, theme_fonts, text=text, lang=lang, alt_lang=alt_lang, ) font_family = _build_font_stack(latin_face, ea_face, cs_face) return TextRun( text=text, font_size_px=font_size_px, font_family=font_family, fill=fill, fill_opacity=fill_opacity, defs=defs, bold=bold, italic=italic, underline=underline, strikethrough=strikethrough, letter_spacing_px=letter_spacing_px, ) def _lst_style_level_prs( lst_styles: tuple[ET.Element, ...], level: int, ) -> tuple[ET.Element, ...]: """Return txBody/lstStyle paragraph properties for a paragraph level.""" level_idx = min(max(level, 0), 8) + 1 level_prs: list[ET.Element] = [] for lst_style in lst_styles: lvl_pr = lst_style.find(f"a:lvl{level_idx}pPr", NS) if lvl_pr is not None: level_prs.append(lvl_pr) return tuple(level_prs) def _child_chain( sources: tuple[ET.Element | None, ...], path: str, ) -> ET.Element | None: for src in sources: if src is None: continue child = src.find(path, NS) if child is not None: return child return None def _emu_px_attr_chain( sources: tuple[ET.Element | None, ...], attr: str, default: float, ) -> float: value = _attr_chain(sources, attr) if value is None: return default try: return emu_to_px(int(value)) except ValueError: return default def _line_height_ratio(sources: tuple[ET.Element | None, ...]) -> float: ln_spc = _child_chain(sources, "a:lnSpc") if ln_spc is None: return DEFAULT_LINE_HEIGHT_RATIO spc_pct = ln_spc.find("a:spcPct", NS) if spc_pct is None: return DEFAULT_LINE_HEIGHT_RATIO try: return float(spc_pct.attrib.get("val", "100000")) / 100000.0 except ValueError: return DEFAULT_LINE_HEIGHT_RATIO def _spacing_points_px( sources: tuple[ET.Element | None, ...], path: str, ) -> float: spacing = _child_chain(sources, path) if spacing is None: return 0.0 try: return hundredths_pt_to_px(int(spacing.attrib.get("val", "0"))) except ValueError: return 0.0 def _attr_chain(sources: tuple[ET.Element | None, ...], attr: str) -> str | None: """Return the first non-empty value of `attr` from any source element.""" for src in sources: if src is None: continue v = src.attrib.get(attr) if v is not None: return v return None def _typeface(rpr: ET.Element | None, child_tag: str) -> str | None: if rpr is None: return None elem = rpr.find(f"a:{child_tag}", NS) if elem is None: return None val = elem.attrib.get("typeface") return val or None def _typeface_chain( sources: tuple[ET.Element | None, ...], child_tag: str, ) -> str | None: for src in sources: face = _typeface(src, child_tag) if face: return face return None def _theme_script_from_lang(lang: str | None) -> str | None: """Map a DrawingML language tag to one theme supplemental-script key.""" if not lang: return None normalized = lang.strip().replace("_", "-").lower() if not normalized: return None parts = normalized.split("-") primary = parts[0] if primary == "ja": return "Jpan" if primary == "ko": return "Hang" if primary != "zh": return None if any(part in {"hant", "cht", "tw", "hk", "mo"} for part in parts[1:]): return "Hant" return "Hans" def _theme_script_from_text(text: str) -> str | None: """Infer a CJK theme script from glyph ranges, defaulting plain Han to Hans.""" if any( 0x3100 <= ord(char) <= 0x312F or 0x31A0 <= ord(char) <= 0x31BF for char in text ): return "Hant" return { "ko-KR": "Hang", "ja-JP": "Jpan", "zh-CN": "Hans", }.get(detect_text_lang(text)) def _run_theme_script( text: str, lang: str | None, alt_lang: str | None, ) -> str | None: """Resolve EA script from run language first, then alternate language/text.""" return ( _theme_script_from_lang(lang) or _theme_script_from_lang(alt_lang) or _theme_script_from_text(text) ) def _resolve_theme_typeface( face: str | None, theme_fonts: dict[str, str], *, text: str = "", lang: str | None = None, alt_lang: str | None = None, ) -> str | None: """Resolve DrawingML major/minor Latin, EA, and complex-script tokens.""" if not face or not face.startswith("+"): return face code = face[1:] if code == "mj-lt": return theme_fonts.get("majorLatin") or face if code == "mn-lt": return theme_fonts.get("minorLatin") or face if code in {"mj-ea", "mn-ea"}: prefix = "major" if code.startswith("mj") else "minor" script = _run_theme_script(text, lang, alt_lang) script_face = ( theme_fonts.get(f"{prefix}Script{script}") if script is not None else None ) return ( theme_fonts.get(f"{prefix}EastAsia") or script_face or theme_fonts.get(f"{prefix}Latin") or face ) if code == "mj-cs": return ( theme_fonts.get("majorComplexScript") or theme_fonts.get("majorLatin") or face ) if code == "mn-cs": return ( theme_fonts.get("minorComplexScript") or theme_fonts.get("minorLatin") or face ) return face def _build_font_stack(latin: str | None, ea: str | None, cs: str | None) -> str: """Build a CSS font-family stack: original PPT names first, then fallbacks.""" parts: list[str] = [] seen: set[str] = set() for face in (latin, ea, cs): if face and face not in seen: parts.append(_quote_font(face)) seen.add(face) # Generic fallback so the browser can render even if PPT fonts are absent. parts.append("sans-serif") return ", ".join(parts) def _quote_font(name: str) -> str: """Quote a font name if it contains spaces or non-ASCII chars. Uses XML entity-escaped double quotes (") so the resulting CSS string survives being embedded inside an SVG attribute that itself uses double quotes. CSS parsers accept the unescaped form after attribute parsing. """ if any(c.isspace() or ord(c) > 127 for c in name): return f""{name}"" return name def _resolve_bullet_prefix( sources: tuple[ET.Element | None, ...], level: int, autonum_state: dict[int, int], ) -> str: """Render bullet glyphs / numbering as a literal text prefix.""" bu_none = _child_chain(sources, "a:buNone") if bu_none is not None: autonum_state.pop(level, None) return "" bu_char = _child_chain(sources, "a:buChar") if bu_char is not None: ch = bu_char.attrib.get("char", "•") return f"{ch} " bu_auto = _child_chain(sources, "a:buAutoNum") if bu_auto is not None: start_at = bu_auto.attrib.get("startAt") if start_at is not None: try: autonum_state[level] = int(start_at) except ValueError: autonum_state[level] = 1 else: autonum_state[level] = autonum_state.get(level, 0) + 1 return _format_auto_number( autonum_state[level], bu_auto.attrib.get("type", "arabicPeriod"), ) return "" def _format_auto_number(value: int, kind: str) -> str: lower = kind.lower() if "alphalc" in lower: token = _alpha_number(value, uppercase=False) elif "alphauc" in lower: token = _alpha_number(value, uppercase=True) elif "romanlc" in lower: token = _roman_number(value).lower() elif "romanuc" in lower: token = _roman_number(value).upper() else: token = str(value) if "parenboth" in lower: return f"({token}) " if "parenr" in lower: return f"{token}) " if "period" in lower: return f"{token}. " return f"{token} " def _alpha_number(value: int, *, uppercase: bool) -> str: value = max(1, value) chars: list[str] = [] while value: value -= 1 chars.append(chr(ord("A") + (value % 26))) value //= 26 text = "".join(reversed(chars)) return text if uppercase else text.lower() def _roman_number(value: int) -> str: value = max(1, min(value, 3999)) parts: list[str] = [] for n, token in ( (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"), ): while value >= n: parts.append(token) value -= n return "".join(parts) # --------------------------------------------------------------------------- # Layout / emission # --------------------------------------------------------------------------- def _has_visible_text(paragraphs: list[TextParagraph]) -> bool: for p in paragraphs: for r in p.runs: if r.text.strip(): return True return False def _collect_text_defs(paragraphs: list[TextParagraph]) -> list[str]: """Return unique text fill defs referenced by parsed runs.""" defs: list[str] = [] seen: set[str] = set() for para in paragraphs: for run in para.runs: for item in run.defs: if item not in seen: defs.append(item) seen.add(item) return defs # --------------------------------------------------------------------------- # Word-wrap / text measurement # --------------------------------------------------------------------------- def _is_cjk(ch: str) -> bool: """Check if a character is CJK (Chinese/Japanese/Korean) or full-width.""" return is_cjk_char(ch) def _char_width(ch: str, font_size: float, bold: bool) -> float: """Estimate a single character's rendered width in pixels. Mirrors svg_to_pptx/drawingml/utils.py estimate_text_width so wrapping breaks align with the same heuristic used to estimate text-box sizes elsewhere. """ if _is_cjk(ch): w = font_size # CJK is approximately 1em per glyph elif ch == ' ': w = font_size * 0.3 elif ch in 'mMwWOQ%': w = font_size * 0.75 elif ch in 'iIlj!|': w = font_size * 0.3 elif ch.isdigit(): # digits are tabular (uniform ~0.55em) in most UI fonts, including # '1' — classing it with 'il|' under-sizes the width and makes # renderers that ignore wrap="none" (LibreOffice) wrap the line w = font_size * 0.55 else: w = font_size * 0.55 # Bold Latin generally expands a little. CJK glyphs keep their em advance # in common PPT fonts; applying the bold multiplier causes short Chinese # titles such as "少年强国说" to wrap even though PowerPoint keeps them on # one line. if bold and not _is_cjk(ch): w *= 1.05 return w def _estimate_run_width(text: str, run: TextRun) -> float: glyph_width = sum(_char_width(c, run.font_size_px, run.bold) for c in text) tracking_width = run.letter_spacing_px * max(len(text) - 1, 0) return (glyph_width + tracking_width) * 1.05 def _advance_width(ch: str, index_in_segment: int, run: TextRun) -> float: """Return the width added by one character inside a measured line segment.""" tracking = run.letter_spacing_px if index_in_segment > 0 else 0.0 return _char_width(ch, run.font_size_px, run.bold) + tracking def _find_break_point( text: str, start: int, max_width: float, run: TextRun, ) -> tuple[int, float]: """Find the longest prefix of text[start:] that fits in max_width. Returns (end_index, used_width). Prefers breaking after whitespace, after CJK characters, or after hyphens. If even the first character doesn't fit, returns (start, 0.0) — the caller should flush the current line first. """ cur_w = 0.0 last_break = start last_break_w = 0.0 for i in range(start, len(text)): ch = text[i] ch_w = _advance_width(ch, i - start, run) if cur_w + ch_w > max_width: if last_break > start: return last_break, last_break_w return start, 0.0 cur_w += ch_w # Update last_break point if ch.isspace() or _is_cjk(ch) or ch in "-—、,。!?:;": last_break = i + 1 last_break_w = cur_w # Whole rest fits return len(text), cur_w def _wrap_paragraph_into_lines( para: TextParagraph, max_width: float, ) -> list[list[TextRun]]: """Split a paragraph's runs into display lines respecting `max_width`. Each line is a list of (possibly truncated) TextRuns. Explicit a:br runs force a new line. When max_width is +inf the original runs are returned unchanged (one logical line per a:br segment). Bullet prefix is prepended to the first non-empty run if present. """ lines: list[list[TextRun]] = [[]] cur_w = 0.0 if _should_keep_single_line(para, max_width): return [[_copy_run(run, text=run.text) for run in para.runs if not run.is_break and run.text]] if para.bullet_prefix and para.runs: first_run = next((r for r in para.runs if not r.is_break), None) if first_run is not None: bullet_run = _copy_run(first_run, text=para.bullet_prefix) lines[-1].append(bullet_run) cur_w = _estimate_run_width(para.bullet_prefix, bullet_run) for run in para.runs: if run.is_break: # Keep the break on the line it terminates so consecutive breaks # form a break-only empty line. Visible text owns a non-empty # line's height; the break rPr owns only that empty line. lines[-1].append(run) lines.append([]) cur_w = 0.0 continue if not run.text: continue text = run.text i = 0 while i < len(text): avail = max_width - cur_w if avail <= 0 and lines[-1]: # Line is full; start a new one lines.append([]) cur_w = 0.0 avail = max_width end, used = _find_break_point(text, i, avail, run) if end == i: # Nothing fits even from a fresh line — force one char to avoid # an infinite loop. if lines[-1]: lines.append([]) cur_w = 0.0 continue end = i + 1 used = _advance_width(text[i], 0, run) chunk = text[i:end] lines[-1].append(_copy_run(run, text=chunk)) cur_w += used i = end if i < len(text): # More to render — wrap to next line lines.append([]) cur_w = 0.0 return lines def _should_keep_single_line(para: TextParagraph, max_width: float) -> bool: if max_width == float("inf") or para.bullet_prefix: return False if any(run.is_break for run in para.runs): return False text_runs = [run for run in para.runs if run.text] if not text_runs: return False text = "".join(run.text for run in text_runs) non_space_count = sum(1 for ch in text if not ch.isspace()) # Short labels/titles are usually intentionally single-line in PPT. Let # them overflow slightly rather than inventing a line break from imperfect # font metrics or alignment spaces. if non_space_count <= 18: return True estimated = sum(_estimate_run_width(run.text, run) for run in text_runs) return estimated <= max_width * 1.12 def _copy_run(run: TextRun, *, text: str) -> TextRun: return TextRun( text=text, font_size_px=run.font_size_px, font_family=run.font_family, fill=run.fill, fill_opacity=run.fill_opacity, defs=list(run.defs), bold=run.bold, italic=run.italic, underline=run.underline, strikethrough=run.strikethrough, letter_spacing_px=run.letter_spacing_px, ) def _paragraph_height_from_lines(p: TextParagraph, lines: list[list[TextRun]]) -> float: """Total px height after wrapping. Each line uses its own max font size.""" if not lines: return p.empty_line_font_size_px * p.line_height_ratio height = 0.0 for line in lines: height += _line_height(p, line) return height def _line_height(p: TextParagraph, line: list[TextRun]) -> float: return _line_font_size(p, line) * p.line_height_ratio def _line_font_size(p: TextParagraph, line: list[TextRun]) -> float: visible_sizes = [ run.font_size_px for run in line if not run.is_break and run.text ] if visible_sizes: return max(visible_sizes) break_sizes = [run.font_size_px for run in line if run.is_break] return max(break_sizes, default=p.empty_line_font_size_px) def _clip_lines_to_bottom( para: TextParagraph, lines: list[list[TextRun]], top_y: float, bottom_y: float, ) -> list[list[TextRun]]: """Return the leading display lines whose line boxes fit in the text frame.""" visible: list[list[TextRun]] = [] cursor_y = top_y for line in lines: line_h = _line_height(para, line) # PowerPoint lets the first line that starts within the box render even # when it slightly exceeds the bottom — only suppress lines whose top # is already at/below the bottom edge. if cursor_y >= bottom_y: break visible.append(line) cursor_y += line_h return visible def _paragraph_height(p: TextParagraph) -> float: """Legacy helper kept for callers that don't pre-wrap (currently unused).""" lines = _wrap_paragraph_into_lines(p, float("inf")) return _paragraph_height_from_lines(p, lines) def _emit_paragraph( para: TextParagraph, lines: list[list[TextRun]], inner_x: float, inner_w: float, top_y: float, ) -> str: """Render a paragraph (already split into lines) as one element. Each pre-wrapped display line becomes a sequence of s: the first tspan on a line carries the explicit x and dy (line-height advance); subsequent tspans on the same line inherit x. """ align = para.align if align == "ctr": anchor_x = inner_x + inner_w / 2.0 text_anchor = "middle" elif align == "r": anchor_x = inner_x + inner_w text_anchor = "end" else: # 'l' / 'just' / 'dist' / unknown anchor_x = inner_x + para.indent_px + para.margin_left_px text_anchor = "start" if not lines: return "" visible_lines = [ [run for run in line if not run.is_break and run.text] for line in lines ] first_line_idx = next( (index for index, line in enumerate(visible_lines) if line), None, ) if first_line_idx is None: return "" first_run = visible_lines[first_line_idx][0] first_baseline = top_y + 0.85 * first_run.font_size_px spans: list[str] = [] for line_idx, line in enumerate(visible_lines): line_advance = None if line_idx > 0: height_line = ( lines[line_idx - 1] if line_idx <= first_line_idx else lines[line_idx] ) line_advance = _line_height(para, height_line) if not line: if line_advance is not None: spans.append( f'' ) continue for run_idx, run in enumerate(line): attrs = _run_tspan_attrs(run) if run_idx == 0 and line_advance is not None: spans.append( f'{_xml_escape(run.text)}' ) else: spans.append( f"{_xml_escape(run.text)}" ) base_attrs = _text_base_attrs(first_run, anchor_x, first_baseline, text_anchor) return f"{''.join(spans)}" def _text_base_attrs(run: TextRun | None, x: float, y: float, text_anchor: str) -> str: parts = [ f'x="{fmt_num(x)}"', f'y="{fmt_num(y)}"', f'text-anchor="{text_anchor}"', 'xml:space="preserve"', ] if run is None: return " " + " ".join(parts) parts.append(f'font-family="{run.font_family}"') parts.append(f'font-size="{fmt_num(run.font_size_px)}"') parts.append(f'fill="{run.fill}"') if run.fill_opacity < 1.0: parts.append( f'fill-opacity="{format_ooxml_alpha(run.fill_opacity)}"' ) if run.bold: parts.append('font-weight="bold"') if run.italic: parts.append('font-style="italic"') if run.underline and run.strikethrough: parts.append('text-decoration="underline line-through"') elif run.underline: parts.append('text-decoration="underline"') elif run.strikethrough: parts.append('text-decoration="line-through"') if run.letter_spacing_px: parts.append(f'letter-spacing="{fmt_num(run.letter_spacing_px)}"') return " " + " ".join(parts) def _run_tspan_attrs(run: TextRun) -> str: """Per-run overrides on a . Only emit attributes that differ from the run that drove the parent (we keep things simple: emit only overrides that can plausibly change run-to-run, never re-emit common defaults). For v1 we always emit fill, font family, and font size so each imported run keeps its resolved typeface even when adjacent runs differ. """ parts = [ f'fill="{run.fill}"', f'font-family="{run.font_family}"', f'font-size="{fmt_num(run.font_size_px)}"', ] if run.fill_opacity < 1.0: parts.append( f'fill-opacity="{format_ooxml_alpha(run.fill_opacity)}"' ) if run.bold: parts.append('font-weight="bold"') if run.italic: parts.append('font-style="italic"') if run.underline and run.strikethrough: parts.append('text-decoration="underline line-through"') elif run.underline: parts.append('text-decoration="underline"') elif run.strikethrough: parts.append('text-decoration="line-through"') if run.letter_spacing_px: parts.append(f'letter-spacing="{fmt_num(run.letter_spacing_px)}"') return " " + " ".join(parts) def _xml_escape(text: str) -> str: return (text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """))