fixed text formatting

newlines broke when editing characters next to newlines due to different
line handling in krita and inkscape
This commit is contained in:
2026-09-22 12:45:27 +02:00
parent 76826a0892
commit 3b3c32b94e
2 changed files with 188 additions and 1 deletions
+187 -1
View File
@@ -196,6 +196,155 @@ def prefix_ids(root, prefix):
el.text = _URL_RE.sub(sub, el.text)
XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
_NUM = r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?"
def _single_num(v):
"""Einzelwert einer x/y/dy-Angabe; None bei Listen (Einzelzeichen-Positionierung)."""
if v is None:
return None
parts = [p for p in re.split(r"[\s,]+", v.strip()) if p]
if len(parts) != 1:
return None
try:
return float(parts[0])
except ValueError:
return None
def _style_get(style, key):
m = re.search(r"(?:^|;)\s*%s\s*:\s*([^;]+)" % re.escape(key), style or "")
return m.group(1).strip() if m else None
def _style_set(style, key, value):
parts = [p for p in (style or "").split(";") if p.strip()
and p.split(":", 1)[0].strip() != key]
parts.append("%s:%s" % (key, value))
return ";".join(p.strip() for p in parts)
def _split_blocks(steps, tol=0.02):
"""Zeilenabstände in Blöcke mit gemeinsamem Grundabstand teilen.
Rückgabe: Liste (erste_zeile, letzte_zeile, grundabstand, [vielfache])."""
blocks, start, base, mults_steps = [], 0, None, []
for i, st in enumerate(steps): # st = Abstand Zeile i -> i+1
cand = st if base is None else min(base, st)
trial = mults_steps + [st]
if all(abs(x - round(x / cand) * cand) <= tol * cand for x in trial):
base, mults_steps = cand, trial
else:
blocks.append((start, i, base, [int(round(x / base)) for x in mults_steps] if base else []))
start, base, mults_steps = i + 1, None, []
n = len(steps) + 1
blocks.append((start, n - 1, base, [int(round(x / base)) for x in mults_steps] if base else []))
return blocks
def inkscape_text_lines(root):
"""Krita-Mehrzeilentext (tspan mit x + relativem dy) in Inkscape-Zeilen
(sodipodi:role="line", absolutes y, line-height) umbauen. Wechselt der
Zeilenabstand, wird der Text in mehrere Textobjekte (Absätze) geteilt,
weil Inkscape Abstände zwischen Zeilen mit unterschiedlichem line-height mittelt.
Rückgabe: (umgebaut, davon geteilt, unverändert gelassen)."""
role = q(SODI_NS, "role")
tspan_tag = q(SVG_NS, "tspan")
text_tag = q(SVG_NS, "text")
parents = {c: p for p in root.iter() for c in p}
fixed = split = kept = 0
for text in list(root.iter(text_tag)):
# Krita rendert führende/abschließende Leerzeichen -> in Inkscape erhalten
text.set(XML_SPACE, "preserve")
style = text.get("style", "")
fs = _style_get(style, "font-size")
if fs and re.fullmatch(_NUM, fs): # einheitenlos ist in CSS ungültig
style = _style_set(style, "font-size", fs + "px")
anchor = text.attrib.pop("text-anchor", None) or _style_get(style, "text-anchor")
if anchor:
style = _style_set(style, "text-anchor", anchor)
align = {"middle": "center", "end": "end"}.get(anchor, "start")
style = _style_set(style, "text-align", align)
text.set("style", style)
kids = list(text)
if not kids: # einfacher Einzeiler
continue
if ((text.text or "").strip()
or any(k.tag != tspan_tag or (k.tail or "").strip() for k in kids)
or any(k.get(role) for k in kids)):
kept += 1
continue
# absolute Grundlinien und x je Zeile bestimmen
ys, xs, ok = [], [], True
cur_y = _single_num(text.get("y")) or 0.0
cur_x = _single_num(text.get("x")) or 0.0
for k in kids:
for attr in ("x", "y", "dx", "dy"):
if k.get(attr) is not None and _single_num(k.get(attr)) is None:
ok = False
if k.get("dx") is not None:
ok = False
if not ok:
break
if k.get("x") is not None:
cur_x = _single_num(k.get("x"))
if k.get("y") is not None:
cur_y = _single_num(k.get("y"))
if k.get("dy") is not None:
cur_y += _single_num(k.get("dy"))
xs.append(cur_x)
ys.append(cur_y)
steps = [b2 - a2 for a2, b2 in zip(ys, ys[1:])]
if not ok or any(st <= 0 for st in steps):
kept += 1
continue
blocks = _split_blocks(steps)
for k in kids:
text.remove(k)
k.attrib.pop("dy", None)
def fill(el, first, last, base, mults):
for i in range(first, last + 1):
if i > first:
for e in range(1, mults[i - first - 1]): # Leerzeilen
empty = ET.SubElement(el, tspan_tag)
empty.set(role, "line")
empty.set("x", "%.6g" % xs[i])
empty.set("y", "%.6g" % (ys[i - 1] + e * base))
k = kids[i]
k.set(role, "line")
k.set("x", "%.6g" % xs[i])
k.set("y", "%.6g" % ys[i])
el.append(k)
el.set("x", "%.6g" % xs[first])
el.set("y", "%.6g" % ys[first])
if base:
el.set("style", _style_set(el.get("style", ""), "line-height", "%.6gpx" % base))
if len(blocks) == 1:
fill(text, *blocks[0])
fixed += 1
continue
# mehrere Absätze -> je Absatz ein eigenes <text> an derselben Stelle,
# jeweils mit Kritas Transformation
parent = parents[text]
idx = list(parent).index(text)
parent.remove(text)
for bi, blk in enumerate(blocks):
t2 = ET.Element(text_tag, dict(text.attrib))
if text.get("id"):
t2.set("id", "%s_a%d" % (text.get("id"), bi + 1))
fill(t2, *blk)
parent.insert(idx + bi, t2)
fixed += 1
split += 1
return fixed, split, kept
def is_mask(node):
return node.type().endswith("mask")
@@ -314,7 +463,19 @@ class SvgExporter(object):
tree = ET.ElementTree(root)
if hasattr(ET, "indent"):
# Text-Inhalte vor dem Einrücken sichern: Whitespace zwischen tspans
# wäre bei xml:space="preserve" sichtbarer Text
saved = []
for t in root.iter(q(SVG_NS, "text")):
saved.append((t, t.text))
for el in t.iter():
if el is not t:
saved.append((el, el.text, el.tail))
ET.indent(tree, space=" ")
for entry in saved:
entry[0].text = entry[1]
if len(entry) == 3:
entry[0].tail = entry[2]
os.makedirs(os.path.dirname(self.svg_path) or ".", exist_ok=True)
tree.write(self.svg_path, encoding="utf-8", xml_declaration=True)
self.log(" -> %s (%d Warnung(en))" % (self.svg_path, self.warnings))
@@ -596,6 +757,18 @@ class SvgExporter(object):
uid = self._uid(name)
prefix_ids(src, uid + "_")
skip = {q(SVG_NS, "title"), q(SVG_NS, "metadata"), q(SODI_NS, "namedview")}
single_shape = len([c for c in src if c.tag not in skip
and c.tag != q(SVG_NS, "defs")]) == 1
fixed, split, kept = inkscape_text_lines(src)
if fixed:
self.log(" · „%s“: %d Text(e) in Inkscape-Zeilen umgebaut" % (name, fixed))
if split:
self.log(" · „%s“: %d Text(e) wegen wechselnder Zeilenabstände in Absätze geteilt"
% (name, split))
if kept:
self.warn("„%s“: %d Text(e) mit Sonderpositionierung unverändert übernommen – "
"beim Bearbeiten in Inkscape evtl. Zeilenversatz" % (name, kept))
g = ET.SubElement(svg_parent, q(SVG_NS, "g"), {"id": uid})
g.set(q(INK_NS, "label"), name)
tf = []
@@ -608,7 +781,6 @@ class SvgExporter(object):
g.set("transform", " ".join(tf))
self._apply_common(node, g)
skip = {q(SVG_NS, "title"), q(SVG_NS, "metadata"), q(SODI_NS, "namedview")}
n = 0
for child in list(src):
if child.tag in skip:
@@ -616,6 +788,20 @@ class SvgExporter(object):
g.append(child)
n += 1
self.log(" · Vektorebene „%s“: %d Element(e) übernommen" % (name, n))
# Eine Ebene mit nur einem Objekt (auch ein in Absätze geteilter Text)
# braucht keine eigene Gruppe, sofern die Gruppe nichts Eigenes trägt:
# Transformation auf die Objekte übertragen und Gruppe auflösen.
if single_shape and g.get("opacity") is None and g.get("style") is None:
gtf = g.get("transform")
idx = list(svg_parent).index(g)
svg_parent.remove(g)
for child in list(g):
if child.tag != q(SVG_NS, "defs"):
ctf = child.get("transform")
child.set("transform", gtf + (" " + ctf if ctf else ""))
svg_parent.insert(idx, child)
idx += 1
return True