The Smell of Molten Projects in the Morning

Ed Nisley's Blog: Shop notes, electronics, firmware, machinery, 3D printing, laser cuttery, and curiosities. Contents: 100% human thinking, 0% AI slop.

Category: Machine Shop

Mechanical widgetry

  • Punched Cards: Test Card Generator Script

    Punched Cards: Test Card Generator Script

    The Punched Card.py program can generate a set of test cards to simplify tweaking the rest of the process:

    • Test Card 1 - unpunched
    • Test Card 2 - unpunched
    • Test Card 3 - unpunched
    • Test Card 4 - unpunched
    • Test Card 5 - unpunched

    The Bash script calls the Python program twice to generate the two SVG files for printing and punching each card, which seemed simple enough.

    Unfortunately, sending the SVG file directly to the printer demonstrated my lack of understanding of that whole process, so the script now:

    • Converts the SVG to a PNG file
    • Composites the logo image into a first blank-card sized PNG image
    • Composites the logo underneath the card PNG to a second PNG
    • Composites that PNG onto a Letter page in the proper position to hit a 1/3 Letter blank card to a third PNG

    Which goes a little something like this:

    #/usr/bin/bash
    outdir="Cards/Tests/"
    prefix="test"
    for i in $(seq 5)
    do
        printf "Test pattern %s: print" ${i}
        python Punched\ Card.py --layout print --test $i --seq 0 > ${outdir}test-${i}-pr.svg
        printf ", punch"
        python Punched\ Card.py --lbsvg --layout laser --test $i --seq 0 > ${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
    
    

    Although the script could print the final PNG for each card as it’s generated, I prefer to print them after eyeballing the results to fix the inevitable bloopers:

    lp -d EPSON_ET-3830_Series -o media=TLetter Cards/Tests/test-?-lt.png
    

    Using ImageMagick to slam PNG images around was significantly less complex and more direct than trying to contort the SVG file to produce the same result. In particular:

    • Uniformly scaling the logo image to fit the card height actually worked, as opposed to specifying the logo file as an image in SVG file.
    • Scaling the card PNG while compositing it onto the final page PNG worked much consistently, which counts for a lot.
    • Hitting the middle of a 1/3 Letter blank fed from the printer’s paper tray required tedious trial-and-error.

    The Cards/Tests/ output directory lives in the Programs directory with the Bash and Python programs, which made sense at the time.

    The Card logo.png file also lives in Cards/Tests/ so I can have a different logo for each set of cards. A symlink to the appropriate logo file in Logos simplifies changing the artwork.

    None of the constants will match your setup, so have fun.

  • Punched Cards: Character Translation & Positioning

    Punched Cards: Character Translation & Positioning

    With SVG scaling more-or-less settled, converting a line of text into the SVG layouts for printing and punching is reasonably straightforward feasible.

    The Python program contains several test patterns:

    TestStrings = ( " " * NumCols,      # blank card for printing
                    "█" * NumCols,      # lace card for amusement
                    "0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz",
                    "¢.<(+|!$*);¬,%_>?:#@'=" + '"' + "&-/█",
                    "▯" * NumCols,      # hack for row number alignment
                    )
    
    

    A Contents string contains the characters going onto the card, which can come from either the command line or TestStrings:

    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()
    
    

    The args.seq command line option gives either the starting sequence number or, when zero, leaves the original text unchanged. Adding sequence numbers to the Apollo AGC source code is non-canon; so be it. All of the adjusting turns Contents into an 80 character string.

    The CharMap converts each Unicode character in Contents into a list of the holes to be punched in that column:

    CharMap = {
        " ": (),
        "0": (0,),
        "1": (1,),
    … snippage …
        "Z": (10,9),
        "a": (12,10,1),
    … snippage …
        "&": (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
    }
    
    

    The numbers in the list are effectively “row names” which must be translated into the physical row index (from the top of the card, because Y coordinates increase downward in SVG files):

    RowMap = (2,3,4,5,6,7,8,9,10,11,2,1,0)
    
    

    The top three card rows are named “12 / 11 / 10”, with the “10 row” in the same place as the “0 row” = third row from the top = index 2, which is why the mapping is not one-to-one.

    Whereupon punching the holes goes a little something like this:

    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",
                    )
                )
    
    

    Mercifully, everything is in millimeters and LightBurn plunks the holes exactly where they should be:

    Punched Cards - laser SVG layout
    Punched Cards – laser SVG layout

    That’s the LightBurn layout with the rectangles on the black layer set to Fill. The SVG rectangles have fill="none" because LightBurn uses only the vector stroke, so you’ll see hollow rectangles in any other program. The card outline goes on the red layer set to Line for cutting.

    Punching evaporating those holes must happen on the printed card made with the same text string, which requires another pass through the program with different command-line parameters.

    A real blank card has a digit corresponding to the row name printed in every column, much like the bottom card:

    Punched cards - lace and blank tests
    Punched cards – lace and blank tests

    In these cards, the digits aren’t printed at the positions where holes will appear:

    Punched Cards - print SVG layout
    Punched Cards – print SVG layout

    Preventing a digit sliver from peeking around the edge of a hole makes the text-to-hole alignment look better than it really is. As a bonus, if the holes evaporated in the 9 row don’t match up with the missing digits, poking the STOP button on the laser minimizes the damage; for these cards, I can tolerate a slight punching error.

    FWIW, back in the day you could get cards printed with any ink color you wanted, as long as you were buying a million or two. While green ink is non-canon for the cards I remember, it was possible.

    Not printing a digit requires some gyrations:

    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 = RowGlyphs[-1] 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
                    )
                )
    
    

    The xoffset and yoffset values come from empirical measurements and will likely not match whatever your printer produces.

    The text and hole layout assume the corner targets have been aligned, so the final alignment depends on at least:

    • Eyeballometric positioning of red dot at target center
    • Red dot pointer to actual laser spot alignment
    • Correct focus distance
    • Printer stability in both X and Y

    The font gets selected by the class_ and font_family attributes, with Inkscape requiring the former and LightBurn the latter. Just to keep things interesting, each program ignores the other attribute. The class_ attribute selects one of the Style entries emitted while blurting out the accumulated SVG elements:

     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)
     
    

    The print(canvas) statement squirts the SVG text to stdout, where the Bash script directs it into a suitable file.

    The Contents string appears across the top of the card in a dark-ish gray-ish color resembling a well-worn IBM 029 keypunch machine ribbon, with the KEYPUNCH029 font providing gritty 5×7 verisimilitude:

    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
                )
            )
    
    

    Although lowercase letters on a punched card are definitely non-canon, the args.lower command-line switch applies italic to them and the font maps them to uppercase.

    Dumping unfiltered text into an SVG file allows code injection attacks, which I discovered when this test card passed through:

    "¢.<(+|!$*);¬,%_>?:#@'=" + '"' + "&-/█"
    

    The svg.escape() function replaces characters like “&” with “&amp;”., which I filed under “Things I should have learned by now.”

    Two passes through the program with appropriate switches and the same text will produce two matching SVG files. Although they’re scaled to the same size, the SVG-to-be-printed requires considerable processing before the printer sees it …

  • Punched Cards: SVG Scaling

    Punched Cards: SVG Scaling

    With a fixture aligning a printed card in the laser for cutting and trays for incoming and outgoing cards, it’s time to generate SVGs for printing and cutting. I use the svg.py library to translate card geometry into SVG elements.

    For example, this draws a rectangle around the card perimeter with a color that will automagically put it on a LightBurn tool layer used for alignment:

    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",
            )
        )
    

    Because I know the exact size of the card layout, all the coordinates / sizes / widths will be in hard millimeters. The constant INCH converts from the hard inch sizes of the OG IBM card layout.

    The args.layout variable corresponds to a command-line switch selecting output for either “print” or “laser”, because each requires different SVG elements and attributes. In this case, the card outline appears only in the laser file, because it should not be printed on the card face.

    Eventually, the SVG for the laser will look like this, with the blue rectangle boxing its red perimeter:

    Punched Cards - laser SVG layout
    Punched Cards – laser SVG layout

    The corresponding SVG to be printed, without the rectangle:

    Punched Cards - print SVG layout
    Punched Cards – print SVG layout

    Both SVGs have four alignment targets at exactly the same coordinates, although in different colors for their different purposes. The laser targets appear on a tool layer where they can be selected to position the laser head over the corresponding printed targets on the card in the fixture:

    Punched cards - laser fixture alignment
    Punched cards – laser fixture alignment

    Subsequent steps in the process composite the fancy logo under the SVG layout and scale the result before printing the card.

    This chunk of code produces those 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",
            )
        )
    

    The stroke attribute sets the color of the result, with the Python ternary operator picking the appropriate RGB value based on args.layout.

    The args.lbsvg variable comes into play when the LightBurn interpretation of an SVG attribute or element differs from the Inkscape interpretation. I have absolutely no idea what’s going on in those situations, other than that the two programs sometimes regard coordinates / distances differently.

    For example, LightBurn regards the “user units” in a Path as millimeters and Inkscape regards them as “SVG pixels” requiring a scale factor I defined as SVGSCALE = 96.0/25.4 to get the right size. If there’s a less awful way to match them, I’m all eyes.

    Although setting args.layout to “laser” and args.lbsvg to True generally makes sense, I may want to print the “laser” layout for cross-checking. Hilarity generally ensues until I remember args.lbsvg. It’s worth noting the code has zero error checking, so hilarity generally continues until I fix whatever got missed.

    With that in mind, this Path traces the card perimeter:

    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",
            ),
        )
    
    

    Note that the stroke_width attribute also requires scaling, lest it take on a broad-brush aspect.

    More on generating the various characters and punching the holes to come …

    The Python source code as a GitHub Gist:

    # 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)
    view raw Punched Card.py hosted with ❤ by GitHub

    I derive a modest satisfaction from knowing Microsoft (which owns GitHub) uses my source code to train their Copilot LLM. Future generations of vibe coders will look at their programs and wonder WTF just happened.

  • Punched Cards: Storage Trays

    Punched Cards: Storage Trays

    Keeping stacks of punched cards under control is important for orderly production:

    Punched cards - storage trays
    Punched cards – storage trays

    The two on the left fit finished cards and the larger ones fit 1/3 Letter paper raw cards.

    The patterns come from boxes.py:

    I added a finger slot behind the front opening so I could pick up the whole stack at once:

    Punched Card Trays - LightBurn layout
    Punched Card Trays – LightBurn layout

    Admittedly, my “stacks” are nothing compared to the Bad Old Days, but …

    The program adds a sequence number in columns 73-80 as a last-ditch effort to ensure the punch pattern matches the print pattern: after a few dozen cards, the digits in the last few columns become recognizable.

    The LightBurn SVG layout as a GitHub Gist:

    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.
  • Punched Cards: Laser Fixture

    Punched Cards: Laser Fixture

    Creating a punched card with a laser requires a fixture holding the printed card-to-be flat and slightly above the honeycomb to reduce flash burns / schmutz on the underside:

    Punched cards - laser fixture overview
    Punched cards – laser fixture overview

    A closer look while evaporating the holes:

    Punched cards - laser fixture
    Punched cards – laser fixture

    The finger-crushingly strong magnets hold the fixture firmly to the (steel!) honeycomb, while allowing some adjustment. Unlike most fixtures, this one must slide around to align the printed targets with the laser positions; for reasons to be explained later, LightBurn’s Print and Cut alignment isn’t useful.

    The pieces:

    Punched Card Fixture
    Punched Card Fixture

    The top layout (on a LightBurn tool layer) matches the 1/3 Letter just-printed card, with targets bracketing the finished card outline. All the other pieces derive from those outlines with suitable offsets.

    Glue the next three pieces together:

    • Chipboard extending a millimeter over the card edges to hold it down
    • Thin cardboard, about 0.6 mm thick, a millimeter beyond the card sides and flush with its top
    • 3 mm MDF baseplate on the honeycomb

    The card-shaped baseplate cutout lies 2 mm outside the card perimeter, for obvious reasons.

    Set the laser speed / power so the blue lines on the baseplate mark the MDF for easy positioning of the cardboard spacer. The three parallel lines in front make it obvious when the card isn’t flush against the rear edge of the spacer; I’d only need one line if my paper cutter were perfectly calibrated.

    The big blue rectangle on the bottom cuts a hole in a sheet of corrugated cardboard covering the platform, ensuring the air flows across the card and through the honeycomb behind the fixture; you want as little smoke hovering over the card as possible. The seam in my cardboard sheet was where they glued the box together; there’s no reason to be fussy with an air shield.

    When the cutting is done, the finished card falls free:

    Punched cards - laser fixture - cut
    Punched cards – laser fixture – cut

    A snippet of masking tape helps extract the card without bending it.

    The LightBurn SVG layout as a GitHub Gist:

    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.

  • Punched Card Production

    Punched Card Production

    For reasons I cannot divulge at the moment, I have undertaken a project requiring Old School punched cards, although they will never be fed through a card reader. Because we live in the future, punched cards are no longer a cheap and readily available resource; I will always deeply regret trashing an entire box back in the day.

    However, living in the future does confer some advantages:

    Punched cards - Apollo 11 CM
    Punched cards – Apollo 11 CM

    The process involves a vast number of moving parts, not all of which I fully understand, but I can (generally) produce consistent results and that must suffice. This post is an overview; I will go into the moving parts in more detail so I can remember why I did what I did.

    A Python program converts a line of text into an SVG file that contains either the card’s printable contents or the paths required to cut its holes & perimeter. A handful of command-line switches determines the outcome, so you run the program twice with different switches for each line of text to get a matched pair of SVG files.

    A Bash script read a text file and hands each line to the Python program, producing two SVG files for each card. It then invokes Inkscape to convert the printable SVG into a PNG image, uses Imagemagic to composite the logo behind the card contents & scale the result to make my printer’s output match the laser’s dead-on positioning, then properly position the card image in a Letter-size PNG image that’s apparently the only way to print it accurately on a punched card:

    Composited Letter layout - exvb-00000710-lt
    Composited Letter layout – exvb-00000710-lt

    That’s not full size.

    N.B.: there’s no such thing as a blank card that will be punched later, because the printed card includes the text across the top. The program also suppresses the row digits where a punch will appear, thus making slight misalignments less painful and mismatched SVG files more obvious.

    Print all the card images on precut 1/3 Letter size sheets of heavy cardstock:

    Ext Verb cards - 0280 skewed print
    Ext Verb cards – 0280 skewed print

    Yes, the printing on the middle card is slightly skewed with respect to the precut card blank. The overall process must handle about two millimeters of positioning inaccuracy and whatever angular skew comes from the printer’s paper feed rollers / guides.

    A DOS Windows BAT file feeds the SVG files with the holes & outline paths to LightBurn, one by one. No lie.

    Put each printed card in a fixture and align its targets, whereupon LightBurn evaporates the holes and cuts the outline:

    Punched cards - laser fixture overview
    Punched cards – laser fixture overview

    In my somewhat biased opinion, the results look good:

    Ext Verb cards - 0270-0290 punched
    Ext Verb cards – 0270-0290 punched

    The Python program also produces cards with test patterns useful for wringing out the process:

    Punched cards - character tests
    Punched cards – character tests

    “Punching” a lace card is no problem and, given an all-blank text line, the result looks like a blank card:

    Punched cards - lace and blank tests
    Punched cards – lace and blank tests

    If you happen to have a card punch, be my guest.

    The source text for the cards comes from the Apollo Guidance Computer in the Apollo 11 Command Module, via an amazing GitHub repository. You can run a virtual AGC in the privacy & comfort of your own home.

    Useful links:

  • OMTech Laser Cutter: Custom Air Fitting Wrench

    OMTech Laser Cutter: Custom Air Fitting Wrench

    Changing the lens on the laser requires unscrewing the nozzle after removing the assist air fitting that collides with the focus pen holder:

    Laser head - assist air vs focus pen
    Laser head – assist air vs focus pen

    All the 12 mm open-end wrenches in my Drawer o’ Spare Wrenches being much too large, I finally got around to making a custom wrench:

    Air fitting wrenches
    Air fitting wrenches

    The plywood wrench came from a traced scan of a similar wrench, then adjusting the jaw opening to 12 mm. It served to verify the overall shape & size, then became a template for the real wrench atop a scrap of 1/8 inch aluminum sheet with flaking paint.

    Some bandsawing and filing later:

    Air fitting wrench - at nozzle
    Air fitting wrench – at nozzle

    A little wrench makes swapping the lens somewhat less tedious, which is a Good Thing™.

    Protip: Remember to adjust the Focus Distance by the difference between the two lenses.