# -*- 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 -> , Pixel-/Datei-/Füll-/Klonebenen -> (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) 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"): ET.indent(tree, space=" ") 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 + "_") 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) 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: continue g.append(child) n += 1 self.log(" · Vektorebene „%s“: %d Element(e) übernommen" % (name, n)) 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