Extract Inkscape Layers to PDF files for LaTeX Beamer Presentations
I like doing fancy presentations with little text (but in the titles, I heard that’s called “power titles” occasionally) but with a lot of graphics and animations. As a computer scientist who uses a lot of math stuff in his work, I of course use LaTeX even for creating presentations for its math features, although, in my opinion, LaTeX is not the perfect technology for making slides. My animations and graphics were done in TikZ so far, with all advantages and downsides: While on the one hand, you can use all the macros of your main document and profit from the wide range of powerfull TikZ/PGF libraries, TikZ animations can be quite brittle and hard to create and maintain.
After discovering the great textext plugin for Inkscape which lets you render LaTeX code from within Inkscape which can be updated (and re-rendered) after creation and allows for the inclusion of a LaTeX preamble, I decided to create graphics for my presentations in Inkscape. In practice, I create a document with the same size as my PDF slides and include a subset of my macros, maybe with a small set of additional definitions for fonts etc., in the preambles of my LaTeX objects. Animations are realized via Inkscape layers. Then, I toggled the layers according to my animation plan and exported several PDFs which I then included via a for-loop in my LaTeX beamer presentation. While that already constitutes a big step forward to WYSIWYG graphics / animations creation for LaTeX, this last step of manually exporting multiple PDFs is quite cumbersome, especially if you change something in your graphics. That’s where my small python script steps in.
The python script I wrote lets you annotate your Inkscape layers with LaTeX Beamer overlay specifications like <1,3-6,7->
— just append that to the name of a layer, and my script will automatically extract several PDF files where at each step, those annotated layers whose overlay range does not include the step are set to be invisible. Now, when you update something in your graphics and animations, just run the script to re-generate your animations. That’s the workflow I wanted to have.
In contrast to other Inkscape-based scripts / workflows like Inksclides etc., the goal of my script is not creating whole presentations from Inkscape vector graphics documents. You could probably do that (e.g., merge the generated PDFs after creation), but inserting a frame in the middle is already quite difficult since you have to update all your layer overlay annotations afterward. It’s really just for making animations within a single frame, but for that purpose, I could not find anything more suitable so far.
So here’s the script, and here a sample SVG that you can use as an example. Enjoy!
#!/usr/bin/python3
import xml.etree.ElementTree as ET
import re
import sys
import os
import pyperclip
from pathlib import Path
from subprocess import call
###########################################################################
# This script extracts "layers" for usage in LaTeX presentations #
# from inkscape SVG files. For this, append a LaTeX overlay specification #
# to the label of the layer, e.g., "1,2-5,3,17-", which you surround by #
# either angle or square brackets. Then, call this script with the name #
# of the SVG file as argument (works also with a path). It will export #
# multiple PDF files "...-step-N.pdf" in the same directory as the SVG #
# file. After execution, the script shows instructions of how to use the #
# generated PDF animation slides in LaTeX and copies the for loop doing #
# the task to the system clipboard. #
# #
# Requirements: #
# - inkscape #
# - pyperclip library ("pip3 install pyperclip") #
###########################################################################
ns = {'svg': 'http://www.w3.org/2000/svg',
'inkscape': 'http://www.inkscape.org/namespaces/inkscape'}
class OverlayRange:
def __init__(self, start, end):
self.start = int(start)
self.end = int(end)
def __str__(self):
if (self.end == -1):
return "range(" + str(self.start) + "-" + ")"
else:
return "range(" + str(self.start) + "-" + str(self.end) + ")"
def max(self):
if (self.end > self.start):
return self.end
else:
return self.start
def setMax(self, max):
if (self.end == -1):
self.end = max
def visibleAt(self, step):
return (self.start <= step and step <= self.end)
@classmethod
def fromStartOnly(cls, start):
return cls(start, -1)
def parseOverlayRange(string):
arr = string.split('-')
if (len(arr) == 1):
return OverlayRange(arr[0], arr[0])
elif (len(arr) == 2 and arr[1] == ''):
return OverlayRange.fromStartOnly(arr[0])
elif (len(arr) == 2):
return OverlayRange(arr[0], arr[1])
class Layer:
def __init__(self, label, element):
self.label = label
self.element = element
self.ranges = []
def addRange(self, therange):
self.ranges.append(therange)
def maxRange(self):
return max(map(lambda r: r.max(), self.ranges))
def visibleAt(self, step):
for therange in self.ranges:
if (therange.visibleAt(step)):
return True
return False
def toNS(elem, namespace):
return '{' + ns.get(namespace) + '}' + elem
if (len(sys.argv) < 2):
print('Expecting input SVG file as argument')
inputfile = sys.argv[1]
outprefix = inputfile.split('.svg')[0]
tree = ET.parse(inputfile)
root = tree.getroot()
layers = []
for elem in tree.iter():
if (elem.tag != toNS('g', 'svg')):
continue
label = elem.get(toNS('label', 'inkscape'))
if (label == None):
continue
groupmode = elem.get(toNS('groupmode', 'inkscape'))
if (groupmode == None or groupmode != 'layer'):
continue
regex = r"^([^(<\[)]*)\W+(?:<|\[)([0-9]+(?:-(?:[0-9]+)?)?(?:,[0-9]+(?:-(?:[0-9]+)?)?)*)(?:>|\])$"
m = re.search(regex, label)
if (m == None):
continue
layer = Layer(m.group(1), elem)
for theRange in m.group(2).split(','):
layer.addRange(parseOverlayRange(theRange))
layers.append(layer)
maxLayer = max(map(lambda l: l.maxRange(), layers))
print("Maximum overlay number: " + str(maxLayer))
for layer in layers:
for therange in layer.ranges:
therange.setMax(maxLayer)
for i in range(1,maxLayer+1):
print("Animation step " + str(i))
for layer in layers:
if (layer.visibleAt(i)):
print (" Layer " + layer.label + " visible")
layer.element.set('style', 'display:inline')
elif (not layer.visibleAt(i)):
print (" Layer " + layer.label + " hidden")
layer.element.set('style', 'display:none')
layerOutPrefix = outprefix + '-step-' + str(i)
svgoutfile = layerOutPrefix + '.svg'
pdfoutfile = layerOutPrefix + '.pdf'
if Path(svgoutfile).exists():
print("Step SVG file " + svgoutfile + " already exists, won't override but cancel")
quit()
print(" Exporting layer " + str(i) + " to file " + pdfoutfile)
tree.write(svgoutfile)
call(['inkscape', '-z', '-C', '--export-pdf=' + pdfoutfile, svgoutfile])
os.remove(svgoutfile)
latexinclude = " \\usepackage{pgffor}\n" +\
" \\usepackage{tikz}"
latexMacro = " \\newcommand<>{\\fullsizegraphic}[1]{\n" +\
" \\begin{tikzpicture}[remember picture,overlay]\n" +\
" \\node[at=(current page.center)] {\n" +\
" \includegraphics{#1}\n" +\
" };\n" +\
" \\end{tikzpicture}\n" +\
" }"
latexForLoop = " \\foreach \\n in {1,...," + str(maxLayer) + "}{\n" +\
" \\only<\\n>{\\fullsizegraphic{" + outprefix + "-step-\\n.pdf}}\n" +\
" }"
print("\nDone. Usage in Latex:")
print(" Include in preamble:\n")
print(latexinclude)
print("")
print(latexMacro)
print("\n Use in frame:\n")
print(latexForLoop)
pyperclip.copy(latexForLoop)
print("\nCopied for loop to clipboard.")