commit 76826a0892aed4a06d0c6deafe5fe139f92f2d7e Author: Ulrich Date: Mon Sep 21 22:16:52 2026 +0200 erste lauffähige Version, exportiert .kra erfolgreich als .svg vollständig von Claude geschrieben diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0031574 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# temporäres: +*.pyc + +# fertig installierbares +*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d7b2cc --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# KRA → SVG (Inkscape) Export – Krita 5.x Python-Plugin + +## Installation +1. diesen ganzen Ordner als ZIP packen +2. Krita: *Werkzeuge → Skripte → Python-Plugin aus Datei importieren…* → `kra2svg_inkscape.zip` + (alternativ `kra2svg_inkscape.desktop` + Ordner `kra2svg_inkscape/` nach `pykrita/` im Ressourcenordner kopieren). +3. *Einstellungen → Krita einrichten → Python-Plugin-Verwaltung* → „KRA → SVG (Inkscape) Export“ aktivieren. +4. Krita neu starten. Aufruf: *Werkzeuge → Skripte → SVG für Inkscape exportieren…* + +## Skriptnutzung (Scripter / Kommandozeile) +```python +from kra2svg_inkscape.exporter import export_document, export_files, ExportOptions +export_document(Krita.instance().activeDocument(), "/pfad/bild.svg", ExportOptions(margin=2)) +export_files(["/pfad/a.kra", "/pfad/b.kra"], "/pfad/out", ExportOptions(embed_png=False), print) +``` diff --git a/kra2svg_inkscape.desktop b/kra2svg_inkscape.desktop new file mode 100644 index 0000000..7dc211b --- /dev/null +++ b/kra2svg_inkscape.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Service +ServiceTypes=Krita/PythonPlugin +X-KDE-Library=kra2svg_inkscape +X-Python-2-Compatible=false +X-Krita-Manual=Manual.html +Name=KRA → SVG (Inkscape) Export +Comment=Exportiert den Ebenenbaum als SVG mit Gruppen, zugeschnittenen PNGs und echten Vektorobjekten diff --git a/kra2svg_inkscape/Manual.html b/kra2svg_inkscape/Manual.html new file mode 100644 index 0000000..04e7999 --- /dev/null +++ b/kra2svg_inkscape/Manual.html @@ -0,0 +1,11 @@ +

KRA → SVG (Inkscape) Export

+

Aufruf: Werkzeuge → Skripte → SVG für Inkscape exportieren…

+ diff --git a/kra2svg_inkscape/__init__.py b/kra2svg_inkscape/__init__.py new file mode 100644 index 0000000..b2cda0c --- /dev/null +++ b/kra2svg_inkscape/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from krita import Extension, Krita + + +class Kra2SvgExtension(Extension): + def __init__(self, parent): + super().__init__(parent) + + def setup(self): + pass + + def createActions(self, window): + action = window.createAction("kra2svg_inkscape_export", + "SVG für Inkscape exportieren…", "tools/scripts") + action.triggered.connect(self.show_dialog) + + def show_dialog(self): + from .dialog import ExportDialog + win = Krita.instance().activeWindow() + dlg = ExportDialog(win.qwindow() if win else None) + dlg.exec_() + + +Krita.instance().addExtension(Kra2SvgExtension(Krita.instance())) diff --git a/kra2svg_inkscape/dialog.py b/kra2svg_inkscape/dialog.py new file mode 100644 index 0000000..81eee08 --- /dev/null +++ b/kra2svg_inkscape/dialog.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- +import json +import os +import traceback + +from krita import Krita +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QDialog, + QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, + QHBoxLayout, QLabel, QLineEdit, QListWidget, + QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, + QRadioButton, QSpinBox, QStackedWidget, QVBoxLayout, + QWidget, QAbstractItemView) + +from .exporter import ExportOptions, export_document, export_files + +SETTINGS_GROUP = "kra2svg_inkscape" + + +class ExportDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("KRA → SVG für Inkscape") + self.resize(640, 620) + self.app = Krita.instance() + + lay = QVBoxLayout(self) + + # ---- Modus ---- + mode_box = QHBoxLayout() + self.rb_single = QRadioButton("Aktives Dokument") + self.rb_batch = QRadioButton("Batch (mehrere .kra-Dateien)") + grp = QButtonGroup(self) + grp.addButton(self.rb_single) + grp.addButton(self.rb_batch) + mode_box.addWidget(self.rb_single) + mode_box.addWidget(self.rb_batch) + mode_box.addStretch() + lay.addLayout(mode_box) + + self.stack = QStackedWidget() + lay.addWidget(self.stack) + + # Einzel + w_single = QWidget() + f = QFormLayout(w_single) + self.ed_out = QLineEdit() + b = QPushButton("…") + b.clicked.connect(self._browse_out) + row = QHBoxLayout() + row.addWidget(self.ed_out) + row.addWidget(b) + f.addRow("SVG-Datei:", row) + self.stack.addWidget(w_single) + + # Batch + w_batch = QWidget() + v = QVBoxLayout(w_batch) + v.setContentsMargins(0, 0, 0, 0) + self.lst = QListWidget() + self.lst.setSelectionMode(QAbstractItemView.ExtendedSelection) + v.addWidget(self.lst) + row = QHBoxLayout() + b_add = QPushButton("Dateien hinzufügen…") + b_add.clicked.connect(self._add_files) + b_dir = QPushButton("Ordner hinzufügen…") + b_dir.clicked.connect(self._add_folder) + b_rm = QPushButton("Entfernen") + b_rm.clicked.connect(self._remove_files) + for x in (b_add, b_dir, b_rm): + row.addWidget(x) + row.addStretch() + v.addLayout(row) + f2 = QFormLayout() + self.ed_dir = QLineEdit() + b = QPushButton("…") + b.clicked.connect(self._browse_dir) + row = QHBoxLayout() + row.addWidget(self.ed_dir) + row.addWidget(b) + f2.addRow("Ausgabeordner:", row) + v.addLayout(f2) + self.stack.addWidget(w_batch) + + self.rb_single.toggled.connect(lambda on: self.stack.setCurrentIndex(0 if on else 1)) + + # ---- Optionen ---- + box = QGroupBox("Optionen") + ob = QFormLayout(box) + self.cb_skip = QCheckBox("Unsichtbare Ebenen überspringen") + self.cb_vec = QCheckBox("Vektorebenen als SVG-Objekte erhalten") + self.cb_layers = QCheckBox("Krita-Gruppen als Inkscape-Ebenen (statt Gruppen)") + self.cb_clip = QCheckBox("Auf Leinwand beschneiden") + self.cb_phys = QCheckBox("Physische Größe (mm) aus Dokument-DPI") + for cb in (self.cb_skip, self.cb_vec, self.cb_layers, self.cb_clip, self.cb_phys): + ob.addRow(cb) + self.sp_margin = QSpinBox() + self.sp_margin.setRange(0, 512) + self.sp_margin.setSuffix(" px") + ob.addRow("Rand um PNGs:", self.sp_margin) + prow = QHBoxLayout() + self.rb_embed = QRadioButton("PNGs einbetten") + self.rb_assets = QRadioButton("Separate Asset-Dateien (_assets/)") + g2 = QButtonGroup(self) + g2.addButton(self.rb_embed) + g2.addButton(self.rb_assets) + prow.addWidget(self.rb_embed) + prow.addWidget(self.rb_assets) + ob.addRow("Bilder:", prow) + lay.addWidget(box) + + # ---- Log ---- + self.progress = QProgressBar() + self.progress.setVisible(False) + lay.addWidget(self.progress) + self.log_view = QPlainTextEdit() + self.log_view.setReadOnly(True) + self.log_view.setMinimumHeight(160) + lay.addWidget(QLabel("Protokoll:")) + lay.addWidget(self.log_view, 1) + + bb = QDialogButtonBox() + self.btn_export = bb.addButton("Exportieren", QDialogButtonBox.AcceptRole) + bb.addButton(QDialogButtonBox.Close) + self.btn_export.clicked.connect(self._run) + bb.rejected.connect(self.reject) + lay.addWidget(bb) + + self._load_settings() + + # ---- Einstellungen ---------------------------------------------------- # + def _options(self): + return ExportOptions( + skip_invisible=self.cb_skip.isChecked(), + keep_vectors=self.cb_vec.isChecked(), + embed_png=self.rb_embed.isChecked(), + margin=self.sp_margin.value(), + groups_as_layers=self.cb_layers.isChecked(), + clip_to_canvas=self.cb_clip.isChecked(), + physical_size=self.cb_phys.isChecked(), + ) + + def _load_settings(self): + try: + o = ExportOptions.from_dict(json.loads( + self.app.readSetting(SETTINGS_GROUP, "options", "{}") or "{}")) + except ValueError: + o = ExportOptions() + self.cb_skip.setChecked(o.skip_invisible) + self.cb_vec.setChecked(o.keep_vectors) + self.cb_layers.setChecked(o.groups_as_layers) + self.cb_clip.setChecked(o.clip_to_canvas) + self.cb_phys.setChecked(o.physical_size) + self.sp_margin.setValue(o.margin) + (self.rb_embed if o.embed_png else self.rb_assets).setChecked(True) + self.ed_dir.setText(self.app.readSetting(SETTINGS_GROUP, "batch_dir", "")) + + doc = self.app.activeDocument() + if doc is not None: + self.rb_single.setChecked(True) + src = doc.fileName() + if src: + self.ed_out.setText(os.path.splitext(src)[0] + ".svg") + else: + self.rb_single.setEnabled(False) + self.rb_batch.setChecked(True) + self.stack.setCurrentIndex(0 if self.rb_single.isChecked() else 1) + + def _save_settings(self): + self.app.writeSetting(SETTINGS_GROUP, "options", json.dumps(self._options().to_dict())) + self.app.writeSetting(SETTINGS_GROUP, "batch_dir", self.ed_dir.text()) + + # ---- Dateiauswahl ----------------------------------------------------- # + def _browse_out(self): + p, _ = QFileDialog.getSaveFileName(self, "SVG speichern", self.ed_out.text(), + "SVG (*.svg)") + if p: + if not p.lower().endswith(".svg"): + p += ".svg" + self.ed_out.setText(p) + + def _browse_dir(self): + d = QFileDialog.getExistingDirectory(self, "Ausgabeordner", self.ed_dir.text()) + if d: + self.ed_dir.setText(d) + + def _existing(self): + return {self.lst.item(i).text() for i in range(self.lst.count())} + + def _add_files(self): + files, _ = QFileDialog.getOpenFileNames(self, ".kra-Dateien wählen", "", + "Krita (*.kra)") + have = self._existing() + for p in files: + if p not in have: + self.lst.addItem(p) + + def _add_folder(self): + d = QFileDialog.getExistingDirectory(self, "Ordner mit .kra-Dateien") + if not d: + return + have = self._existing() + for name in sorted(os.listdir(d)): + p = os.path.join(d, name) + if name.lower().endswith(".kra") and p not in have: + self.lst.addItem(p) + + def _remove_files(self): + for it in self.lst.selectedItems(): + self.lst.takeItem(self.lst.row(it)) + + # ---- Export ----------------------------------------------------------- # + def _log(self, msg): + self.log_view.appendPlainText(msg) + QApplication.processEvents() + + def _run(self): + self._save_settings() + opts = self._options() + self.log_view.clear() + self.btn_export.setEnabled(False) + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + if self.rb_single.isChecked(): + self._run_single(opts) + else: + self._run_batch(opts) + except Exception: + self._log(traceback.format_exc()) + QMessageBox.critical(self, "Export fehlgeschlagen", + "Details stehen im Protokoll.") + finally: + QApplication.restoreOverrideCursor() + self.btn_export.setEnabled(True) + self.progress.setVisible(False) + + def _run_single(self, opts): + doc = self.app.activeDocument() + if doc is None: + QMessageBox.warning(self, "Kein Dokument", "Es ist kein Dokument geöffnet.") + return + out = self.ed_out.text().strip() + if not out: + QMessageBox.warning(self, "Ziel fehlt", "Bitte eine SVG-Datei angeben.") + return + warnings = export_document(doc, out, opts, self._log) + self._log("Fertig. %d Warnung(en)." % warnings) + + def _run_batch(self, opts): + files = [self.lst.item(i).text() for i in range(self.lst.count())] + out_dir = self.ed_dir.text().strip() + if not files or not out_dir: + QMessageBox.warning(self, "Angaben fehlen", + "Bitte Dateien und einen Ausgabeordner wählen.") + return + self.progress.setRange(0, len(files)) + self.progress.setValue(0) + self.progress.setVisible(True) + res = export_files(files, out_dir, opts, self._log, + progress=lambda i, n: self.progress.setValue(i)) + ok = sum(1 for r in res if r[1]) + self._log("Fertig: %d von %d Datei(en) exportiert." % (ok, len(res))) diff --git a/kra2svg_inkscape/exporter.py b/kra2svg_inkscape/exporter.py new file mode 100644 index 0000000..640344b --- /dev/null +++ b/kra2svg_inkscape/exporter.py @@ -0,0 +1,675 @@ +# -*- 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