676 lines
25 KiB
Python
676 lines
25 KiB
Python
# -*- 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
|