Created
July 24, 2026 04:31
-
-
Save kalebo/dd4d8c6017306837434ce739b6bd95f2 to your computer and use it in GitHub Desktop.
SVG label-sheet template generator for Inkscape
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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