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