Files
kra2svg/kra2svg_inkscape/exporter.py
T

676 lines
25 KiB
Python
Raw Normal View History

# -*- 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)
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