Skip to content

Instantly share code, notes, and snippets.

@kalebo
Created July 24, 2026 04:31
Show Gist options
  • Select an option

  • Save kalebo/dd4d8c6017306837434ce739b6bd95f2 to your computer and use it in GitHub Desktop.

Select an option

Save kalebo/dd4d8c6017306837434ce739b6bd95f2 to your computer and use it in GitHub Desktop.
SVG label-sheet template generator for Inkscape
#!/usr/bin/env python3
"""Generate an SVG label-sheet template for Inkscape.
Everything is in millimetres, and the document is set up so that
1 SVG user unit == 1 mm. Coordinates in the file are therefore the
numbers you measured off the sheet, with no dpi conversion anywhere.
Output structure:
layer "template" -- non-printing outline of every label cell (locked)
layer "labels" -- one real object in cell (0,0) plus <use> clones
for every other cell. Edit the master, all
clones follow.
namedview -- Inkscape guides on every cell edge, for snapping
Inside "labels" there is also a locked "top mark" sublayer, which
prints an orientation mark into the top margin so a part-used sheet
can be re-fed the same way round on later passes.
The "template" layer is visible on screen but never printed, because
printing goes through an export of the "labels" subtree alone:
inkscape --export-type=pdf --export-area-page \
--export-id=layer-labels --export-id-only sheet.svg
Do NOT try to do this with a CSS @media print rule: Inkscape applies
@media blocks unconditionally, so display:none would hide the outlines
on screen as well.
"""
import argparse
import sys
import xml.dom.minidom
MM_PER_IN = 25.4
def esc(x):
"""Trim float noise so the file stays readable."""
return f"{round(x, 4):g}"
def build(a):
xs = [a.margin_left + c * a.pitch_x for c in range(a.cols)]
ys = [a.margin_top + r * a.pitch_y for r in range(a.rows)]
cells = [(x, y) for y in ys for x in xs]
x0, y0 = cells[0]
shape = (
f'<rect width="{esc(a.label_w)}" height="{esc(a.label_h)}" '
f'rx="{esc(a.rx)}" ry="{esc(a.rx)}" '
f'style="fill:none;stroke:none" />'
)
outlines = "\n".join(
f' <rect x="{esc(x)}" y="{esc(y)}" '
f'width="{esc(a.label_w)}" height="{esc(a.label_h)}" '
f'rx="{esc(a.rx)}" ry="{esc(a.rx)}" />'
for x, y in cells
)
clones = "\n".join(
f' <use xlink:href="#label-master" '
f'transform="translate({esc(x - x0)},{esc(y - y0)})" />'
for x, y in cells[1:]
)
# Inkscape stores guide positions with the origin at the BOTTOM-left,
# so y is flipped relative to the SVG coordinate system.
# dedupe: abutting labels (pitch == size) share an edge
gxs = sorted({round(v, 4) for x in xs for v in (x, x + a.label_w)})
gys = sorted({round(v, 4) for y in ys for v in (y, y + a.label_h)})
guides = "\n".join(
[f' <sodipodi:guide position="{esc(gx)},0" '
f'orientation="1,0" inkscape:locked="true" />' for gx in gxs]
+ [f' <sodipodi:guide position="0,{esc(a.page_h - gy)}" '
f'orientation="0,1" inkscape:locked="true" />' for gy in gys]
)
# Orientation mark, printed into the top margin so a part-used sheet
# can be re-fed the same way round. Lives INSIDE the labels layer
# (as a locked sublayer) because export-id-only exports that subtree
# and nothing else -- a top-level sibling layer would not print.
mark = ""
if a.top_mark:
fs = min(6.0, max(2.0, a.margin_top * 0.35))
cx, cy = a.page_w / 2, a.margin_top / 2
half = len(a.top_mark) * fs * 0.38 + fs * 1.3 # rough text half-width
tris = "\n".join(
f' <path d="M {esc(sx - fs * 0.4)},{esc(cy + fs * 0.4)} '
f'L {esc(sx + fs * 0.4)},{esc(cy + fs * 0.4)} '
f'L {esc(sx)},{esc(cy - fs * 0.5)} Z" />'
for sx in (cx - half, cx + half)
)
mark = f'''
<g inkscape:groupmode="layer" id="layer-topmark"
inkscape:label="top mark" sodipodi:insensitive="true"
style="fill:#000;stroke:none">
{tris}
<text x="{esc(cx)}" y="{esc(cy + fs * 0.35)}" text-anchor="middle"
style="font-family:sans-serif;font-size:{esc(fs)}px;
font-weight:bold;letter-spacing:{esc(fs * 0.15)}"
>{a.top_mark}</text>
</g>'''
return f'''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="{esc(a.page_w)}mm" height="{esc(a.page_h)}mm"
viewBox="0 0 {esc(a.page_w)} {esc(a.page_h)}">
<title>{a.cols}x{a.rows} label sheet</title>
<!-- NB: no @media print rule here. Inkscape's CSS engine applies
@media blocks unconditionally, so a print-only display:none
would hide the outlines on screen too. Non-printing is handled
at export time instead, by exporting only the "labels" layer
(export-id=layer-labels). -->
<style type="text/css"><![CDATA[
.template {{
fill: none;
stroke: #d00;
stroke-width: {esc(a.hairline)};
}}
]]></style>
<sodipodi:namedview id="namedview"
inkscape:document-units="mm" units="mm"
showguides="true" inkscape:bbox-nodes="true"
inkscape:bbox-paths="true" showgrid="false">
{guides}
</sodipodi:namedview>
<!-- Inkscape's File > Print renders every VISIBLE layer; the
export-id trick applies only to command-line export. So hide
this layer (Layers dialog, eye icon) before printing from the
GUI. The explicit display:inline is what that toggle flips.
NB: no double hyphen may appear inside an XML comment. -->
<g inkscape:groupmode="layer" id="layer-template"
inkscape:label="template" sodipodi:insensitive="true"
style="display:inline">
<g class="template">
{outlines}
</g>
</g>
<!-- The translate MUST live on #label-master itself, not on a
wrapping <g>. A <use> clones the referenced element and its own
transform, but NOT its ancestors' transforms, so a wrapper would
be silently dropped and every clone would be displaced by
-(margin_left, margin_top). Clone transforms are therefore
relative: translate(x - x0, y - y0), composed outside the
master's own translate(x0, y0) to give translate(x, y). -->
<g inkscape:groupmode="layer" id="layer-labels"
inkscape:label="labels">
<g id="label-master" transform="translate({esc(x0)},{esc(y0)})">
{shape}
</g>
{clones}{mark}
</g>
</svg>
'''
def main():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("-o", "--out", default="sheet.svg")
p.add_argument("--unit", choices=["mm", "in"], default="mm",
help="unit of all dimension arguments (default mm)")
p.add_argument("--page-w", type=float, default=215.9)
p.add_argument("--page-h", type=float, default=279.4)
p.add_argument("--margin-left", type=float, required=True)
p.add_argument("--margin-top", type=float, required=True)
p.add_argument("--label-w", type=float, required=True)
p.add_argument("--label-h", type=float, required=True)
p.add_argument("--pitch-x", type=float, required=True)
p.add_argument("--pitch-y", type=float, required=True)
p.add_argument("--cols", type=int, required=True)
p.add_argument("--rows", type=int, required=True)
p.add_argument("--rx", type=float, default=0.0, help="corner radius")
p.add_argument("--top-mark", default="TOP",
help="text printed in the top margin; empty to omit")
p.add_argument("--hairline", type=float, default=0.1,
help="template stroke width in mm")
a = p.parse_args()
if a.unit == "in":
for k in ("page_w", "page_h", "margin_left", "margin_top",
"label_w", "label_h", "pitch_x", "pitch_y", "rx"):
setattr(a, k, getattr(a, k) * MM_PER_IN)
right = a.margin_left + (a.cols - 1) * a.pitch_x + a.label_w
bottom = a.margin_top + (a.rows - 1) * a.pitch_y + a.label_h
if right > a.page_w or bottom > a.page_h:
p.error(f"grid overruns page: extends to "
f"{right:.2f} x {bottom:.2f} mm on a "
f"{a.page_w:.2f} x {a.page_h:.2f} mm page")
svg = build(a)
# Guard against malformed output (e.g. a stray "--" inside an XML
# comment, which is illegal and which Inkscape silently tolerates).
try:
xml.dom.minidom.parseString(svg)
except Exception as e:
sys.exit(f"internal error: generated SVG is not well-formed: {e}")
with open(a.out, "w") as f:
f.write(svg)
print(f"{a.out}: {a.cols}x{a.rows}, right margin "
f"{a.page_w - right:.2f} mm, bottom margin "
f"{a.page_h - bottom:.2f} mm")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment