diff --git a/.gitignore b/.gitignore index 0031574..6541772 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # temporäres: +__pycache__/ *.pyc # fertig installierbares diff --git a/kra2svg_inkscape/exporter.py b/kra2svg_inkscape/exporter.py index 640344b..1dd7d7b 100644 --- a/kra2svg_inkscape/exporter.py +++ b/kra2svg_inkscape/exporter.py @@ -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 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