| 1 | import os |
| 2 | import sys |
| 3 | import re |
| 4 | import argparse |
| 5 | from pathlib import Path |
| 6 | from xml.etree import ElementTree as ET |
| 7 | |
| 8 | _SCRIPTS_DIR = Path(__file__).resolve().parents[1] |
| 9 | if str(_SCRIPTS_DIR) not in sys.path: |
| 10 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 11 | |
| 12 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 13 | |
| 14 | configure_utf8_stdio() |
| 15 | |
| 16 | |
| 17 | SVG_NS = "http://www.w3.org/2000/svg" |
| 18 | NSMAP = {"svg": SVG_NS} |
| 19 | |
| 20 | # Ensure pretty element names without ns0 prefix on write |
| 21 | ET.register_namespace("", SVG_NS) |
| 22 | |
| 23 | |
| 24 | TEXT_STYLE_ATTRS = { |
| 25 | # common text styling |
| 26 | "font-family", |
| 27 | "font-size", |
| 28 | "font-weight", |
| 29 | "font-style", |
| 30 | "font-variant", |
| 31 | "font-stretch", |
| 32 | "letter-spacing", |
| 33 | "word-spacing", |
| 34 | "kerning", |
| 35 | "text-anchor", |
| 36 | "text-decoration", |
| 37 | "dominant-baseline", |
| 38 | "writing-mode", |
| 39 | "direction", |
| 40 | # color/paint |
| 41 | "fill", |
| 42 | "fill-opacity", |
| 43 | "stroke", |
| 44 | "stroke-width", |
| 45 | "stroke-opacity", |
| 46 | "opacity", |
| 47 | "paint-order", |
| 48 | # transforms/filters |
| 49 | "transform", |
| 50 | "clip-path", |
| 51 | "filter", |
| 52 | } |
| 53 | |
| 54 | |
| 55 | num_re = re.compile(r"^[\s,]*([+-]?(?:\d+\.?\d*|\d*\.\d+))") |
| 56 | |
| 57 | |
| 58 | def parse_first_number(val: str | None) -> float | None: |
| 59 | """Parse the first numeric token from an SVG attribute value.""" |
| 60 | if val is None: |
| 61 | return None |
| 62 | m = num_re.match(val) |
| 63 | if not m: |
| 64 | return None |
| 65 | try: |
| 66 | return float(m.group(1)) |
| 67 | except ValueError: |
| 68 | return None |
| 69 | |
| 70 | |
| 71 | def format_number(n: float | None) -> str | None: |
| 72 | """Format a float for compact SVG attribute output.""" |
| 73 | if n is None: |
| 74 | return None |
| 75 | if abs(n - round(n)) < 1e-6: |
| 76 | return str(int(round(n))) |
| 77 | # Trim trailing zeros |
| 78 | s = f"{n:.6f}".rstrip("0").rstrip(".") |
| 79 | return s |
| 80 | |
| 81 | |
| 82 | def parse_style(style_str: str | None) -> dict[str, str]: |
| 83 | """Parse an inline SVG style string into a mapping.""" |
| 84 | out: dict[str, str] = {} |
| 85 | if not style_str: |
| 86 | return out |
| 87 | # split by ; and then : |
| 88 | for chunk in style_str.split(";"): |
| 89 | if not chunk.strip(): |
| 90 | continue |
| 91 | if ":" in chunk: |
| 92 | k, v = chunk.split(":", 1) |
| 93 | out[k.strip()] = v.strip() |
| 94 | return out |
| 95 | |
| 96 | |
| 97 | def style_to_string(style_map: dict[str, str]) -> str: |
| 98 | """Serialize a style mapping back into an inline SVG style string.""" |
| 99 | if not style_map: |
| 100 | return "" |
| 101 | return ";".join(f"{k}:{v}" for k, v in style_map.items()) |
| 102 | |
| 103 | |
| 104 | def merge_styles(parent_style: str | None, child_style: str | None) -> str: |
| 105 | """Merge parent and child inline styles, preferring child values.""" |
| 106 | p = parse_style(parent_style) |
| 107 | c = parse_style(child_style) |
| 108 | p.update(c) # child overrides |
| 109 | return style_to_string(p) |
| 110 | |
| 111 | |
| 112 | def get_attr(elem: ET.Element | None, name: str, default: str | None = None) -> str | None: |
| 113 | """Read an attribute from an element with a default fallback.""" |
| 114 | return elem.get(name) if elem is not None and name in elem.attrib else default |
| 115 | |
| 116 | |
| 117 | def compute_line_positions( |
| 118 | text_el: ET.Element, |
| 119 | tspan_el: ET.Element, |
| 120 | cur_x: float | None, |
| 121 | cur_y: float | None, |
| 122 | ) -> tuple[float | None, float | None]: |
| 123 | """ |
| 124 | Compute absolute x,y for a tspan based on parent <text> current baseline and tspan's x/y/dx/dy. |
| 125 | Returns (new_x, new_y). |
| 126 | """ |
| 127 | del text_el |
| 128 | t_x_attr = get_attr(tspan_el, "x") |
| 129 | t_y_attr = get_attr(tspan_el, "y") |
| 130 | t_dx_attr = get_attr(tspan_el, "dx") |
| 131 | t_dy_attr = get_attr(tspan_el, "dy") |
| 132 | |
| 133 | nx = parse_first_number(t_x_attr) if t_x_attr is not None else cur_x |
| 134 | if t_dx_attr is not None: |
| 135 | dx = parse_first_number(t_dx_attr) or 0.0 |
| 136 | nx = (nx or 0.0) + dx |
| 137 | |
| 138 | ny = parse_first_number(t_y_attr) if t_y_attr is not None else cur_y |
| 139 | if t_dy_attr is not None: |
| 140 | dy = parse_first_number(t_dy_attr) or 0.0 |
| 141 | ny = (ny or 0.0) + dy |
| 142 | |
| 143 | return nx, ny |
| 144 | |
| 145 | |
| 146 | def collect_text_content(el: ET.Element) -> str: |
| 147 | """Collect all text content from an element subtree.""" |
| 148 | # Gather all text within the element (flatten nested tspans if any) |
| 149 | parts = [] |
| 150 | for s in el.itertext(): |
| 151 | if s: |
| 152 | parts.append(s) |
| 153 | return "".join(parts) |
| 154 | |
| 155 | |
| 156 | def _has_non_xml_whitespace(text: str | None) -> bool: |
| 157 | """Return whether text contains content beyond XML layout whitespace.""" |
| 158 | return bool(text and text.strip(" \t\r\n")) |
| 159 | |
| 160 | |
| 161 | def copy_text_attrs( |
| 162 | src_el: ET.Element, |
| 163 | dst_el: ET.Element, |
| 164 | exclude: set[str] | None = None, |
| 165 | ) -> None: |
| 166 | """Copy shared text styling attributes between SVG text elements.""" |
| 167 | exclude = exclude or set() |
| 168 | # Copy style string first |
| 169 | if "style" in src_el.attrib and "style" not in exclude: |
| 170 | dst_el.set("style", src_el.attrib["style"]) |
| 171 | for k in TEXT_STYLE_ATTRS: |
| 172 | if k in exclude: |
| 173 | continue |
| 174 | v = src_el.get(k) |
| 175 | if v is not None: |
| 176 | dst_el.set(k, v) |
| 177 | # xml:space preservation |
| 178 | xml_space = src_el.get("{http://www.w3.org/XML/1998/namespace}space") |
| 179 | if xml_space is not None and "{http://www.w3.org/XML/1998/namespace}space" not in exclude: |
| 180 | dst_el.set("{http://www.w3.org/XML/1998/namespace}space", xml_space) |
| 181 | |
| 182 | |
| 183 | PARAGRAPH_MARK_ATTR = "data-paragraph-line-height" |
| 184 | PARAGRAPH_SPACE_BEFORE_ATTR = "data-paragraph-space-before" |
| 185 | # Marks a line-break tspan as a SOFT break inside the current paragraph |
| 186 | # (SVG used dy to simulate text wrapping; the downstream converter should |
| 187 | # merge its runs into the previous <a:p> rather than start a new one). |
| 188 | PARAGRAPH_SOFT_BREAK_ATTR = "data-paragraph-soft-break" |
| 189 | # Marks an authored visual line boundary that remains a hard DrawingML break |
| 190 | # in the default single-frame preserve mode. |
| 191 | PARAGRAPH_LINE_BREAK_ATTR = "data-paragraph-line-break" |
| 192 | |
| 193 | # Tolerance for detecting "base line-height" vs "paragraph gap": dy values |
| 194 | # within ±DY_TOLERANCE_PX of each other are considered the same line-height. |
| 195 | DY_TOLERANCE_PX = 0.5 |
| 196 | # Cap on dy / base ratio. Anything beyond this (e.g. a 5x gap) is rejected |
| 197 | # as a real section break that shouldn't merge into one text frame. |
| 198 | MAX_DY_MULTIPLIER = 3.0 |
| 199 | LIST_MARKER_RE = re.compile( |
| 200 | r"^\s*(?:[•·・]\s*|[-–—*]\s+|\d+[.)、]\s+|[((]\d+[))]\s*)\S+" |
| 201 | ) |
| 202 | |
| 203 | |
| 204 | def _starts_with_list_marker(line_group: list[ET.Element]) -> bool: |
| 205 | """Return True when a visual line starts with an ordered/unordered marker.""" |
| 206 | text = "".join(collect_text_content(tspan) for tspan in line_group) |
| 207 | return bool(LIST_MARKER_RE.match(text)) |
| 208 | |
| 209 | |
| 210 | def _positional_tspan_attribute(tspan: ET.Element) -> str | None: |
| 211 | """Return the unsupported nested position attribute, if any.""" |
| 212 | for name in ("x", "y"): |
| 213 | if tspan.get(name) is not None: |
| 214 | return name |
| 215 | raw_dy = tspan.get("dy") |
| 216 | dy = parse_first_number(raw_dy) if raw_dy is not None else None |
| 217 | if dy is not None and abs(dy) > 1e-6: |
| 218 | return "dy" |
| 219 | return None |
| 220 | |
| 221 | |
| 222 | def nested_positional_tspan_errors(root: ET.Element) -> list[str]: |
| 223 | """Describe nested tspans whose baseline jumps cannot be exported.""" |
| 224 | errors: list[str] = [] |
| 225 | for text_el in root.iter(f"{{{SVG_NS}}}text"): |
| 226 | text_label = ( |
| 227 | f"<text id={text_el.get('id')!r}>" |
| 228 | if text_el.get("id") |
| 229 | else "<text>" |
| 230 | ) |
| 231 | for direct_child in list(text_el): |
| 232 | if direct_child.tag != f"{{{SVG_NS}}}tspan": |
| 233 | continue |
| 234 | for descendant in direct_child.iter(f"{{{SVG_NS}}}tspan"): |
| 235 | if descendant is direct_child: |
| 236 | continue |
| 237 | attribute = _positional_tspan_attribute(descendant) |
| 238 | if attribute is None: |
| 239 | continue |
| 240 | errors.append( |
| 241 | f"{text_label} contains a nested <tspan> with {attribute}; " |
| 242 | "move x/y/non-zero dy to a direct child of <text>" |
| 243 | ) |
| 244 | return errors |
| 245 | |
| 246 | |
| 247 | def _build_paragraph_child_view( |
| 248 | text_el: ET.Element, |
| 249 | is_svg_tag, |
| 250 | ) -> tuple[list[ET.Element], ET.Element | None] | None: |
| 251 | """Return direct tspan children plus an optional synthetic leading line. |
| 252 | |
| 253 | The synthetic line lets paragraph classification accept common SVG |
| 254 | authoring where the first visual line is direct text under <text>. This |
| 255 | helper does not mutate the tree; _emit_mergeable_paragraph commits the |
| 256 | synthetic line only after all paragraph checks pass. |
| 257 | """ |
| 258 | direct_children = list(text_el) |
| 259 | direct_tspans = [c for c in direct_children if is_svg_tag(c, "tspan")] |
| 260 | if len(direct_tspans) != len(direct_children): |
| 261 | return None |
| 262 | |
| 263 | raw_lead = text_el.text or "" |
| 264 | synthetic_first: ET.Element | None = None |
| 265 | if raw_lead.strip(): |
| 266 | base_x_raw = get_attr(text_el, "x") |
| 267 | if base_x_raw is None: |
| 268 | return None |
| 269 | if any((child.tail or "").strip() for child in direct_tspans): |
| 270 | return None |
| 271 | synthetic_first = ET.Element(f"{{{SVG_NS}}}tspan") |
| 272 | synthetic_first.set("x", base_x_raw) |
| 273 | synthetic_first.text = raw_lead.lstrip() |
| 274 | |
| 275 | view = ([synthetic_first] if synthetic_first is not None else []) + direct_tspans |
| 276 | return view, synthetic_first |
| 277 | |
| 278 | |
| 279 | def _get_font_size_px(elem: ET.Element) -> float | None: |
| 280 | """Read font-size from an attribute or inline style.""" |
| 281 | size = parse_first_number(get_attr(elem, "font-size")) |
| 282 | if size is not None: |
| 283 | return size |
| 284 | style_size = parse_style(get_attr(elem, "style")).get("font-size") |
| 285 | return parse_first_number(style_size) |
| 286 | |
| 287 | |
| 288 | def _effective_line_font_size_px( |
| 289 | text_el: ET.Element, |
| 290 | line_group: list[ET.Element], |
| 291 | ) -> float: |
| 292 | """Return the positioned line starter's effective font size.""" |
| 293 | line_size = _get_font_size_px(line_group[0]) |
| 294 | if line_size is not None: |
| 295 | return line_size |
| 296 | parent_size = _get_font_size_px(text_el) |
| 297 | return parent_size if parent_size is not None else 16.0 |
| 298 | |
| 299 | |
| 300 | def _classify_paragraph_block( |
| 301 | text_el: ET.Element, |
| 302 | is_svg_tag, |
| 303 | is_new_line_tspan, |
| 304 | preserve_line_breaks: bool, |
| 305 | ) -> tuple[float, list[float], list[str], list[list[ET.Element]], ET.Element | None] | None: |
| 306 | """Detect a mergeable paragraph block. |
| 307 | |
| 308 | Returns ``(base_line_height_px, extra_space_before_px_per_line, |
| 309 | break_kind_per_line, line_groups, synthetic_first_line)`` if the children |
| 310 | form a mergeable paragraph. Each list has one entry per direct-child tspan |
| 311 | (line), including a synthetic first line when the source used leading text: |
| 312 | |
| 313 | - extra_space_before_px_per_line[i]: extra px above base line-height, |
| 314 | used as <a:spcBef> on the downstream <a:p>. First entry is 0. |
| 315 | - break_kind_per_line[i]: ``paragraph`` starts a fresh <a:p>, ``soft`` |
| 316 | joins the previous line for reflow, and ``line`` preserves the visual |
| 317 | boundary as a hard DrawingML break. First entry is ``paragraph``. |
| 318 | |
| 319 | Conditions (all must hold): |
| 320 | - No direct text under <text>, except simple leading text that can be |
| 321 | promoted into a synthetic first-line <tspan>. |
| 322 | - Every direct child is a <tspan>. |
| 323 | - Every logical line starts with a new-line tspan. |
| 324 | - Direct-child inline formatting tspans without x/y/dy are allowed only |
| 325 | after a line starts; they are normalized into the previous line. |
| 326 | - First line-break tspan has dy == 0 (or no dy). |
| 327 | - All subsequent line-break tspans use positive dy (no <y>). |
| 328 | - dy values cluster around a single minimum "base line-height"; |
| 329 | any larger dy must be ≤ MAX_DY_MULTIPLIER × base. Anything larger |
| 330 | is treated as a section break and rejected. |
| 331 | - Every line-break tspan that sets x repeats the parent <text>'s x. |
| 332 | - A line-break tspan cannot add a non-zero dx offset. |
| 333 | - No nested tspan inside any line carries x/y/non-zero dy. |
| 334 | - Adjacent lines with different effective font sizes start new paragraphs. |
| 335 | """ |
| 336 | base_x = parse_first_number(get_attr(text_el, "x")) |
| 337 | child_view = _build_paragraph_child_view(text_el, is_svg_tag) |
| 338 | if child_view is None: |
| 339 | return None |
| 340 | direct_tspans, synthetic_first = child_view |
| 341 | |
| 342 | if len(direct_tspans) < 2: |
| 343 | return None |
| 344 | |
| 345 | line_groups: list[list[ET.Element]] = [] |
| 346 | for tspan in direct_tspans: |
| 347 | if is_new_line_tspan(tspan): |
| 348 | line_groups.append([tspan]) |
| 349 | else: |
| 350 | if not line_groups: |
| 351 | return None |
| 352 | line_groups[-1].append(tspan) |
| 353 | |
| 354 | if len(line_groups) < 2: |
| 355 | return None |
| 356 | |
| 357 | # First pass: validate per-line structural rules and collect dy values. |
| 358 | dy_values: list[float] = [] # one per line (0 for first) |
| 359 | for idx, group in enumerate(line_groups): |
| 360 | tspan = group[0] |
| 361 | |
| 362 | t_y = get_attr(tspan, "y") |
| 363 | if t_y is not None: |
| 364 | return None |
| 365 | |
| 366 | t_x_raw = get_attr(tspan, "x") |
| 367 | if t_x_raw is not None: |
| 368 | t_x = parse_first_number(t_x_raw) |
| 369 | if base_x is None or t_x is None or abs(t_x - base_x) > 1e-6: |
| 370 | return None |
| 371 | t_dx_raw = get_attr(tspan, "dx") |
| 372 | t_dx = parse_first_number(t_dx_raw) if t_dx_raw is not None else None |
| 373 | if t_dx is not None and abs(t_dx) > 1e-6: |
| 374 | return None |
| 375 | |
| 376 | t_dy_raw = get_attr(tspan, "dy") |
| 377 | t_dy = parse_first_number(t_dy_raw) if t_dy_raw is not None else None |
| 378 | |
| 379 | if idx == 0: |
| 380 | if t_dy is not None and abs(t_dy) > 1e-6: |
| 381 | return None |
| 382 | dy_values.append(0.0) |
| 383 | else: |
| 384 | if t_dy is None or t_dy <= 0: |
| 385 | return None |
| 386 | dy_values.append(t_dy) |
| 387 | |
| 388 | # Second pass: pick the base line-height as the minimum positive dy and |
| 389 | # express each line's dy as base + extra space-before. |
| 390 | positive_dys = [d for d in dy_values[1:] if d > 0] |
| 391 | if not positive_dys: |
| 392 | return None |
| 393 | base = min(positive_dys) |
| 394 | font_size = _get_font_size_px(text_el) |
| 395 | if font_size is not None and base > font_size * MAX_DY_MULTIPLIER + DY_TOLERANCE_PX: |
| 396 | return None |
| 397 | |
| 398 | extras: list[float] = [0.0] # first line never has space-before |
| 399 | break_kinds = ["paragraph"] |
| 400 | line_font_sizes = [ |
| 401 | _effective_line_font_size_px(text_el, group) |
| 402 | for group in line_groups |
| 403 | ] |
| 404 | for idx, d in enumerate(dy_values[1:], start=1): |
| 405 | if d + DY_TOLERANCE_PX < base: |
| 406 | return None # below base — line overlap, not a paragraph |
| 407 | if d > base * MAX_DY_MULTIPLIER + DY_TOLERANCE_PX: |
| 408 | return None # gap too large — treat as section break |
| 409 | extra = d - base |
| 410 | if extra < 0: |
| 411 | extra = 0.0 |
| 412 | # dy at the base line-height = soft break (SVG was simulating wrap); |
| 413 | # dy strictly greater than base = hard paragraph break. List markers |
| 414 | # and font-size changes also start a fresh paragraph so semantically |
| 415 | # distinct visual lines do not merge into one PowerPoint line. |
| 416 | reflow_candidate = ( |
| 417 | abs(extra) <= DY_TOLERANCE_PX |
| 418 | and not _starts_with_list_marker(line_groups[idx]) |
| 419 | and abs(line_font_sizes[idx] - line_font_sizes[idx - 1]) <= 1e-6 |
| 420 | ) |
| 421 | explicit_soft_break = line_groups[idx][0].get( |
| 422 | PARAGRAPH_SOFT_BREAK_ATTR |
| 423 | ) |
| 424 | if explicit_soft_break == "0": |
| 425 | break_kind = "paragraph" |
| 426 | elif explicit_soft_break == "1": |
| 427 | break_kind = "soft" |
| 428 | elif reflow_candidate: |
| 429 | break_kind = "line" if preserve_line_breaks else "soft" |
| 430 | else: |
| 431 | break_kind = "paragraph" |
| 432 | extras.append(0.0 if break_kind != "paragraph" else extra) |
| 433 | break_kinds.append(break_kind) |
| 434 | |
| 435 | return base, extras, break_kinds, line_groups, synthetic_first |
| 436 | |
| 437 | |
| 438 | def _emit_mergeable_paragraph( |
| 439 | text_el: ET.Element, |
| 440 | base_dy: float, |
| 441 | extras: list[float], |
| 442 | break_kinds: list[str], |
| 443 | line_groups: list[list[ET.Element]], |
| 444 | synthetic_first: ET.Element | None = None, |
| 445 | ) -> None: |
| 446 | """Rewrite text_el in place so it stays a single <text> with paragraph rows. |
| 447 | |
| 448 | The base line-height goes on the parent <text> via PARAGRAPH_MARK_ATTR. |
| 449 | Each direct-child tspan is normalized: x/y/dx/dy stripped; inline-run |
| 450 | styling and nested tspans are preserved. Per-tspan attrs: |
| 451 | - PARAGRAPH_SOFT_BREAK_ATTR="1" on tspans that should be appended to |
| 452 | the previous <a:p> downstream (SVG used dy to simulate wrap) |
| 453 | - PARAGRAPH_LINE_BREAK_ATTR="1" on tspans that retain an authored line |
| 454 | boundary inside the previous <a:p> |
| 455 | - PARAGRAPH_SPACE_BEFORE_ATTR on tspans that open a new paragraph |
| 456 | with an extra gap (omitted when 0) |
| 457 | """ |
| 458 | text_el.set(PARAGRAPH_MARK_ATTR, format_number(base_dy)) |
| 459 | if synthetic_first is not None: |
| 460 | text_el.text = None |
| 461 | text_el.insert(0, synthetic_first) |
| 462 | |
| 463 | # Normalize authoring variants before the downstream converter reads the |
| 464 | # paragraph: a line-break tspan may be followed by direct-child inline |
| 465 | # formatting tspans. Wrap those original siblings in one unstyled line |
| 466 | # container so every direct child of <text> is one logical visual line. |
| 467 | # Keeping the authored runs as siblings is important: nesting later runs |
| 468 | # under the positioned first run would incorrectly inherit its typography, |
| 469 | # and moving them independently would lose the first run's tail whitespace. |
| 470 | normalized_lines: list[ET.Element] = [] |
| 471 | for group in line_groups: |
| 472 | line = group[0] |
| 473 | if len(group) == 1: |
| 474 | normalized_lines.append(line) |
| 475 | continue |
| 476 | |
| 477 | container = ET.Element(f"{{{SVG_NS}}}tspan") |
| 478 | for k in ("x", "y", "dx", "dy"): |
| 479 | line.attrib.pop(k, None) |
| 480 | for run in group: |
| 481 | container.append(run) |
| 482 | normalized_lines.append(container) |
| 483 | |
| 484 | for child in list(text_el): |
| 485 | text_el.remove(child) |
| 486 | for line in normalized_lines: |
| 487 | text_el.append(line) |
| 488 | |
| 489 | extras_iter = iter(extras) |
| 490 | break_iter = iter(break_kinds) |
| 491 | for tspan in normalized_lines: |
| 492 | for k in ("x", "y", "dx", "dy"): |
| 493 | if k in tspan.attrib: |
| 494 | del tspan.attrib[k] |
| 495 | for k in ( |
| 496 | PARAGRAPH_SOFT_BREAK_ATTR, |
| 497 | PARAGRAPH_LINE_BREAK_ATTR, |
| 498 | PARAGRAPH_SPACE_BEFORE_ATTR, |
| 499 | ): |
| 500 | tspan.attrib.pop(k, None) |
| 501 | try: |
| 502 | extra = next(extras_iter) |
| 503 | break_kind = next(break_iter) |
| 504 | except StopIteration: |
| 505 | extra = 0.0 |
| 506 | break_kind = "paragraph" |
| 507 | if break_kind == "soft": |
| 508 | tspan.set(PARAGRAPH_SOFT_BREAK_ATTR, "1") |
| 509 | elif break_kind == "line": |
| 510 | tspan.set(PARAGRAPH_LINE_BREAK_ATTR, "1") |
| 511 | elif extra > 1e-6: |
| 512 | tspan.set(PARAGRAPH_SPACE_BEFORE_ATTR, format_number(extra)) |
| 513 | |
| 514 | |
| 515 | def flatten_text_with_tspans( |
| 516 | tree: ET.ElementTree, |
| 517 | merge_paragraphs: bool = False, |
| 518 | preserve_line_breaks: bool = False, |
| 519 | ) -> bool: |
| 520 | """Flatten multi-line tspan text into independent text nodes when needed. |
| 521 | |
| 522 | When ``merge_paragraphs`` is True, mergeable paragraph blocks (same x, |
| 523 | dy clustered around one base line-height) are kept as a single <text>. |
| 524 | ``preserve_line_breaks`` marks ordinary visual rows as hard line breaks |
| 525 | instead of reflowable continuations. Default split behavior still promotes |
| 526 | every positioned row to its own <text>. |
| 527 | """ |
| 528 | root = tree.getroot() |
| 529 | positional_errors = nested_positional_tspan_errors(root) |
| 530 | if positional_errors: |
| 531 | preview = "; ".join(positional_errors[:3]) |
| 532 | suffix = ( |
| 533 | "" |
| 534 | if len(positional_errors) <= 3 |
| 535 | else f"; +{len(positional_errors) - 3} more" |
| 536 | ) |
| 537 | raise ValueError(f"Unsupported nested positional <tspan>: {preview}{suffix}") |
| 538 | parent_map = {c: p for p in root.iter() for c in p} |
| 539 | changed = False |
| 540 | |
| 541 | def is_svg_tag(el: ET.Element, name: str) -> bool: |
| 542 | return el.tag == f"{{{SVG_NS}}}{name}" |
| 543 | |
| 544 | def is_new_line_tspan(tspan: ET.Element) -> bool: |
| 545 | """Determine whether a tspan represents a new line (has its own y or non-zero dy).""" |
| 546 | t_dy_attr = get_attr(tspan, "dy") |
| 547 | t_y_attr = get_attr(tspan, "y") |
| 548 | t_x_attr = get_attr(tspan, "x") |
| 549 | dy_val = parse_first_number(t_dy_attr) if t_dy_attr is not None else None |
| 550 | # Has its own y attribute, or has non-zero dy, or has its own x attribute (indicating a new line) |
| 551 | if t_y_attr is not None: |
| 552 | return True |
| 553 | if dy_val is not None and dy_val != 0: |
| 554 | return True |
| 555 | # If tspan has an x attribute and there are preceding sibling tspans, treat it as a new line |
| 556 | if t_x_attr is not None: |
| 557 | return True |
| 558 | return False |
| 559 | |
| 560 | # Collect candidates first to avoid modifying while iterating |
| 561 | candidates = [] |
| 562 | for el in root.iter(): |
| 563 | if is_svg_tag(el, "text"): |
| 564 | has_tspan_child = any(is_svg_tag(c, "tspan") for c in list(el)) |
| 565 | if has_tspan_child: |
| 566 | candidates.append(el) |
| 567 | |
| 568 | for text_el in candidates: |
| 569 | parent = parent_map.get(text_el) |
| 570 | if parent is None: |
| 571 | continue |
| 572 | |
| 573 | # First check whether any tspan needs flattening (dy != 0 or has its own y attribute) |
| 574 | needs_flatten = False |
| 575 | for child in list(text_el): |
| 576 | if not is_svg_tag(child, "tspan"): |
| 577 | continue |
| 578 | if is_new_line_tspan(child): |
| 579 | needs_flatten = True |
| 580 | break |
| 581 | |
| 582 | # If no tspan needs a line break, skip the entire text element |
| 583 | if not needs_flatten: |
| 584 | continue |
| 585 | |
| 586 | # Single-frame fast path: conservative same-x/dy blocks stay in one |
| 587 | # <text>. The downstream converter either preserves visual breaks or |
| 588 | # reflows them. Split mode promotes each positioned line to <text>. |
| 589 | if merge_paragraphs: |
| 590 | paragraph = _classify_paragraph_block( |
| 591 | text_el, |
| 592 | is_svg_tag, |
| 593 | is_new_line_tspan, |
| 594 | preserve_line_breaks, |
| 595 | ) |
| 596 | if paragraph is not None: |
| 597 | base_dy, extras, break_kinds, line_groups, synthetic_first = paragraph |
| 598 | _emit_mergeable_paragraph( |
| 599 | text_el, |
| 600 | base_dy, |
| 601 | extras, |
| 602 | break_kinds, |
| 603 | line_groups, |
| 604 | synthetic_first=synthetic_first, |
| 605 | ) |
| 606 | changed = True |
| 607 | continue |
| 608 | |
| 609 | base_x = parse_first_number(get_attr(text_el, "x")) or 0.0 |
| 610 | base_y = parse_first_number(get_attr(text_el, "y")) or 0.0 |
| 611 | cur_x, cur_y = base_x, base_y |
| 612 | |
| 613 | new_texts = [] |
| 614 | |
| 615 | # Collect tspan elements belonging to the same line |
| 616 | current_line_tspans = [] |
| 617 | current_line_lead_text = text_el.text or None |
| 618 | |
| 619 | for idx, child in enumerate(list(text_el)): |
| 620 | if not is_svg_tag(child, "tspan"): |
| 621 | continue |
| 622 | |
| 623 | content = collect_text_content(child) |
| 624 | |
| 625 | # Check whether this tspan starts a new line |
| 626 | if is_new_line_tspan(child): |
| 627 | # Save previously accumulated same-line tspans first |
| 628 | if current_line_tspans or _has_non_xml_whitespace( |
| 629 | current_line_lead_text |
| 630 | ): |
| 631 | ne = _create_text_element_from_line( |
| 632 | text_el, current_line_lead_text, current_line_tspans, cur_x, cur_y |
| 633 | ) |
| 634 | new_texts.append(ne) |
| 635 | current_line_tspans = [] |
| 636 | current_line_lead_text = None |
| 637 | |
| 638 | # Update position |
| 639 | nx, ny = compute_line_positions(text_el, child, cur_x, cur_y) |
| 640 | cur_x, cur_y = nx, ny |
| 641 | |
| 642 | # Keep raw XML whitespace and tails until the shared downstream |
| 643 | # text normalizer sees the whole line. A whitespace-only run can |
| 644 | # still be the visible boundary between two formatted runs. |
| 645 | if content or child.tail: |
| 646 | current_line_tspans.append(child) |
| 647 | |
| 648 | # Process the last line |
| 649 | if current_line_tspans or _has_non_xml_whitespace( |
| 650 | current_line_lead_text |
| 651 | ): |
| 652 | ne = _create_text_element_from_line( |
| 653 | text_el, current_line_lead_text, current_line_tspans, cur_x, cur_y |
| 654 | ) |
| 655 | new_texts.append(ne) |
| 656 | |
| 657 | if new_texts: |
| 658 | # Replace original <text> with the list of new <text> nodes |
| 659 | try: |
| 660 | idx = list(parent).index(text_el) |
| 661 | except ValueError: |
| 662 | idx = None |
| 663 | |
| 664 | # Insert in place to preserve drawing order |
| 665 | for i, ne in enumerate(new_texts): |
| 666 | if idx is not None: |
| 667 | parent.insert(idx + i, ne) |
| 668 | else: |
| 669 | parent.append(ne) |
| 670 | |
| 671 | # Remove the original <text> |
| 672 | parent.remove(text_el) |
| 673 | changed = True |
| 674 | |
| 675 | return changed |
| 676 | |
| 677 | |
| 678 | def _has_tspan_children(elem: ET.Element) -> bool: |
| 679 | """Return True if elem contains any nested <tspan> children (inline runs).""" |
| 680 | return any(c.tag == f"{{{SVG_NS}}}tspan" for c in list(elem)) |
| 681 | |
| 682 | |
| 683 | def _copy_inline_tspan(src: ET.Element, strip_line_attrs: bool) -> ET.Element: |
| 684 | """Deep-copy a tspan as an inline run, preserving nested tspan structure, head text, and tail text. |
| 685 | |
| 686 | When strip_line_attrs is True, x/y/dy are dropped because the enclosing |
| 687 | <text> owns the resolved line position. Drop dx only from a positioned |
| 688 | line starter, where compute_line_positions already consumed it; preserve |
| 689 | dx on later inline runs. |
| 690 | Nested tspans are copied recursively without stripping (they are already inline-only). |
| 691 | """ |
| 692 | new = ET.Element(f"{{{SVG_NS}}}tspan") |
| 693 | consumed_dx = ( |
| 694 | strip_line_attrs |
| 695 | and _positional_tspan_attribute(src) is not None |
| 696 | ) |
| 697 | for k, v in src.attrib.items(): |
| 698 | if strip_line_attrs and k in ("x", "y", "dy"): |
| 699 | continue |
| 700 | if k == "dx" and consumed_dx: |
| 701 | continue |
| 702 | new.set(k, v) |
| 703 | new.text = src.text |
| 704 | for child in list(src): |
| 705 | if child.tag == f"{{{SVG_NS}}}tspan": |
| 706 | new.append(_copy_inline_tspan(child, strip_line_attrs=False)) |
| 707 | new.tail = src.tail |
| 708 | return new |
| 709 | |
| 710 | |
| 711 | def _create_text_element_from_line( |
| 712 | text_el: ET.Element, |
| 713 | lead_text: str | None, |
| 714 | tspans: list[ET.Element], |
| 715 | x: float | None, |
| 716 | y: float | None, |
| 717 | ) -> ET.Element: |
| 718 | """ |
| 719 | Create a text element from a line's content (may contain leading text and multiple tspans). |
| 720 | If there is only one tspan with no nested tspan children and no leading text, the line |
| 721 | collapses to a plain <text>...</text>. Otherwise the tspan structure (including any |
| 722 | nested inline tspans) is preserved so per-run formatting survives the flatten step. |
| 723 | """ |
| 724 | ne = ET.Element(f"{{{SVG_NS}}}text") |
| 725 | |
| 726 | # Copy attrs from parent <text> |
| 727 | copy_text_attrs(text_el, ne, exclude={"x", "y"}) |
| 728 | ne.set("x", format_number(x)) |
| 729 | ne.set("y", format_number(y)) |
| 730 | |
| 731 | # Transform |
| 732 | p_tf = text_el.get("transform") |
| 733 | if p_tf: |
| 734 | ne.set("transform", p_tf) |
| 735 | |
| 736 | # Compact path: a single tspan with no nested inline runs or parent-owned |
| 737 | # tail collapses to <text>text</text>. A tail must remain outside the tspan |
| 738 | # so its parent typography and xml:space semantics remain intact. |
| 739 | if ( |
| 740 | not lead_text |
| 741 | and len(tspans) == 1 |
| 742 | and not _has_tspan_children(tspans[0]) |
| 743 | and not tspans[0].tail |
| 744 | ): |
| 745 | tspan = tspans[0] |
| 746 | content = collect_text_content(tspan) |
| 747 | |
| 748 | xml_space_attr = "{http://www.w3.org/XML/1998/namespace}space" |
| 749 | xml_space = tspan.get(xml_space_attr) |
| 750 | if xml_space is not None: |
| 751 | ne.set(xml_space_attr, xml_space) |
| 752 | |
| 753 | # Merge style |
| 754 | merged_style = merge_styles(text_el.get("style"), tspan.get("style")) |
| 755 | if merged_style: |
| 756 | ne.set("style", merged_style) |
| 757 | |
| 758 | # Override specific attributes from tspan |
| 759 | for attr in TEXT_STYLE_ATTRS: |
| 760 | cv = tspan.get(attr) |
| 761 | if cv is not None: |
| 762 | ne.set(attr, cv) |
| 763 | |
| 764 | # Combine transform |
| 765 | c_tf = tspan.get("transform") |
| 766 | if p_tf and c_tf: |
| 767 | ne.set("transform", f"{p_tf} {c_tf}") |
| 768 | elif c_tf: |
| 769 | ne.set("transform", c_tf) |
| 770 | |
| 771 | ne.text = content |
| 772 | else: |
| 773 | # Preserve tspan structure, including nested inline tspans and tail text |
| 774 | if lead_text: |
| 775 | ne.text = lead_text |
| 776 | |
| 777 | for tspan in tspans: |
| 778 | ne.append(_copy_inline_tspan(tspan, strip_line_attrs=True)) |
| 779 | |
| 780 | return ne |
| 781 | |
| 782 | |
| 783 | def process_svg_file( |
| 784 | src_path: str, |
| 785 | dst_path: str, |
| 786 | merge_paragraphs: bool = False, |
| 787 | ) -> bool: |
| 788 | """Flatten eligible tspan lines in one SVG file.""" |
| 789 | try: |
| 790 | tree = ET.parse(src_path) |
| 791 | except ET.ParseError as e: |
| 792 | print(f"[WARN] Failed to parse {src_path}: {e}") |
| 793 | return False |
| 794 | |
| 795 | changed = flatten_text_with_tspans(tree, merge_paragraphs=merge_paragraphs) |
| 796 | |
| 797 | # Ensure destination directory exists |
| 798 | os.makedirs(os.path.dirname(dst_path), exist_ok=True) |
| 799 | |
| 800 | # Write out XML without XML declaration to mimic input style |
| 801 | tree.write(dst_path, encoding="utf-8", xml_declaration=False, method="xml") |
| 802 | return changed |
| 803 | |
| 804 | |
| 805 | def _compute_default_out_base(inp: str) -> str: |
| 806 | """Compute default output path for directory or file input.""" |
| 807 | if os.path.isdir(inp): |
| 808 | # Default: if input ends with svg_output, use sibling svg_output_flattext; |
| 809 | # otherwise append _flattext to the directory name at the same level. |
| 810 | head, tail = os.path.split(os.path.normpath(inp)) |
| 811 | if tail == "svg_output": |
| 812 | return os.path.join(head, "svg_output_flattext") |
| 813 | return inp.rstrip("/\\") + "_flattext" |
| 814 | else: |
| 815 | base, ext = os.path.splitext(inp) |
| 816 | return base + "_flattext" + ext |
| 817 | |
| 818 | |
| 819 | def _interactive_get_paths() -> tuple[str | None, str | None]: |
| 820 | """ |
| 821 | Interactive mode: prompt the user for input path (SVG file or directory) |
| 822 | and optional output path. Returns (inp, out_base) or (None, None) if cancelled. |
| 823 | """ |
| 824 | print("[Interactive mode] No arguments provided; running interactively.") |
| 825 | print("Please enter the path to process (SVG file or directory containing SVGs).") |
| 826 | print("Enter q to quit.\n") |
| 827 | |
| 828 | while True: |
| 829 | raw = input("Input path (file/dir): ").strip() |
| 830 | if raw.lower() in {"q", "quit", "exit"} or raw == "": |
| 831 | return None, None |
| 832 | inp = os.path.expanduser(raw) |
| 833 | if os.path.exists(inp): |
| 834 | break |
| 835 | print("Path does not exist. Please re-enter or enter q to quit.") |
| 836 | |
| 837 | default_out = _compute_default_out_base(inp) |
| 838 | if os.path.isdir(inp): |
| 839 | prompt = f"Output directory [default: {default_out}]: " |
| 840 | else: |
| 841 | prompt = f"Output file [default: {default_out}]: " |
| 842 | |
| 843 | raw_out = input(prompt).strip() |
| 844 | out_base = os.path.expanduser(raw_out) if raw_out else default_out |
| 845 | |
| 846 | return inp, out_base |
| 847 | |
| 848 | |
| 849 | def main() -> None: |
| 850 | """Run the CLI entry point.""" |
| 851 | # CLI parsing with optional interactive mode |
| 852 | parser = argparse.ArgumentParser( |
| 853 | description="Flatten <tspan> lines into multiple <text> nodes for better compatibility.", |
| 854 | add_help=True, |
| 855 | ) |
| 856 | parser.add_argument("input", nargs="?", help="Input path: SVG file or directory") |
| 857 | parser.add_argument("output", nargs="?", help="Optional output file/dir") |
| 858 | parser.add_argument( |
| 859 | "-i", |
| 860 | "--interactive", |
| 861 | action="store_true", |
| 862 | help="Run in interactive prompt mode to input paths", |
| 863 | ) |
| 864 | parser.add_argument( |
| 865 | "--merge-paragraphs", |
| 866 | action="store_true", |
| 867 | default=False, |
| 868 | help=( |
| 869 | "Opt-in: merge mergeable paragraph blocks (same x, dy clustered " |
| 870 | "around one base line-height) into a single <text> annotated for " |
| 871 | "downstream multi-<a:p> conversion. Default off — every line-break " |
| 872 | "tspan becomes its own <text>, preserving SVG pixel fidelity." |
| 873 | ), |
| 874 | ) |
| 875 | |
| 876 | args = parser.parse_args() |
| 877 | |
| 878 | if args.interactive or not args.input: |
| 879 | inp, out_base = _interactive_get_paths() |
| 880 | if not inp: |
| 881 | print("Cancelled. Usage: python3 scripts/svg_finalize/flatten_tspan.py <input_dir_or_svg> [output_dir]") |
| 882 | sys.exit(0) |
| 883 | else: |
| 884 | inp = args.input |
| 885 | out_base = args.output |
| 886 | |
| 887 | if os.path.isdir(inp): |
| 888 | # If output base not provided, create a sibling folder named svg_output_flattext for svg_output |
| 889 | if out_base is None: |
| 890 | out_base = _compute_default_out_base(inp) |
| 891 | |
| 892 | total = 0 |
| 893 | changed_count = 0 |
| 894 | out_base_abs = os.path.abspath(out_base) |
| 895 | for root, dirs, files in os.walk(inp): |
| 896 | # Avoid recursing into the output directory when it lives under input |
| 897 | dirs[:] = [d for d in dirs if os.path.abspath(os.path.join(root, d)) != out_base_abs] |
| 898 | rel_root = os.path.relpath(root, inp) |
| 899 | for f in files: |
| 900 | if not f.lower().endswith(".svg"): |
| 901 | continue |
| 902 | src = os.path.join(root, f) |
| 903 | dst = os.path.join(out_base, rel_root, f) if rel_root != "." else os.path.join(out_base, f) |
| 904 | total += 1 |
| 905 | changed = process_svg_file(src, dst, merge_paragraphs=args.merge_paragraphs) |
| 906 | if changed: |
| 907 | changed_count += 1 |
| 908 | print(f"Processed {total} SVG(s). With <tspan> flattened: {changed_count}.") |
| 909 | print(f"Output written to: {out_base}") |
| 910 | else: |
| 911 | src = inp |
| 912 | if out_base is None: |
| 913 | out_base = _compute_default_out_base(src) |
| 914 | changed = process_svg_file(src, out_base, merge_paragraphs=args.merge_paragraphs) |
| 915 | print(f"Written: {out_base} (flattened: {changed})") |
| 916 | |
| 917 | |
| 918 | if __name__ == "__main__": |
| 919 | main() |
| 920 |