Files
Templated-PPT-Master/runtime/ppt-master/scripts/svg_finalize/flatten_tspan.py
T

920 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import sys
import re
import argparse
from pathlib import Path
from xml.etree import ElementTree as ET
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
configure_utf8_stdio()
SVG_NS = "http://www.w3.org/2000/svg"
NSMAP = {"svg": SVG_NS}
# Ensure pretty element names without ns0 prefix on write
ET.register_namespace("", SVG_NS)
TEXT_STYLE_ATTRS = {
# common text styling
"font-family",
"font-size",
"font-weight",
"font-style",
"font-variant",
"font-stretch",
"letter-spacing",
"word-spacing",
"kerning",
"text-anchor",
"text-decoration",
"dominant-baseline",
"writing-mode",
"direction",
# color/paint
"fill",
"fill-opacity",
"stroke",
"stroke-width",
"stroke-opacity",
"opacity",
"paint-order",
# transforms/filters
"transform",
"clip-path",
"filter",
}
num_re = re.compile(r"^[\s,]*([+-]?(?:\d+\.?\d*|\d*\.\d+))")
def parse_first_number(val: str | None) -> float | None:
"""Parse the first numeric token from an SVG attribute value."""
if val is None:
return None
m = num_re.match(val)
if not m:
return None
try:
return float(m.group(1))
except ValueError:
return None
def format_number(n: float | None) -> str | None:
"""Format a float for compact SVG attribute output."""
if n is None:
return None
if abs(n - round(n)) < 1e-6:
return str(int(round(n)))
# Trim trailing zeros
s = f"{n:.6f}".rstrip("0").rstrip(".")
return s
def parse_style(style_str: str | None) -> dict[str, str]:
"""Parse an inline SVG style string into a mapping."""
out: dict[str, str] = {}
if not style_str:
return out
# split by ; and then :
for chunk in style_str.split(";"):
if not chunk.strip():
continue
if ":" in chunk:
k, v = chunk.split(":", 1)
out[k.strip()] = v.strip()
return out
def style_to_string(style_map: dict[str, str]) -> str:
"""Serialize a style mapping back into an inline SVG style string."""
if not style_map:
return ""
return ";".join(f"{k}:{v}" for k, v in style_map.items())
def merge_styles(parent_style: str | None, child_style: str | None) -> str:
"""Merge parent and child inline styles, preferring child values."""
p = parse_style(parent_style)
c = parse_style(child_style)
p.update(c) # child overrides
return style_to_string(p)
def get_attr(elem: ET.Element | None, name: str, default: str | None = None) -> str | None:
"""Read an attribute from an element with a default fallback."""
return elem.get(name) if elem is not None and name in elem.attrib else default
def compute_line_positions(
text_el: ET.Element,
tspan_el: ET.Element,
cur_x: float | None,
cur_y: float | None,
) -> tuple[float | None, float | None]:
"""
Compute absolute x,y for a tspan based on parent <text> current baseline and tspan's x/y/dx/dy.
Returns (new_x, new_y).
"""
del text_el
t_x_attr = get_attr(tspan_el, "x")
t_y_attr = get_attr(tspan_el, "y")
t_dx_attr = get_attr(tspan_el, "dx")
t_dy_attr = get_attr(tspan_el, "dy")
nx = parse_first_number(t_x_attr) if t_x_attr is not None else cur_x
if t_dx_attr is not None:
dx = parse_first_number(t_dx_attr) or 0.0
nx = (nx or 0.0) + dx
ny = parse_first_number(t_y_attr) if t_y_attr is not None else cur_y
if t_dy_attr is not None:
dy = parse_first_number(t_dy_attr) or 0.0
ny = (ny or 0.0) + dy
return nx, ny
def collect_text_content(el: ET.Element) -> str:
"""Collect all text content from an element subtree."""
# Gather all text within the element (flatten nested tspans if any)
parts = []
for s in el.itertext():
if s:
parts.append(s)
return "".join(parts)
def _has_non_xml_whitespace(text: str | None) -> bool:
"""Return whether text contains content beyond XML layout whitespace."""
return bool(text and text.strip(" \t\r\n"))
def copy_text_attrs(
src_el: ET.Element,
dst_el: ET.Element,
exclude: set[str] | None = None,
) -> None:
"""Copy shared text styling attributes between SVG text elements."""
exclude = exclude or set()
# Copy style string first
if "style" in src_el.attrib and "style" not in exclude:
dst_el.set("style", src_el.attrib["style"])
for k in TEXT_STYLE_ATTRS:
if k in exclude:
continue
v = src_el.get(k)
if v is not None:
dst_el.set(k, v)
# xml:space preservation
xml_space = src_el.get("{http://www.w3.org/XML/1998/namespace}space")
if xml_space is not None and "{http://www.w3.org/XML/1998/namespace}space" not in exclude:
dst_el.set("{http://www.w3.org/XML/1998/namespace}space", xml_space)
PARAGRAPH_MARK_ATTR = "data-paragraph-line-height"
PARAGRAPH_SPACE_BEFORE_ATTR = "data-paragraph-space-before"
# Marks a line-break tspan as a SOFT break inside the current paragraph
# (SVG used dy to simulate text wrapping; the downstream converter should
# merge its runs into the previous <a:p> rather than start a new one).
PARAGRAPH_SOFT_BREAK_ATTR = "data-paragraph-soft-break"
# Marks an authored visual line boundary that remains a hard DrawingML break
# in the default single-frame preserve mode.
PARAGRAPH_LINE_BREAK_ATTR = "data-paragraph-line-break"
# Tolerance for detecting "base line-height" vs "paragraph gap": dy values
# within ±DY_TOLERANCE_PX of each other are considered the same line-height.
DY_TOLERANCE_PX = 0.5
# Cap on dy / base ratio. Anything beyond this (e.g. a 5x gap) is rejected
# as a real section break that shouldn't merge into one text frame.
MAX_DY_MULTIPLIER = 3.0
LIST_MARKER_RE = re.compile(
r"^\s*(?:[•·・]\s*|[-–—*]\s+|\d+[.)、]\s+|[((]\d+[))]\s*)\S+"
)
def _starts_with_list_marker(line_group: list[ET.Element]) -> bool:
"""Return True when a visual line starts with an ordered/unordered marker."""
text = "".join(collect_text_content(tspan) for tspan in line_group)
return bool(LIST_MARKER_RE.match(text))
def _positional_tspan_attribute(tspan: ET.Element) -> str | None:
"""Return the unsupported nested position attribute, if any."""
for name in ("x", "y"):
if tspan.get(name) is not None:
return name
raw_dy = tspan.get("dy")
dy = parse_first_number(raw_dy) if raw_dy is not None else None
if dy is not None and abs(dy) > 1e-6:
return "dy"
return None
def nested_positional_tspan_errors(root: ET.Element) -> list[str]:
"""Describe nested tspans whose baseline jumps cannot be exported."""
errors: list[str] = []
for text_el in root.iter(f"{{{SVG_NS}}}text"):
text_label = (
f"<text id={text_el.get('id')!r}>"
if text_el.get("id")
else "<text>"
)
for direct_child in list(text_el):
if direct_child.tag != f"{{{SVG_NS}}}tspan":
continue
for descendant in direct_child.iter(f"{{{SVG_NS}}}tspan"):
if descendant is direct_child:
continue
attribute = _positional_tspan_attribute(descendant)
if attribute is None:
continue
errors.append(
f"{text_label} contains a nested <tspan> with {attribute}; "
"move x/y/non-zero dy to a direct child of <text>"
)
return errors
def _build_paragraph_child_view(
text_el: ET.Element,
is_svg_tag,
) -> tuple[list[ET.Element], ET.Element | None] | None:
"""Return direct tspan children plus an optional synthetic leading line.
The synthetic line lets paragraph classification accept common SVG
authoring where the first visual line is direct text under <text>. This
helper does not mutate the tree; _emit_mergeable_paragraph commits the
synthetic line only after all paragraph checks pass.
"""
direct_children = list(text_el)
direct_tspans = [c for c in direct_children if is_svg_tag(c, "tspan")]
if len(direct_tspans) != len(direct_children):
return None
raw_lead = text_el.text or ""
synthetic_first: ET.Element | None = None
if raw_lead.strip():
base_x_raw = get_attr(text_el, "x")
if base_x_raw is None:
return None
if any((child.tail or "").strip() for child in direct_tspans):
return None
synthetic_first = ET.Element(f"{{{SVG_NS}}}tspan")
synthetic_first.set("x", base_x_raw)
synthetic_first.text = raw_lead.lstrip()
view = ([synthetic_first] if synthetic_first is not None else []) + direct_tspans
return view, synthetic_first
def _get_font_size_px(elem: ET.Element) -> float | None:
"""Read font-size from an attribute or inline style."""
size = parse_first_number(get_attr(elem, "font-size"))
if size is not None:
return size
style_size = parse_style(get_attr(elem, "style")).get("font-size")
return parse_first_number(style_size)
def _effective_line_font_size_px(
text_el: ET.Element,
line_group: list[ET.Element],
) -> float:
"""Return the positioned line starter's effective font size."""
line_size = _get_font_size_px(line_group[0])
if line_size is not None:
return line_size
parent_size = _get_font_size_px(text_el)
return parent_size if parent_size is not None else 16.0
def _classify_paragraph_block(
text_el: ET.Element,
is_svg_tag,
is_new_line_tspan,
preserve_line_breaks: bool,
) -> tuple[float, list[float], list[str], list[list[ET.Element]], ET.Element | None] | None:
"""Detect a mergeable paragraph block.
Returns ``(base_line_height_px, extra_space_before_px_per_line,
break_kind_per_line, line_groups, synthetic_first_line)`` if the children
form a mergeable paragraph. Each list has one entry per direct-child tspan
(line), including a synthetic first line when the source used leading text:
- extra_space_before_px_per_line[i]: extra px above base line-height,
used as <a:spcBef> on the downstream <a:p>. First entry is 0.
- break_kind_per_line[i]: ``paragraph`` starts a fresh <a:p>, ``soft``
joins the previous line for reflow, and ``line`` preserves the visual
boundary as a hard DrawingML break. First entry is ``paragraph``.
Conditions (all must hold):
- No direct text under <text>, except simple leading text that can be
promoted into a synthetic first-line <tspan>.
- Every direct child is a <tspan>.
- Every logical line starts with a new-line tspan.
- Direct-child inline formatting tspans without x/y/dy are allowed only
after a line starts; they are normalized into the previous line.
- First line-break tspan has dy == 0 (or no dy).
- All subsequent line-break tspans use positive dy (no <y>).
- dy values cluster around a single minimum "base line-height";
any larger dy must be ≤ MAX_DY_MULTIPLIER × base. Anything larger
is treated as a section break and rejected.
- Every line-break tspan that sets x repeats the parent <text>'s x.
- A line-break tspan cannot add a non-zero dx offset.
- No nested tspan inside any line carries x/y/non-zero dy.
- Adjacent lines with different effective font sizes start new paragraphs.
"""
base_x = parse_first_number(get_attr(text_el, "x"))
child_view = _build_paragraph_child_view(text_el, is_svg_tag)
if child_view is None:
return None
direct_tspans, synthetic_first = child_view
if len(direct_tspans) < 2:
return None
line_groups: list[list[ET.Element]] = []
for tspan in direct_tspans:
if is_new_line_tspan(tspan):
line_groups.append([tspan])
else:
if not line_groups:
return None
line_groups[-1].append(tspan)
if len(line_groups) < 2:
return None
# First pass: validate per-line structural rules and collect dy values.
dy_values: list[float] = [] # one per line (0 for first)
for idx, group in enumerate(line_groups):
tspan = group[0]
t_y = get_attr(tspan, "y")
if t_y is not None:
return None
t_x_raw = get_attr(tspan, "x")
if t_x_raw is not None:
t_x = parse_first_number(t_x_raw)
if base_x is None or t_x is None or abs(t_x - base_x) > 1e-6:
return None
t_dx_raw = get_attr(tspan, "dx")
t_dx = parse_first_number(t_dx_raw) if t_dx_raw is not None else None
if t_dx is not None and abs(t_dx) > 1e-6:
return None
t_dy_raw = get_attr(tspan, "dy")
t_dy = parse_first_number(t_dy_raw) if t_dy_raw is not None else None
if idx == 0:
if t_dy is not None and abs(t_dy) > 1e-6:
return None
dy_values.append(0.0)
else:
if t_dy is None or t_dy <= 0:
return None
dy_values.append(t_dy)
# Second pass: pick the base line-height as the minimum positive dy and
# express each line's dy as base + extra space-before.
positive_dys = [d for d in dy_values[1:] if d > 0]
if not positive_dys:
return None
base = min(positive_dys)
font_size = _get_font_size_px(text_el)
if font_size is not None and base > font_size * MAX_DY_MULTIPLIER + DY_TOLERANCE_PX:
return None
extras: list[float] = [0.0] # first line never has space-before
break_kinds = ["paragraph"]
line_font_sizes = [
_effective_line_font_size_px(text_el, group)
for group in line_groups
]
for idx, d in enumerate(dy_values[1:], start=1):
if d + DY_TOLERANCE_PX < base:
return None # below base — line overlap, not a paragraph
if d > base * MAX_DY_MULTIPLIER + DY_TOLERANCE_PX:
return None # gap too large — treat as section break
extra = d - base
if extra < 0:
extra = 0.0
# dy at the base line-height = soft break (SVG was simulating wrap);
# dy strictly greater than base = hard paragraph break. List markers
# and font-size changes also start a fresh paragraph so semantically
# distinct visual lines do not merge into one PowerPoint line.
reflow_candidate = (
abs(extra) <= DY_TOLERANCE_PX
and not _starts_with_list_marker(line_groups[idx])
and abs(line_font_sizes[idx] - line_font_sizes[idx - 1]) <= 1e-6
)
explicit_soft_break = line_groups[idx][0].get(
PARAGRAPH_SOFT_BREAK_ATTR
)
if explicit_soft_break == "0":
break_kind = "paragraph"
elif explicit_soft_break == "1":
break_kind = "soft"
elif reflow_candidate:
break_kind = "line" if preserve_line_breaks else "soft"
else:
break_kind = "paragraph"
extras.append(0.0 if break_kind != "paragraph" else extra)
break_kinds.append(break_kind)
return base, extras, break_kinds, line_groups, synthetic_first
def _emit_mergeable_paragraph(
text_el: ET.Element,
base_dy: float,
extras: list[float],
break_kinds: list[str],
line_groups: list[list[ET.Element]],
synthetic_first: ET.Element | None = None,
) -> None:
"""Rewrite text_el in place so it stays a single <text> with paragraph rows.
The base line-height goes on the parent <text> via PARAGRAPH_MARK_ATTR.
Each direct-child tspan is normalized: x/y/dx/dy stripped; inline-run
styling and nested tspans are preserved. Per-tspan attrs:
- PARAGRAPH_SOFT_BREAK_ATTR="1" on tspans that should be appended to
the previous <a:p> downstream (SVG used dy to simulate wrap)
- PARAGRAPH_LINE_BREAK_ATTR="1" on tspans that retain an authored line
boundary inside the previous <a:p>
- PARAGRAPH_SPACE_BEFORE_ATTR on tspans that open a new paragraph
with an extra gap (omitted when 0)
"""
text_el.set(PARAGRAPH_MARK_ATTR, format_number(base_dy))
if synthetic_first is not None:
text_el.text = None
text_el.insert(0, synthetic_first)
# Normalize authoring variants before the downstream converter reads the
# paragraph: a line-break tspan may be followed by direct-child inline
# formatting tspans. Wrap those original siblings in one unstyled line
# container so every direct child of <text> is one logical visual line.
# Keeping the authored runs as siblings is important: nesting later runs
# under the positioned first run would incorrectly inherit its typography,
# and moving them independently would lose the first run's tail whitespace.
normalized_lines: list[ET.Element] = []
for group in line_groups:
line = group[0]
if len(group) == 1:
normalized_lines.append(line)
continue
container = ET.Element(f"{{{SVG_NS}}}tspan")
for k in ("x", "y", "dx", "dy"):
line.attrib.pop(k, None)
for run in group:
container.append(run)
normalized_lines.append(container)
for child in list(text_el):
text_el.remove(child)
for line in normalized_lines:
text_el.append(line)
extras_iter = iter(extras)
break_iter = iter(break_kinds)
for tspan in normalized_lines:
for k in ("x", "y", "dx", "dy"):
if k in tspan.attrib:
del tspan.attrib[k]
for k in (
PARAGRAPH_SOFT_BREAK_ATTR,
PARAGRAPH_LINE_BREAK_ATTR,
PARAGRAPH_SPACE_BEFORE_ATTR,
):
tspan.attrib.pop(k, None)
try:
extra = next(extras_iter)
break_kind = next(break_iter)
except StopIteration:
extra = 0.0
break_kind = "paragraph"
if break_kind == "soft":
tspan.set(PARAGRAPH_SOFT_BREAK_ATTR, "1")
elif break_kind == "line":
tspan.set(PARAGRAPH_LINE_BREAK_ATTR, "1")
elif extra > 1e-6:
tspan.set(PARAGRAPH_SPACE_BEFORE_ATTR, format_number(extra))
def flatten_text_with_tspans(
tree: ET.ElementTree,
merge_paragraphs: bool = False,
preserve_line_breaks: bool = False,
) -> bool:
"""Flatten multi-line tspan text into independent text nodes when needed.
When ``merge_paragraphs`` is True, mergeable paragraph blocks (same x,
dy clustered around one base line-height) are kept as a single <text>.
``preserve_line_breaks`` marks ordinary visual rows as hard line breaks
instead of reflowable continuations. Default split behavior still promotes
every positioned row to its own <text>.
"""
root = tree.getroot()
positional_errors = nested_positional_tspan_errors(root)
if positional_errors:
preview = "; ".join(positional_errors[:3])
suffix = (
""
if len(positional_errors) <= 3
else f"; +{len(positional_errors) - 3} more"
)
raise ValueError(f"Unsupported nested positional <tspan>: {preview}{suffix}")
parent_map = {c: p for p in root.iter() for c in p}
changed = False
def is_svg_tag(el: ET.Element, name: str) -> bool:
return el.tag == f"{{{SVG_NS}}}{name}"
def is_new_line_tspan(tspan: ET.Element) -> bool:
"""Determine whether a tspan represents a new line (has its own y or non-zero dy)."""
t_dy_attr = get_attr(tspan, "dy")
t_y_attr = get_attr(tspan, "y")
t_x_attr = get_attr(tspan, "x")
dy_val = parse_first_number(t_dy_attr) if t_dy_attr is not None else None
# Has its own y attribute, or has non-zero dy, or has its own x attribute (indicating a new line)
if t_y_attr is not None:
return True
if dy_val is not None and dy_val != 0:
return True
# If tspan has an x attribute and there are preceding sibling tspans, treat it as a new line
if t_x_attr is not None:
return True
return False
# Collect candidates first to avoid modifying while iterating
candidates = []
for el in root.iter():
if is_svg_tag(el, "text"):
has_tspan_child = any(is_svg_tag(c, "tspan") for c in list(el))
if has_tspan_child:
candidates.append(el)
for text_el in candidates:
parent = parent_map.get(text_el)
if parent is None:
continue
# First check whether any tspan needs flattening (dy != 0 or has its own y attribute)
needs_flatten = False
for child in list(text_el):
if not is_svg_tag(child, "tspan"):
continue
if is_new_line_tspan(child):
needs_flatten = True
break
# If no tspan needs a line break, skip the entire text element
if not needs_flatten:
continue
# Single-frame fast path: conservative same-x/dy blocks stay in one
# <text>. The downstream converter either preserves visual breaks or
# reflows them. Split mode promotes each positioned line to <text>.
if merge_paragraphs:
paragraph = _classify_paragraph_block(
text_el,
is_svg_tag,
is_new_line_tspan,
preserve_line_breaks,
)
if paragraph is not None:
base_dy, extras, break_kinds, line_groups, synthetic_first = paragraph
_emit_mergeable_paragraph(
text_el,
base_dy,
extras,
break_kinds,
line_groups,
synthetic_first=synthetic_first,
)
changed = True
continue
base_x = parse_first_number(get_attr(text_el, "x")) or 0.0
base_y = parse_first_number(get_attr(text_el, "y")) or 0.0
cur_x, cur_y = base_x, base_y
new_texts = []
# Collect tspan elements belonging to the same line
current_line_tspans = []
current_line_lead_text = text_el.text or None
for idx, child in enumerate(list(text_el)):
if not is_svg_tag(child, "tspan"):
continue
content = collect_text_content(child)
# Check whether this tspan starts a new line
if is_new_line_tspan(child):
# Save previously accumulated same-line tspans first
if current_line_tspans or _has_non_xml_whitespace(
current_line_lead_text
):
ne = _create_text_element_from_line(
text_el, current_line_lead_text, current_line_tspans, cur_x, cur_y
)
new_texts.append(ne)
current_line_tspans = []
current_line_lead_text = None
# Update position
nx, ny = compute_line_positions(text_el, child, cur_x, cur_y)
cur_x, cur_y = nx, ny
# Keep raw XML whitespace and tails until the shared downstream
# text normalizer sees the whole line. A whitespace-only run can
# still be the visible boundary between two formatted runs.
if content or child.tail:
current_line_tspans.append(child)
# Process the last line
if current_line_tspans or _has_non_xml_whitespace(
current_line_lead_text
):
ne = _create_text_element_from_line(
text_el, current_line_lead_text, current_line_tspans, cur_x, cur_y
)
new_texts.append(ne)
if new_texts:
# Replace original <text> with the list of new <text> nodes
try:
idx = list(parent).index(text_el)
except ValueError:
idx = None
# Insert in place to preserve drawing order
for i, ne in enumerate(new_texts):
if idx is not None:
parent.insert(idx + i, ne)
else:
parent.append(ne)
# Remove the original <text>
parent.remove(text_el)
changed = True
return changed
def _has_tspan_children(elem: ET.Element) -> bool:
"""Return True if elem contains any nested <tspan> children (inline runs)."""
return any(c.tag == f"{{{SVG_NS}}}tspan" for c in list(elem))
def _copy_inline_tspan(src: ET.Element, strip_line_attrs: bool) -> ET.Element:
"""Deep-copy a tspan as an inline run, preserving nested tspan structure, head text, and tail text.
When strip_line_attrs is True, x/y/dy are dropped because the enclosing
<text> owns the resolved line position. Drop dx only from a positioned
line starter, where compute_line_positions already consumed it; preserve
dx on later inline runs.
Nested tspans are copied recursively without stripping (they are already inline-only).
"""
new = ET.Element(f"{{{SVG_NS}}}tspan")
consumed_dx = (
strip_line_attrs
and _positional_tspan_attribute(src) is not None
)
for k, v in src.attrib.items():
if strip_line_attrs and k in ("x", "y", "dy"):
continue
if k == "dx" and consumed_dx:
continue
new.set(k, v)
new.text = src.text
for child in list(src):
if child.tag == f"{{{SVG_NS}}}tspan":
new.append(_copy_inline_tspan(child, strip_line_attrs=False))
new.tail = src.tail
return new
def _create_text_element_from_line(
text_el: ET.Element,
lead_text: str | None,
tspans: list[ET.Element],
x: float | None,
y: float | None,
) -> ET.Element:
"""
Create a text element from a line's content (may contain leading text and multiple tspans).
If there is only one tspan with no nested tspan children and no leading text, the line
collapses to a plain <text>...</text>. Otherwise the tspan structure (including any
nested inline tspans) is preserved so per-run formatting survives the flatten step.
"""
ne = ET.Element(f"{{{SVG_NS}}}text")
# Copy attrs from parent <text>
copy_text_attrs(text_el, ne, exclude={"x", "y"})
ne.set("x", format_number(x))
ne.set("y", format_number(y))
# Transform
p_tf = text_el.get("transform")
if p_tf:
ne.set("transform", p_tf)
# Compact path: a single tspan with no nested inline runs or parent-owned
# tail collapses to <text>text</text>. A tail must remain outside the tspan
# so its parent typography and xml:space semantics remain intact.
if (
not lead_text
and len(tspans) == 1
and not _has_tspan_children(tspans[0])
and not tspans[0].tail
):
tspan = tspans[0]
content = collect_text_content(tspan)
xml_space_attr = "{http://www.w3.org/XML/1998/namespace}space"
xml_space = tspan.get(xml_space_attr)
if xml_space is not None:
ne.set(xml_space_attr, xml_space)
# Merge style
merged_style = merge_styles(text_el.get("style"), tspan.get("style"))
if merged_style:
ne.set("style", merged_style)
# Override specific attributes from tspan
for attr in TEXT_STYLE_ATTRS:
cv = tspan.get(attr)
if cv is not None:
ne.set(attr, cv)
# Combine transform
c_tf = tspan.get("transform")
if p_tf and c_tf:
ne.set("transform", f"{p_tf} {c_tf}")
elif c_tf:
ne.set("transform", c_tf)
ne.text = content
else:
# Preserve tspan structure, including nested inline tspans and tail text
if lead_text:
ne.text = lead_text
for tspan in tspans:
ne.append(_copy_inline_tspan(tspan, strip_line_attrs=True))
return ne
def process_svg_file(
src_path: str,
dst_path: str,
merge_paragraphs: bool = False,
) -> bool:
"""Flatten eligible tspan lines in one SVG file."""
try:
tree = ET.parse(src_path)
except ET.ParseError as e:
print(f"[WARN] Failed to parse {src_path}: {e}")
return False
changed = flatten_text_with_tspans(tree, merge_paragraphs=merge_paragraphs)
# Ensure destination directory exists
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
# Write out XML without XML declaration to mimic input style
tree.write(dst_path, encoding="utf-8", xml_declaration=False, method="xml")
return changed
def _compute_default_out_base(inp: str) -> str:
"""Compute default output path for directory or file input."""
if os.path.isdir(inp):
# Default: if input ends with svg_output, use sibling svg_output_flattext;
# otherwise append _flattext to the directory name at the same level.
head, tail = os.path.split(os.path.normpath(inp))
if tail == "svg_output":
return os.path.join(head, "svg_output_flattext")
return inp.rstrip("/\\") + "_flattext"
else:
base, ext = os.path.splitext(inp)
return base + "_flattext" + ext
def _interactive_get_paths() -> tuple[str | None, str | None]:
"""
Interactive mode: prompt the user for input path (SVG file or directory)
and optional output path. Returns (inp, out_base) or (None, None) if cancelled.
"""
print("[Interactive mode] No arguments provided; running interactively.")
print("Please enter the path to process (SVG file or directory containing SVGs).")
print("Enter q to quit.\n")
while True:
raw = input("Input path (file/dir): ").strip()
if raw.lower() in {"q", "quit", "exit"} or raw == "":
return None, None
inp = os.path.expanduser(raw)
if os.path.exists(inp):
break
print("Path does not exist. Please re-enter or enter q to quit.")
default_out = _compute_default_out_base(inp)
if os.path.isdir(inp):
prompt = f"Output directory [default: {default_out}]: "
else:
prompt = f"Output file [default: {default_out}]: "
raw_out = input(prompt).strip()
out_base = os.path.expanduser(raw_out) if raw_out else default_out
return inp, out_base
def main() -> None:
"""Run the CLI entry point."""
# CLI parsing with optional interactive mode
parser = argparse.ArgumentParser(
description="Flatten <tspan> lines into multiple <text> nodes for better compatibility.",
add_help=True,
)
parser.add_argument("input", nargs="?", help="Input path: SVG file or directory")
parser.add_argument("output", nargs="?", help="Optional output file/dir")
parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="Run in interactive prompt mode to input paths",
)
parser.add_argument(
"--merge-paragraphs",
action="store_true",
default=False,
help=(
"Opt-in: merge mergeable paragraph blocks (same x, dy clustered "
"around one base line-height) into a single <text> annotated for "
"downstream multi-<a:p> conversion. Default off — every line-break "
"tspan becomes its own <text>, preserving SVG pixel fidelity."
),
)
args = parser.parse_args()
if args.interactive or not args.input:
inp, out_base = _interactive_get_paths()
if not inp:
print("Cancelled. Usage: python3 scripts/svg_finalize/flatten_tspan.py <input_dir_or_svg> [output_dir]")
sys.exit(0)
else:
inp = args.input
out_base = args.output
if os.path.isdir(inp):
# If output base not provided, create a sibling folder named svg_output_flattext for svg_output
if out_base is None:
out_base = _compute_default_out_base(inp)
total = 0
changed_count = 0
out_base_abs = os.path.abspath(out_base)
for root, dirs, files in os.walk(inp):
# Avoid recursing into the output directory when it lives under input
dirs[:] = [d for d in dirs if os.path.abspath(os.path.join(root, d)) != out_base_abs]
rel_root = os.path.relpath(root, inp)
for f in files:
if not f.lower().endswith(".svg"):
continue
src = os.path.join(root, f)
dst = os.path.join(out_base, rel_root, f) if rel_root != "." else os.path.join(out_base, f)
total += 1
changed = process_svg_file(src, dst, merge_paragraphs=args.merge_paragraphs)
if changed:
changed_count += 1
print(f"Processed {total} SVG(s). With <tspan> flattened: {changed_count}.")
print(f"Output written to: {out_base}")
else:
src = inp
if out_base is None:
out_base = _compute_default_out_base(src)
changed = process_svg_file(src, out_base, merge_paragraphs=args.merge_paragraphs)
print(f"Written: {out_base} (flattened: {changed})")
if __name__ == "__main__":
main()