Skip to content

Instantly share code, notes, and snippets.

@ednisley
Created February 4, 2026 20:09
Show Gist options
  • Select an option

  • Save ednisley/7372fad06d0f3a4ffafe09703c46dfb5 to your computer and use it in GitHub Desktop.

Select an option

Save ednisley/7372fad06d0f3a4ffafe09703c46dfb5 to your computer and use it in GitHub Desktop.
Python source code: Generate SVG images for punched card printing & laser-cuttery
# Generator for punched cards
# Ed Nisley - KE4ZNU
# 2026-01-20 cargo-culted from various sources
import svg
import math
from argparse import ArgumentParser
from pathlib import Path
import curses.ascii
import itertools
INCH = 25.4
X = 0
Y = 1
SVGSCALE = 96.0/25.4 # converts "millimeters as SVG points" to real millimeters
parser = ArgumentParser(description="Create SVG files to print & laser-cut a punched card")
parser.add_argument('--debug',action='store_true',
help="Enable various test outputs, do not use XML file")
parser.add_argument('--lower',action='store_true',
help="Fake lowercase with italics")
parser.add_argument('--test', type=int, choices=range(7), default=0,
help="Various test patterns to verify card generation")
parser.add_argument('--lbsvg', action='store_true',
help="Work around LightBurn SVG issues")
parser.add_argument('--layout', default="print", choices=["laser","print"],
help="Laser-engrave hole & char text into card")
parser.add_argument('--seq',type=int, default=0,
help="If nonzero, use as squence number in col 72-80")
parser.add_argument('--logofile', default="Card logo.png",
help="Card logo filename")
parser.add_argument('--prefix', default="",
help="Card number prefix, no more than 5 characters")
parser.add_argument('contents',nargs="*",default='Your text goes here',
help="Line of text to be punched on card")
args = parser.parse_args()
PageSize = (round(8.5*INCH,3), round(11.0*INCH,3)) # sheet of paper
CardSize = (7.375*INCH,3.25*INCH) # punch card bounding box
NumCols = 80
NumRows = 12
HoleSize = (0.055*INCH,0.125*INCH) # punched hole
HoleOC = (0.087*INCH,0.250*INCH)
BaseHoleAt = (0.251*INCH,0.250*INCH) # center point
TargetOC = (200,80) # alignment targets around card
TargetOD = 5
#--- map ASCII / Unicode characters to rows
# rows are names, not indexes: Row 12 is along the top of the card
# Row 10 is the same as Row 0
CharMap = {
" ": (),
"0": (0,),
"1": (1,),
"2": (2,),
"3": (3,),
"4": (4,),
"5": (5,),
"6": (6,),
"7": (7,),
"8": (8,),
"9": (9,),
"A": (12,1),
"B": (12,2),
"C": (12,3),
"D": (12,4),
"E": (12,5),
"F": (12,6),
"G": (12,7),
"H": (12,8),
"I": (12,9),
"J": (11,1),
"K": (11,2),
"L": (11,3),
"M": (11,4),
"N": (11,5),
"O": (11,6),
"P": (11,7),
"Q": (11,8),
"R": (11,9),
"S": (10,2),
"T": (10,3),
"U": (10,4),
"V": (10,5),
"W": (10,6),
"X": (10,7),
"Y": (10,8),
"Z": (10,9),
"a": (12,10,1),
"b": (12,10,2),
"c": (12,10,3),
"d": (12,10,4),
"e": (12,10,5),
"f": (12,10,6),
"g": (12,10,7),
"h": (12,10,8),
"i": (12,10,9),
"j": (12,11,1),
"k": (12,11,2),
"l": (12,11,3),
"m": (12,11,4),
"n": (12,11,5),
"o": (12,11,6),
"p": (12,11,7),
"q": (12,11,8),
"r": (12,11,9),
"s": (10,11,2),
"t": (10,11,3),
"u": (10,11,4),
"v": (10,11,5),
"w": (10,11,6),
"x": (10,11,7),
"y": (10,11,8),
"z": (10,11,9),
"¢": (12,2,8),
".": (12,3,8),
"<": (12,4,8),
"(": (12,5,8),
"+": (12,6,8),
"|": (12,7,8),
"!": (11,2,8),
"$": (11,3,8),
"*": (11,4,8),
")": (11,5,8),
";": (11,6,8),
"¬": (11,7,8),
",": (10,3,8),
"%": (10,4,8),
"_": (10,5,8),
">": (10,6,8),
"?": (10,7,8),
":": (2,8),
"#": (3,8),
"@": (4,8),
"'": (5,8),
"=": (6,8),
'"': (7,8),
"&": (12,),
"-": (11,),
"/": (10,1),
"█": (12,11,10,1,2,3,4,5,6,7,8,9), # used for lace card test pattern
"▯": (12,10,2,4,6,8), # used for alignment tests with hack for row numbers
}
#--- map row name to physical row offset from top
RowMap = (2,3,4,5,6,7,8,9,10,11,2,1,0)
RowGlyphs = "0123456789⁰¹²▯" # last four should never appear, hollow box is a hack
#--- pretty punch patterns
TestStrings = ( " " * NumCols, # blank card for printing
"█" * NumCols, # lace card for amusement
"0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz",
"¢.<(+|!$*);¬,%_>?:#@'=" + '"' + "&-/█",
"▯" * NumCols, # hack for row number alignment
)
#--- LightBurn layer colors
HoleCut = "black" # C00 Black
CardMark = "blue" # C01 Blue
CardCut = "red" # C02 Red
CardText = "green" # C03 Green
CardGray = "rgb(125,135,185)" # C17 Dark Gray
Tooling = "rgb(12,150,217)" # T2 Tool
#--- LightBurn uses only the stroke
DefStroke = 0.20
DefFill = "none"
#---------------------
# Set up card contents
if args.test: # test patterns used without changes
Contents = TestStrings[args.test - 1].ljust(NumCols,' ')
else: # real cards need cleaning
Contents = ''.join(itertools.chain(*args.contents))
if args.seq:
nl = 8 - len(args.prefix)
Contents = Contents.ljust(NumCols - 8,' ')[:(NumCols - 8)]
Contents = Contents + f"{args.prefix}{args.seq:0{nl}d}"
else:
Contents = Contents.ljust(NumCols,' ')[:NumCols]
if not args.lower:
Contents = Contents.upper()
#--- accumulate tooling layout
ToolEls = []
# mark center of card for drag-n-drop location
if args.layout == "laser":
ToolEls.append(
svg.Circle(
cx=svg.mm(CardSize[X]/2),
cy=svg.mm(CardSize[Y]/2),
r="2mm",
stroke=Tooling,
stroke_width=svg.mm(DefStroke),
fill="none",
)
)
# mark card perimeter for alignment check
if args.layout == "laser":
ToolEls.append(
svg.Rect(
x=0,
y=0,
width=svg.mm(CardSize[X]),
height=svg.mm(CardSize[Y]),
stroke=Tooling,
stroke_width=svg.mm(DefStroke),
fill="none",
)
)
#--- accumulate alignment targets
MarkEls = []
# alignment targets
for c in ((1,1),(-1,1),(-1,-1),(1,-1)):
ctr = (CardSize[X]/2 + c[X]*TargetOC[X]/2,CardSize[Y]/2 + c[Y]*TargetOC[Y]/2)
MarkEls.append(
svg.Circle(
cx=svg.mm(ctr[X]),
cy=svg.mm(ctr[Y]),
r=svg.mm(TargetOD/2),
stroke=Tooling if args.layout == "laser" else CardMark,
stroke_width=svg.mm(DefStroke),
fill="none",
)
)
MarkEls.append(
svg.Path(
d=[
svg.M(ctr[X] + TargetOD/2,ctr[Y] - TargetOD/2),
svg.l(-TargetOD,TargetOD),
svg.m(0,-TargetOD),
svg.l(TargetOD,TargetOD),
],
transform="scale(" + repr(1.0 if args.lbsvg else SVGSCALE) + ")",
stroke=Tooling if args.layout == "laser" else CardMark,
stroke_width=svg.mm(DefStroke if args.lbsvg else DefStroke/SVGSCALE),
fill="none",
)
)
#--- accumulate card cuts
CardEls = []
# card perimeter with magic numbers from card dimensions
if args.layout == "laser":
CardEls.append(
svg.Path(
d=[
svg.M(0.25*INCH,0),
svg.h(CardSize[X] - 2*0.25*INCH),
svg.a(0.250*INCH,0.25*INCH,0,0,1,0.25*INCH,0.25*INCH),
svg.v(CardSize[Y] - 2*0.25*INCH),
svg.a(0.25*INCH,0.25*INCH,0,0,1,-0.25*INCH,0.25*INCH),
svg.H(0.25*INCH),
svg.a(0.25*INCH,0.25*INCH,0,0,1,-0.25*INCH,-0.25*INCH),
svg.V(0.25*INCH/math.tan(math.radians(30))),
svg.Z(),
],
transform="scale(" + repr(1.0 if args.lbsvg else SVGSCALE) + ")",
stroke=CardCut if args.layout == "laser" else Tooling,
stroke_width=svg.mm(DefStroke if args.lbsvg else DefStroke/SVGSCALE),
fill="none",
),
)
# label hole positions in rows 0-9
# special hack for outline boxes
TextEls = []
if args.layout == "print":
xoffset = 0.3 # tiny offsets to align chars with cuts
yoffset = 1.5
for c in range(NumCols):
glyph = Contents[c]
rnx = CharMap[glyph] # will include row name 10 aliased as row name 0
for rn in range(10):
pch = RowGlyphs[rn] # default is digit for row
if ((rn in rnx) or ((rn == 0) and (10 in rnx))): # suppress punched holes
pch = "▯" if glyph == "▯" else " " # except for alignment tests
r = RowMap[rn]
TextEls.append(
svg.Text(
x=svg.mm(BaseHoleAt[X] + c*HoleOC[X] + xoffset),
y=svg.mm(BaseHoleAt[Y] + r*HoleOC[Y] + yoffset),
class_=["holes"],
font_family="Arial", # required by LightBurn
font_size="3.0mm", # required by LightBurn
text_anchor="middle",
text=pch
)
)
# number the columns in tiny print
if args.layout == "print":
xoffset = 0.3
yoffset = 0.5
#xoffset = -0.25 if args.lbsvg else 0.0 # original hack
for c in range(NumCols):
for y in (22.7,80.0): # magic numbers between the rows
TextEls.append(
svg.Text(
x=svg.mm(BaseHoleAt[X] + c*HoleOC[X] + xoffset),
y=svg.mm(y + yoffset),
class_=["cols"],
font_family="Arial", # required by LightBurn
font_size="1.5mm", # required by LightBurn
text_anchor="middle",
text=f"{c+1: 2d}",
)
)
# add text attribution
if args.layout == "print":
TextEls.append(
svg.Text(
x=svg.mm(175.3),
y=svg.mm(72.5 if args.lbsvg else 73.0),
class_=["attrib"],
font_family="Arial", # required by LightBurn
font_size="2.0mm", # ignored by LightBurn
text_anchor="middle",
dominant_baseline="middle",
text="softsolder.com",
)
)
#--- accumulate holes
HoleEls = []
# punch the holes
if args.layout == "laser":
for c in range(len(Contents)):
glyph = Contents[c]
if not (glyph in CharMap):
glyph = ' '
for rn in CharMap[glyph]:
r = RowMap[rn]
HoleEls.append(
svg.Rect(
x=svg.mm(BaseHoleAt[X] + c*HoleOC[X] - HoleSize[X]/2),
y=svg.mm(BaseHoleAt[Y] + r*HoleOC[Y] - HoleSize[Y]/2),
width=svg.mm(HoleSize[X]),
height=svg.mm(HoleSize[Y]),
stroke=HoleCut,
stroke_width=svg.mm(DefStroke),
fill="none",
)
)
# print punched characters across the top edge
# The KEYPUNCH029 font does not include lowercase characters, so
# fake lowercase with italics, which LightBurn ignores
if args.layout == "print":
xoffset = 0.3
for c in range(len(Contents)):
glyph = Contents[c]
if not (glyph in CharMap):
glyph = ' '
fc = "dottylc" if curses.ascii.islower(glyph) else "dotty"
glyph = svg.escape(glyph) # escape the characters that wreck SVG syntax
TextEls.append(
svg.Text(
x=svg.mm(BaseHoleAt[X] + c*HoleOC[X] + xoffset),
y=svg.mm(5.0), # align just below card edge
class_=[fc],
font_family="KEYPUNCH029", # required by LightBurn
font_size="4.0mm", # required by LightBurn
text_anchor="middle",
text=glyph
)
)
#--- assemble and blurt out the SVG file
if not args.debug:
canvas = svg.SVG(
width=svg.mm(PageSize[X]),
height=svg.mm(PageSize[Y]),
elements=[
svg.Style(
text = f"\n.attrib{{ font: 2mm Arial; fill:{CardText}}}" +
f"\n.holes{{ font: 3.0mm Arial; fill:{CardText}}}" +
f"\n.cols{{ font: 1.5mm Arial; fill:{CardText}}}" +
f"\n.dotty{{ font: 4.0mm KEYPUNCH029; fill:{CardGray}}}" +
f"\n.dottylc{{ font: italic 4.0mm KEYPUNCH029; fill:{CardGray}}}"
),
ToolEls,
MarkEls,
CardEls,
TextEls,
HoleEls,
],
)
print(canvas)
@ednisley
Copy link
Author

ednisley commented Feb 4, 2026

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment