返回 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
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
920 lines PYTHON