Technical reference for maintaining pdftonemonic and the surrounding CUPS tooling. Last updated: 2026-04-04.
The PPD declares a single CUPS filter for PDF jobs:
*cupsFilter: "application/pdf 50 /Library/Printers/Nemonic/pdftonemonic"
Upstream of that, macOS may run cgtexttopdf (or app-specific code) so the spool file is already PDF when it reaches this binary. The filter reads PDF bytes, rasterizes with Core Graphics, auto-crops, rotates for the sticky-note path, dithers, and writes ESC/POS (GS v 0 plus wrappers) to stdout for the USB backend.
CUPS invokes filters as:
filter job user title copies options [filename]
In Swift, CommandLine.arguments[0] is the executable path; arguments[6] is the spool file path when present. Jobs can also pass "-" or rely on stdin.
Order of reads (see loadJobPDF in pdftonemonic.swift):
- If stdin is not a TTY (pipe from CUPS), read stdin to end. If non-empty, use that as the PDF.
- Otherwise read
arguments[6]if it is a non-empty path and not"-".
Manual testing from Terminal: if you pass a file path as argv[6], close stdin so the filter does not block waiting for you:
./pdftonemonic 1 user title 1 "" /path/to/job.pdf < /dev/null > out.binThe harness scripts (test.sh, preflight_pdf.sh, run_print_gates.sh, diagnose_print.sh) all follow the same CUPS argv shape and use < /dev/null where appropriate.
Some PDFs produced by Quartz / BBEdit / “ColorModel=Gray” print tickets rasterize as empty when drawn straight into a DeviceGray context with drawPDFPage. Drawing into DeviceRGB (premultiplied first, little-endian BGRA) and then flattening to grayscale preserves blends and opacity that otherwise become all white.
The first-pass bitmap is still used for ink bounds (crop) and downstream scaling; the final thermal pass remains grayscale + fixed threshold dither.
Ink bounds are found by scanning the gray buffer after the RGB→gray flatten step.
Coordinate system: For a memory-backed CGContext created like the driver’s (default bitmap layout), row 0 in the buffer is the bottom row of the image. CGImage.cropping(to:) uses Quartz’s bottom-left origin: the rectangle’s y is the distance up from the bottom of the image to the bottom edge of the crop.
So if the scan finds content in rows minY…maxY (inclusive) in buffer row index (0 at bottom), the crop rect is:
CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1)
after applying horizontal padding and clamping to page bounds.
What went wrong historically: Inverting Y “for top-left” when cropping shifted the rectangle to the wrong vertical band. On US Letter–sized PDFs, body text often sits in the lower portion of the page; a bogus crop grabbed the white top margin, yield zero effective ink and blank output. Small sticker-sized PDFs could still “work” if the wrong band accidentally overlapped content.
Do not assume “invert Y” fixes crops without re-validating against Letter + BBEdit PDFs.
| Variable | Purpose |
|---|---|
NEMONIC_PREVIEW_ONLY |
If 1 / true / yes, no ESC/POS is written to stdout (CUPS may still feed paper). |
NEMONIC_THRESHOLD |
Dither threshold (1–255). 0 would mark nothing black; invalid values clamp with stderr. |
NEMONIC_DEBUG_DIR |
If set, writes stage PNGs (rendered-rgb, rendered, cropped, final-raster, dithered) per page. |
NEMONIC_CROP_PADDING |
Pixels of padding around the tight ink box (default 16). |
NEMONIC_RIGHT_MARGIN |
Dots reserved on the sticky edge (default 12). |
NEMONIC_SCALE_ADJUST |
Multiplier on fitted scale (default 1.0). |
NEMONIC_INTERPOLATION |
When set, controls interpolation when flattening RGB→gray and drawing the cropped image; unset uses high quality for the scaled draw. |
NEMONIC_MODE |
Force mode for testing: Sticky (default, 90° rotation) or Receipt (normal portrait). Overrides the PPD NemonicMode job option. |
The PPD now exposes a Print Mode choice (*NemonicMode) with two values:
- Sticky (default) — the classic behaviour: the filter applies a +90° rotation so text reads correctly on sticky notes where the adhesive runs along one edge. The rightMargin safety zone is reserved on the sticky side.
- Receipt — no extra rotation. Content is laid out normally across the 80 mm print head width and flows down the long dimension of the paper. Ideal for receipts, tickets, logs, etc.
The mode is passed to the filter in the CUPS options string and logged at startup (NemonicMode=...). You can also force it with the NEMONIC_MODE environment variable when running the filter directly or via the preflight harness.
When you have two queues for the same physical printer, set different defaults:
lpadmin -p Nemonic_Sticky -o NemonicMode=Sticky
lpadmin -p Nemonic_Receipt -o NemonicMode=ReceiptOn startup the filter prints filterBuildTag to stderr so /private/var/log/cups/error_log proves which binary CUPS ran (confirms sudo ./install.sh picked up your build).
| Script | Role |
|---|---|
run_print_gates.sh |
Compile → file + /dev/null → stdin pipe with sixth arg - (stdin PDF) → ink count → optional installed binary byte check. |
test.sh |
Canonical PDF + preview PNG + human checklist. |
preflight_pdf.sh [file] |
Your real PDF; fails on low byte count or low ink. |
diagnose_print.sh |
Installed vs fresh swiftc binary, shasum, raster byte count. |
Always run gates or preflight before debugging with physical prints.
- Confirm installed filter matches the repo:
bash diagnose_print.shand check stderr forfilterBuildTagon a reallpjob inerror_log. unset NEMONIC_PREVIEW_ONLYeverywhere (shell,launchdenv forcupsdif ever injected).- Capture
/private/var/log/cups/error_loglines for the job (argv,CONTENT_TYPE, filter stderr). - Reproduce offline:
bash preflight_pdf.sh --openthe same PDF path the app prints. - If still unclear, set
NEMONIC_DEBUG_DIR=/tmp/nemdeb(filter must run in a context that passes env through — for manual runs export it; CUPS only passes whitelisted env — for local runs from shell this is enough) and inspect stage PNGs.
- BBEdit / most apps: Submit
application/pdf(often withColorModel=Grayin the job options). The regression we fixed was not “missing PDF” but raster/crop behavior on those PDFs. - Raw text via
lpr: May go throughcgtexttopdf, which can lay out text oddly for sticky media. Fun scripts use a Swift path to avoid that. SeeLEARNINGS.md§2 for historical notes.
LEARNINGS.md— hardware, protocol, historical experiments.README.md— user-facing install and preflight.pdftonemonic.swift— source of truth for the pipeline.