Last active
April 14, 2026 22:44
-
-
Save ednisley/3432b5ef44f5e408d109d6e7a49d1e4b to your computer and use it in GitHub Desktop.
Python and Bash code: Punched Card production in the modern age
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/bash | |
| outdir="Cards/" | |
| prefix="card" | |
| sn=100 | |
| attrib="softsolder.com" | |
| while getopts d:p:s: flag | |
| do | |
| case "${flag}" in | |
| d) outdir=${OPTARG};; | |
| p) prefix=${OPTARG};; | |
| s) sn=${OPTARG};; | |
| esac | |
| done | |
| shift $((OPTIND - 1)) | |
| fname=$1 | |
| while IFS= read -r line | |
| do | |
| printf "Text: %s\n" "${line}" | |
| printf -v sns "%0.8d" ${sn} | |
| printf " format print" | |
| python Punched\ Card.py --layout print --seq $sn --prefix "$prefix" "$line" --attrib "${attrib}" > ${outdir}${prefix}-${sns}-pr.svg | |
| printf ", format punch" | |
| python Punched\ Card.py --lbsvg --layout laser --seq $sn --prefix "$prefix" "$line" --attrib "${attrib}" > ${outdir}${prefix}-${sns}-lb.svg | |
| tf1=$(mktemp --suffix=.png ${prefix}-XXXX) | |
| printf ", Inkscape → PNG" | |
| inkscape --actions="select-all; page-fit-to-selection; export-dpi:300" --export-filename=$tf1 ${outdir}${prefix}-${sns}-pr.svg | |
| printf ", Imagemagick → logo" | |
| tf2=$(mktemp --suffix=.png ${prefix}-XXXX) | |
| # slam into rectangle slightly larger than 205×85 mm = 2421×1004 bounding box of the targets | |
| #magick composite ${outdir}"Card logo.png" -gravity center -geometry "x880+0+20" -size 2429x1012 canvas:white $tf2 | |
| magick composite ${outdir}"Card logo.png" -gravity center -geometry "x880+0+20" -size 2421x1004 canvas:white $tf2 | |
| printf " → composite" | |
| tf3=$(mktemp --suffix=.png ${prefix}-XXXX) | |
| magick composite $tf1 $tf2 $tf3 | |
| printf " → page" | |
| magick composite -density 300 -gravity east -geometry "97.0%x97.9%+100-50" $tf3 -size 3300x2550 canvas:white ${outdir}${prefix}-${sns}-lt.png | |
| rm $tf1 $tf2 $tf3 | |
| #printf ", PNG → printer" | |
| #lp -d EPSON_ET-3830_Series -o media=TLetter ${outdir}${prefix}-${sns}-lt.png | |
| printf ", done\n" | |
| ((sn+=10)) | |
| done < "${fname}" |
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/bash | |
| outdir="Cards/Tests/" | |
| prefix="test" | |
| attrib="softsolder.com" | |
| for i in 1 3 4 # ignore lace card = 2 and alignment card = 5, otherwise $(seq 5) | |
| do | |
| printf "Test pattern %s: print" ${i} | |
| python Punched\ Card.py --layout print --test $i --seq 0 --attrib "${attrib}" > ${outdir}test-${i}-pr.svg | |
| printf ", punch" | |
| python Punched\ Card.py --lbsvg --layout laser --test $i --seq 0 --attrib "${attrib}" > ${outdir}test-${i}-lb.svg | |
| tf1=$(mktemp --suffix=.png ${prefix}-XXXX) | |
| printf ", Inkscape → PNG" | |
| inkscape --actions="select-all; page-fit-to-selection; export-dpi:300" --export-filename=$tf1 ${outdir}${prefix}-${i}-pr.svg | |
| printf ", Imagemagick → logo" | |
| tf2=$(mktemp --suffix=.png ${prefix}-XXXX) | |
| magick composite ${outdir}"Card logo.png" -gravity center -geometry "x880+0+20" -size 2421x1004 canvas:white $tf2 | |
| printf ", Imagemagick → page" | |
| tf3=$(mktemp --suffix=.png ${prefix}-XXXX) | |
| magick composite $tf1 $tf2 $tf3 | |
| magick composite -density 300 -gravity east -geometry "97.0%x97.9%+100-50" $tf3 -size 3300x2550 canvas:white ${outdir}${prefix}-${i}-lt.png | |
| #printf ", PNG → printer" | |
| #lp -d EPSON_ET-3830_Series -o media=TLetter ${outdir}${prefix}-${i}-lt.png | |
| rm $tf1 $tf2 $tf3 | |
| printf ", done\n" | |
| done |
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('--attrib', default="softsolder.com", | |
| help="Attribution URL") | |
| 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 | |
| #--- pretty punch patterns | |
| TestStrings = ( " " * NumCols, # blank card for printing | |
| "█" * NumCols, # lace card for amusement | |
| "0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz", | |
| "¢.<(+|!$*);¬,%_>?:#@'=" + '"' + "&-/█", | |
| "🞋 " * (NumCols//2), # 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 | |
| SeqNum = ' ' * 8 | |
| if args.test: # test patterns used without changes | |
| Contents = TestStrings[args.test - 1].ljust(NumCols,' ') | |
| SeqNum = f"test{args.test:04d}" | |
| else: # real cards need cleaning | |
| Contents = ''.join(itertools.chain(*args.contents)) | |
| if args.seq: | |
| nl = 8 - len(args.prefix) | |
| SeqNum = f"{args.prefix}{args.seq:0{nl}d}" | |
| Contents = Contents.ljust(NumCols - 8,' ')[:(NumCols - 8)] + SeqNum | |
| 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 | |
| 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 hard-inch 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 | |
| # ugly hack to put centering mark in row numbers | |
| TextEls = [] | |
| if args.layout == "print": | |
| offset = (0.3,1.3) # tiny offsets to align chars with cuts | |
| 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): | |
| if glyph == "🞋": # this is an alignment column | |
| pch = '+' | |
| else: # normal column, suppress char in punched hole | |
| pch = ' ' if ((rn in rnx) or ((rn == 0) and (10 in rnx))) else RowGlyphs[rn] | |
| r = RowMap[rn] | |
| TextEls.append( | |
| svg.Text( | |
| x=svg.mm(BaseHoleAt[X] + c*HoleOC[X] + offset[X]), | |
| y=svg.mm(BaseHoleAt[Y] + r*HoleOC[Y] + offset[Y]), | |
| 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": | |
| offset = (0.3,0.3) | |
| 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] + offset[X]), | |
| y=svg.mm(y + offset[Y]), | |
| 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(182.0), | |
| y=svg.mm(73.3), | |
| class_=["attrib"], | |
| font_family="Arial", # required by LightBurn | |
| font_size="2.0mm", # ignored by LightBurn | |
| text_anchor="end", | |
| dominant_baseline="middle", | |
| text=args.attrib, | |
| ) | |
| ) | |
| #--- 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 | |
| for c in range(len(Contents)): | |
| glyph = Contents[c] | |
| if not (glyph in CharMap): | |
| glyph = ' ' | |
| if args.layout == "print": | |
| fc = "dottylc" if curses.ascii.islower(glyph) else "dotty" | |
| xoffset = 0.3 | |
| else: | |
| fc = "dottytool" | |
| xoffset = 0.0 | |
| glyph = svg.escape(glyph) # escape 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}}}" + | |
| f"\n.dottytool{{ font: 4.0mm KEYPUNCH029; fill:{Tooling}}}" | |
| ), | |
| 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/04/15/punched-cards-summary/