Created
February 4, 2026 20:09
-
-
Save ednisley/7372fad06d0f3a4ffafe09703c46dfb5 to your computer and use it in GitHub Desktop.
Python source code: Generate SVG images for punched card printing & laser-cuttery
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
| # 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) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
More details on my blog at https://softsolder.com/2026/02/05/punched-cards-python-svg-generator/