Files
kra2svg/kra2svg_inkscape/exporter.py
T
uha4 3b3c32b94e fixed text formatting
newlines broke when editing characters next to newlines due to different
line handling in krita and inkscape
2026-09-22 12:45:27 +02:00

862 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.
# -*- coding: utf-8 -*-
"""
Kernlogik: Krita-Dokument -> SVG für Inkscape.
Unabhängig von der GUI nutzbar, z. B. aus dem Krita-Scripter:
from kra2svg_inkscape.exporter import export_document, export_files, ExportOptions
export_document(Krita.instance().activeDocument(), "/tmp/bild.svg", ExportOptions(margin=2))
export_files(["/pfad/a.kra", "/pfad/b.kra"], "/pfad/ausgabe", ExportOptions(embed_png=False))
Grundprinzip
------------
* Gearbeitet wird immer auf einer Kopie (aktives Dokument -> clone(); Batch -> frisch
geöffnetes Dokument). Diese Kopie wird nach 8 Bit sRGB konvertiert, damit
pixelData() verlässlich BGRA-8 liefert und die Farben in SVG/Inkscape stimmen.
* Gruppen -> <g>, Pixel-/Datei-/Füll-/Klonebenen -> <image> (auf Alpha-Inhalt
zugeschnitten, mit Offset), Vektorebenen -> echte SVG-Objekte aus toSvg().
* Was sich in SVG nicht abbilden lässt, wird gerendert:
- Masken/Effekte an Ebenen: stecken in der Projektion der Ebene (automatisch).
- Masken/Effekte an Gruppen oder Vektorebenen: ganze Ebene/Gruppe wird gerastert.
- Filterebenen, nicht abbildbare Mischmodi, "Alpha erben": brauchen den
Hintergrund -> alle Geschwister von unten bis einschließlich dieser Ebene
werden zu EINEM Bild zusammengerechnet ("Backdrop-Baking").
"""
import base64
import os
import re
import xml.etree.ElementTree as ET
from contextlib import contextmanager
from urllib.parse import quote
from PyQt5.QtCore import QBuffer, QByteArray, QIODevice, QRect, Qt
from PyQt5.QtGui import QImage, QPainter
# --------------------------------------------------------------------------- #
# Konstanten
# --------------------------------------------------------------------------- #
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
INK_NS = "http://www.inkscape.org/namespaces/inkscape"
SODI_NS = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
KRITA_NS = "http://krita.org/namespaces/svg/krita"
for _p, _u in (("", SVG_NS), ("xlink", XLINK_NS), ("inkscape", INK_NS),
("sodipodi", SODI_NS), ("krita", KRITA_NS)):
ET.register_namespace(_p, _u)
SRGB_PROFILE = "sRGB-elle-V2-srgbtrc.icc"
# Krita-Compositing-IDs -> CSS mix-blend-mode ("" = normal, kein Style nötig).
# Alles, was hier fehlt, wird gerendert.
BLEND_MAP = {
"normal": "",
"multiply": "multiply",
"screen": "screen",
"overlay": "overlay",
"darken": "darken",
"lighten": "lighten",
"dodge": "color-dodge",
"burn": "color-burn",
"hard_light": "hard-light",
"soft_light_svg": "soft-light",
"diff": "difference",
"exclusion": "exclusion",
# Krita rechnet diese im HSY-Modell, CSS im HSL-ähnlichen Modell -> sehr ähnlich,
# aber nicht bitgenau. Bei Bedarf hier auskommentieren, dann wird gerendert.
"hue": "hue",
"saturation": "saturation",
"color": "color",
"luminize": "luminosity",
}
EFFECT_MASKS = {"transparencymask", "filtermask", "transformmask", "colorizemask"}
RASTER_TYPES_COVER_CANVAS = {"filterlayer", "filllayer"}
def q(ns, tag):
return "{%s}%s" % (ns, tag)
# --------------------------------------------------------------------------- #
# Optionen
# --------------------------------------------------------------------------- #
class ExportOptions(object):
def __init__(self, skip_invisible=True, keep_vectors=True, embed_png=True,
margin=0, groups_as_layers=False, clip_to_canvas=True,
physical_size=True):
self.skip_invisible = skip_invisible # unsichtbare Ebenen weglassen
self.keep_vectors = keep_vectors # Vektorebenen als SVG-Objekte
self.embed_png = embed_png # data:-URI statt Asset-Dateien
self.margin = max(0, int(margin)) # transparenter Rand um PNGs (px)
self.groups_as_layers = groups_as_layers # Gruppen als Inkscape-Ebenen
self.clip_to_canvas = clip_to_canvas # Inhalt außerhalb der Leinwand abschneiden
self.physical_size = physical_size # width/height in mm gemäß DPI
def to_dict(self):
return dict(self.__dict__)
@classmethod
def from_dict(cls, d):
o = cls()
for k, v in d.items():
if hasattr(o, k):
setattr(o, k, v)
return o
# --------------------------------------------------------------------------- #
# Hilfsfunktionen
# --------------------------------------------------------------------------- #
def sanitize(name, fallback="ebene"):
s = re.sub(r"[^A-Za-z0-9_\-]+", "_", name or "").strip("_")
return s or fallback
def alpha_bbox(data, w, h):
"""Bounding-Box aller Pixel mit Alpha > 0 in BGRA-8-Daten, oder None.
Die inneren Schleifen laufen über bytes.strip() in C, daher auch bei
großen Ebenen schnell genug."""
alpha = data[3::4]
top = bottom = None
left, right = w, 0
for row in range(h):
r = alpha[row * w:(row + 1) * w]
stripped = r.lstrip(b"\x00")
if not stripped:
continue
l = w - len(stripped)
rr = len(r.rstrip(b"\x00"))
if top is None:
top = row
bottom = row
if l < left:
left = l
if rr > right:
right = rr
if top is None:
return None
return left, top, right - left, bottom - top + 1
def length_to_px(value, dpi):
"""SVG-Längenangabe -> Dokumentpixel (bei gegebener Auflösung)."""
m = re.match(r"\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\s*([a-zA-Z%]*)", value or "")
if not m:
return None
num, unit = float(m.group(1)), m.group(2).lower()
factors = {
"": dpi / 72.0, # einheitenlos: Krita arbeitet intern in pt
"pt": dpi / 72.0,
"px": dpi / 96.0, # CSS-px
"pc": dpi / 6.0,
"mm": dpi / 25.4,
"cm": dpi / 2.54,
"in": float(dpi),
}
if unit not in factors:
return None
return num * factors[unit]
_URL_RE = re.compile(r"url\(\s*['\"]?#([^)'\"\s]+)['\"]?\s*\)")
def prefix_ids(root, prefix):
"""Alle IDs eines SVG-Fragments mit Präfix versehen und Referenzen anpassen,
damit Verläufe/Muster mehrerer Vektorebenen nicht kollidieren."""
ids = {}
for el in root.iter():
i = el.get("id")
if i:
ids[i] = prefix + i
el.set("id", prefix + i)
if not ids:
return
def sub(m):
return "url(#%s)" % ids.get(m.group(1), m.group(1))
href_keys = (q(XLINK_NS, "href"), "href")
for el in root.iter():
for k, v in list(el.attrib.items()):
if k == "id":
continue
nv = _URL_RE.sub(sub, v)
if k in href_keys and nv.startswith("#"):
nv = "#" + ids.get(nv[1:], nv[1:])
if nv != v:
el.set(k, nv)
if el.text and "url(" in el.text:
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")
def has_effect_masks(node):
return any(c.type() in EFFECT_MASKS for c in node.childNodes())
def is_passthrough(node):
fn = getattr(node, "passThroughMode", None)
try:
return bool(fn()) if callable(fn) else False
except Exception:
return False
def inherits_alpha(node):
fn = getattr(node, "inheritAlpha", None)
try:
return bool(fn()) if callable(fn) else False
except Exception:
return False
def blend_css(node):
"""CSS-Mischmodus oder None, falls nicht abbildbar."""
return BLEND_MAP.get(node.blendingMode())
# --------------------------------------------------------------------------- #
# Exporter
# --------------------------------------------------------------------------- #
class SvgExporter(object):
def __init__(self, doc, options=None, log=None):
self.doc = doc
self.opt = options or ExportOptions()
self._log_fn = log or (lambda msg: None)
self.warnings = 0
self._ids = set()
self._asset_names = set()
# ---- Logging ---------------------------------------------------------- #
def log(self, msg):
self._log_fn(msg)
def warn(self, msg):
self.warnings += 1
self._log_fn(" ⚠ " + msg)
# ---- Dokument --------------------------------------------------------- #
def _refresh(self):
self.doc.refreshProjection()
self.doc.waitForDone()
def _prepare(self):
d = self.doc
if (d.colorModel(), d.colorDepth(), d.colorProfile()) != ("RGBA", "U8", SRGB_PROFILE):
self.log(" Konvertiere Arbeitskopie %s/%s -> RGBA/U8 sRGB"
% (d.colorModel(), d.colorDepth()))
ok = d.setColorSpace("RGBA", "U8", SRGB_PROFILE)
d.waitForDone()
if not ok and d.colorModel() == "RGBA":
d.setColorSpace("RGBA", "U8", d.colorProfile())
d.waitForDone()
self.warn("sRGB-Profil nicht verfügbar, Originalprofil behalten")
if (d.colorModel(), d.colorDepth()) != ("RGBA", "U8"):
raise RuntimeError("Farbraumkonvertierung nach RGBA/U8 fehlgeschlagen")
self._convert_nodes(d.topLevelNodes())
self._refresh()
self.W, self.H = d.width(), d.height()
self.dpi = float(d.resolution() or 72)
self.canvas = QRect(0, 0, self.W, self.H)
def _convert_nodes(self, nodes):
"""Krita erlaubt Ebenen mit eigenem Farbraum (z. B. GRAYA). Diese werden
in der Arbeitskopie einzeln nach RGBA/U8 sRGB konvertiert."""
for n in nodes:
if is_mask(n):
continue
try:
model, depth = n.colorModel(), n.colorDepth()
except Exception:
model, depth = "RGBA", "U8"
if (model, depth) != ("RGBA", "U8") and n.type() != "grouplayer":
ok = n.setColorSpace("RGBA", "U8", SRGB_PROFILE)
self.doc.waitForDone()
self.log(" · Ebene „%s“: %s/%s -> RGBA/U8%s"
% (n.name(), model, depth, "" if ok else " (fehlgeschlagen)"))
self._convert_nodes(n.childNodes())
@contextmanager
def _ensure_visible(self, node):
"""Unsichtbare Knoten kurz einblenden, damit ihre Projektion aktuell ist."""
if node is None or node.visible():
yield
return
node.setVisible(True)
self._refresh()
try:
yield
finally:
node.setVisible(False)
self._refresh()
# ---- Einstieg ---------------------------------------------------------- #
def export(self, svg_path):
self.svg_path = os.path.abspath(svg_path)
base = os.path.splitext(self.svg_path)[0]
self.asset_dir = base + "_assets"
self.asset_rel = os.path.basename(self.asset_dir)
self._prepare()
root = self._build_root()
self._process_siblings(None, self.doc.topLevelNodes(), root)
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))
return self.svg_path
def _build_root(self):
W, H = self.W, self.H
if self.opt.physical_size:
w_attr = "%.4fmm" % (W / self.dpi * 25.4)
h_attr = "%.4fmm" % (H / self.dpi * 25.4)
units = "mm"
else:
w_attr, h_attr, units = "%dpx" % W, "%dpx" % H, "px"
root = ET.Element(q(SVG_NS, "svg"), {
"version": "1.1",
"width": w_attr,
"height": h_attr,
"viewBox": "0 0 %d %d" % (W, H),
})
root.set(q(SODI_NS, "docname"), os.path.basename(self.svg_path))
nv = ET.SubElement(root, q(SODI_NS, "namedview"), {"id": "namedview"})
nv.set(q(INK_NS, "document-units"), units)
title = ET.SubElement(root, q(SVG_NS, "title"))
name = os.path.basename(self.doc.fileName() or "") or self.doc.name() or "Krita-Export"
title.text = name
return root
# ---- Baum ------------------------------------------------------------- #
def _needs_backdrop(self, node):
t = node.type()
if t == "filterlayer":
return True
if inherits_alpha(node):
return True
if t == "grouplayer" and is_passthrough(node):
return False
return blend_css(node) is None
def _process_siblings(self, parent, nodes, svg_parent):
layers = [n for n in nodes if not is_mask(n)] # Krita: Index 0 = unten
cut = -1
for i, n in enumerate(layers):
if n.visible() and self._needs_backdrop(n):
cut = i
if cut >= 0:
self._bake(parent, layers, cut, svg_parent)
rest = layers[cut + 1:]
else:
rest = layers
for n in rest:
self._process_node(n, svg_parent)
def _process_node(self, node, svg_parent):
name = node.name()
if self.opt.skip_invisible and not node.visible():
self.log(" – übersprungen (unsichtbar): %s" % name)
return
t = node.type()
try:
if t == "grouplayer":
reason = self._group_raster_reason(node)
if reason:
self.warn("Gruppe „%s“ gerendert (%s)" % (name, reason))
self._raster(node, svg_parent)
else:
self._group(node, svg_parent)
elif t == "vectorlayer" and self.opt.keep_vectors:
if has_effect_masks(node):
self.warn("Vektorebene „%s“ gerendert (Masken/Effekte)" % name)
self._raster(node, svg_parent)
elif not self._vector(node, svg_parent):
self._raster(node, svg_parent)
else:
if t != "paintlayer" and t != "vectorlayer":
self.log(" · %s „%s“ als Bild" % (t, name))
if has_effect_masks(node):
self.log(" · Masken/Effekte von „%s“ eingerechnet" % name)
self._raster(node, svg_parent)
except Exception as e: # eine kaputte Ebene soll nicht den Export abbrechen
self.warn("Fehler bei „%s“ (%s): %s – versuche Rasterung" % (name, t, e))
try:
self._raster(node, svg_parent)
except Exception as e2:
self.warn("Ebene „%s“ ausgelassen: %s" % (name, e2))
def _group_raster_reason(self, node):
if has_effect_masks(node):
return "Gruppenmasken/-effekte"
if is_passthrough(node):
for c in node.childNodes():
if not is_mask(c) and c.visible() and self._needs_backdrop(c):
return "Durchreichen-Modus mit nicht abbildbarem Inhalt"
return None
# ---- Elemente --------------------------------------------------------- #
def _uid(self, name):
base = "k_" + sanitize(name)
cand, i = base, 2
while cand in self._ids:
cand = "%s_%d" % (base, i)
i += 1
self._ids.add(cand)
return cand
def _apply_common(self, node, el, isolate=False):
op = node.opacity() / 255.0
if op < 0.999:
el.set("opacity", "%.4g" % op)
styles = []
if not (node.type() == "grouplayer" and is_passthrough(node)):
mode = blend_css(node)
if mode:
styles.append("mix-blend-mode:%s" % mode)
if isolate:
styles.append("isolation:isolate")
if not node.visible():
styles.append("display:none")
if styles:
el.set("style", ";".join(styles))
def _group(self, node, svg_parent):
g = ET.SubElement(svg_parent, q(SVG_NS, "g"), {"id": self._uid(node.name())})
g.set(q(INK_NS, "label"), node.name())
if self.opt.groups_as_layers:
g.set(q(INK_NS, "groupmode"), "layer")
isolate = (not is_passthrough(node)) and any(
not is_mask(c) and blend_css(c) for c in node.childNodes())
self._apply_common(node, g, isolate=isolate)
self._process_siblings(node, node.childNodes(), g)
if len(g) == 0:
svg_parent.remove(g)
self.log(" – leere Gruppe ausgelassen: %s" % node.name())
def _raster(self, node, svg_parent):
with self._ensure_visible(node):
res = self._grab(node.projectionPixelData, node.bounds())
if res is None:
self.log(" – leer: %s" % node.name())
return
img, x, y = res
el = self._image_el(svg_parent, img, x, y, node.name())
self._apply_common(node, el)
def _bake(self, parent, layers, cut, svg_parent):
included = [l for l in layers[:cut + 1]]
above = layers[cut + 1:]
visible = [l for l in included if l.visible()]
dropped = [l.name() for l in included if not l.visible()]
culprits = [l.name() for l in visible if self._needs_backdrop(l)]
where = "Oberste Ebene" if parent is None else "Gruppe „%s“" % parent.name()
self.warn("%s: %d Ebene(n) zusammengerendert wegen %s"
% (where, len(visible), ", ".join("„%s“" % c for c in culprits)))
if dropped and not self.opt.skip_invisible:
self.warn("unsichtbare Ebenen im gerenderten Bereich entfallen: %s"
% ", ".join(dropped))
rect = QRect()
for l in visible:
rect = rect.united(l.bounds())
if l.type() in RASTER_TYPES_COVER_CANVAS:
rect = rect.united(self.canvas)
hidden_now = []
for l in above:
if l.visible():
l.setVisible(False)
hidden_now.append(l)
try:
self._refresh()
with self._ensure_visible(parent):
reader = self.doc.pixelData if parent is None else parent.projectionPixelData
res = self._grab(reader, rect)
finally:
for l in hidden_now:
l.setVisible(True)
if hidden_now:
self._refresh()
if res is None:
return
img, x, y = res
label = "[gerendert] " + " + ".join(l.name() for l in visible)
if len(label) > 120:
label = label[:117] + "…"
self._image_el(svg_parent, img, x, y, label)
def _grab(self, reader, rect):
"""Pixel lesen, auf Alpha-Inhalt zuschneiden, Rand anfügen.
Rückgabe (QImage, x, y) in Dokumentkoordinaten oder None."""
r = QRect(rect)
if self.opt.clip_to_canvas:
r = r.intersected(self.canvas)
if r.isEmpty():
return None
x, y, w, h = r.x(), r.y(), r.width(), r.height()
data = bytes(reader(x, y, w, h))
if len(data) == w * h * 2: # GRAYA/U8, falls Konvertierung nicht griff
g, a = data[0::2], data[1::2]
buf = bytearray(w * h * 4)
buf[0::4] = g
buf[1::4] = g
buf[2::4] = g
buf[3::4] = a
data = bytes(buf)
if len(data) != w * h * 4:
raise RuntimeError("unerwartetes Pixelformat (%d Bytes für %dx%d)" % (len(data), w, h))
bb = alpha_bbox(data, w, h)
if bb is None:
return None
bx, by, bw, bh = bb
full = QImage(data, w, h, w * 4, QImage.Format_ARGB32) # BGRA im Speicher
img = full.copy(bx, by, bw, bh)
m = self.opt.margin
if m > 0:
padded = QImage(bw + 2 * m, bh + 2 * m, QImage.Format_ARGB32)
padded.fill(Qt.transparent)
p = QPainter(padded)
p.setCompositionMode(QPainter.CompositionMode_Source)
p.drawImage(m, m, img)
p.end()
img = padded
return img, x + bx - m, y + by - m
def _image_el(self, svg_parent, img, x, y, label):
uid = self._uid(label)
el = ET.SubElement(svg_parent, q(SVG_NS, "image"), {
"id": uid,
"x": str(x),
"y": str(y),
"width": str(img.width()),
"height": str(img.height()),
"preserveAspectRatio": "none",
})
el.set(q(INK_NS, "label"), label)
el.set(q(XLINK_NS, "href"), self._png_href(img, uid))
return el
def _png_href(self, img, uid):
if self.opt.embed_png:
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QIODevice.WriteOnly)
img.save(buf, "PNG")
buf.close()
return "data:image/png;base64," + base64.b64encode(bytes(ba)).decode("ascii")
os.makedirs(self.asset_dir, exist_ok=True)
fname = uid[2:] + ".png"
path = os.path.join(self.asset_dir, fname)
if not img.save(path, "PNG"):
raise RuntimeError("PNG konnte nicht geschrieben werden: " + path)
return quote(self.asset_rel) + "/" + quote(fname)
def _vector(self, node, svg_parent):
"""Vektorebene als SVG-Objekte übernehmen. False -> Aufrufer rastert."""
name = node.name()
fn = getattr(node, "toSvg", None)
if not callable(fn):
self.warn("„%s“: toSvg() nicht verfügbar – gerendert" % name)
return False
text = fn()
if not text or not text.strip():
self.log(" – leere Vektorebene: %s" % name)
return True
try:
src = ET.fromstring(text.encode("utf-8"))
except ET.ParseError as e:
self.warn("„%s“: SVG nicht lesbar (%s) – gerendert" % (name, e))
return False
# Krita-internes Koordinatensystem (pt) -> Dokumentpixel
vb = [float(v) for v in re.split(r"[\s,]+", (src.get("viewBox") or "").strip()) if v]
wpx = length_to_px(src.get("width"), self.dpi)
hpx = length_to_px(src.get("height"), self.dpi)
if len(vb) == 4 and vb[2] > 0 and vb[3] > 0 and wpx and hpx:
sx, sy, ox, oy = wpx / vb[2], hpx / vb[3], vb[0], vb[1]
else:
sx = sy = self.dpi / 72.0
ox = oy = 0.0
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 = []
pos = node.position()
if pos.x() or pos.y():
tf.append("translate(%d,%d)" % (pos.x(), pos.y()))
tf.append("scale(%.8g,%.8g)" % (sx, sy))
if ox or oy:
tf.append("translate(%.8g,%.8g)" % (-ox, -oy))
g.set("transform", " ".join(tf))
self._apply_common(node, g)
n = 0
for child in list(src):
if child.tag in skip:
continue
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
# --------------------------------------------------------------------------- #
# Komfortfunktionen
# --------------------------------------------------------------------------- #
def export_document(doc, svg_path, options=None, log=None):
"""Exportiert ein (offenes) Dokument über eine Arbeitskopie."""
log = log or (lambda m: None)
log("Exportiere %s" % (doc.fileName() or doc.name()))
work = doc.clone()
if work is None:
raise RuntimeError("Dokument konnte nicht geklont werden")
try:
exp = SvgExporter(work, options, log)
exp.export(svg_path)
return exp.warnings
finally:
work.close()
def export_files(paths, out_dir, options=None, log=None, progress=None):
"""Batch-Export mehrerer .kra-Dateien. progress(i, n) wird nach jeder Datei aufgerufen.
Rückgabe: Liste (Pfad, Erfolg, Meldung)."""
from krita import Krita # erst hier, damit das Modul auch ohne Krita importierbar ist
app = Krita.instance()
log = log or (lambda m: None)
old_batch = app.batchmode()
app.setBatchmode(True)
results = []
try:
for i, path in enumerate(paths):
stem = os.path.splitext(os.path.basename(path))[0]
out = os.path.join(out_dir, stem + ".svg")
log("[%d/%d] %s" % (i + 1, len(paths), path))
doc = app.openDocument(path)
if doc is None:
log(" ✗ konnte nicht geöffnet werden")
results.append((path, False, "nicht geöffnet"))
else:
try:
doc.setBatchmode(True)
doc.waitForDone()
exp = SvgExporter(doc, options, log)
exp.export(out)
results.append((path, True, "%d Warnung(en)" % exp.warnings))
except Exception as e:
log(" ✗ Fehler: %s" % e)
results.append((path, False, str(e)))
finally:
doc.close()
if progress:
progress(i + 1, len(paths))
finally:
app.setBatchmode(old_batch)
return results