#!/usr/bin/env python3 # coding=utf-8 """ Extension Inkscape : decoupe une image en couches empilees par nuances de gris. L'image selectionnee est convertie en gris, floutee puis quantifiee en quelques niveaux ; chaque niveau devient une forme fermee dont le contour suit la frontiere de l'aplat, a la maniere des courbes de niveau d'une carte. La logique vit dans `gray_iso_layers_core`, sans dependance a inkex. """ import base64 import io import os import sys from urllib.parse import unquote, unquote_to_bytes, urlparse from urllib.request import url2pathname import inkex from inkex.localization import localize # L'extension vit dans son propre sous-dossier des extensions Inkscape, qui # n'est pas forcement dans sys.path : on l'y ajoute pour trouver gray_iso_layers_core. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from gray_iso_layers_core import ( # noqa: E402 gray_to_hex, iso_boards, lines_to_d, parse_color, rings_to_d, scale_lines, scale_rings) # Installe _() dans les builtins : catalogue « grayisolayers » de la langue # de l'interface (Inkscape transmet domaine et dossier par l'environnement), # textes anglais d'origine sinon. localize() class GrayIsoLayers(inkex.EffectExtension): """Decoupe chaque image selectionnee en planches a empiler, une par niveau de gris, chacune dans son propre calque avec sa decoupe et le repere de la planche suivante.""" def add_arguments(self, pars): # Un argument par du .inx, memes noms et memes valeurs par defaut. pars.add_argument("--tab", default="levels") pars.add_argument("--levels", type=int, default=16) pars.add_argument("--shapes", default="light") pars.add_argument("--mark", type=inkex.Boolean, default=True) pars.add_argument("--blur", type=float, default=1.0) pars.add_argument("--edges", type=inkex.Boolean, default=True) pars.add_argument("--resolution", type=int, default=800) pars.add_argument("--min_area", type=float, default=0.02) pars.add_argument("--simplify", type=float, default=0.4) pars.add_argument("--smooth", type=inkex.Boolean, default=True) pars.add_argument("--fill_mode", default="gray") pars.add_argument("--fill_color", default="4294967295") pars.add_argument("--stroke_width", type=float, default=0.2) pars.add_argument("--unit", default="mm") pars.add_argument("--stroke_color", default="4278190335") pars.add_argument("--mark_color", default="255") pars.add_argument("--keep_original", type=inkex.Boolean, default=True) def effect(self): images = list(self.svg.selection.get(inkex.Image)) if not images: inkex.errormsg(_("Select at least one bitmap image (File > Import), " "then run the extension again.")) return if self.options.levels < 2: inkex.errormsg(_("At least 2 gray levels are needed.")) return # Un calque par planche, commun a toutes les images traitees. self.board_layers = {} for image in images: self.process(image) def board_layer(self, number, above): """Calque de la planche `number` (1 = la plus au fond), cree a la demande au premier plan du document, juste au-dessus de `above` (calque precedent ou image).""" if number not in self.board_layers: layer = inkex.Layer() layer.label = _("Board no. {}").format(number) root = self.svg # Un calque Inkscape est un enfant direct de la racine : on remonte # jusqu'a l'ancetre de premier plan pour s'inserer apres lui. while above.getparent() is not None and above.getparent() is not root: above = above.getparent() root.insert(root.index(above) + 1, layer) self.board_layers[number] = layer return self.board_layers[number] def process(self, image): """Traite une image : calcul dans gray_iso_layers_core, ecriture dans le SVG.""" opt = self.options try: field = self.load_field(image, opt.resolution) except Exception as error: # fichier absent, format inconnu, lien casse... inkex.errormsg(_("Cannot read the image '{}' ({}). Embed it in the " "document (right-click > Embed Image) or check its " "link.").format(image.get_id(), error)) return rows, cols = field.shape boards = iso_boards( field, levels=opt.levels, blur=opt.blur / 100.0 * max(rows, cols), preserve_edges=opt.edges, min_area=opt.min_area / 100.0, tolerance=opt.simplify, shapes=opt.shapes) if sum(1 for board in boards if board[2]) < 2: inkex.errormsg(_("The image '{}' is uniform: only the base level has " "a shape. Reduce the smoothing or pick a more " "contrasted image.").format(image.get_id())) # Les couches sont calculees dans le repere propre de l'image, puis # ecrites en coordonnees du document : les calques n'ont ainsi aucune # transformation, meme pour une image tournee ou etiree. left, top, width, height = self.image_box(image, cols, rows) scale_x, scale_y = width / (cols - 1.0), height / (rows - 1.0) box = (left, top, left + width, top + height) matrix = image.composed_transform().matrix above = image for number, (_level, gray, rings, mark) in enumerate(boards, 1): # Les planches sont numerotees du fond vers l'avant. Calque cree # meme si le niveau est vide : autant de calques que de niveaux. layer = self.board_layer(number, above) above = layer if not rings: continue node = inkex.PathElement() node.label = _("Cut") node.set("d", rings_to_d(scale_rings(rings, scale_x, scale_y, left, top), smooth=opt.smooth, box=box, matrix=matrix)) node.style = self.layer_style(gray) layer.add(node) # Repere d'assemblage : le bord de la planche suivante, reporte # sur celle-ci pour savoir ou la poser. mark_style = self.mark_style() if opt.mark and mark else None if mark_style is not None and (mark[0] or mark[1]): node = inkex.PathElement() node.label = _("Marking of board no. {}").format(number + 1) node.set("d", lines_to_d(scale_lines(mark, scale_x, scale_y, left, top), smooth=opt.smooth, box=box, matrix=matrix)) node.style = mark_style layer.add(node) if not opt.keep_original: image.delete() def layer_style(self, gray): """Style d'une couche : remplissage selon le mode, trait facultatif.""" opt = self.options style = inkex.Style({"fill": "none", "fill-rule": "evenodd", "stroke": "none"}) if opt.fill_mode == "gray": style["fill"] = gray_to_hex(gray) elif opt.fill_mode == "color": color, opacity = parse_color(opt.fill_color, ("#ffffff", 1.0)) style["fill"] = color style["fill-opacity"] = str(opacity) width = self.svg.unittouu("{}{}".format(opt.stroke_width, opt.unit)) if width > 0: color, opacity = parse_color(opt.stroke_color, ("#ff0000", 1.0)) style["stroke"] = color style["stroke-opacity"] = str(opacity) style["stroke-width"] = str(round(width, 6)) style["stroke-linejoin"] = "round" return style def mark_style(self): """Style du repere d'assemblage : un trait sans remplissage, de la meme epaisseur que la decoupe. `None` s'il n'y a pas de trait.""" opt = self.options width = self.svg.unittouu("{}{}".format(opt.stroke_width, opt.unit)) if width <= 0: return None color, opacity = parse_color(opt.mark_color, ("#000000", 1.0)) return inkex.Style({ "fill": "none", "stroke": color, "stroke-opacity": str(opacity), "stroke-width": str(round(width, 6)), "stroke-linejoin": "round", "stroke-linecap": "round"}) @staticmethod def image_box(image, cols, rows): """Rectangle reellement occupe par le bitmap, dans le repere de l'image. Sauf preserveAspectRatio="none", le bitmap garde ses proportions et il est centre dans le cadre x, y, width, height (cas « meet » par defaut). """ left, top, width, height = image.left, image.top, image.width, image.height ratio = image.get("preserveAspectRatio", "xMidYMid meet") if "none" not in ratio and "slice" not in ratio: zoom = min(width / cols, height / rows) fitted_width, fitted_height = cols * zoom, rows * zoom left += (width - fitted_width) / 2.0 top += (height - fitted_height) / 2.0 width, height = fitted_width, fitted_height return left, top, width, height def load_field(self, image, resolution): """Lit le bitmap et renvoie ses gris (0 noir, 1 blanc) en tableau 2D, ramene a `resolution` points sur son grand cote.""" import numpy from PIL import Image href = image.get("xlink:href") or image.get("href") or "" if href.startswith("data:"): header, _sep, payload = href.partition(",") data = base64.b64decode(payload) if ";base64" in header \ else unquote_to_bytes(payload) source = io.BytesIO(data) else: if href.startswith("file:"): path = url2pathname(urlparse(href).path) else: path = unquote(href) # Lien relatif : il part du dossier du document. source = self.absolute_href(path) with Image.open(source) as picture: # La transparence se lit comme du blanc (fond de page). rgba = picture.convert("RGBA") white = Image.new("RGBA", rgba.size, (255, 255, 255, 255)) gray = Image.alpha_composite(white, rgba).convert("L") zoom = resolution / float(max(gray.size)) size = (max(2, int(round(gray.width * zoom))), max(2, int(round(gray.height * zoom)))) gray = gray.resize(size, Image.LANCZOS) return numpy.asarray(gray, dtype=float) / 255.0 if __name__ == "__main__": GrayIsoLayers().run()