├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── VERSION ├── examples ├── layer_by_layer.png ├── progressive_images.png ├── source.svg └── source_screenshot_with_layers.png ├── export_layers.inx └── export_layers.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | Please use the following tags when editing this file: 5 | *Added* for new features. 6 | *Changed* for changes in existing functionality. 7 | *Deprecated* for soon-to-be removed features. 8 | *Removed* for now removed features. 9 | *Fixed* for any bug fixes. 10 | 11 | ## Version 3.3.4 12 | ### Added 13 | - Diagnostic warning for cases where tagged are groups instead of layers 14 | ### Fixed 15 | - Create all intermediate parent directories of an output directory. 16 | Previously a longer output directory path crashed the extension. 17 | - Fix issue where layer did not have a `style` attribute. 18 | 19 | ## Version 3.3.3 20 | ### Added 21 | - Option to export visible layers only 22 | 23 | ## Version 3.3.2 24 | ### Fixed 25 | - Fix relative path processing 26 | 27 | ## Version 3.3.1 28 | ### Changed 29 | - Change supported Python version from 3.9 to 3.8 30 | 31 | ## Version 3.3 32 | ### Changed 33 | - Simplified UI. Now output directory and prefix are specified independently. 34 | NB: This breaks CLI backward compatibility 35 | - Treat a relative output directory as relative to the original SGV file 36 | 37 | ## Version 3.2 38 | ### Added 39 | - Enable sub-layer support, treat sub-layers in the same way as groups 40 | 41 | ## Version 3.1.1 42 | ### Fixed 43 | - Object clones are now exported correctly to raster images 44 | 45 | ## Version 3.1 46 | ### Changed 47 | - Output SVGs do not contain layers that are not exported (were hidden before) 48 | - JPEG does not require ImageMagick anymore 49 | ### Fixed 50 | - Do not open extra Inkscape UI during export 51 | 52 | ## Version 3.0 53 | ### Added 54 | - Selector for output directory 55 | - Option to progressively include content in layers 56 | - Possibility of adding a sub-directory for exported files 57 | ### Changed 58 | - Output file format is now: /path/to/output/directory/\[subdirectory/\]\[prefix_\]\[number_\]layer_name.ext 59 | with [subdirectory], [prefix] and [number] being optional 60 | ### Fixed 61 | - Use Inkscape order for SVG 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dmitry T. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inkscape Export Layers 2 | 3 | Inkscape extension to export SVG layers as standalone PDF, PNG, SVG or JPEG images. 4 | The exported images can progressively reveal content of an Inkscape document. 5 | 6 | This version works with Inkscape version 1. 7 | 8 | ## How it works 9 | 10 | There are two options for your layers when exporting: 11 | - `[fixed] or [f]`: If a layer label starts with the prefix `[fixed]` or `[f]` this layer is always exported and 12 | combined with other layers. It is very useful for backgrounds or fixed elements. 13 | - `[export] or [e]`: If a layer label starts with `[export]` or `[e]` this layer is exported along with any `[fixed]` layer and 14 | combined into a single image. With the option `Progressively reveal elements of a figure`, exported layers are included one by one 15 | to the output images. 16 | 17 | The order of layers remains untouched during export, i.e. a higher layer in the layer list is drawn above lower layers. 18 | 19 | **Example** 20 | 21 | ![Base document](examples/source_screenshot_with_layers.png) 22 | 23 | The layer `[f] always_show` is a fixed layer and will always be exported combined with the `[export]` layers. 24 | 25 | With the `Progressively reveal elements of a figure` checked, elements of the figures are progressively revealed. 26 | ![Base document](examples/progressive_images.png) 27 | 28 | Without this option, elements of the figures are only composed of the fixed layer(s) and individual exported layers. 29 | ![Base document](examples/layer_by_layer.png) 30 | 31 | 32 | ## How to use 33 | 34 | To use the extension once your layers are ready: 35 | 36 | 1. Get the code from the github page. (`Code -> Download ZIP` if you are unfamiliar with git clone) 37 | 1. Put the `inkscape-export-layers` directory where Inkscape will find it. (`~/.config/inkscape/extensions/` will work for your user on linux) 38 | 1. Open Inkscape 39 | 1. Go to `Extensions` > `Export` > `Export layers...` 40 | 1. Follow instructions in pop-up dialog and set options for your export 41 | 1. Click `Apply`. 42 | 43 | ## Requirements 44 | 45 | 1. Make sure the 'Inkscape' program is available from the command line. This may not be the case if you are using flatpack installation. 46 | 47 | ## Use with beamer 48 | 49 | If you use this extension to make presentation with LateX+Beamer, here are a few steps that will get you started in this direction. 50 | 51 | 1. Pick a 16-9 aspect ratio for your source Inkscape document. 52 | 1. Setup the layers you want to export in the order that you would like. 53 | 1. Use the `Export layers` to output your figures 54 | 1. Include the exported figures in the Beamer `.tex` file 55 | ``` 56 | \documentclass[aspectratio=169,14pt]{beamer} 57 | \usepackage{textpos} 58 | \RequirePackage{graphicx} 59 | \begin{document} 60 | 61 | %a block that progressively reveals content of Inkscape svg file 62 | \begin{frame} 63 | \begin{textblock*}{\paperwidth}(-1cm,-3.546cm) 64 | \includegraphics<1>[width=\paperwidth]{layers/source_slide_01.pdf} 65 | \includegraphics<2>[width=\paperwidth]{layers/source_slide_02.pdf} 66 | \includegraphics<3>[width=\paperwidth]{layers/source_slide_03.pdf} 67 | \end{textblock*} 68 | \end{frame} 69 | 70 | \end{document} 71 | ``` 72 | 1. Compile (highly recommended `latexmk -pdf` which will handle compilation on its own and recompile when source files change) 73 | 74 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 3.3.4 2 | -------------------------------------------------------------------------------- /examples/layer_by_layer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitry-t/inkscape-export-layers/44224b866b7be0d83d747eab4427413d1c6a5ae8/examples/layer_by_layer.png -------------------------------------------------------------------------------- /examples/progressive_images.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitry-t/inkscape-export-layers/44224b866b7be0d83d747eab4427413d1c6a5ae8/examples/progressive_images.png -------------------------------------------------------------------------------- /examples/source_screenshot_with_layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitry-t/inkscape-export-layers/44224b866b7be0d83d747eab4427413d1c6a5ae8/examples/source_screenshot_with_layers.png -------------------------------------------------------------------------------- /export_layers.inx: -------------------------------------------------------------------------------- 1 | 2 | 3 | <_name>Export layers 4 | 5 | com.over9000.export-layers-3 6 | 7 | export_layers.py 8 | 9 | 16 | 17 | 68 | -------------------------------------------------------------------------------- /export_layers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import contextlib 4 | import copy 5 | from dataclasses import dataclass 6 | from pathlib import Path 7 | import shutil 8 | import subprocess 9 | import sys 10 | import tempfile 11 | from typing import List, Tuple 12 | 13 | if sys.platform == 'linux': sys.path.append('/usr/share/inkscape/extensions') # noqa 14 | if sys.platform == 'win32': sys.path.append(r'c:\Program Files\Inkscape\share\inkscape\extensions') # noqa 15 | import inkex 16 | 17 | # inkex.localization.localize() 18 | 19 | @dataclass 20 | class Group: 21 | id: str 22 | label: str # tag + name 23 | name: str 24 | tag: str 25 | is_visible: bool 26 | 27 | 28 | @dataclass 29 | class Export: 30 | visible_layers: List[str] 31 | file_name: str 32 | 33 | 34 | FIXED = '[fixed]' 35 | F = '[f]' 36 | EXPORT = '[export]' 37 | E = '[e]' 38 | 39 | PDF = 'pdf' 40 | SVG = 'svg' 41 | PNG = 'png' 42 | JPEG = 'jpeg' 43 | 44 | 45 | class LayerExport(inkex.Effect): 46 | def add_arguments(self, parser): 47 | super().add_arguments(parser) 48 | 49 | parser.add_argument('-o', '--output-dir', 50 | type=Path, 51 | default='~/', 52 | help='Path to output directory') 53 | parser.add_argument('--prefix', 54 | default='', 55 | help='Prefix for exported file names') 56 | parser.add_argument('--visible-only', 57 | type=inkex.Boolean, 58 | help="Export visible layers only") 59 | parser.add_argument('--enumerate', 60 | type=inkex.Boolean, 61 | help="Extra prefix for exported file names") 62 | parser.add_argument('-f', '--file-type', 63 | choices=(PDF, PNG, SVG, JPEG), 64 | help='Exported file type') 65 | parser.add_argument('--fit-contents', 66 | type=inkex.Boolean, 67 | help='Fit output to content bounds') 68 | parser.add_argument('--dpi', 69 | type=int, 70 | help="Export DPI value") 71 | parser.add_argument('--show-layers-below', 72 | type=inkex.Boolean, 73 | help="Show exported layers below the current layer") 74 | 75 | def effect(self): 76 | output_dir = self.options.output_dir 77 | # If a directory is not specified, Inkscape passes '~'. 78 | # Inkscape treats relative paths w.r.t. the extension directory, 79 | # which is working directory at the same time. 80 | # Possible cases (user input -> extension input -> extension output): 81 | # User: '' -> input: '~' -> output: '/svg_dir' 82 | # User: '~' -> input: '/ext_dir/~' -> output: '/home_dir' 83 | # User: 'rel_path' -> input: '/ext_dir/rel_path' -> output: '/svg_dir/rel_path' 84 | # User: '.' -> input: '/ext_dir' -> output: '/svg_dir' 85 | # User: 'abs_path' -> input: '/abs_path' -> output: '/abs_path' 86 | if output_dir == Path('~'): 87 | # Output directory is unspecified by a user 88 | output_dir = Path(self.svg_path()) 89 | else: 90 | try: 91 | # Make the directory relative to the SVG directory 92 | # self.ext_path() is empty in Inkscape 1.2.1 so calculate 93 | ext_path = Path(__file__).parent.absolute() 94 | output_dir = output_dir.relative_to(ext_path) 95 | # Expand possible '~' in the middle of the path 96 | output_dir = output_dir.expanduser() 97 | if not output_dir.is_absolute(): 98 | # No ~ in the middle, truly relative path 99 | output_dir = self.svg_path() / output_dir 100 | except ValueError: 101 | pass 102 | 103 | output_dir.mkdir(parents=True, exist_ok=True) 104 | 105 | # Check if there are erroneously tagged groups 106 | tagged_group_list = self.get_group_list(layers=False) 107 | if tagged_group_list: 108 | print('WARNING!\n' 109 | f'Only {"visible " if self.options.visible_only else ""}' 110 | 'tagged layers participate in exporting.\n' 111 | 'These groups are tagged perhaps by mistake:\n' 112 | f'{", ".join(group.label for group in tagged_group_list)}\n', 113 | file=sys.stderr) 114 | 115 | layer_list = self.get_group_list(layers=True) 116 | 117 | # print('Found layers: ' + 118 | # ', '.join(f'{layer.id}/{layer.name}' for layer in layer_list), 119 | # file=sys.stderr) 120 | 121 | export_list = self.get_export_list( 122 | layer_list, self.options.show_layers_below, self.options.visible_only) 123 | if not export_list: 124 | print('WARNING!\n' 125 | 'Nothing to export.\n' 126 | 'There are no ' 127 | f'{"visible " if self.options.visible_only else ""}' 128 | f'layers tagged with {EXPORT} or {E}\n', 129 | file=sys.stderr) 130 | 131 | with _make_temp_directory() as tmp_dir: 132 | for export_idx, export in enumerate(export_list): 133 | # print(f'({export_idx}/{len(export_list)})' 134 | # f' Exporting layers [{", ".join(export.visible_layers)}]' 135 | # f' as {export.file_name}... ', end='', file=sys.stderr) 136 | 137 | remove_layers = (self.options.file_type == SVG) 138 | svg_file = self.export_to_svg(export, tmp_dir, remove_layers) 139 | 140 | if self.options.file_type == PNG: 141 | if not self.convert_svg_to_png(svg_file, output_dir, 142 | self.options.prefix): 143 | break 144 | elif self.options.file_type == SVG: 145 | if not self.convert_svg_to_svg(svg_file, output_dir, 146 | self.options.prefix): 147 | break 148 | elif self.options.file_type == PDF: 149 | if not self.convert_svg_to_pdf(svg_file, output_dir, 150 | self.options.prefix): 151 | break 152 | elif self.options.file_type == JPEG: 153 | if not self.convert_png_to_jpeg( 154 | self.convert_svg_to_png(svg_file, tmp_dir, 155 | self.options.prefix), 156 | output_dir, 157 | prefix=''): 158 | break 159 | 160 | def get_group_list(self, layers: bool) -> List[Group]: 161 | """ 162 | Make a list of groups in source svg file 163 | """ 164 | if layers: 165 | xpath_query = '//svg:g[@inkscape:groupmode="layer"]' 166 | else: 167 | xpath_query = '//svg:g[not(@inkscape:groupmode="layer")]' 168 | 169 | group_xml_list = self.document.xpath(xpath_query, namespaces=inkex.NSS) 170 | 171 | group_list = [] 172 | 173 | for group_xml in group_xml_list: 174 | label_attrib_name = '{%s}label' % group_xml.nsmap['inkscape'] 175 | 176 | if label_attrib_name not in group_xml.attrib: 177 | continue 178 | 179 | id = group_xml.attrib['id'] 180 | label = group_xml.attrib[label_attrib_name] 181 | is_visible = group_xml.attrib.get('style') == 'display:inline' 182 | tag = '' 183 | 184 | label_lower = label.lower() 185 | if label_lower.startswith(FIXED): 186 | tag = FIXED 187 | name = label[len(FIXED):].lstrip() 188 | elif label_lower.startswith(F): 189 | tag = FIXED 190 | name = label[len(F):].lstrip() 191 | elif label_lower.startswith(EXPORT): 192 | tag = EXPORT 193 | name = label[len(EXPORT):].lstrip() 194 | elif label_lower.startswith(E): 195 | tag = EXPORT 196 | name = label[len(E):].lstrip() 197 | 198 | if not tag: 199 | continue 200 | 201 | group_list.append(Group(id=id, 202 | label=label, 203 | name=name, 204 | tag=tag, 205 | is_visible=is_visible)) 206 | 207 | return group_list 208 | 209 | def get_export_list(self, 210 | layer_list: List[Group], 211 | show_layers_below: bool, 212 | visible_only: bool) -> List[Export]: 213 | """ 214 | Select layers that should be visible. 215 | Each element of this list will be exported as a separate file 216 | """ 217 | export_list: List[Export] = [] 218 | 219 | for counter, layer in enumerate(layer_list): 220 | # each layer marked as '[export]' is the basis for making a figure that will be exported 221 | 222 | if visible_only and not layer.is_visible: 223 | continue 224 | 225 | if layer.tag == FIXED: 226 | # Fixed layers are not the basis of exported figures 227 | continue 228 | 229 | elif layer.tag == EXPORT: 230 | # determine which other layers should appear in this figure 231 | visible_layers = set() 232 | layer_is_below = True 233 | for other_layer in layer_list: 234 | if other_layer.tag == FIXED: 235 | # fixed layers appear in all figures 236 | # irrespective of their position relative to other layers 237 | visible_layers.add(other_layer.id) 238 | 239 | elif other_layer.id == layer.id: 240 | # the basis layer for this figure is always visible 241 | visible_layers.add(other_layer.id) 242 | # all subsequent layers will be above 243 | layer_is_below = False 244 | 245 | elif layer_is_below and show_layers_below: 246 | visible_layers.add(other_layer.id) 247 | 248 | layer_name = layer.name 249 | if self.options.enumerate: 250 | layer_name = '{:03d}_{}'.format(counter + 1, layer_name) 251 | 252 | export_list.append(Export(visible_layers, layer_name)) 253 | else: 254 | # layers not marked as FIXED of EXPORT are ignored 255 | pass 256 | 257 | return export_list 258 | 259 | def export_to_svg(self, export: Export, output_dir: Path, 260 | remove_layers: bool) -> Path: 261 | """ 262 | Export a current document to an Inkscape SVG file. 263 | :arg Export export: Export description. 264 | :arg str output_dir: Path to an output directory. 265 | :arg boo remove_layers: Remove non-exported layers. 266 | :return Output file path. 267 | """ 268 | document = copy.deepcopy(self.document) 269 | 270 | # Only process high-level layers, treat sub-layers as groups 271 | svg_layers = document.xpath('/svg:svg/svg:g[@inkscape:groupmode="layer"]', 272 | namespaces=inkex.NSS) 273 | 274 | for layer in svg_layers: 275 | if layer.attrib['id'] in export.visible_layers: 276 | layer.attrib['style'] = 'display:inline' 277 | elif remove_layers: 278 | document.getroot().remove(layer) 279 | else: 280 | layer.attrib['style'] = 'display:none' 281 | 282 | output_file = output_dir / (export.file_name + '.svg') 283 | document.write(str(output_file)) 284 | 285 | return output_file 286 | 287 | def convert_svg_to_png(self, svg_file: Path, output_dir: Path, 288 | prefix: str) -> Path: 289 | """ 290 | Convert an SVG file into a PNG file. 291 | :param str svg_file: Path an input SVG file. 292 | :param str output_dir: Path to an output directory. 293 | :return Output file path. 294 | """ 295 | return self._convert_svg(svg_file, output_dir, prefix, 'png') 296 | 297 | def convert_svg_to_svg(self, svg_file: Path, output_dir: Path, 298 | prefix: str) -> Path: 299 | """ 300 | Convert an [Inkscape] SVG file into a standard (plain) SVG file. 301 | :param str svg_file: Path an input SVG file. 302 | :param str output_dir: Path to an output directory. 303 | :return Output file path. 304 | """ 305 | return self._convert_svg(svg_file, output_dir, prefix, 'svg', 306 | ['--export-plain-svg', '--vacuum-defs']) 307 | 308 | def convert_svg_to_pdf(self, svg_file, output_dir, prefix): 309 | """ 310 | Convert an [Inkscape] SVG file into a PDF. 311 | :param str svg_file: Path an input SVG file. 312 | :param str output_dir: Path to an output directory. 313 | :return Output file path. 314 | """ 315 | return self._convert_svg(svg_file, output_dir, prefix, 'pdf') 316 | 317 | def _convert_svg(self, svg_file: Path, output_dir: Path, 318 | prefix: str, out_type: str, extra_args=()) -> Path: 319 | output_file = output_dir / (prefix + svg_file.stem + '.' + out_type) 320 | 321 | command = [ 322 | 'inkscape', str(svg_file), 323 | '--export-area-drawing' if self.options.fit_contents else 324 | '--export-area-page', 325 | '--export-dpi=' + str(self.options.dpi), 326 | '--export-type', out_type, 327 | '--export-filename', str(output_file), 328 | ] + list(extra_args) 329 | 330 | try: 331 | subprocess.check_call(command) 332 | except Exception as e: 333 | raise Exception( 334 | f'Failed to convert {svg_file} to {output_file}.\n{e}') 335 | 336 | return output_file 337 | 338 | def convert_png_to_jpeg(self, 339 | png_file: Path, 340 | output_dir: Path, 341 | prefix: str) -> Path: 342 | """ 343 | Convert a PNG file into a JPEG file. 344 | :param str png_file: Path an input PNG file. 345 | :param str output_dir: Path to an output directory. 346 | :return Output file path. 347 | """ 348 | output_file = output_dir / (prefix + png_file.stem + '.jpeg') 349 | 350 | from PIL import Image 351 | image = Image.open(png_file) 352 | image = image.convert('RGB') 353 | image.save(output_file, quality=95) 354 | 355 | return output_file 356 | 357 | 358 | @contextlib.contextmanager 359 | def _make_temp_directory(): 360 | temp_dir = tempfile.mkdtemp(prefix='tmp-inkscape') 361 | try: 362 | yield Path(temp_dir) 363 | finally: 364 | shutil.rmtree(temp_dir) 365 | 366 | 367 | if __name__ == '__main__': 368 | try: 369 | LayerExport().run(output=False) 370 | except Exception as e: 371 | import traceback 372 | inkex.errormsg(traceback.format_exc(e)) 373 | sys.exit(1) 374 | --------------------------------------------------------------------------------