From bc5928f9fa23f1fb518d4a9ffd60614c39587331 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 11:49:57 -0700 Subject: [PATCH 01/15] docs(specs): add examples reorganization design Three-tier examples taxonomy (tutorial snippets / useful tools / advanced) with acceptance criteria, promoting list layout into the library, extracting the EventKit reminders example to its own repo, and adding CI. --- ...26-08-30-examples-reorganization-design.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/specs/2026-08-30-examples-reorganization-design.md diff --git a/docs/specs/2026-08-30-examples-reorganization-design.md b/docs/specs/2026-08-30-examples-reorganization-design.md new file mode 100644 index 0000000..154a43d --- /dev/null +++ b/docs/specs/2026-08-30-examples-reorganization-design.md @@ -0,0 +1,269 @@ +# Examples Reorganization — Design + +**Date:** 2026-08-30 +**Status:** Approved, ready for implementation planning + +## Context + +LabelKit ships three example executables that have grown ad hoc. Each tangles +three orthogonal concerns — data source, layout, and delivery — and +re-implements all three: + +| | data source | layout | delivery | +|---|---|---|---| +| `example-label` | template store | Stencil | stdout + printer + 2 previewers | +| `example-reminderlist` | EventKit | hand-built ZPL | 4 subcommands | +| `example-listlabel` (uncommitted) | stdin | hand-built ZPL (near-identical) | 4 subcommands | + +Concrete symptoms: + +- `PrinterOptions` is copy-pasted verbatim into all three files. +- `generate_label()` is duplicated between `ReminderList.swift` and + `ListLabel.swift` with only cosmetic differences. +- `example-label` depends on a `templates.json` that is not in the repo. It runs + today only because a copy exists in + `~/Library/Group Containers/group.com.peterichardson.labelkit/`, almost + certainly left by LabelGUI. `StencilTemplateStore.load()` silently no-ops when + the file is missing (`StencilTemplates.swift:50`), so on a fresh clone + `render(name: "label")` throws. The flagship example depends on invisible + machine state. +- `example-reminderlist` pulls EventKit — a dependency and a permission prompt + the library itself does not need — into a package that otherwise just makes + ZPL. +- The DocC catalog documents `StencilProcessor` and `LengthInjector`, neither of + which exists. The real types are `ResolveTemplates` and `InjectLength`. +- LabelGUI carries a 164-line fork of LabelKit's 45-line `KeyValueContext`. + +There is no seam, so each new example copies the last one. + +## Goals + +Three tiers, each with acceptance criteria. The criteria are the part that was +missing; without them the ad-hoc drift recurs. + +| Tier | Purpose | Must | Must not | +|---|---|---|---| +| **1. Tutorial** | Teach one LabelKit feature | Be a `Snippets/*.swift` file; compile on every build; fit on one screen | Take flags; require setup; teach two things | +| **2. Useful** | A tool you would actually run | Run on a fresh clone with zero setup; do one job | Add a dependency, entitlement, or permission prompt LabelKit itself does not need | +| **3. Advanced** | Show non-obvious technique | Demonstrate something not guessable from the API docs | Duplicate Tier 2's job with more knobs | + +Two criteria carry most of the weight: + +- **Tier 2 zero-setup** forces seed templates into the repo, fixing the + `example-label` failure above. +- **Tier 2 no-extra-dependency** sends EventKit to its own repo. + +## Non-goals + +- Migrating documentation from jazzy to DocC. Tracked separately; see + *Deferred*. +- Building a second SwiftUI app. LabelGUI *is* the Tier 3 GUI example. +- Reworking ZPL emission (`^FB`, `^CF`, `^CI28`). Tracked as LabelKit #53 and + #54; this design only affects *where* that code lives. + +## Design + +### Structure + +``` +Snippets/ Tier 1 — auto-discovered, no Package.swift entry + PrintToStdout.swift + PreviewInITerm2.swift + PrintToNetwork.swift + RenderATemplate.swift + ChooseStockAndDevice.swift + +Examples/ + labelprint/ Tier 2 — stdin -> print | preview | zpl | list + Advanced/ + compare-renderers/ Tier 3 — Labelary vs zpl2png, for debugging + batch-badges/ Tier 3 — one template + CSV -> N labels +Apps/LabelGUI Tier 3 — the GUI example + +-> get-reminders separate repo (renamed from asyncgetreminders) +``` + +### Snippets (Tier 1) + +Verified behavior, not assumed: a top-level `Snippets/` directory is +auto-discovered by SwiftPM with **no `Package.swift` entry**; each file is +compiled and linked as an executable; `swift run ` runs it; and a stale +API reference **fails the build** (confirmed with +`module 'LabelKit' has no member named ...`). Top-level `try await` works, which +matters because LabelKit's delivery API is async. + +Snippets use top-level code — no `@main`, no ArgumentParser. They take no flags: +a tutorial that needs configuring is teaching two things at once. + +`// snippet.hide` / `// snippet.show` fence off setup code, and `//!` is the +snippet doc comment. These affect rendered documentation only, which this +project does not yet generate — see *Deferred*. + +### No shared example target + +The `PrinterOptions` triplication resolves by deletion rather than abstraction. +Once `example-label` dissolves and `example-reminderlist` is extracted, +`PrinterOptions` has exactly one consumer (`labelprint`); `compare-renderers` +and `batch-badges` do not print to a network printer. **No `ExampleSupport` +target is created.** Revisit only if a second printing tool appears. + +Tier 1 snippets duplicate freely by design — copy-pasteability is the point, and +a shared helper would defeat it. Applying one sharing policy across both tiers is +what produced the current mess. + +### Library change 1: promote list layout + +`t` (github.com/PeteRichardson/t) will consume LabelKit to print reminders, +making it a second consumer of the "lines -> long label" logic. Leaving layout in +example code would mean `t` reimplements it — a third copy of `generate_label`. + +```swift +public enum ListLine: Sendable, Equatable { + case header(String), item(String), blank +} + +public struct ListLayout: Sendable { + public var headerFontSize = 60, itemFontSize = 50, gap = 20 + public var headerIndent = 60, itemIndent = 100 + public var topMargin = 20 + public var bottomMargin = 318 // empirical: ZD620 + cutter @ 300dpi + + public func makeLabel(_ lines: [ListLine], + environment: ZPLEnvironment) -> ZPLLabel +} +``` + +The name is `ListLayout`. (`TextListLayout` and `ListLabelBuilder` were +considered; `ListLayout` wins on brevity and because the type describes a layout, +not a builder.) `makeLabel` also sets +`environment.options.geometry.heightDots` to the computed length, as the current +`generate_label` does. + +`bottomMargin` is a known wart — an empirical cutter allowance rather than a +style choice. It stays a defaulted property with a comment rather than being +invented into a larger abstraction. + +**The seam:** *layout* (`[ListLine]` -> `ZPLLabel`) moves to the library because +it has two consumers. *Parsing* (text -> `[ListLine]`: the `:`-suffix header +convention, blank collapsing, trailing-blank trimming) stays in `labelprint` +because it has one — `t` builds `ListLine`s from Eisenhower quadrants directly +and never parses text. + +This also improves LabelKit #54: the `^FB` rework then lands in one library-side +place next to the `ZPLLengthEstimator` changes it depends on, rather than in +example code. + +### Library change 2: `StencilTemplateStore.init(fileURL:)` + +`StencilTemplateStore` currently exposes only `public init() throws`, which +resolves to Application Support / group-container paths, and `fileURL` is +`private(set)`. There is no way to point it at a template file shipped in the +repo, so the Tier 2 zero-setup rule cannot be satisfied without this addition. +Prerequisite, not a nicety. + +Seed templates are then checked into the repo at +`Examples/Resources/templates.json` and used by `RenderATemplate` and +`batch-badges`. Snippets and examples resolve it relative to the package so a +fresh clone works with no Application Support state; the existing zero-argument +`init()` behavior is unchanged for apps like LabelGUI that want the user's own +template store. + +### CI + +There is no `.github/workflows/` at all. This is load-bearing: the entire case +for snippets is that `swift build` catches rot, which is only true if something +runs `swift build`. A minimal macOS workflow running `swift build` and +`swift test` is **in scope for this work**, not deferred. + +### Tier 2: `labelprint` + +Subcommands `print` (default), `preview`, `zpl`, `list`. `list` echoes the parsed +lines, which is how the header/blank parse gets debugged without burning a label. +Printer options resolve flag -> environment (`LABELKIT_PRINTER_HOST` / +`LABELKIT_PRINTER_PORT`) -> `PrinterDefaults`. + +**Tier 2 tools drop the `example-` prefix.** They are meant to be symlinked into +`~/bin`, and the prefix argues against the thing we want people to do with them. +Accepted tradeoff: it is less obvious from `swift run --list` which products are +examples. + +### Tier 3 + +- **`compare-renderers`** — takes ZPL, renders through both `LabelaryRenderer` + and `ZPL2PNGRenderer`, shows both inline and reports dimensions. This tool + would have caught the `^FB` discrepancy behind #54 immediately, which is the + concrete case for its existence. +- **`batch-badges`** — one Stencil template + a CSV -> N labels, via + `ResolveTemplates` and `KeyValueContext`. Natural consumer of the seed + template. +- **LabelGUI** — documented as the Tier 3 GUI example. Its forked + `KeyValueContext` is reconciled with the library type. The reconciliation + *direction* is not yet decided, because nobody has read the 119-line + difference. Step 7 therefore begins with an explicit investigation task — + diff the two, classify what the fork added as (a) GUI-specific and staying in + LabelGUI, (b) generally useful and promoted into LabelKit, or (c) redundant + and deleted — and only then edit. Do not assume the fork is pure duplication. + +## Migration map + +| Today | Becomes | +|---|---| +| `example-label` | `Snippets/RenderATemplate.swift` + `Snippets/PreviewInITerm2.swift` + `Advanced/compare-renderers`; target deleted | +| `ReminderList.swift` (layout) | `ListLayout` in LabelKit | +| `ReminderList.swift` (CLI) | deleted | +| `Reminders.swift` | `get-reminders` repo (renamed from `asyncgetreminders`) | +| `example-listlabel` (uncommitted) | `Examples/labelprint/`, layout removed | +| `PrinterOptions` x3 | x1, in `labelprint` | +| LabelGUI forked `KeyValueContext` (164 lines) | reconciled with library's (45) | +| DocC `StencilProcessor` / `LengthInjector` | corrected to real symbol names | + +## Testing + +Promoting layout makes it testable for the first time — neither example ever had +a test for it. `Tests/LabelKitTests/` gains coverage for `ListLayout`: per-line- +type cursor advance, `^LL` computation, empty input, header vs item indent. + +`labelprint` gets a small test target for its parser: header detection, blank +collapsing, trailing-blank trimming, `^`/`~` handling. + +Snippets are covered by compilation. CI runs `swift build` + `swift test`. + +Tests use Swift Testing (`import Testing`, `@Suite`/`@Test`), matching the rest +of the suite. + +## Sequencing + +1. CI + `StencilTemplateStore.init(fileURL:)` + seed templates — unblocks the rest +2. `ListLayout` into LabelKit, with tests +3. `labelprint`; delete `example-listlabel` +4. Snippets; delete `example-label` +5. `get-reminders` extraction; delete `example-reminderlist` +6. Tier 3 tools +7. LabelGUI `KeyValueContext` reconciliation + DocC symbol fixes + +Steps 1-2 unblock `t` #42. + +## Companion work in other repos + +- **`get-reminders`** — rename `asyncgetreminders`, discard its contents (2022 + Xcode project, deprecated `requestAccess(to:completion:)`, and an unawaited + authorization race), and port the newer `Reminders.swift` in as the + implementation. Output contract is plain lines sorted by priority; priority and + due date do not survive the pipe, which is the trade that keeps it composable. + A `--by-list` mode emitting `Groceries:` headers composes with `labelprint`'s + header convention with no shared code — only a shared text convention. +- **`t` #41** — bump deployment target to macOS 14 (LabelKit requires it). +- **`t` #42** — add label printing via LabelKit. Blocked by #41 and by step 2 + above. + +## Deferred + +- **jazzy -> DocC migration.** Snippets' compile-checking, zero-wiring and + runnability are available today; `@Snippet` embedding in generated docs is not, + because `Scripts/generate_docs.sh` uses jazzy, which does not understand DocC + directives. Separate issue. +- **LabelKit #53** (`^CF`, `^CI28`, `^FH`) and **#54** (`^FB`) — sequenced after + this reorganization, since both touch code that moves here. +- **`ResolveTemplates.init?()` prints to stdout on failure** + (`ResolveTemplates.swift:43`) — a library writing to stdout. Noted, out of + scope. From 462b254b47be6d548dcdbc5cc766bebd4b50c463 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 11:58:56 -0700 Subject: [PATCH 02/15] docs(specs): extract reminders into a new repo rather than reusing asyncgetreminders --- ...26-08-30-examples-reorganization-design.md | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/specs/2026-08-30-examples-reorganization-design.md b/docs/specs/2026-08-30-examples-reorganization-design.md index 154a43d..21fe9c1 100644 --- a/docs/specs/2026-08-30-examples-reorganization-design.md +++ b/docs/specs/2026-08-30-examples-reorganization-design.md @@ -80,7 +80,7 @@ Examples/ batch-badges/ Tier 3 — one template + CSV -> N labels Apps/LabelGUI Tier 3 — the GUI example --> get-reminders separate repo (renamed from asyncgetreminders) +-> reminders new separate repo ``` ### Snippets (Tier 1) @@ -211,7 +211,7 @@ examples. | `example-label` | `Snippets/RenderATemplate.swift` + `Snippets/PreviewInITerm2.swift` + `Advanced/compare-renderers`; target deleted | | `ReminderList.swift` (layout) | `ListLayout` in LabelKit | | `ReminderList.swift` (CLI) | deleted | -| `Reminders.swift` | `get-reminders` repo (renamed from `asyncgetreminders`) | +| `Reminders.swift` | new `reminders` repo | | `example-listlabel` (uncommitted) | `Examples/labelprint/`, layout removed | | `PrinterOptions` x3 | x1, in `labelprint` | | LabelGUI forked `KeyValueContext` (164 lines) | reconciled with library's (45) | @@ -237,7 +237,7 @@ of the suite. 2. `ListLayout` into LabelKit, with tests 3. `labelprint`; delete `example-listlabel` 4. Snippets; delete `example-label` -5. `get-reminders` extraction; delete `example-reminderlist` +5. `reminders` extraction; delete `example-reminderlist` 6. Tier 3 tools 7. LabelGUI `KeyValueContext` reconciliation + DocC symbol fixes @@ -245,13 +245,26 @@ Steps 1-2 unblock `t` #42. ## Companion work in other repos -- **`get-reminders`** — rename `asyncgetreminders`, discard its contents (2022 - Xcode project, deprecated `requestAccess(to:completion:)`, and an unawaited - authorization race), and port the newer `Reminders.swift` in as the - implementation. Output contract is plain lines sorted by priority; priority and - due date do not survive the pipe, which is the trade that keeps it composable. - A `--by-list` mode emitting `Groceries:` headers composes with `labelprint`'s - header convention with no shared code — only a shared text convention. +- **`reminders`** — a **new** repo, seeded with `Reminders.swift` from + `Examples/ReminderList/`. The name is deliberately broader than + `get-reminders`, which would constrain the tool to reading at the outset; + `reminders` leaves room to add functionality later. + + `asyncgetreminders` is **not** reused. It was a 2022 experiment in async + + EventKit rather than a usable tool, and its contents are strictly worse than + what already exists here: an Xcode project rather than a package, the + deprecated `requestAccess(to:completion:)`, and an `init()` that fires the + authorization request without awaiting it — so a fetch can run before access is + granted. It stays where it is, as the experiment it was. + + Output contract is plain lines sorted by priority; priority and due date do not + survive the pipe, which is the trade that keeps it composable. A `--by-list` + mode emitting `Groceries:` headers composes with `labelprint`'s header + convention with no shared code — only a shared text convention. + + **Naming note:** `keith/reminders-cli` (~900 stars) also installs a binary + called `reminders`. The repo name is unaffected, but the installed binary name + would collide if that tool is ever installed via Homebrew. - **`t` #41** — bump deployment target to macOS 14 (LabelKit requires it). - **`t` #42** — add label printing via LabelKit. Blocked by #41 and by step 2 above. From c764ca795e4fb0caa96d2e53de460ae61f077f4b Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 12:11:57 -0700 Subject: [PATCH 03/15] docs(plans): add examples reorganization implementation plan --- .../2026-08-30-examples-reorganization.md | 1346 +++++++++++++++++ 1 file changed, 1346 insertions(+) create mode 100644 docs/plans/2026-08-30-examples-reorganization.md diff --git a/docs/plans/2026-08-30-examples-reorganization.md b/docs/plans/2026-08-30-examples-reorganization.md new file mode 100644 index 0000000..eb65ab9 --- /dev/null +++ b/docs/plans/2026-08-30-examples-reorganization.md @@ -0,0 +1,1346 @@ +# LabelKit Examples Reorganization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reorganize LabelKit's ad-hoc examples into three tiers (compiled tutorial snippets, installable tools, advanced demos), promoting the duplicated list-layout logic into the library where it can finally be tested. + +**Architecture:** Layout (`[ListLine]` → `ZPLLabel`) moves into LabelKit because it gains a second consumer (`t`); text parsing stays in the `labelprint` example because it has only one. Tutorials become SwiftPM `Snippets/`, which compile on every build so they cannot rot. CI is added because that compile-checking is the entire justification for snippets and nothing currently runs `swift build`. + +**Tech Stack:** Swift 6, SwiftPM, Swift Testing (`import Testing`, `@Suite`/`@Test` — not XCTest), ArgumentParser, Stencil, GitHub Actions. + +**Spec:** `docs/specs/2026-08-30-examples-reorganization-design.md` + +## Global Constraints + +- Swift tools version 6.0; platform floor `.macOS(.v14)`. Do not change either. +- Tests use Swift Testing (`import Testing`, `@Suite`, `@Test`, `#expect`). Never XCTest. +- An example must not add a dependency, entitlement, or permission prompt that LabelKit itself does not need. +- Tier 2 tools must run on a fresh clone with zero setup (no Application Support state). +- Tier 1 snippets take no command-line flags and must fit on one screen. +- Tier 2 product names drop the `example-` prefix. Tier 1 snippet executables are named by their filename. +- `Workspace/Build/` and `Apps/LabelGUI/Build/` are derived artifacts — never edit or search them. +- Do not send anything to the physical printer during implementation. + +## Scope + +This plan covers the **LabelKit repo only** — spec steps 1, 2, 3, 4, 6, 7. + +Spec step 5 (extracting `Reminders.swift` into a new `reminders` repo) is a +separate subsystem in a different repo with a different dependency (EventKit) +and no shared code. It gets its own plan. **Task 8 here deletes +`example-reminderlist`, so the extraction plan must be executed first** — or +Task 8 deferred until it is. + +## Prerequisites + +The working tree currently has uncommitted work from an earlier session: an +untracked `Examples/ListLabel/` plus modifications to `Package.swift`, +`README.md`, and `AGENTS.md`. Task 4 transforms that code into `labelprint`. +Commit it first so Task 4's diff is meaningful: + +```bash +git add Examples/ListLabel Package.swift README.md AGENTS.md +git commit -m "feat(examples): add example-listlabel for printing stdin as a list label" +``` + +## File Structure + +| File | Responsibility | +|---|---| +| `.github/workflows/ci.yml` | Build + test on every push/PR | +| `Sources/LabelKit/Labels/ListLayout.swift` | `ListLine`, `ListLayout` — lines to ZPL. New public API. | +| `Tests/LabelKitTests/ListLayoutTests.swift` | Layout unit tests | +| `Sources/LabelKit/Processors/StencilTemplates.swift` | Gains `init(fileURL:)` | +| `Examples/Resources/templates.json` | Seed templates, checked in | +| `Examples/labelprint/LabelPrint.swift` | Tier 2 CLI. Parsing + subcommands. **Not** named `main.swift`. | +| `Tests/LabelPrintTests/ParseLinesTests.swift` | Parser tests | +| `Snippets/*.swift` | Tier 1 tutorials | +| `Examples/Advanced/compare-renderers/CompareRenderers.swift` | Tier 3 renderer diff | +| `Examples/Advanced/batch-badges/BatchBadges.swift` | Tier 3 template batch | + +--- + +### Task 1: Add CI + +**Files:** +- Create: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: nothing +- Produces: a CI job that runs `swift build` (which compiles `Snippets/`) and `swift test`. Every later task depends on this to enforce that snippets cannot rot. + +Verified facts that make this safe: `Sources/LabelKit/Helpers/zpl2png` is a +universal binary (x86_64 + arm64), so it runs on any GitHub macOS runner; and +the Labelary tests use an in-process `StubURLProtocol` rather than real network +calls, so the suite does not need internet. + +- [ ] **Step 1: Create the workflow** + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-and-test: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Show toolchain + run: swift --version + + # Builds the library, the example executables, and every file in + # Snippets/. A tutorial referencing a renamed API fails here. + - name: Build + run: swift build + + - name: Test + run: swift test +``` + +- [ ] **Step 2: Verify the same commands pass locally** + +Run: `swift build && swift test` +Expected: `Build complete!` then a passing run (89 tests at time of writing). + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: build and test on macOS via GitHub Actions" +``` + +--- + +### Task 2: Template store file-URL initializer + seed templates + +**Files:** +- Modify: `Sources/LabelKit/Processors/StencilTemplates.swift:24` +- Create: `Examples/Resources/templates.json` +- Create: `Tests/LabelKitTests/StencilTemplateStoreTests.swift` + +**Interfaces:** +- Consumes: nothing +- Produces: `public init(fileURL: URL)` on `StencilTemplateStore`, and a checked-in template archive at `Examples/Resources/templates.json` containing a template named `label`. Tasks 5 and 7 consume both. + +Why: the store currently exposes only `public init() throws`, which resolves to +Application Support / group-container paths. There is no way to point it at a +file in the repo, so the zero-setup rule cannot be met without this. + +- [ ] **Step 1: Write the failing test** + +Create `Tests/LabelKitTests/StencilTemplateStoreTests.swift`: + +```swift +// +// StencilTemplateStoreTests.swift +// LabelKitTests +// + +import Foundation +import Testing +@testable import LabelKit + +@Suite("StencilTemplateStore file-URL initializer") +struct StencilTemplateStoreTests { + + @Test func loadsTemplatesFromAnExplicitFileURL() throws { + let tmp = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("labelkit-store-\(UUID().uuidString).json") + let json = #"{"version":1,"templates":{"greeting":"^XA^FD{{ who }}^FS^XZ"}}"# + try Data(json.utf8).write(to: tmp) + defer { try? FileManager.default.removeItem(at: tmp) } + + let store = StencilTemplateStore(fileURL: tmp) + try store.load() + + #expect(try store.render(name: "greeting", context: ["who": "world"]) + == "^XA^FDworld^FS^XZ") + } + + @Test func missingFileLeavesTheStoreEmptyRatherThanThrowing() throws { + let missing = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("labelkit-absent-\(UUID().uuidString).json") + + let store = StencilTemplateStore(fileURL: missing) + try store.load() // documented no-op when the file is absent + + #expect(store.listNames().isEmpty) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `swift test --filter StencilTemplateStoreTests` +Expected: FAIL — compile error, no `init(fileURL:)` member. + +- [ ] **Step 3: Add the initializer** + +In `Sources/LabelKit/Processors/StencilTemplates.swift`, directly after the +existing `public init() throws` (line 24-27), add: + +```swift + /// Initializes a store backed by an explicit file, bypassing the + /// Application Support / group-container search performed by `init()`. + /// + /// Used by examples and snippets so they read templates checked into the + /// repository and run on a fresh clone with no user state. Apps that want + /// the user's own template store should keep using `init()`. + public init(fileURL: URL) { + self.fileURL = fileURL + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `swift test --filter StencilTemplateStoreTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Create the seed template archive** + +Create `Examples/Resources/templates.json`: + +```json +{ + "version": 1, + "templates": { + "label": "^XA^PW600^LL354\n^FO10,30^A0N,30,30^FDHeader^FS\n^FO10,80^A0N,24,24^FD{{ line1 }}^FS\n^FO10,120^A0N,24,24^FD{{ line2 }}^FS\n^XZ", + "badge": "^XA^PW600^LL354\n^FO20,40^A0N,40,40^FD{{ name }}^FS\n^FO20,100^A0N,24,24^FD{{ title }}^FS\n^XZ" + } +} +``` + +- [ ] **Step 6: Run the full suite and commit** + +Run: `swift test` +Expected: all tests pass. + +```bash +git add Sources/LabelKit/Processors/StencilTemplates.swift \ + Tests/LabelKitTests/StencilTemplateStoreTests.swift \ + Examples/Resources/templates.json +git commit -m "feat(templates): add StencilTemplateStore.init(fileURL:) and seed templates + +Examples and snippets can now read templates checked into the repo instead of +depending on Application Support state that does not exist on a fresh clone." +``` + +--- + +### Task 3: Promote `ListLayout` into the library + +**Files:** +- Create: `Sources/LabelKit/Labels/ListLayout.swift` +- Create: `Tests/LabelKitTests/ListLayoutTests.swift` + +**Interfaces:** +- Consumes: `ZPLLabel(_:processors:environment:)`, `ZPLEnvironment(stock:device:)`, `ZPLOptions.geometry.heightDots` +- Produces: + - `public enum ListLine: Sendable, Equatable { case header(String), item(String), blank }` + - `public struct ListLayout: Sendable` with properties `headerFontSize`, `itemFontSize`, `gap`, `headerIndent`, `itemIndent`, `topMargin`, `bottomMargin`, `printWidthDots`, a memberwise-defaulted `init`, and + `public func makeLabel(_ lines: [ListLine], environment: ZPLEnvironment) -> ZPLLabel` + + Task 4 (`labelprint`) and the companion `t` work both consume these exact names. + +Note: `^`/`~` sanitizing lives here, not in the parser. Emitting valid ZPL from +arbitrary text is layout's job, and putting it here means `t` gets the same +protection for free — reminder titles are arbitrary user text too. + +- [ ] **Step 1: Write the failing tests** + +Create `Tests/LabelKitTests/ListLayoutTests.swift`: + +```swift +// +// ListLayoutTests.swift +// LabelKitTests +// + +import Testing +@testable import LabelKit + +@Suite("ListLayout") +struct ListLayoutTests { + + private var environment: ZPLEnvironment { + ZPLEnvironment(stock: Stock.Preset.label4x, device: Device.Preset.ZD620) + } + + @Test func itemUsesItemFontAndIndentAtFirstBaseline() throws { + let zpl = try ListLayout().makeLabel([.item("eggs")], environment: environment).zpl() + // topMargin 20 + itemFontSize 50 + gap 20 = 90 + #expect(zpl.contains("^A0N,50,50^FO100,90^FDeggs^FS")) + } + + @Test func headerUsesHeaderFontAndSmallerIndent() throws { + let zpl = try ListLayout().makeLabel([.header("Costco:")], environment: environment).zpl() + // topMargin 20 + headerFontSize 60 + gap 20 = 100 + #expect(zpl.contains("^A0N,60,60^FO60,100^FDCostco:^FS")) + } + + @Test func blankAdvancesHalfAnItemPitchAndEmitsNothing() throws { + let zpl = try ListLayout() + .makeLabel([.item("a"), .blank, .item("b")], environment: environment).zpl() + // a at 90; blank adds (50+20)/2 = 35 -> 125; b adds 70 -> 195 + #expect(zpl.contains("^FO100,90^FDa^FS")) + #expect(zpl.contains("^FO100,195^FDb^FS")) + } + + @Test func labelLengthIsLastBaselinePlusBottomMargin() throws { + let zpl = try ListLayout().makeLabel([.item("only")], environment: environment).zpl() + #expect(zpl.contains("^LL408")) // 90 + 318 + } + + @Test func emptyInputStillProducesAWellFormedLabel() throws { + let zpl = try ListLayout().makeLabel([], environment: environment).zpl() + #expect(zpl.hasPrefix("^XA")) + #expect(zpl.hasSuffix("^XZ")) + #expect(zpl.contains("^LL338")) // topMargin 20 + 318 + } + + @Test func commandIntroducersAreStrippedFromText() throws { + let zpl = try ListLayout() + .makeLabel([.item("bad^XZand~JAmore")], environment: environment).zpl() + #expect(zpl.contains("^FDbadXZandJAmore^FS")) + // Exactly one ^XZ: the label terminator, not one smuggled in via text. + #expect(zpl.components(separatedBy: "^XZ").count - 1 == 1) + } + + @Test func computedLengthIsPublishedOnTheEnvironmentGeometry() throws { + let label = ListLayout().makeLabel([.item("only")], environment: environment) + #expect(label.environment.options.geometry.heightDots == 408) + } + + @Test func styleOverridesAreHonored() throws { + var layout = ListLayout() + layout.itemFontSize = 30 + layout.gap = 10 + layout.itemIndent = 40 + let zpl = try layout.makeLabel([.item("small")], environment: environment).zpl() + #expect(zpl.contains("^A0N,30,30^FO40,60^FDsmall^FS")) // 20 + 30 + 10 + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `swift test --filter ListLayoutTests` +Expected: FAIL — compile error, `ListLayout` / `ListLine` not found. + +- [ ] **Step 3: Write the implementation** + +Create `Sources/LabelKit/Labels/ListLayout.swift`: + +```swift +// +// ListLayout.swift +// LabelKit +// + +import Foundation + +/// One row of a list label. +public enum ListLine: Sendable, Equatable { + /// A section heading, set larger and closer to the left margin. + case header(String) + /// An ordinary list entry, set smaller and indented under its header. + case item(String) + /// A separator that contributes vertical space but no text. + case blank +} + +/// Lays a sequence of ``ListLine`` values out as a single long label. +/// +/// The printer has no notion of a list, so every row's baseline is computed +/// here and emitted as an absolute `^FO` coordinate. The label's `^LL` follows +/// from the final baseline plus ``bottomMargin``. +/// +/// Callers supply the text; this type owns the geometry. Parsing text *into* +/// ``ListLine`` values is deliberately not part of it — different producers +/// (piped text, reminder quadrants) group their lines differently. +public struct ListLayout: Sendable { + public var headerFontSize: Int + public var itemFontSize: Int + public var gap: Int + public var headerIndent: Int + public var itemIndent: Int + public var topMargin: Int + /// Trailing blank media the ZD620 needs to clear its cutter at 300 dpi. + /// Empirically determined; not a style choice. + public var bottomMargin: Int + public var printWidthDots: Int + + public init( + headerFontSize: Int = 60, + itemFontSize: Int = 50, + gap: Int = 20, + headerIndent: Int = 60, + itemIndent: Int = 100, + topMargin: Int = 20, + bottomMargin: Int = 318, + printWidthDots: Int = 1200 + ) { + self.headerFontSize = headerFontSize + self.itemFontSize = itemFontSize + self.gap = gap + self.headerIndent = headerIndent + self.itemIndent = itemIndent + self.topMargin = topMargin + self.bottomMargin = bottomMargin + self.printWidthDots = printWidthDots + } + + /// Builds a label listing `lines`, and publishes the computed length on the + /// returned label's geometry so previewers and targets agree on its size. + public func makeLabel(_ lines: [ListLine], environment: ZPLEnvironment) -> ZPLLabel { + var y = topMargin + var rows: [String] = [] + + for line in lines { + switch line { + case .header(let text): + y += headerFontSize + gap + rows.append("^A0N,\(headerFontSize),\(headerFontSize)" + + "^FO\(headerIndent),\(y)^FD\(Self.sanitized(text))^FS") + case .item(let text): + y += itemFontSize + gap + rows.append("^A0N,\(itemFontSize),\(itemFontSize)" + + "^FO\(itemIndent),\(y)^FD\(Self.sanitized(text))^FS") + case .blank: + y += (itemFontSize + gap) / 2 + } + } + + // y is the last baseline; the cutter needs clearance past it. + let length = y + bottomMargin + let body = rows.joined(separator: "\n") + + // ^LH/^LS persist in printer configuration between jobs, so zero them + // explicitly rather than inheriting whatever the last job left behind. + let zpl = "^XA^PW\(printWidthDots)^LH0,0^LS0^LL\(length)\(body)^XZ" + + var environment = environment + environment.options.geometry.heightDots = length + return ZPLLabel(zpl, processors: [], environment: environment) + } + + /// Removes the two characters ZPL treats as command introducers. + /// + /// List text is arbitrary user input, so a stray `^` or `~` would otherwise + /// be parsed as the start of a command rather than printed. + private static func sanitized(_ text: String) -> String { + text.filter { $0 != "^" && $0 != "~" } + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `swift test --filter ListLayoutTests` +Expected: PASS (8 tests). + +- [ ] **Step 5: Run the full suite** + +Run: `swift test` +Expected: all pass, no regressions. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/LabelKit/Labels/ListLayout.swift Tests/LabelKitTests/ListLayoutTests.swift +git commit -m "feat(labels): add ListLayout for rendering line lists as long labels + +Promotes layout logic that was duplicated across two examples into the library, +where it is unit-tested for the first time and reusable by outside consumers." +``` + +--- + +### Task 4: Convert `example-listlabel` into `labelprint` + +**Files:** +- Create: `Examples/labelprint/LabelPrint.swift` +- Create: `Tests/LabelPrintTests/ParseLinesTests.swift` +- Delete: `Examples/ListLabel/ListLabel.swift` +- Modify: `Package.swift` + +**Interfaces:** +- Consumes: `ListLine`, `ListLayout.makeLabel(_:environment:)` from Task 3 +- Produces: executable product `labelprint`; internal `parseLines(_ text: String) -> [ListLine]` + +Verified: a `testTarget` can `@testable import` an executable target with +`@main` on Swift 6/macOS. The source file must **not** be named `main.swift` — +top-level code conflicts with `@main`. + +- [ ] **Step 1: Write the failing parser tests** + +Create `Tests/LabelPrintTests/ParseLinesTests.swift`: + +```swift +// +// ParseLinesTests.swift +// LabelPrintTests +// + +import Testing +import LabelKit +@testable import labelprint + +@Suite("labelprint line parsing") +struct ParseLinesTests { + + @Test func lineEndingInColonBecomesAHeader() { + #expect(parseLines("Costco:") == [.header("Costco:")]) + } + + @Test func ordinaryLineBecomesAnItem() { + #expect(parseLines("eggs") == [.item("eggs")]) + } + + @Test func interiorBlankLineIsPreserved() { + #expect(parseLines("a\n\nb") == [.item("a"), .blank, .item("b")]) + } + + @Test func trailingBlankLinesAreTrimmed() { + // A text file almost always ends in a newline; that must not stretch + // the label with empty rows. + #expect(parseLines("a\n\n\n") == [.item("a")]) + } + + @Test func surroundingWhitespaceIsStripped() { + #expect(parseLines(" eggs ") == [.item("eggs")]) + #expect(parseLines(" \t ") == []) + } + + @Test func emptyInputYieldsNoLines() { + #expect(parseLines("") == []) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `swift test --filter ParseLinesTests` +Expected: FAIL — no such module `labelprint`. + +- [ ] **Step 3: Create the tool** + +Create `Examples/labelprint/LabelPrint.swift` (git mv the old file first if you +prefer to preserve history: `git mv Examples/ListLabel/ListLabel.swift Examples/labelprint/LabelPrint.swift`): + +```swift +// +// LabelPrint.swift +// labelprint +// +// Reads lines of text on stdin and prints them as a single long list label. +// + +import Foundation +import LabelKit +import ArgumentParser +import OSLog + +fileprivate let logger = Logger(subsystem: "com.example.labelprint", category: "printing") + +/// Printer connection options. +/// +/// Resolution order is flag → environment variable → ``PrinterDefaults``. +struct PrinterOptions: ParsableArguments { + @Option(name: .long, help: "Printer hostname or IP address (env: LABELKIT_PRINTER_HOST)") + var host: String = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_HOST"] ?? PrinterDefaults.host + + @Option(name: .long, help: "Printer TCP port (env: LABELKIT_PRINTER_PORT)") + var port: UInt16 = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_PORT"].flatMap(UInt16.init) ?? PrinterDefaults.port +} + +/// Classifies raw input lines, dropping the trailing blanks a text file usually +/// ends with. +/// +/// A line ending in `:` is a section header. This is the convention producers +/// pipe against, which is why it lives here rather than in LabelKit. +func parseLines(_ text: String) -> [ListLine] { + var lines = text + .split(separator: "\n", omittingEmptySubsequences: false) + .map { line -> ListLine in + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .blank } + if trimmed.hasSuffix(":") { return .header(trimmed) } + return .item(trimmed) + } + + while lines.last == .blank { + lines.removeLast() + } + return lines +} + +func makeEnvironment() -> ZPLEnvironment { + ZPLEnvironment(stock: Stock.Preset.label4x, device: Device.Preset.ZD620) +} + +/// Reads all of stdin and classifies it, failing if nothing was piped in. +func readLinesFromStdin() throws -> [ListLine] { + let data = FileHandle.standardInput.readDataToEndOfFile() + let lines = parseLines(String(decoding: data, as: UTF8.self)) + guard !lines.isEmpty else { + throw ValidationError("No input on stdin. Pipe a file, e.g. `cat list.txt | labelprint`.") + } + return lines +} + +@main +struct LabelPrint: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "labelprint", + abstract: "Print lines of text from stdin as a long list label", + subcommands: [Print.self, Preview.self, Zpl.self, List.self], + defaultSubcommand: Print.self + ) +} + +struct Print: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print list to network printer (defaults to \(PrinterDefaults.host):\(PrinterDefaults.port))" + ) + + @OptionGroup var printer: PrinterOptions + + @Flag(name: .shortAndLong, help: "Enable debug logging to Console") + var debug: Bool = false + + func run() async throws { + let lines = try readLinesFromStdin() + if debug { logger.info("Read \(lines.count) line(s); printing to \(printer.host):\(printer.port)") } + + let label = ListLayout().makeLabel(lines, environment: makeEnvironment()) + let target = NetworkTarget(device: label.environment.options.device, + host: printer.host, port: printer.port) + try await label.print(to: target) + } +} + +struct Preview: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print list as a png preview to stdout (requires iterm2 image support)" + ) + + @Flag(name: .shortAndLong, help: "Enable debug logging to Console") + var debug: Bool = false + + func run() async throws { + let lines = try readLinesFromStdin() + if debug { logger.info("Read \(lines.count) line(s); rendering preview") } + + let label = ListLayout().makeLabel(lines, environment: makeEnvironment()) + let target = ITerm2Target(device: label.environment.options.device) + try await label.preview(using: ZPL2PNGRenderer.self, to: target, + timeout: 2.0, fallbackHeightDots: 500) + } +} + +struct Zpl: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print ZPL for the list to stdout" + ) + + @Flag(name: .shortAndLong, help: "Enable debug logging to Console") + var debug: Bool = false + + func run() async throws { + let lines = try readLinesFromStdin() + if debug { logger.info("Read \(lines.count) line(s); emitting ZPL") } + + let label = ListLayout().makeLabel(lines, environment: makeEnvironment()) + print(try label.zpl()) + } +} + +struct List: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Echo the parsed lines as text, to debug header and blank detection" + ) + + func run() async throws { + for line in try readLinesFromStdin() { + switch line { + case .header(let text): print("HEADER \(text)") + case .item(let text): print("ITEM \(text)") + case .blank: print("BLANK") + } + } + } +} +``` + +- [ ] **Step 4: Wire up Package.swift** + +In `Package.swift`, replace the `example-listlabel` product with: + +```swift + .executable(name: "labelprint", targets: ["labelprint"]) +``` + +replace the `ListLabel` target with: + +```swift + .executableTarget( + name: "labelprint", + dependencies: [ + "LabelKit", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Examples/labelprint" + ), + .testTarget( + name: "LabelPrintTests", + dependencies: ["labelprint"], + path: "Tests/LabelPrintTests" + ) +``` + +- [ ] **Step 5: Remove the old example** + +```bash +rm -rf Examples/ListLabel +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `swift test --filter ParseLinesTests` +Expected: PASS (6 tests). + +- [ ] **Step 7: Verify the tool end to end** + +Run: +```bash +printf 'Costco:\neggs\n\nDraegers:\ncream\n' | swift run labelprint list +``` +Expected: +``` +HEADER Costco: +ITEM eggs +BLANK +HEADER Draegers: +ITEM cream +``` + +Run: `printf 'Costco:\neggs\n' | swift run labelprint zpl` +Expected: one `^XA...^XZ` label containing `^A0N,60,60^FO60,100^FDCostco:^FS`. + +Run: `printf '' | swift run labelprint zpl` +Expected: the "No input on stdin" error and a non-zero exit status. + +- [ ] **Step 8: Commit** + +```bash +git add -A Package.swift Examples Tests/LabelPrintTests +git commit -m "refactor(examples): replace example-listlabel with labelprint + +Layout now comes from LabelKit's ListLayout; the tool keeps only stdin parsing. +Drops the example- prefix since Tier 2 tools are meant to be installed." +``` + +--- + +### Task 5: Add Tier 1 snippets + +**Files:** +- Create: `Snippets/PrintToStdout.swift`, `Snippets/ChooseStockAndDevice.swift`, `Snippets/PrintAList.swift`, `Snippets/RenderATemplate.swift`, `Snippets/PreviewInITerm2.swift` + +**Interfaces:** +- Consumes: `ListLayout`/`ListLine` (Task 3), `StencilTemplateStore.init(fileURL:)` and `Examples/Resources/templates.json` (Task 2) +- Produces: nothing consumed by later tasks + +SwiftPM auto-discovers a top-level `Snippets/` directory — **do not add anything +to `Package.swift`**. Each file is compiled and linked as an executable named +after the file, and uses top-level code with no `@main`. + +- [ ] **Step 1: Create the snippets** + +`Snippets/PrintToStdout.swift`: + +```swift +//! Write a label's ZPL to stdout. +//! +//! The smallest end-to-end LabelKit pipeline: build a label, pick a target, +//! deliver it. + +import LabelKit + +let label = ZPLLabel( + "^XA^FO50,50^A0N,40,40^FDHello^FS^XZ", + environment: ZPLEnvironment(stock: Stock.Preset.label2x1, + device: Device.Preset.ZD620) +) + +try await label.print(to: StdoutTarget(device: Device.Preset.ZD620)) +``` + +`Snippets/ChooseStockAndDevice.swift`: + +```swift +//! Combine media and printer into a render geometry. +//! +//! Stock describes the physical media in inches; Device describes the printer's +//! resolution. Together they produce the geometry in dots that ZPL needs. + +import LabelKit + +let stock = Stock.Preset.label2x1 +let device = Device.Preset.ZD620 + +print("stock: \(stock.widthInches)in wide") +print("device: \(device.dpi.rawValue) dpi") +print("width: \(stock.widthDots(at: device.dpi) ?? 0) dots") + +// Continuous stock has no fixed height, so heightDots is nil. +print("height: \(String(describing: Stock.Preset.label4x.heightDots(at: device.dpi)))") +``` + +`Snippets/PrintAList.swift`: + +```swift +//! Lay a list of lines out as one long label. +//! +//! ListLayout computes each row's baseline and the label's overall length, so +//! callers supply text rather than coordinates. + +import LabelKit + +let lines: [ListLine] = [ + .header("Costco:"), + .item("eggs"), + .item("bananas"), + .blank, + .header("Draegers:"), + .item("cream cheese"), +] + +let label = ListLayout().makeLabel( + lines, + environment: ZPLEnvironment(stock: Stock.Preset.label4x, + device: Device.Preset.ZD620) +) + +try await label.print(to: StdoutTarget(device: Device.Preset.ZD620)) +``` + +`Snippets/RenderATemplate.swift`: + +```swift +//! Fill a Stencil template with values and render it to ZPL. +//! +//! Templates keep the layout separate from the data, so the same label can be +//! reused with different content. + +import Foundation +import LabelKit + +// snippet.hide +// Read the templates checked into the repository so this runs on a fresh clone. +let templates = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Snippets/ + .deletingLastPathComponent() // package root + .appendingPathComponent("Examples/Resources/templates.json") +// snippet.show + +let store = StencilTemplateStore(fileURL: templates) +try store.load() + +let zpl = try store.render(name: "label", context: [ + "line1": "First line", + "line2": "Second line", +]) + +print(zpl) +``` + +`Snippets/PreviewInITerm2.swift`: + +```swift +//! Render a label to a PNG and show it inline in iTerm2. +//! +//! ITerm2Target emits the escape codes that display an image in the terminal. +//! Run this in iTerm2; other terminals will print the raw escape sequence. + +import LabelKit + +let label = ZPLLabel( + "^XA^FO50,50^A0N,40,40^FDPreview^FS^XZ", + environment: ZPLEnvironment(stock: Stock.Preset.label2x1, + device: Device.Preset.ZD620) +) + +try await label.preview( + using: ZPL2PNGRenderer.self, + to: ITerm2Target(device: Device.Preset.ZD620), + timeout: 2.0 +) +``` + +- [ ] **Step 2: Verify SwiftPM discovers and compiles them** + +Run: `swift build` +Expected: `Compiling PrintToStdout`, `Compiling PrintAList`, etc., then +`Build complete!` — with no `Package.swift` change. + +- [ ] **Step 3: Verify they run** + +Run: `swift run PrintToStdout` +Expected: `^XA^FO50,50^A0N,40,40^FDHello^FS^XZ` + +Run: `swift run PrintAList` +Expected: a `^XA...^XZ` label containing `^FDCostco:^FS` and `^FDeggs^FS`. + +Run: `swift run RenderATemplate` +Expected: ZPL containing `First line` and `Second line`, proving the seed +templates resolve without Application Support state. + +- [ ] **Step 4: Verify the anti-rot property actually holds** + +Temporarily append `let broken = LabelKit.NoSuchType()` to +`Snippets/PrintToStdout.swift`, run `swift build`, and confirm it **fails** with +`no member named 'NoSuchType'`. Then remove the line and confirm the build +passes again. This is the property the whole tier depends on; verify it once +rather than trusting it. + +- [ ] **Step 5: Commit** + +```bash +git add Snippets +git commit -m "docs(snippets): add Tier 1 tutorial snippets + +Compiled on every build, so a tutorial referencing a renamed API breaks CI." +``` + +--- + +### Task 6: Add `compare-renderers` and retire `example-label` + +**Files:** +- Create: `Examples/Advanced/compare-renderers/CompareRenderers.swift` +- Delete: `Examples/LabelCLI/LabelCLI.swift` +- Modify: `Package.swift` + +**Interfaces:** +- Consumes: `LabelaryRenderer`, `ZPL2PNGRenderer`, `ITerm2Target` +- Produces: executable product `compare-renderers` + +`example-label`'s parts are now fully rehomed: template rendering and iTerm2 +preview became snippets in Task 5, and its dual-renderer behavior becomes this +tool. This tool exists because that dual rendering is a real debugging +technique — it is what reveals that `zpl2png` does not implement `^FB` line +breaks while Labelary does (see issue #54). + +- [ ] **Step 1: Create the tool** + +```swift +// +// CompareRenderers.swift +// compare-renderers +// +// Renders the same ZPL through both previewers to expose disagreements. +// + +import Foundation +import LabelKit +import ArgumentParser + +@main +struct CompareRenderers: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "compare-renderers", + abstract: "Render ZPL from stdin through Labelary and zpl2png, side by side", + discussion: """ + The two renderers do not implement identical subsets of ZPL. When a \ + label prints differently than it previews, rendering it both ways \ + shows which renderer is wrong. Labelary is the reference \ + implementation; zpl2png runs locally and offline. + """ + ) + + @Option(name: .long, help: "Label width in inches") + var width: Double = 4.0 + + @Option(name: .long, help: "Label height in inches") + var height: Double = 6.0 + + func run() async throws { + let zpl = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self) + guard !zpl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ValidationError("No ZPL on stdin. Pipe a label, e.g. `labelprint zpl < list.txt | compare-renderers`.") + } + + let device = Device.Preset.ZD620 + let stock = Stock(widthInches: width, heightInches: height, + isContinuous: false, gapInches: 0) + let label = ZPLLabel(zpl, environment: ZPLEnvironment(stock: stock, device: device)) + let target = ITerm2Target(device: device) + + print("── Labelary (reference) ──") + try await label.preview(using: LabelaryRenderer.self, to: target, timeout: 10.0) + + print("── zpl2png (local) ──") + try await label.preview(using: ZPL2PNGRenderer.self, to: target, timeout: 10.0) + + print("If these differ, the local renderer is missing a ZPL feature.") + } +} +``` + +- [ ] **Step 2: Wire up Package.swift** + +Remove the `example-label` product and the `LabelCLI` target. Add: + +```swift + .executable(name: "compare-renderers", targets: ["CompareRenderers"]) +``` + +and: + +```swift + .executableTarget( + name: "CompareRenderers", + dependencies: [ + "LabelKit", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Examples/Advanced/compare-renderers" + ) +``` + +- [ ] **Step 3: Delete the old example** + +```bash +rm -rf Examples/LabelCLI +``` + +- [ ] **Step 4: Verify** + +Run: `swift build` +Expected: `Build complete!`, no `example-label` product. + +Run: `printf 'Costco:\neggs\n' | swift run labelprint zpl | swift run compare-renderers` +Expected: two labelled preview blocks. Requires network for the Labelary half; +if offline, the Labelary preview errors and that is acceptable for this check. + +Run: `printf '' | swift run compare-renderers` +Expected: the "No ZPL on stdin" error, non-zero exit. + +- [ ] **Step 5: Commit** + +```bash +git add -A Package.swift Examples +git commit -m "feat(examples): add compare-renderers, retire example-label + +example-label's template and preview behavior moved to snippets; its +dual-renderer behavior becomes a debugging tool for renderer disagreements." +``` + +--- + +### Task 7: Add `batch-badges` + +**Files:** +- Create: `Examples/Advanced/batch-badges/BatchBadges.swift` +- Create: `Examples/Resources/badges.csv` +- Modify: `Package.swift` + +**Interfaces:** +- Consumes: `StencilTemplateStore.init(fileURL:)` and the `badge` template from Task 2 +- Produces: executable product `batch-badges` + +- [ ] **Step 1: Create the sample data** + +Create `Examples/Resources/badges.csv`: + +```csv +name,title +Ada Lovelace,First Programmer +Grace Hopper,Rear Admiral +Alan Turing,Cryptanalyst +``` + +- [ ] **Step 2: Create the tool** + +```swift +// +// BatchBadges.swift +// batch-badges +// +// One template + a CSV -> one label per row. +// + +import Foundation +import LabelKit +import ArgumentParser + +@main +struct BatchBadges: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "batch-badges", + abstract: "Render one badge label per row of a CSV, from a single template", + discussion: """ + Shows the payoff of keeping layout in a template and data outside it: \ + the template is written once and rendered N times with different \ + values, rather than building N labels by string concatenation. + """ + ) + + @Option(name: .long, help: "CSV file with a header row (default: the bundled sample)") + var csv: String? + + @Option(name: .long, help: "Template name in the store") + var template: String = "badge" + + func run() async throws { + let packageRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // batch-badges/ + .deletingLastPathComponent() // Advanced/ + .deletingLastPathComponent() // Examples/ + .deletingLastPathComponent() // package root + + let resources = packageRoot.appendingPathComponent("Examples/Resources") + let csvURL = csv.map { URL(fileURLWithPath: $0) } + ?? resources.appendingPathComponent("badges.csv") + + let store = StencilTemplateStore( + fileURL: resources.appendingPathComponent("templates.json")) + try store.load() + + let rows = try parseCSV(String(contentsOf: csvURL, encoding: .utf8)) + guard !rows.isEmpty else { + throw ValidationError("No data rows in \(csvURL.lastPathComponent).") + } + + for row in rows { + print(try store.render(name: template, context: row)) + } + } + + /// Minimal comma-splitting CSV reader: a header row names the template + /// variables, and each later row supplies their values. Deliberately does + /// not handle quoted fields or embedded commas — that is not the lesson. + func parseCSV(_ text: String) throws -> [[String: String]] { + let lines = text + .split(separator: "\n", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + guard let header = lines.first else { return [] } + let keys = header.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + + return lines.dropFirst().map { line in + let values = line.split(separator: ",", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + return Dictionary(uniqueKeysWithValues: zip(keys, values)) + } + } +} +``` + +- [ ] **Step 3: Wire up Package.swift** + +```swift + .executable(name: "batch-badges", targets: ["BatchBadges"]) +``` + +```swift + .executableTarget( + name: "BatchBadges", + dependencies: [ + "LabelKit", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Examples/Advanced/batch-badges" + ) +``` + +- [ ] **Step 4: Verify** + +Run: `swift run batch-badges` +Expected: three `^XA...^XZ` labels, containing `Ada Lovelace`, `Grace Hopper`, +and `Alan Turing` respectively. + +- [ ] **Step 5: Commit** + +```bash +git add -A Package.swift Examples +git commit -m "feat(examples): add batch-badges advanced template example" +``` + +--- + +### Task 8: Retire `example-reminderlist`, fix docs, reconcile LabelGUI + +**Files:** +- Delete: `Examples/ReminderList/` +- Modify: `Package.swift`, `Sources/LabelKit/LabelKit.docc/LabelKit.md`, `README.md`, `AGENTS.md` +- Investigate: `Apps/LabelGUI/LabelGUI/KeyValueContext.swift` vs `Sources/LabelKit/Processors/KeyValueContext.swift` + +**Interfaces:** +- Consumes: everything above +- Produces: final documented state + +**Blocked by** the companion `reminders` repo plan. Do not delete +`Examples/ReminderList/` until `Reminders.swift` has been transplanted into that +repo and it builds there. If that has not happened, stop and say so rather than +deleting the only copy. + +- [ ] **Step 1: Confirm the reminders code has a new home** + +Verify `Reminders.swift` exists in the new `reminders` repo and builds there. +If it does not, stop — the rest of this task can proceed, but skip Steps 2-3. + +- [ ] **Step 2: Delete the example** + +```bash +rm -rf Examples/ReminderList +``` + +- [ ] **Step 3: Remove it from Package.swift** + +Delete the `example-reminderlist` product and the `ReminderList` target. After +this, `Package.swift` declares exactly these executables: `labelprint`, +`compare-renderers`, `batch-badges`. EventKit no longer appears anywhere in the +package. + +- [ ] **Step 4: Fix the DocC dead symbol references** + +In `Sources/LabelKit/LabelKit.docc/LabelKit.md`, the processor list names two +types that do not exist. Replace: + +``` + - ``StencilProcessor``: a processor to resolve Swift-Stencil Template to generate ZPL + - LengthInjector: a processor to inject an estimated length (^LL) into a ZPL string +``` + +with: + +``` + - ``ResolveTemplates``: a processor to resolve Swift-Stencil templates to generate ZPL + - ``InjectLength``: a processor to inject an estimated length (^LL) into a ZPL string +``` + +Then add `ListLayout` to the "Other Types" list: + +``` +- ``ListLayout``: Lays a sequence of ``ListLine`` values out as a single long label. +``` + +- [ ] **Step 5: Verify no other docs reference deleted symbols** + +Run: +```bash +grep -rn "StencilProcessor\|LengthInjector\|example-label\|example-reminderlist\|example-listlabel" \ + README.md AGENTS.md Sources/LabelKit/LabelKit.docc/ +``` +Expected: no matches once Step 6 is done. Every hit is a doc that still +advertises a deleted executable. + +- [ ] **Step 6: Update README.md and AGENTS.md** + +Rewrite the Examples section of `README.md` to describe the three tiers, with +the real commands: + +```sh +swift run PrintToStdout # Tier 1: tutorial snippets +swift run PrintAList + +cat list.txt | swift run labelprint # Tier 2: print to the printer +cat list.txt | swift run labelprint preview # PNG preview in iTerm2 +cat list.txt | swift run labelprint zpl # ZPL to stdout +cat list.txt | swift run labelprint list # show the parsed lines + +swift run batch-badges # Tier 3: template + CSV -> N labels +cat list.txt | swift run labelprint zpl | swift run compare-renderers +``` + +Update the Commands block in `AGENTS.md` to match, replacing the +`example-label` / `example-reminderlist` / `example-listlabel` lines. + +- [ ] **Step 7: Investigate the LabelGUI KeyValueContext fork** + +Do not assume this is duplication. `Apps/LabelGUI/LabelGUI/KeyValueContext.swift` +is 164 lines; `Sources/LabelKit/Processors/KeyValueContext.swift` is 45. Run: + +```bash +diff Sources/LabelKit/Processors/KeyValueContext.swift \ + Apps/LabelGUI/LabelGUI/KeyValueContext.swift +``` + +Classify every addition in the fork as one of: +- **(a) GUI-specific** — stays in LabelGUI +- **(b) generally useful** — promote into LabelKit's type +- **(c) redundant** — delete + +Write the classification into the commit message. If (b) is non-empty, promoting +it is a library API change; do that as its own commit with tests. If the diff +turns out to be larger or more entangled than a single task can absorb, stop and +report rather than pushing through — this step is scoped as an investigation, and +discovering it is a bigger job is a valid outcome. + +- [ ] **Step 8: Full verification** + +Run: `swift build && swift test` +Expected: `Build complete!` and a fully passing suite. + +Run: `swift package describe --type json | grep -o '"name" : "[a-z-]*"' | sort -u` +Expected: confirms only the intended products remain. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "refactor(examples): retire example-reminderlist and refresh docs + +EventKit leaves the package entirely; the reminders tool now lives in its own +repo. Fixes DocC references to StencilProcessor and LengthInjector, neither of +which has ever existed." +``` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec item | Task | +|---|---| +| CI in scope | 1 | +| `StencilTemplateStore.init(fileURL:)` | 2 | +| Seed templates, zero-setup | 2 | +| Promote list layout, with tests | 3 | +| Layout in library / parsing in example seam | 3, 4 | +| `labelprint`, drop `example-` prefix | 4 | +| Parser test target | 4 | +| Tier 1 snippets | 5 | +| `compare-renderers`, dissolve `example-label` | 5, 6 | +| `batch-badges` | 7 | +| Delete `example-reminderlist` | 8 | +| DocC dead symbols | 8 | +| LabelGUI fork investigation | 8 | +| No shared example target | honored — `PrinterOptions` appears only in Task 4 | +| `reminders` repo extraction | out of scope, companion plan | + +**Type consistency:** `ListLine` and `ListLayout.makeLabel(_:environment:)` are +defined in Task 3 and used with those exact names in Tasks 4 and 5. +`parseLines(_:)` is defined in Task 4 and tested there. `PrinterOptions` is +defined once, in Task 4. + +**Placeholder scan:** clean. An earlier draft had Task 7 stage an intentionally +invalid `parseCSV` signature for a later step to replace; that was a placeholder +and has been removed in favor of the real implementation inline. + +## Follow-on issues (do not implement here) + +- LabelKit #53 — `^CF`, `^CI28`, `^FH`. Now a change to `ListLayout` in one place. +- LabelKit #54 — `^FB` field blocks; needs `ZPLLengthEstimator` to learn `^FB` first. +- jazzy → DocC migration, which is what makes `//!` and `@Snippet` render. +- `t` #41 / #42 — unblocked once Task 3 lands. From 54aaab39eceb701d522d830e2719a7f1c3fddc89 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 12:14:53 -0700 Subject: [PATCH 04/15] feat(examples): add example-listlabel for printing stdin as a list label --- AGENTS.md | 1 + Examples/ListLabel/ListLabel.swift | 235 +++++++++++++++++++++++++++++ Package.swift | 11 +- README.md | 13 +- 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 Examples/ListLabel/ListLabel.swift diff --git a/AGENTS.md b/AGENTS.md index f790cfc..52f5dac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ swift test --filter ZPLLabelTests # run one suite swift test --filter ZPLLabelTests/initialRenderWithContext # run one test swift run example-label # LabelCLI example swift run example-reminderlist zpl|preview|print|list # ReminderList example +cat list.txt | swift run example-listlabel print|preview|zpl # ListLabel example (stdin) ./Scripts/generate_docs.sh # generate docs with jazzy ``` diff --git a/Examples/ListLabel/ListLabel.swift b/Examples/ListLabel/ListLabel.swift new file mode 100644 index 0000000..89be30e --- /dev/null +++ b/Examples/ListLabel/ListLabel.swift @@ -0,0 +1,235 @@ +// +// ListLabel.swift +// LabelKit +// +// Reads lines of text on stdin and prints them as a single long list label. +// + +import Foundation +import LabelKit +import ArgumentParser +import OSLog + +// Logger for the ListLabel tool +fileprivate let logger = Logger(subsystem: "com.example.listlabel", category: "printing") + +/// Printer connection options. +/// +/// Resolution order is flag → environment variable → ``PrinterDefaults``, so the +/// example printer stays the default when nothing is supplied. +struct PrinterOptions: ParsableArguments { + @Option(name: .long, help: "Printer hostname or IP address (env: LABELKIT_PRINTER_HOST)") + var host: String = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_HOST"] ?? PrinterDefaults.host + + @Option(name: .long, help: "Printer TCP port (env: LABELKIT_PRINTER_PORT)") + var port: UInt16 = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_PORT"].flatMap(UInt16.init) ?? PrinterDefaults.port +} + +/// One line of input, classified by how it should be laid out on the label. +enum ListLine: Equatable { + /// A section header — a non-empty line ending in `:`, set larger and flush left. + case header(String) + /// An ordinary list item, set smaller and indented under its header. + case item(String) + /// A blank separator line, which contributes vertical space but no text. + case blank +} + +/// Classifies raw input lines, dropping the trailing blanks a text file usually ends with. +/// +/// - Parameter text: The full contents of stdin. +/// - Returns: One ``ListLine`` per input line, with trailing blanks removed so a +/// file ending in newlines doesn't stretch the label. +func parseLines(_ text: String) -> [ListLine] { + var lines = text + .split(separator: "\n", omittingEmptySubsequences: false) + .map { line -> ListLine in + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .blank } + if trimmed.hasSuffix(":") { return .header(trimmed) } + return .item(trimmed) + } + + while lines.last == .blank { + lines.removeLast() + } + return lines +} + +/// Removes the characters ZPL treats as command introducers. +/// +/// Input arrives from stdin, so a stray `^` or `~` would otherwise be parsed as +/// the start of a ZPL command rather than printed as text. +func sanitize(_ text: String) -> String { + text.filter { $0 != "^" && $0 != "~" } +} + +/// Builds a single long label listing every parsed line. +/// +/// Headers are set at ``headerFontSize`` flush against the left margin; items are +/// set at ``itemFontSize`` and indented beneath them; blank lines advance the +/// cursor by a half gap. The label's `^LL` is computed from the final cursor +/// position plus the bottom margin the cutter needs. +func generate_label(lines: [ListLine]) -> ZPLLabel { + let headerFontSize = 60 + let itemFontSize = 50 + let gap = 20 + + let headerIndent = 60 + let itemIndent = 100 + + let topMargin = 20 // space before first line + let bottomMargin = 318 // empirically needed for ZD620 + cutter @ 300 dpi + + var y = topMargin + + let rows = lines.compactMap { line -> String? in + switch line { + case .header(let text): + y += headerFontSize + gap + return "^A0N,\(headerFontSize),\(headerFontSize)^FO\(headerIndent),\(y)^FD\(sanitize(text))^FS" + case .item(let text): + y += itemFontSize + gap + return "^A0N,\(itemFontSize),\(itemFontSize)^FO\(itemIndent),\(y)^FD\(sanitize(text))^FS" + case .blank: + y += (itemFontSize + gap) / 2 + return nil + } + } + .joined(separator: "\n") + + // y now holds the baseline of the last line + let length = y + bottomMargin // label length in dots + + let header = "^PW1200^LH0,0^LS0" + + let zpl = "^XA\(header)^LL\(length)\(rows)^XZ" + + var zplenv = ZPLEnvironment(stock: Stock.Preset.label4x, + device: Device.Preset.ZD620) + zplenv.options.geometry.heightDots = length + + let label = ZPLLabel( + zpl, + processors: [], + environment: zplenv + ) + return label +} + +/// Reads all of stdin and classifies it, failing if nothing was piped in. +func readLinesFromStdin() throws -> [ListLine] { + let data = FileHandle.standardInput.readDataToEndOfFile() + let text = String(decoding: data, as: UTF8.self) + let lines = parseLines(text) + guard !lines.isEmpty else { + throw ValidationError("No input on stdin. Pipe a file, e.g. `cat list.txt | example-listlabel`.") + } + return lines +} + +@main +struct ListLabel: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "example-listlabel", + abstract: "Print lines of text from stdin as a long list label", + subcommands: [Print.self, Preview.self, Zpl.self], + defaultSubcommand: Print.self + ) +} + +struct Print: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print list to network printer (defaults to \(PrinterDefaults.host):\(PrinterDefaults.port))", + ) + + @OptionGroup var printer: PrinterOptions + + @Flag(name: .shortAndLong, help: "Enable debug logging to Console") + var debug: Bool = false + + func run() async throws { + if debug { + logger.info("Reading list from stdin") + } + let lines = try readLinesFromStdin() + if debug { + logger.info("Read \(lines.count) line(s) from stdin") + logger.info("Generating ZPL label") + } + + let label = generate_label(lines: lines) + let zpl = try label.zpl() + if debug { + logger.info("Label generated, ZPL length: \(zpl.count) bytes") + logger.info("Connecting to printer at \(printer.host):\(printer.port)") + } + + let target = NetworkTarget(device: label.environment.options.device, host: printer.host, port: printer.port) + + if debug { + logger.info("Sending ZPL payload to printer") + } + try await label.print(to: target) + + if debug { + logger.info("Print command completed successfully") + } + } +} + +struct Preview: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print list as a png preview to stdout (requires iterm2 image support)", + ) + + @Flag(name: .shortAndLong, help: "Enable debug logging to Console") + var debug: Bool = false + + func run() async throws { + if debug { + logger.info("Reading list from stdin") + } + let lines = try readLinesFromStdin() + if debug { + logger.info("Read \(lines.count) line(s) from stdin") + logger.info("Generating ZPL label preview") + } + + let label = generate_label(lines: lines) + let target = ITerm2Target(device: label.environment.options.device) + try await label.preview(using: ZPL2PNGRenderer.self, to: target, timeout: 2.0, fallbackHeightDots: 500) + + if debug { + logger.info("Preview command completed successfully") + } + } +} + +struct Zpl: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print ZPL for the list to stdout", + ) + + @Flag(name: .shortAndLong, help: "Enable debug logging to Console") + var debug: Bool = false + + func run() async throws { + if debug { + logger.info("Reading list from stdin") + } + let lines = try readLinesFromStdin() + if debug { + logger.info("Read \(lines.count) line(s) from stdin") + logger.info("Generating ZPL label") + } + + let label = generate_label(lines: lines) + let zpl = try label.zpl() + if debug { + logger.info("Label generated, ZPL length: \(zpl.count) bytes") + } + + print(zpl) + } +} diff --git a/Package.swift b/Package.swift index 81ab27b..81a12ed 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,8 @@ let package = Package( products: [ .library(name: "LabelKit", targets: ["LabelKit"]), .executable(name: "example-label", targets: ["LabelCLI"]), - .executable(name: "example-reminderlist", targets: ["ReminderList"]) + .executable(name: "example-reminderlist", targets: ["ReminderList"]), + .executable(name: "example-listlabel", targets: ["ListLabel"]) ], dependencies: [ .package( @@ -45,6 +46,14 @@ let package = Package( ], path: "Examples/ReminderList", sources: ["ReminderList.swift", "Reminders.swift"] + ), + .executableTarget( + name: "ListLabel", + dependencies: [ + "LabelKit", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Examples/ListLabel" ) ] ) diff --git a/README.md b/README.md index 742932a..8da08b6 100644 --- a/README.md +++ b/README.md @@ -144,12 +144,13 @@ visual for this project. ## Examples -Two example executables ship with the package: +Three example executables ship with the package: | Executable | Description | |------------|-------------| | `example-label` | Loads a Stencil template from the template store, prints the processed ZPL to stdout, and renders iTerm2 previews via both Labelary and zpl2png | | `example-reminderlist` | Prints your uncompleted Reminders.app items as a label on 4" continuous stock | +| `example-listlabel` | Reads lines of text on stdin and prints them as one long list label; lines ending in `:` become section headers and blank lines become gaps | ```sh swift run example-label # ZPL to stdout + iTerm2 previews @@ -161,18 +162,26 @@ swift run example-reminderlist zpl # generated ZPL to stdout swift run example-reminderlist preview # PNG preview inline in iTerm2 swift run example-reminderlist print # send to network printer swift run example-reminderlist print -d # ...with debug logging to Console + +cat shopping.txt | swift run example-listlabel # send to network printer +cat shopping.txt | swift run example-listlabel preview # PNG preview inline in iTerm2 +cat shopping.txt | swift run example-listlabel zpl # generated ZPL to stdout ``` +`example-listlabel` defaults to its `print` subcommand, so a bare pipe goes +straight to the printer. + Every subcommand takes `-d`/`--debug` to enable OSLog output to Console. ### Choosing a printer -Both examples default to a printer at `192.168.0.133:9100`. Override it with +All three examples default to a printer at `192.168.0.133:9100`. Override it with `--host`/`--port` on any command that talks to a printer: ```sh swift run example-reminderlist print --host 10.0.1.42 --port 9100 swift run example-label --print --host 10.0.1.42 +cat shopping.txt | swift run example-listlabel print --host 10.0.1.42 ``` or set the environment variables once: From 168a748df772d2fd14fa571e8a89031ccdf00925 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 12:17:15 -0700 Subject: [PATCH 05/15] ci: build and test on macOS via GitHub Actions --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a0d8721 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-and-test: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Show toolchain + run: swift --version + + # Builds the library, the example executables, and every file in + # Snippets/. A tutorial referencing a renamed API fails here. + - name: Build + run: swift build + + - name: Test + run: swift test From 98b603721d5e11504826394d8c883a6ac4924196 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 12:23:42 -0700 Subject: [PATCH 06/15] feat(templates): add StencilTemplateStore.init(fileURL:) and seed templates Examples and snippets can now read templates checked into the repo instead of depending on Application Support state that does not exist on a fresh clone. --- Examples/Resources/templates.json | 7 ++++ .../Processors/StencilTemplates.swift | 10 ++++++ .../StencilTemplateStoreTests.swift | 36 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 Examples/Resources/templates.json create mode 100644 Tests/LabelKitTests/StencilTemplateStoreTests.swift diff --git a/Examples/Resources/templates.json b/Examples/Resources/templates.json new file mode 100644 index 0000000..fb97fa4 --- /dev/null +++ b/Examples/Resources/templates.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "templates": { + "label": "^XA^PW600^LL354\n^FO10,30^A0N,30,30^FDHeader^FS\n^FO10,80^A0N,24,24^FD{{ line1 }}^FS\n^FO10,120^A0N,24,24^FD{{ line2 }}^FS\n^XZ", + "badge": "^XA^PW600^LL354\n^FO20,40^A0N,40,40^FD{{ name }}^FS\n^FO20,100^A0N,24,24^FD{{ title }}^FS\n^XZ" + } +} diff --git a/Sources/LabelKit/Processors/StencilTemplates.swift b/Sources/LabelKit/Processors/StencilTemplates.swift index 1daa5c8..0201285 100644 --- a/Sources/LabelKit/Processors/StencilTemplates.swift +++ b/Sources/LabelKit/Processors/StencilTemplates.swift @@ -26,6 +26,16 @@ public final class StencilTemplateStore: Loader { self.fileURL = file } + /// Initializes a store backed by an explicit file, bypassing the + /// Application Support / group-container search performed by `init()`. + /// + /// Used by examples and snippets so they read templates checked into the + /// repository and run on a fresh clone with no user state. Apps that want + /// the user's own template store should keep using `init()`. + public init(fileURL: URL) { + self.fileURL = fileURL + } + private static func findFirstExistingJSON( filename: String, folderNames: [String], diff --git a/Tests/LabelKitTests/StencilTemplateStoreTests.swift b/Tests/LabelKitTests/StencilTemplateStoreTests.swift new file mode 100644 index 0000000..b64d035 --- /dev/null +++ b/Tests/LabelKitTests/StencilTemplateStoreTests.swift @@ -0,0 +1,36 @@ +// +// StencilTemplateStoreTests.swift +// LabelKitTests +// + +import Foundation +import Testing +@testable import LabelKit + +@Suite("StencilTemplateStore file-URL initializer") +struct StencilTemplateStoreTests { + + @Test func loadsTemplatesFromAnExplicitFileURL() throws { + let tmp = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("labelkit-store-\(UUID().uuidString).json") + let json = #"{"version":1,"templates":{"greeting":"^XA^FD{{ who }}^FS^XZ"}}"# + try Data(json.utf8).write(to: tmp) + defer { try? FileManager.default.removeItem(at: tmp) } + + let store = StencilTemplateStore(fileURL: tmp) + try store.load() + + #expect(try store.render(name: "greeting", context: ["who": "world"]) + == "^XA^FDworld^FS^XZ") + } + + @Test func missingFileLeavesTheStoreEmptyRatherThanThrowing() throws { + let missing = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("labelkit-absent-\(UUID().uuidString).json") + + let store = StencilTemplateStore(fileURL: missing) + try store.load() // documented no-op when the file is absent + + #expect(store.listNames().isEmpty) + } +} From d556698ad249dd7d0dfeec5e6d8a239799e3c1f3 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 12:30:09 -0700 Subject: [PATCH 07/15] feat(labels): add ListLayout for rendering line lists as long labels Promotes layout logic that was duplicated across two examples into the library, where it is unit-tested for the first time and reusable by outside consumers. --- Sources/LabelKit/Labels/ListLayout.swift | 100 ++++++++++++++++++++++ Tests/LabelKitTests/ListLayoutTests.swift | 69 +++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 Sources/LabelKit/Labels/ListLayout.swift create mode 100644 Tests/LabelKitTests/ListLayoutTests.swift diff --git a/Sources/LabelKit/Labels/ListLayout.swift b/Sources/LabelKit/Labels/ListLayout.swift new file mode 100644 index 0000000..b652feb --- /dev/null +++ b/Sources/LabelKit/Labels/ListLayout.swift @@ -0,0 +1,100 @@ +// +// ListLayout.swift +// LabelKit +// + +import Foundation + +/// One row of a list label. +public enum ListLine: Sendable, Equatable { + /// A section heading, set larger and closer to the left margin. + case header(String) + /// An ordinary list entry, set smaller and indented under its header. + case item(String) + /// A separator that contributes vertical space but no text. + case blank +} + +/// Lays a sequence of ``ListLine`` values out as a single long label. +/// +/// The printer has no notion of a list, so every row's baseline is computed +/// here and emitted as an absolute `^FO` coordinate. The label's `^LL` follows +/// from the final baseline plus ``bottomMargin``. +/// +/// Callers supply the text; this type owns the geometry. Parsing text *into* +/// ``ListLine`` values is deliberately not part of it — different producers +/// (piped text, reminder quadrants) group their lines differently. +public struct ListLayout: Sendable { + public var headerFontSize: Int + public var itemFontSize: Int + public var gap: Int + public var headerIndent: Int + public var itemIndent: Int + public var topMargin: Int + /// Trailing blank media the ZD620 needs to clear its cutter at 300 dpi. + /// Empirically determined; not a style choice. + public var bottomMargin: Int + public var printWidthDots: Int + + public init( + headerFontSize: Int = 60, + itemFontSize: Int = 50, + gap: Int = 20, + headerIndent: Int = 60, + itemIndent: Int = 100, + topMargin: Int = 20, + bottomMargin: Int = 318, + printWidthDots: Int = 1200 + ) { + self.headerFontSize = headerFontSize + self.itemFontSize = itemFontSize + self.gap = gap + self.headerIndent = headerIndent + self.itemIndent = itemIndent + self.topMargin = topMargin + self.bottomMargin = bottomMargin + self.printWidthDots = printWidthDots + } + + /// Builds a label listing `lines`, and publishes the computed length on the + /// returned label's geometry so previewers and targets agree on its size. + public func makeLabel(_ lines: [ListLine], environment: ZPLEnvironment) -> ZPLLabel { + var y = topMargin + var rows: [String] = [] + + for line in lines { + switch line { + case .header(let text): + y += headerFontSize + gap + rows.append("^A0N,\(headerFontSize),\(headerFontSize)" + + "^FO\(headerIndent),\(y)^FD\(Self.sanitized(text))^FS") + case .item(let text): + y += itemFontSize + gap + rows.append("^A0N,\(itemFontSize),\(itemFontSize)" + + "^FO\(itemIndent),\(y)^FD\(Self.sanitized(text))^FS") + case .blank: + y += (itemFontSize + gap) / 2 + } + } + + // y is the last baseline; the cutter needs clearance past it. + let length = y + bottomMargin + let body = rows.joined(separator: "\n") + + // ^LH/^LS persist in printer configuration between jobs, so zero them + // explicitly rather than inheriting whatever the last job left behind. + let zpl = "^XA^PW\(printWidthDots)^LH0,0^LS0^LL\(length)\(body)^XZ" + + var environment = environment + environment.options.geometry.heightDots = length + return ZPLLabel(zpl, processors: [], environment: environment) + } + + /// Removes the two characters ZPL treats as command introducers. + /// + /// List text is arbitrary user input, so a stray `^` or `~` would otherwise + /// be parsed as the start of a command rather than printed. + private static func sanitized(_ text: String) -> String { + text.filter { $0 != "^" && $0 != "~" } + } +} diff --git a/Tests/LabelKitTests/ListLayoutTests.swift b/Tests/LabelKitTests/ListLayoutTests.swift new file mode 100644 index 0000000..78f4ef0 --- /dev/null +++ b/Tests/LabelKitTests/ListLayoutTests.swift @@ -0,0 +1,69 @@ +// +// ListLayoutTests.swift +// LabelKitTests +// + +import Testing +@testable import LabelKit + +@Suite("ListLayout") +struct ListLayoutTests { + + private var environment: ZPLEnvironment { + ZPLEnvironment(stock: Stock.Preset.label4x, device: Device.Preset.ZD620) + } + + @Test func itemUsesItemFontAndIndentAtFirstBaseline() throws { + let zpl = try ListLayout().makeLabel([.item("eggs")], environment: environment).zpl() + // topMargin 20 + itemFontSize 50 + gap 20 = 90 + #expect(zpl.contains("^A0N,50,50^FO100,90^FDeggs^FS")) + } + + @Test func headerUsesHeaderFontAndSmallerIndent() throws { + let zpl = try ListLayout().makeLabel([.header("Costco:")], environment: environment).zpl() + // topMargin 20 + headerFontSize 60 + gap 20 = 100 + #expect(zpl.contains("^A0N,60,60^FO60,100^FDCostco:^FS")) + } + + @Test func blankAdvancesHalfAnItemPitchAndEmitsNothing() throws { + let zpl = try ListLayout() + .makeLabel([.item("a"), .blank, .item("b")], environment: environment).zpl() + // a at 90; blank adds (50+20)/2 = 35 -> 125; b adds 70 -> 195 + #expect(zpl.contains("^FO100,90^FDa^FS")) + #expect(zpl.contains("^FO100,195^FDb^FS")) + } + + @Test func labelLengthIsLastBaselinePlusBottomMargin() throws { + let zpl = try ListLayout().makeLabel([.item("only")], environment: environment).zpl() + #expect(zpl.contains("^LL408")) // 90 + 318 + } + + @Test func emptyInputStillProducesAWellFormedLabel() throws { + let zpl = try ListLayout().makeLabel([], environment: environment).zpl() + #expect(zpl.hasPrefix("^XA")) + #expect(zpl.hasSuffix("^XZ")) + #expect(zpl.contains("^LL338")) // topMargin 20 + 318 + } + + @Test func commandIntroducersAreStrippedFromText() throws { + let zpl = try ListLayout() + .makeLabel([.item("bad^XZand~JAmore")], environment: environment).zpl() + #expect(zpl.contains("^FDbadXZandJAmore^FS")) + // Exactly one ^XZ: the label terminator, not one smuggled in via text. + #expect(zpl.components(separatedBy: "^XZ").count - 1 == 1) + } + + @Test func computedLengthIsPublishedOnTheEnvironmentGeometry() throws { + let label = ListLayout().makeLabel([.item("only")], environment: environment) + #expect(label.environment.options.geometry.heightDots == 408) + } + + @Test func styleOverridesAreHonored() throws { + var layout = ListLayout() + layout.itemFontSize = 30 + layout.gap = 10 + layout.itemIndent = 40 + let zpl = try layout.makeLabel([.item("small")], environment: environment).zpl() + #expect(zpl.contains("^A0N,30,30^FO40,60^FDsmall^FS")) // 20 + 30 + 10 + } +} From ad82040e784eadc1b42c907cb997260d8f9e6316 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 12:39:15 -0700 Subject: [PATCH 08/15] refactor(examples): replace example-listlabel with labelprint Layout now comes from LabelKit's ListLayout; the tool keeps only stdin parsing. Drops the example- prefix since Tier 2 tools are meant to be installed. --- Examples/ListLabel/ListLabel.swift | 235 -------------------- Examples/labelprint/LabelPrint.swift | 142 ++++++++++++ Package.swift | 11 +- Tests/LabelPrintTests/ParseLinesTests.swift | 39 ++++ 4 files changed, 189 insertions(+), 238 deletions(-) delete mode 100644 Examples/ListLabel/ListLabel.swift create mode 100644 Examples/labelprint/LabelPrint.swift create mode 100644 Tests/LabelPrintTests/ParseLinesTests.swift diff --git a/Examples/ListLabel/ListLabel.swift b/Examples/ListLabel/ListLabel.swift deleted file mode 100644 index 89be30e..0000000 --- a/Examples/ListLabel/ListLabel.swift +++ /dev/null @@ -1,235 +0,0 @@ -// -// ListLabel.swift -// LabelKit -// -// Reads lines of text on stdin and prints them as a single long list label. -// - -import Foundation -import LabelKit -import ArgumentParser -import OSLog - -// Logger for the ListLabel tool -fileprivate let logger = Logger(subsystem: "com.example.listlabel", category: "printing") - -/// Printer connection options. -/// -/// Resolution order is flag → environment variable → ``PrinterDefaults``, so the -/// example printer stays the default when nothing is supplied. -struct PrinterOptions: ParsableArguments { - @Option(name: .long, help: "Printer hostname or IP address (env: LABELKIT_PRINTER_HOST)") - var host: String = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_HOST"] ?? PrinterDefaults.host - - @Option(name: .long, help: "Printer TCP port (env: LABELKIT_PRINTER_PORT)") - var port: UInt16 = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_PORT"].flatMap(UInt16.init) ?? PrinterDefaults.port -} - -/// One line of input, classified by how it should be laid out on the label. -enum ListLine: Equatable { - /// A section header — a non-empty line ending in `:`, set larger and flush left. - case header(String) - /// An ordinary list item, set smaller and indented under its header. - case item(String) - /// A blank separator line, which contributes vertical space but no text. - case blank -} - -/// Classifies raw input lines, dropping the trailing blanks a text file usually ends with. -/// -/// - Parameter text: The full contents of stdin. -/// - Returns: One ``ListLine`` per input line, with trailing blanks removed so a -/// file ending in newlines doesn't stretch the label. -func parseLines(_ text: String) -> [ListLine] { - var lines = text - .split(separator: "\n", omittingEmptySubsequences: false) - .map { line -> ListLine in - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { return .blank } - if trimmed.hasSuffix(":") { return .header(trimmed) } - return .item(trimmed) - } - - while lines.last == .blank { - lines.removeLast() - } - return lines -} - -/// Removes the characters ZPL treats as command introducers. -/// -/// Input arrives from stdin, so a stray `^` or `~` would otherwise be parsed as -/// the start of a ZPL command rather than printed as text. -func sanitize(_ text: String) -> String { - text.filter { $0 != "^" && $0 != "~" } -} - -/// Builds a single long label listing every parsed line. -/// -/// Headers are set at ``headerFontSize`` flush against the left margin; items are -/// set at ``itemFontSize`` and indented beneath them; blank lines advance the -/// cursor by a half gap. The label's `^LL` is computed from the final cursor -/// position plus the bottom margin the cutter needs. -func generate_label(lines: [ListLine]) -> ZPLLabel { - let headerFontSize = 60 - let itemFontSize = 50 - let gap = 20 - - let headerIndent = 60 - let itemIndent = 100 - - let topMargin = 20 // space before first line - let bottomMargin = 318 // empirically needed for ZD620 + cutter @ 300 dpi - - var y = topMargin - - let rows = lines.compactMap { line -> String? in - switch line { - case .header(let text): - y += headerFontSize + gap - return "^A0N,\(headerFontSize),\(headerFontSize)^FO\(headerIndent),\(y)^FD\(sanitize(text))^FS" - case .item(let text): - y += itemFontSize + gap - return "^A0N,\(itemFontSize),\(itemFontSize)^FO\(itemIndent),\(y)^FD\(sanitize(text))^FS" - case .blank: - y += (itemFontSize + gap) / 2 - return nil - } - } - .joined(separator: "\n") - - // y now holds the baseline of the last line - let length = y + bottomMargin // label length in dots - - let header = "^PW1200^LH0,0^LS0" - - let zpl = "^XA\(header)^LL\(length)\(rows)^XZ" - - var zplenv = ZPLEnvironment(stock: Stock.Preset.label4x, - device: Device.Preset.ZD620) - zplenv.options.geometry.heightDots = length - - let label = ZPLLabel( - zpl, - processors: [], - environment: zplenv - ) - return label -} - -/// Reads all of stdin and classifies it, failing if nothing was piped in. -func readLinesFromStdin() throws -> [ListLine] { - let data = FileHandle.standardInput.readDataToEndOfFile() - let text = String(decoding: data, as: UTF8.self) - let lines = parseLines(text) - guard !lines.isEmpty else { - throw ValidationError("No input on stdin. Pipe a file, e.g. `cat list.txt | example-listlabel`.") - } - return lines -} - -@main -struct ListLabel: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "example-listlabel", - abstract: "Print lines of text from stdin as a long list label", - subcommands: [Print.self, Preview.self, Zpl.self], - defaultSubcommand: Print.self - ) -} - -struct Print: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Print list to network printer (defaults to \(PrinterDefaults.host):\(PrinterDefaults.port))", - ) - - @OptionGroup var printer: PrinterOptions - - @Flag(name: .shortAndLong, help: "Enable debug logging to Console") - var debug: Bool = false - - func run() async throws { - if debug { - logger.info("Reading list from stdin") - } - let lines = try readLinesFromStdin() - if debug { - logger.info("Read \(lines.count) line(s) from stdin") - logger.info("Generating ZPL label") - } - - let label = generate_label(lines: lines) - let zpl = try label.zpl() - if debug { - logger.info("Label generated, ZPL length: \(zpl.count) bytes") - logger.info("Connecting to printer at \(printer.host):\(printer.port)") - } - - let target = NetworkTarget(device: label.environment.options.device, host: printer.host, port: printer.port) - - if debug { - logger.info("Sending ZPL payload to printer") - } - try await label.print(to: target) - - if debug { - logger.info("Print command completed successfully") - } - } -} - -struct Preview: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Print list as a png preview to stdout (requires iterm2 image support)", - ) - - @Flag(name: .shortAndLong, help: "Enable debug logging to Console") - var debug: Bool = false - - func run() async throws { - if debug { - logger.info("Reading list from stdin") - } - let lines = try readLinesFromStdin() - if debug { - logger.info("Read \(lines.count) line(s) from stdin") - logger.info("Generating ZPL label preview") - } - - let label = generate_label(lines: lines) - let target = ITerm2Target(device: label.environment.options.device) - try await label.preview(using: ZPL2PNGRenderer.self, to: target, timeout: 2.0, fallbackHeightDots: 500) - - if debug { - logger.info("Preview command completed successfully") - } - } -} - -struct Zpl: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Print ZPL for the list to stdout", - ) - - @Flag(name: .shortAndLong, help: "Enable debug logging to Console") - var debug: Bool = false - - func run() async throws { - if debug { - logger.info("Reading list from stdin") - } - let lines = try readLinesFromStdin() - if debug { - logger.info("Read \(lines.count) line(s) from stdin") - logger.info("Generating ZPL label") - } - - let label = generate_label(lines: lines) - let zpl = try label.zpl() - if debug { - logger.info("Label generated, ZPL length: \(zpl.count) bytes") - } - - print(zpl) - } -} diff --git a/Examples/labelprint/LabelPrint.swift b/Examples/labelprint/LabelPrint.swift new file mode 100644 index 0000000..20a49fa --- /dev/null +++ b/Examples/labelprint/LabelPrint.swift @@ -0,0 +1,142 @@ +// +// LabelPrint.swift +// labelprint +// +// Reads lines of text on stdin and prints them as a single long list label. +// + +import Foundation +import LabelKit +import ArgumentParser +import OSLog + +fileprivate let logger = Logger(subsystem: "com.example.labelprint", category: "printing") + +/// Printer connection options. +/// +/// Resolution order is flag → environment variable → ``PrinterDefaults``. +struct PrinterOptions: ParsableArguments { + @Option(name: .long, help: "Printer hostname or IP address (env: LABELKIT_PRINTER_HOST)") + var host: String = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_HOST"] ?? PrinterDefaults.host + + @Option(name: .long, help: "Printer TCP port (env: LABELKIT_PRINTER_PORT)") + var port: UInt16 = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_PORT"].flatMap(UInt16.init) ?? PrinterDefaults.port +} + +/// Classifies raw input lines, dropping the trailing blanks a text file usually +/// ends with. +/// +/// A line ending in `:` is a section header. This is the convention producers +/// pipe against, which is why it lives here rather than in LabelKit. +func parseLines(_ text: String) -> [ListLine] { + var lines = text + .split(separator: "\n", omittingEmptySubsequences: false) + .map { line -> ListLine in + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .blank } + if trimmed.hasSuffix(":") { return .header(trimmed) } + return .item(trimmed) + } + + while lines.last == .blank { + lines.removeLast() + } + return lines +} + +func makeEnvironment() -> ZPLEnvironment { + ZPLEnvironment(stock: Stock.Preset.label4x, device: Device.Preset.ZD620) +} + +/// Reads all of stdin and classifies it, failing if nothing was piped in. +func readLinesFromStdin() throws -> [ListLine] { + let data = FileHandle.standardInput.readDataToEndOfFile() + let lines = parseLines(String(decoding: data, as: UTF8.self)) + guard !lines.isEmpty else { + throw ValidationError("No input on stdin. Pipe a file, e.g. `cat list.txt | labelprint`.") + } + return lines +} + +@main +struct LabelPrint: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "labelprint", + abstract: "Print lines of text from stdin as a long list label", + subcommands: [Print.self, Preview.self, Zpl.self, List.self], + defaultSubcommand: Print.self + ) +} + +struct Print: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print list to network printer (defaults to \(PrinterDefaults.host):\(PrinterDefaults.port))" + ) + + @OptionGroup var printer: PrinterOptions + + @Flag(name: .shortAndLong, help: "Enable debug logging to Console") + var debug: Bool = false + + func run() async throws { + let lines = try readLinesFromStdin() + if debug { logger.info("Read \(lines.count) line(s); printing to \(printer.host):\(printer.port)") } + + let label = ListLayout().makeLabel(lines, environment: makeEnvironment()) + let target = NetworkTarget(device: label.environment.options.device, + host: printer.host, port: printer.port) + try await label.print(to: target) + } +} + +struct Preview: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print list as a png preview to stdout (requires iterm2 image support)" + ) + + @Flag(name: .shortAndLong, help: "Enable debug logging to Console") + var debug: Bool = false + + func run() async throws { + let lines = try readLinesFromStdin() + if debug { logger.info("Read \(lines.count) line(s); rendering preview") } + + let label = ListLayout().makeLabel(lines, environment: makeEnvironment()) + let target = ITerm2Target(device: label.environment.options.device) + try await label.preview(using: ZPL2PNGRenderer.self, to: target, + timeout: 2.0, fallbackHeightDots: 500) + } +} + +struct Zpl: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print ZPL for the list to stdout" + ) + + @Flag(name: .shortAndLong, help: "Enable debug logging to Console") + var debug: Bool = false + + func run() async throws { + let lines = try readLinesFromStdin() + if debug { logger.info("Read \(lines.count) line(s); emitting ZPL") } + + let label = ListLayout().makeLabel(lines, environment: makeEnvironment()) + print(try label.zpl()) + } +} + +struct List: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Echo the parsed lines as text, to debug header and blank detection" + ) + + func run() async throws { + for line in try readLinesFromStdin() { + switch line { + case .header(let text): print("HEADER \(text)") + case .item(let text): print("ITEM \(text)") + case .blank: print("BLANK") + } + } + } +} diff --git a/Package.swift b/Package.swift index 81a12ed..e0eb6ab 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( .library(name: "LabelKit", targets: ["LabelKit"]), .executable(name: "example-label", targets: ["LabelCLI"]), .executable(name: "example-reminderlist", targets: ["ReminderList"]), - .executable(name: "example-listlabel", targets: ["ListLabel"]) + .executable(name: "labelprint", targets: ["labelprint"]) ], dependencies: [ .package( @@ -48,12 +48,17 @@ let package = Package( sources: ["ReminderList.swift", "Reminders.swift"] ), .executableTarget( - name: "ListLabel", + name: "labelprint", dependencies: [ "LabelKit", .product(name: "ArgumentParser", package: "swift-argument-parser") ], - path: "Examples/ListLabel" + path: "Examples/labelprint" + ), + .testTarget( + name: "LabelPrintTests", + dependencies: ["labelprint", "LabelKit"], + path: "Tests/LabelPrintTests" ) ] ) diff --git a/Tests/LabelPrintTests/ParseLinesTests.swift b/Tests/LabelPrintTests/ParseLinesTests.swift new file mode 100644 index 0000000..eda5cc2 --- /dev/null +++ b/Tests/LabelPrintTests/ParseLinesTests.swift @@ -0,0 +1,39 @@ +// +// ParseLinesTests.swift +// LabelPrintTests +// + +import Testing +import LabelKit +@testable import labelprint + +@Suite("labelprint line parsing") +struct ParseLinesTests { + + @Test func lineEndingInColonBecomesAHeader() { + #expect(parseLines("Costco:") == [.header("Costco:")]) + } + + @Test func ordinaryLineBecomesAnItem() { + #expect(parseLines("eggs") == [.item("eggs")]) + } + + @Test func interiorBlankLineIsPreserved() { + #expect(parseLines("a\n\nb") == [.item("a"), .blank, .item("b")]) + } + + @Test func trailingBlankLinesAreTrimmed() { + // A text file almost always ends in a newline; that must not stretch + // the label with empty rows. + #expect(parseLines("a\n\n\n") == [.item("a")]) + } + + @Test func surroundingWhitespaceIsStripped() { + #expect(parseLines(" eggs ") == [.item("eggs")]) + #expect(parseLines(" \t ") == []) + } + + @Test func emptyInputYieldsNoLines() { + #expect(parseLines("") == []) + } +} From d8aacb4d4bc7a1f66e8c7192b80350d475ca13a2 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 12:48:42 -0700 Subject: [PATCH 09/15] docs(snippets): add Tier 1 tutorial snippets Compiled on every build, so a tutorial referencing a renamed API breaks CI. --- Snippets/ChooseStockAndDevice.swift | 16 ++++++++++++++++ Snippets/PreviewInITerm2.swift | 18 ++++++++++++++++++ Snippets/PrintAList.swift | 23 +++++++++++++++++++++++ Snippets/PrintToStdout.swift | 14 ++++++++++++++ Snippets/RenderATemplate.swift | 25 +++++++++++++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 Snippets/ChooseStockAndDevice.swift create mode 100644 Snippets/PreviewInITerm2.swift create mode 100644 Snippets/PrintAList.swift create mode 100644 Snippets/PrintToStdout.swift create mode 100644 Snippets/RenderATemplate.swift diff --git a/Snippets/ChooseStockAndDevice.swift b/Snippets/ChooseStockAndDevice.swift new file mode 100644 index 0000000..69e49f7 --- /dev/null +++ b/Snippets/ChooseStockAndDevice.swift @@ -0,0 +1,16 @@ +//! Combine media and printer into a render geometry. +//! +//! Stock describes the physical media in inches; Device describes the printer's +//! resolution. Together they produce the geometry in dots that ZPL needs. + +import LabelKit + +let stock = Stock.Preset.label2x1 +let device = Device.Preset.ZD620 + +print("stock: \(stock.widthInches)in wide") +print("device: \(device.nativeDPI.rawValue) dpi") +print("width: \(stock.widthDots(at: device.nativeDPI)) dots") + +// Continuous stock has no fixed height, so heightDots is nil. +print("height: \(String(describing: Stock.Preset.label4x.heightDots(at: device.nativeDPI)))") diff --git a/Snippets/PreviewInITerm2.swift b/Snippets/PreviewInITerm2.swift new file mode 100644 index 0000000..5c5ce63 --- /dev/null +++ b/Snippets/PreviewInITerm2.swift @@ -0,0 +1,18 @@ +//! Render a label to a PNG and show it inline in iTerm2. +//! +//! ITerm2Target emits the escape codes that display an image in the terminal. +//! Run this in iTerm2; other terminals will print the raw escape sequence. + +import LabelKit + +let label = ZPLLabel( + "^XA^FO50,50^A0N,40,40^FDPreview^FS^XZ", + environment: ZPLEnvironment(stock: Stock.Preset.label2x1, + device: Device.Preset.ZD620) +) + +try await label.preview( + using: ZPL2PNGRenderer.self, + to: ITerm2Target(device: Device.Preset.ZD620), + timeout: 2.0 +) diff --git a/Snippets/PrintAList.swift b/Snippets/PrintAList.swift new file mode 100644 index 0000000..73c2a8f --- /dev/null +++ b/Snippets/PrintAList.swift @@ -0,0 +1,23 @@ +//! Lay a list of lines out as one long label. +//! +//! ListLayout computes each row's baseline and the label's overall length, so +//! callers supply text rather than coordinates. + +import LabelKit + +let lines: [ListLine] = [ + .header("Costco:"), + .item("eggs"), + .item("bananas"), + .blank, + .header("Draegers:"), + .item("cream cheese"), +] + +let label = ListLayout().makeLabel( + lines, + environment: ZPLEnvironment(stock: Stock.Preset.label4x, + device: Device.Preset.ZD620) +) + +try await label.print(to: StdoutTarget(device: Device.Preset.ZD620)) diff --git a/Snippets/PrintToStdout.swift b/Snippets/PrintToStdout.swift new file mode 100644 index 0000000..9666379 --- /dev/null +++ b/Snippets/PrintToStdout.swift @@ -0,0 +1,14 @@ +//! Write a label's ZPL to stdout. +//! +//! The smallest end-to-end LabelKit pipeline: build a label, pick a target, +//! deliver it. + +import LabelKit + +let label = ZPLLabel( + "^XA^FO50,50^A0N,40,40^FDHello^FS^XZ", + environment: ZPLEnvironment(stock: Stock.Preset.label2x1, + device: Device.Preset.ZD620) +) + +try await label.print(to: StdoutTarget(device: Device.Preset.ZD620)) diff --git a/Snippets/RenderATemplate.swift b/Snippets/RenderATemplate.swift new file mode 100644 index 0000000..ad40174 --- /dev/null +++ b/Snippets/RenderATemplate.swift @@ -0,0 +1,25 @@ +//! Fill a Stencil template with values and render it to ZPL. +//! +//! Templates keep the layout separate from the data, so the same label can be +//! reused with different content. + +import Foundation +import LabelKit + +// snippet.hide +// Read the templates checked into the repository so this runs on a fresh clone. +let templates = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Snippets/ + .deletingLastPathComponent() // package root + .appendingPathComponent("Examples/Resources/templates.json") +// snippet.show + +let store = StencilTemplateStore(fileURL: templates) +try store.load() + +let zpl = try store.render(name: "label", context: [ + "line1": "First line", + "line2": "Second line", +]) + +print(zpl) From c2de3dc452da1624b790c5c7aa230ea93edbe1a5 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 12:58:09 -0700 Subject: [PATCH 10/15] feat(examples): add compare-renderers, retire example-label example-label's template and preview behavior moved to snippets; its dual-renderer behavior becomes a debugging tool for renderer disagreements. --- .../compare-renderers/CompareRenderers.swift | 51 +++++++ Examples/LabelCLI/LabelCLI.swift | 124 ------------------ Package.swift | 20 +-- 3 files changed, 61 insertions(+), 134 deletions(-) create mode 100644 Examples/Advanced/compare-renderers/CompareRenderers.swift delete mode 100644 Examples/LabelCLI/LabelCLI.swift diff --git a/Examples/Advanced/compare-renderers/CompareRenderers.swift b/Examples/Advanced/compare-renderers/CompareRenderers.swift new file mode 100644 index 0000000..71319dd --- /dev/null +++ b/Examples/Advanced/compare-renderers/CompareRenderers.swift @@ -0,0 +1,51 @@ +// +// CompareRenderers.swift +// compare-renderers +// +// Renders the same ZPL through both previewers to expose disagreements. +// + +import Foundation +import LabelKit +import ArgumentParser + +@main +struct CompareRenderers: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "compare-renderers", + abstract: "Render ZPL from stdin through Labelary and zpl2png, side by side", + discussion: """ + The two renderers do not implement identical subsets of ZPL. When a \ + label prints differently than it previews, rendering it both ways \ + shows which renderer is wrong. Labelary is the reference \ + implementation; zpl2png runs locally and offline. + """ + ) + + @Option(name: .long, help: "Label width in inches") + var width: Double = 4.0 + + @Option(name: .long, help: "Label height in inches") + var height: Double = 6.0 + + func run() async throws { + let zpl = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self) + guard !zpl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ValidationError("No ZPL on stdin. Pipe a label, e.g. `labelprint zpl < list.txt | compare-renderers`.") + } + + let device = Device.Preset.ZD620 + let stock = Stock(widthInches: width, heightInches: height, + isContinuous: false, gapInches: 0) + let label = ZPLLabel(zpl, environment: ZPLEnvironment(stock: stock, device: device)) + let target = ITerm2Target(device: device) + + print("── Labelary (reference) ──") + try await label.preview(using: LabelaryRenderer.self, to: target, timeout: 10.0) + + print("── zpl2png (local) ──") + try await label.preview(using: ZPL2PNGRenderer.self, to: target, timeout: 10.0) + + print("If these differ, the local renderer is missing a ZPL feature.") + } +} diff --git a/Examples/LabelCLI/LabelCLI.swift b/Examples/LabelCLI/LabelCLI.swift deleted file mode 100644 index 95204ea..0000000 --- a/Examples/LabelCLI/LabelCLI.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// LabelCLI.swift -// LabelCLI -// -// Created by Peter Richardson on 8/22/25. -// - -import ArgumentParser -import Foundation -import LabelKit -import Stencil -import OSLog - -// Logger for the LabelCLI tool -fileprivate let logger = Logger(subsystem: "com.example.labelcli", category: "label") - -/// Printer connection options. -/// -/// Resolution order is flag → environment variable → ``PrinterDefaults``, so the -/// example printer stays the default when nothing is supplied. -struct PrinterOptions: ParsableArguments { - @Option(name: .long, help: "Printer hostname or IP address (env: LABELKIT_PRINTER_HOST)") - var host: String = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_HOST"] ?? PrinterDefaults.host - - @Option(name: .long, help: "Printer TCP port (env: LABELKIT_PRINTER_PORT)") - var port: UInt16 = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_PORT"].flatMap(UInt16.init) ?? PrinterDefaults.port -} - -func loadSomeZPL() throws -> String { - // Get Templates from Application Support/.../templates.json - let store = try StencilTemplateStore() - try store.load() - return try store.render(name: "label") -} - -enum LabelCLIError: Error { - case templateStoreUnavailable -} - -func makeLabel() throws -> ZPLLabel { - let zpl = try loadSomeZPL() - let stock = Stock.Preset.label2x1 - let zd620 = Device.Preset.ZD620 - - guard let resolveTemplates = ResolveTemplates() else { - throw LabelCLIError.templateStoreUnavailable - } - - let zplenv = ZPLEnvironment(stock: stock, device: zd620) - let label: ZPLLabel = ZPLLabel( - zpl, - processors: [resolveTemplates, InjectLength(), PrettyPrint()], - environment: zplenv - ) - return label -} - -func printToPrinter(_ label: ZPLLabel, host: String, port: UInt16) async throws { - let printer = NetworkTarget(device: label.environment.options.device, host: host, port: port) - try await label.print(to: printer) -} - -/// Print label zpl text to stdout -func printZPL(_ label: ZPLLabel) async throws { - let stdout = StdoutTarget(device: label.environment.options.device) - try await label.print(to: stdout) -} - -func printPreview(_ label: ZPLLabel, using rendererType: T.Type) async throws { - let target = ITerm2Target(device: label.environment.options.device) - try await label.preview(using: rendererType, to: target, timeout: 2.0) -} - -@main -struct LabelCLI: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "example-label", - abstract: "Render the 'label' template to ZPL and preview it inline in iTerm2" - ) - - @OptionGroup var printer: PrinterOptions - - @Flag(name: [.customShort("p"), .customLong("print")], - help: "Also send the label to the network printer") - var sendToPrinter: Bool = false - - @Flag(name: .shortAndLong, help: "Enable debug logging to Console") - var debug: Bool = false - - // Errors thrown here are surfaced by ArgumentParser on stderr with a non-zero - // exit status, so they no longer vanish the way a bare top-level Task's did. - func run() async throws { - if debug { - logger.info("Building label") - } - let label = try makeLabel() - - if debug { - logger.info("Sending ZPL to stdout") - } - try await printZPL(label) - - if sendToPrinter { - if debug { - logger.info("Connecting to printer at \(printer.host):\(printer.port)") - } - try await printToPrinter(label, host: printer.host, port: printer.port) - } - - if debug { - logger.info("Rendering Labelary preview") - } - try await printPreview(label, using: LabelaryRenderer.self) - - if debug { - logger.info("Rendering zpl2png preview") - } - try await printPreview(label, using: ZPL2PNGRenderer.self) - - if debug { - logger.info("LabelCLI completed successfully") - } - } -} diff --git a/Package.swift b/Package.swift index e0eb6ab..049eca3 100644 --- a/Package.swift +++ b/Package.swift @@ -6,9 +6,9 @@ let package = Package( platforms: [.macOS(.v14)], products: [ .library(name: "LabelKit", targets: ["LabelKit"]), - .executable(name: "example-label", targets: ["LabelCLI"]), .executable(name: "example-reminderlist", targets: ["ReminderList"]), - .executable(name: "labelprint", targets: ["labelprint"]) + .executable(name: "labelprint", targets: ["labelprint"]), + .executable(name: "compare-renderers", targets: ["CompareRenderers"]) ], dependencies: [ .package( @@ -30,14 +30,6 @@ let package = Package( .copy("Helpers/zpl2png") ]), .testTarget(name: "LabelKitTests", dependencies: ["LabelKit"]), - .executableTarget( - name: "LabelCLI", - dependencies: [ - "LabelKit", - .product(name: "ArgumentParser", package: "swift-argument-parser") - ], - path: "Examples/LabelCLI" - ), .executableTarget( name: "ReminderList", dependencies: [ @@ -59,6 +51,14 @@ let package = Package( name: "LabelPrintTests", dependencies: ["labelprint", "LabelKit"], path: "Tests/LabelPrintTests" + ), + .executableTarget( + name: "CompareRenderers", + dependencies: [ + "LabelKit", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Examples/Advanced/compare-renderers" ) ] ) From c1ba26bd51f57d975c34430f601dbd9950b65aad Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 13:09:33 -0700 Subject: [PATCH 11/15] fix(compare-renderers): isolate renderer errors and report PNG dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Give each renderer its own error boundary so one failing (e.g. Labelary unreachable offline) does not stop the other from running; report the failure and continue instead of propagating. - Report each rendered PNG's pixel dimensions and byte size alongside the inline preview, parsed from the IHDR chunk without an image framework dependency — this is the actual diagnostic signal when the two renderers disagree, per the project spec ("shows both inline and reports dimensions"). --- .../compare-renderers/CompareRenderers.swift | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/Examples/Advanced/compare-renderers/CompareRenderers.swift b/Examples/Advanced/compare-renderers/CompareRenderers.swift index 71319dd..fffa7f4 100644 --- a/Examples/Advanced/compare-renderers/CompareRenderers.swift +++ b/Examples/Advanced/compare-renderers/CompareRenderers.swift @@ -40,12 +40,49 @@ struct CompareRenderers: AsyncParsableCommand { let label = ZPLLabel(zpl, environment: ZPLEnvironment(stock: stock, device: device)) let target = ITerm2Target(device: device) - print("── Labelary (reference) ──") - try await label.preview(using: LabelaryRenderer.self, to: target, timeout: 10.0) - - print("── zpl2png (local) ──") - try await label.preview(using: ZPL2PNGRenderer.self, to: target, timeout: 10.0) + // Each renderer gets its own error boundary: one renderer being unreachable + // (e.g. Labelary with no network) must not stop the other from running — + // the offline zpl2png path is half of what this tool is for. + await renderAndShow(label, using: LabelaryRenderer.self, name: "Labelary (reference)", to: target) + await renderAndShow(label, using: ZPL2PNGRenderer.self, name: "zpl2png (local)", to: target) print("If these differ, the local renderer is missing a ZPL feature.") } + + /// Renders `label` via `rendererType`, reports its pixel dimensions and byte size + /// (the actual diagnostic signal when two renderers disagree), and displays it + /// inline. Failures are reported and swallowed so the caller can move on to the + /// next renderer. + private func renderAndShow( + _ label: ZPLLabel, + using rendererType: R.Type, + name: String, + to target: ITerm2Target, + timeout: TimeInterval = 10.0 + ) async { + print("── \(name) ──") + do { + let data = try await label.renderPreview(using: rendererType, timeout: timeout) + if let (width, height) = Self.pngDimensions(data) { + print("\(width)×\(height) px, \(data.count) bytes") + } else { + print("\(data.count) bytes (could not parse PNG dimensions)") + } + try await target.send(.png(data, dpi: target.device.nativeDPI), strict: true) + } catch { + print("Failed: \(error)") + } + } + + /// Reads the width/height from a PNG's IHDR chunk (big-endian `UInt32`s at + /// byte offsets 16 and 20) without pulling in an image framework. + private static func pngDimensions(_ data: Data) -> (width: Int, height: Int)? { + guard data.count >= 24 else { return nil } + let bytes = [UInt8](data) + func readUInt32(at offset: Int) -> UInt32 { + (UInt32(bytes[offset]) << 24) | (UInt32(bytes[offset + 1]) << 16) + | (UInt32(bytes[offset + 2]) << 8) | UInt32(bytes[offset + 3]) + } + return (Int(readUInt32(at: 16)), Int(readUInt32(at: 20))) + } } From d38dc9314caa49c317ac8825b28faca08100d4cd Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 13:18:02 -0700 Subject: [PATCH 12/15] feat(examples): add batch-badges advanced template example --- .../Advanced/batch-badges/BatchBadges.swift | 73 +++++++++++++++++++ Examples/Resources/badges.csv | 4 + Package.swift | 11 ++- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 Examples/Advanced/batch-badges/BatchBadges.swift create mode 100644 Examples/Resources/badges.csv diff --git a/Examples/Advanced/batch-badges/BatchBadges.swift b/Examples/Advanced/batch-badges/BatchBadges.swift new file mode 100644 index 0000000..ef1ab1a --- /dev/null +++ b/Examples/Advanced/batch-badges/BatchBadges.swift @@ -0,0 +1,73 @@ +// +// BatchBadges.swift +// batch-badges +// +// One template + a CSV -> one label per row. +// + +import Foundation +import LabelKit +import ArgumentParser + +@main +struct BatchBadges: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "batch-badges", + abstract: "Render one badge label per row of a CSV, from a single template", + discussion: """ + Shows the payoff of keeping layout in a template and data outside it: \ + the template is written once and rendered N times with different \ + values, rather than building N labels by string concatenation. + """ + ) + + @Option(name: .long, help: "CSV file with a header row (default: the bundled sample)") + var csv: String? + + @Option(name: .long, help: "Template name in the store") + var template: String = "badge" + + func run() async throws { + let packageRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // batch-badges/ + .deletingLastPathComponent() // Advanced/ + .deletingLastPathComponent() // Examples/ + .deletingLastPathComponent() // package root + + let resources = packageRoot.appendingPathComponent("Examples/Resources") + let csvURL = csv.map { URL(fileURLWithPath: $0) } + ?? resources.appendingPathComponent("badges.csv") + + let store = StencilTemplateStore( + fileURL: resources.appendingPathComponent("templates.json")) + try store.load() + + let rows = try parseCSV(String(contentsOf: csvURL, encoding: .utf8)) + guard !rows.isEmpty else { + throw ValidationError("No data rows in \(csvURL.lastPathComponent).") + } + + for row in rows { + print(try store.render(name: template, context: row)) + } + } + + /// Minimal comma-splitting CSV reader: a header row names the template + /// variables, and each later row supplies their values. Deliberately does + /// not handle quoted fields or embedded commas — that is not the lesson. + func parseCSV(_ text: String) throws -> [[String: String]] { + let lines = text + .split(separator: "\n", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + guard let header = lines.first else { return [] } + let keys = header.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + + return lines.dropFirst().map { line in + let values = line.split(separator: ",", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + return Dictionary(uniqueKeysWithValues: zip(keys, values)) + } + } +} diff --git a/Examples/Resources/badges.csv b/Examples/Resources/badges.csv new file mode 100644 index 0000000..6c49483 --- /dev/null +++ b/Examples/Resources/badges.csv @@ -0,0 +1,4 @@ +name,title +Ada Lovelace,First Programmer +Grace Hopper,Rear Admiral +Alan Turing,Cryptanalyst diff --git a/Package.swift b/Package.swift index 049eca3..b16c373 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,8 @@ let package = Package( .library(name: "LabelKit", targets: ["LabelKit"]), .executable(name: "example-reminderlist", targets: ["ReminderList"]), .executable(name: "labelprint", targets: ["labelprint"]), - .executable(name: "compare-renderers", targets: ["CompareRenderers"]) + .executable(name: "compare-renderers", targets: ["CompareRenderers"]), + .executable(name: "batch-badges", targets: ["BatchBadges"]) ], dependencies: [ .package( @@ -59,6 +60,14 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser") ], path: "Examples/Advanced/compare-renderers" + ), + .executableTarget( + name: "BatchBadges", + dependencies: [ + "LabelKit", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Examples/Advanced/batch-badges" ) ] ) From 09041f178e1c2f4f31c1bd744594a3008de8215a Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 13:27:13 -0700 Subject: [PATCH 13/15] fix(batch-badges): reject CSV headers with duplicate column names --- Examples/Advanced/batch-badges/BatchBadges.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Examples/Advanced/batch-badges/BatchBadges.swift b/Examples/Advanced/batch-badges/BatchBadges.swift index ef1ab1a..a80c10b 100644 --- a/Examples/Advanced/batch-badges/BatchBadges.swift +++ b/Examples/Advanced/batch-badges/BatchBadges.swift @@ -64,6 +64,11 @@ struct BatchBadges: AsyncParsableCommand { guard let header = lines.first else { return [] } let keys = header.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + var seenKeys = Set() + for key in keys where !seenKeys.insert(key).inserted { + throw ValidationError("Duplicate column '\(key)' in CSV header.") + } + return lines.dropFirst().map { line in let values = line.split(separator: ",", omittingEmptySubsequences: false) .map { $0.trimmingCharacters(in: .whitespaces) } From e5721fa613f251cdabeb2450ffb26d9f8716ff98 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 13:38:20 -0700 Subject: [PATCH 14/15] docs: fix DocC dead symbols and refresh README/AGENTS examples Reminders.swift has no home in a companion `reminders` repo yet, so example-reminderlist and Examples/ReminderList/ are NOT removed in this commit (deferred until that repo exists and builds) - this only covers the doc-cleanup portion of the examples reorganization. - LabelKit.md: StencilProcessor/LengthInjector never existed; replaced with the real processor names ResolveTemplates/InjectLength, added ListLayout to Other Types, and fixed a stale `example-label` shell snippet. - README.md: rewrote the Examples section into the three real tiers (Snippets/ tutorials, labelprint, Advanced/), fixed the printer-host override examples and the StencilTemplateStore consumer reference - all previously pointed at example-label/example-listlabel, which no longer exist. - AGENTS.md: updated the Commands block to match. KeyValueContext investigation (Apps/LabelGUI/LabelGUI/KeyValueContext.swift vs Sources/LabelKit/Processors/KeyValueContext.swift): not a fork despite the shared filename. The LabelGUI file imports LabelKit and defines only KeyValueTableView, a SwiftUI view consuming LabelKit's KeyValueContext/ KeyValueRow types directly - it re-declares neither. Classification of its full 164 lines: (a) GUI-specific, stays in LabelGUI - all of it. (b) generally useful, promote to LabelKit - none. (c) redundant, delete - none. No library API change made or warranted. swift build && swift test: Build complete!, 105 tests / 25 suites passed. swift package describe confirms products labelprint, compare-renderers, batch-badges, example-reminderlist - no example-label, no example-listlabel. --- AGENTS.md | 7 +- README.md | 79 +++++++++++++++------- Sources/LabelKit/LabelKit.docc/LabelKit.md | 7 +- 3 files changed, 65 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 52f5dac..3eb2c9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,12 @@ swift build # build library + example executables swift test # run all tests (uses Swift Testing, not XCTest) swift test --filter ZPLLabelTests # run one suite swift test --filter ZPLLabelTests/initialRenderWithContext # run one test -swift run example-label # LabelCLI example +swift run PrintToStdout # Tier 1 tutorial snippet +swift run PrintAList # Tier 1 tutorial snippet +cat list.txt | swift run labelprint print|preview|zpl|list # Tier 2: labelprint (stdin) swift run example-reminderlist zpl|preview|print|list # ReminderList example -cat list.txt | swift run example-listlabel print|preview|zpl # ListLabel example (stdin) +swift run batch-badges # Tier 3: template + CSV -> N labels +cat list.txt | swift run labelprint zpl | swift run compare-renderers # Tier 3 ./Scripts/generate_docs.sh # generate docs with jazzy ``` diff --git a/README.md b/README.md index 8da08b6..4cd5191 100644 --- a/README.md +++ b/README.md @@ -144,44 +144,77 @@ visual for this project. ## Examples -Three example executables ship with the package: +Examples are organized in three tiers, from smallest to most complete: + +**Tier 1 — tutorial snippets** (`Snippets/`): single-file, single-concept +programs. Read the source; each one demonstrates exactly one idea. + +| Snippet | Demonstrates | +|---------|--------------| +| `PrintToStdout` | The smallest end-to-end pipeline: build a label, pick a target, deliver it | +| `ChooseStockAndDevice` | Combining `Stock` (inches) and `Device` (DPI) into render geometry | +| `PrintAList` | Laying a sequence of `ListLine` values out with `ListLayout` | +| `RenderATemplate` | Filling a Stencil template with values via `StencilTemplateStore` | +| `PreviewInITerm2` | Rendering ZPL to PNG and showing it inline in iTerm2 | + +```sh +swift run PrintToStdout +swift run ChooseStockAndDevice +swift run PrintAList +swift run RenderATemplate +swift run PreviewInITerm2 +``` + +**Tier 2 — `labelprint`** (`Examples/labelprint/`): reads lines of text on +stdin and prints them as one long list label via `ListLayout`; lines ending in +`:` become section headers and blank lines become gaps. + +```sh +cat list.txt | swift run labelprint # send to network printer (default) +cat list.txt | swift run labelprint preview # PNG preview inline in iTerm2 +cat list.txt | swift run labelprint zpl # generated ZPL to stdout +cat list.txt | swift run labelprint list # echo parsed lines, for debugging headers/gaps +``` + +`labelprint` defaults to its `print` subcommand, so a bare pipe goes straight +to the printer. Every subcommand takes `-d`/`--debug` to enable OSLog output +to Console. + +**Tier 3 — advanced examples** (`Examples/Advanced/`): multi-feature programs +that combine several parts of LabelKit. | Executable | Description | |------------|-------------| -| `example-label` | Loads a Stencil template from the template store, prints the processed ZPL to stdout, and renders iTerm2 previews via both Labelary and zpl2png | -| `example-reminderlist` | Prints your uncompleted Reminders.app items as a label on 4" continuous stock | -| `example-listlabel` | Reads lines of text on stdin and prints them as one long list label; lines ending in `:` become section headers and blank lines become gaps | +| `batch-badges` | Renders one badge label per row of a CSV, from a single Stencil template | +| `compare-renderers` | Renders ZPL from stdin through both Labelary and zpl2png, side by side | ```sh -swift run example-label # ZPL to stdout + iTerm2 previews -swift run example-label --print # ...and send it to the printer -swift run example-label -d # ...with debug logging to Console +swift run batch-badges # template + CSV -> N labels +cat list.txt | swift run labelprint zpl | swift run compare-renderers +``` + +There is also a standalone example not part of the tiers above: +| Executable | Description | +|------------|-------------| +| `example-reminderlist` | Prints your uncompleted Reminders.app items as a label on 4" continuous stock | + +```sh swift run example-reminderlist list # reminder titles as text swift run example-reminderlist zpl # generated ZPL to stdout swift run example-reminderlist preview # PNG preview inline in iTerm2 swift run example-reminderlist print # send to network printer swift run example-reminderlist print -d # ...with debug logging to Console - -cat shopping.txt | swift run example-listlabel # send to network printer -cat shopping.txt | swift run example-listlabel preview # PNG preview inline in iTerm2 -cat shopping.txt | swift run example-listlabel zpl # generated ZPL to stdout ``` -`example-listlabel` defaults to its `print` subcommand, so a bare pipe goes -straight to the printer. - -Every subcommand takes `-d`/`--debug` to enable OSLog output to Console. - ### Choosing a printer -All three examples default to a printer at `192.168.0.133:9100`. Override it with -`--host`/`--port` on any command that talks to a printer: +Executables that talk to a printer default to `192.168.0.133:9100`. Override +it with `--host`/`--port`: ```sh -swift run example-reminderlist print --host 10.0.1.42 --port 9100 -swift run example-label --print --host 10.0.1.42 -cat shopping.txt | swift run example-listlabel print --host 10.0.1.42 +cat list.txt | swift run labelprint print --host 10.0.1.42 --port 9100 +swift run example-reminderlist print --host 10.0.1.42 ``` or set the environment variables once: @@ -203,8 +236,8 @@ in Xcode). ## Configuration -`StencilTemplateStore` (used by `example-label` and available to your own -code) reads named templates from: +`StencilTemplateStore` (used by the `RenderATemplate` snippet, `batch-badges`, +and available to your own code) reads named templates from: ``` ~/Library/Application Support/LabelKit/templates.json diff --git a/Sources/LabelKit/LabelKit.docc/LabelKit.md b/Sources/LabelKit/LabelKit.docc/LabelKit.md index 5317129..3c1dcb0 100644 --- a/Sources/LabelKit/LabelKit.docc/LabelKit.md +++ b/Sources/LabelKit/LabelKit.docc/LabelKit.md @@ -21,8 +21,8 @@ Core pieces include: - ``FileTarget``: can save ZPL or a rendered ZPL PNG in a file - ``ZPLProcessor``: A protocol for components that transform ZPL prior to output - - ``StencilProcessor``: a processor to resolve Swift-Stencil Template to generate ZPL - - LengthInjector: a processor to inject an estimated length (^LL) into a ZPL string + - ``ResolveTemplates``: a processor to resolve Swift-Stencil templates to generate ZPL + - ``InjectLength``: a processor to inject an estimated length (^LL) into a ZPL string - ``ZPLFormatter``: a processor to pretty‑print or minify ZPL - ``ZPLEnvironment`` / ``ZPLOptions``: Context and options passed to processors. @@ -34,6 +34,7 @@ Other Types: - ``ZPLRepresentable``: Protocol for types that can produce ZPL on demand. - ``DPI``: The printer resolution used when converting inches to dots. `Comparable`, ordered by resolution, so resolutions can be sorted, ranged and compared with `min`/`max`. - ``ZPLLengthEstimator``: Estimates ZPL label length (in dots) by walking commands. +- ``ListLayout``: Lays a sequence of ``ListLine`` values out as a single long label. ## Getting Started @@ -63,7 +64,7 @@ let renderer = try ZPL2PNGRenderer( …or from the shell: ```sh -LABELKIT_ZPL2PNG=/opt/zpl2png/bin/zpl2png swift run example-label +LABELKIT_ZPL2PNG=/opt/zpl2png/bin/zpl2png swift run labelprint ``` Both overrides take precedence over every automatic search location, and both throw From a263be317608a75eaeacda3b0649317f57436f55 Mon Sep 17 00:00:00 2001 From: Pete Richardson Date: Sun, 30 Aug 2026 13:54:25 -0700 Subject: [PATCH 15/15] fix: address final-review fix wave (CRLF parsing, ListLayout width, edge cases) - labelprint: split on any newline (not just \n) so CRLF/lone-CR input parses correctly instead of collapsing into one corrupted item; trim with .whitespacesAndNewlines to keep stray CR out of ^FD content - ListLayout: printWidthDots now derives from the environment's RenderGeometry when unset, instead of a hardcoded 1200 that silently disagreed with non-4x stocks like label2x1 - compare-renderers: pngDimensions verifies the PNG signature before reading IHDR offsets, instead of returning confident nonsense for non-PNG input - batch-badges: CSV rows with the wrong field count now raise a ValidationError naming the row and expected/actual counts, instead of silently zipping to an empty value - README: document LabelGUI explicitly as the Tier 3 GUI example - spec: correct the structure block to name PrintAList.swift, matching the shipped tree, instead of the never-built PrintToNetwork.swift Adds CRLF/lone-CR and printWidthDots coverage to the existing test suites (105 -> 110 tests). --- .../Advanced/batch-badges/BatchBadges.swift | 7 ++++++- .../compare-renderers/CompareRenderers.swift | 3 ++- Examples/labelprint/LabelPrint.swift | 4 ++-- README.md | 5 +---- Sources/LabelKit/Labels/ListLayout.swift | 10 +++++++--- Tests/LabelKitTests/ListLayoutTests.swift | 14 ++++++++++++++ Tests/LabelPrintTests/ParseLinesTests.swift | 17 +++++++++++++++++ ...2026-08-30-examples-reorganization-design.md | 2 +- 8 files changed, 50 insertions(+), 12 deletions(-) diff --git a/Examples/Advanced/batch-badges/BatchBadges.swift b/Examples/Advanced/batch-badges/BatchBadges.swift index a80c10b..635599d 100644 --- a/Examples/Advanced/batch-badges/BatchBadges.swift +++ b/Examples/Advanced/batch-badges/BatchBadges.swift @@ -69,9 +69,14 @@ struct BatchBadges: AsyncParsableCommand { throw ValidationError("Duplicate column '\(key)' in CSV header.") } - return lines.dropFirst().map { line in + return try lines.dropFirst().enumerated().map { index, line in let values = line.split(separator: ",", omittingEmptySubsequences: false) .map { $0.trimmingCharacters(in: .whitespaces) } + guard values.count == keys.count else { + // +2: 1-based, plus the header row itself. + throw ValidationError( + "Row \(index + 2) has \(values.count) field(s), expected \(keys.count) to match the header.") + } return Dictionary(uniqueKeysWithValues: zip(keys, values)) } } diff --git a/Examples/Advanced/compare-renderers/CompareRenderers.swift b/Examples/Advanced/compare-renderers/CompareRenderers.swift index fffa7f4..1556f64 100644 --- a/Examples/Advanced/compare-renderers/CompareRenderers.swift +++ b/Examples/Advanced/compare-renderers/CompareRenderers.swift @@ -77,7 +77,8 @@ struct CompareRenderers: AsyncParsableCommand { /// Reads the width/height from a PNG's IHDR chunk (big-endian `UInt32`s at /// byte offsets 16 and 20) without pulling in an image framework. private static func pngDimensions(_ data: Data) -> (width: Int, height: Int)? { - guard data.count >= 24 else { return nil } + let signature: [UInt8] = [137, 80, 78, 71, 13, 10, 26, 10] + guard data.count >= 24, [UInt8](data.prefix(8)) == signature else { return nil } let bytes = [UInt8](data) func readUInt32(at offset: Int) -> UInt32 { (UInt32(bytes[offset]) << 24) | (UInt32(bytes[offset + 1]) << 16) diff --git a/Examples/labelprint/LabelPrint.swift b/Examples/labelprint/LabelPrint.swift index 20a49fa..67f7318 100644 --- a/Examples/labelprint/LabelPrint.swift +++ b/Examples/labelprint/LabelPrint.swift @@ -30,9 +30,9 @@ struct PrinterOptions: ParsableArguments { /// pipe against, which is why it lives here rather than in LabelKit. func parseLines(_ text: String) -> [ListLine] { var lines = text - .split(separator: "\n", omittingEmptySubsequences: false) + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) .map { line -> ListLine in - let trimmed = line.trimmingCharacters(in: .whitespaces) + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return .blank } if trimmed.hasSuffix(":") { return .header(trimmed) } return .item(trimmed) diff --git a/README.md b/README.md index 4cd5191..8eccba5 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ that combine several parts of LabelKit. |------------|-------------| | `batch-badges` | Renders one badge label per row of a CSV, from a single Stencil template | | `compare-renderers` | Renders ZPL from stdin through both Labelary and zpl2png, side by side | +| **LabelGUI** | The Tier 3 GUI example — a SwiftUI app in `Apps/LabelGUI`, built via `Apps/LabelGUI/LabelGUI.xcodeproj` or `Workspace/LabelWorkspace.xcworkspace` in Xcode | ```sh swift run batch-badges # template + CSV -> N labels @@ -228,10 +229,6 @@ Precedence is flag → environment variable → built-in default. No source edit or rebuild required. `example-reminderlist` will prompt for Reminders access on first run. -There is also a SwiftUI app, **LabelGUI**, in `Apps/LabelGUI` (open -`Apps/LabelGUI/LabelGUI.xcodeproj` or `Workspace/LabelWorkspace.xcworkspace` -in Xcode). - --- ## Configuration diff --git a/Sources/LabelKit/Labels/ListLayout.swift b/Sources/LabelKit/Labels/ListLayout.swift index b652feb..82afcb0 100644 --- a/Sources/LabelKit/Labels/ListLayout.swift +++ b/Sources/LabelKit/Labels/ListLayout.swift @@ -34,7 +34,10 @@ public struct ListLayout: Sendable { /// Trailing blank media the ZD620 needs to clear its cutter at 300 dpi. /// Empirically determined; not a style choice. public var bottomMargin: Int - public var printWidthDots: Int + /// `^PW` override. An explicit value wins; `nil` (the default) derives the + /// width from the environment's `RenderGeometry` instead, so the emitted + /// `^PW` agrees with the geometry `makeLabel` publishes for previewers. + public var printWidthDots: Int? public init( headerFontSize: Int = 60, @@ -44,7 +47,7 @@ public struct ListLayout: Sendable { itemIndent: Int = 100, topMargin: Int = 20, bottomMargin: Int = 318, - printWidthDots: Int = 1200 + printWidthDots: Int? = nil ) { self.headerFontSize = headerFontSize self.itemFontSize = itemFontSize @@ -80,10 +83,11 @@ public struct ListLayout: Sendable { // y is the last baseline; the cutter needs clearance past it. let length = y + bottomMargin let body = rows.joined(separator: "\n") + let width = printWidthDots ?? environment.options.geometry.widthDots ?? 1200 // ^LH/^LS persist in printer configuration between jobs, so zero them // explicitly rather than inheriting whatever the last job left behind. - let zpl = "^XA^PW\(printWidthDots)^LH0,0^LS0^LL\(length)\(body)^XZ" + let zpl = "^XA^PW\(width)^LH0,0^LS0^LL\(length)\(body)^XZ" var environment = environment environment.options.geometry.heightDots = length diff --git a/Tests/LabelKitTests/ListLayoutTests.swift b/Tests/LabelKitTests/ListLayoutTests.swift index 78f4ef0..1cb9ffa 100644 --- a/Tests/LabelKitTests/ListLayoutTests.swift +++ b/Tests/LabelKitTests/ListLayoutTests.swift @@ -66,4 +66,18 @@ struct ListLayoutTests { let zpl = try layout.makeLabel([.item("small")], environment: environment).zpl() #expect(zpl.contains("^A0N,30,30^FO40,60^FDsmall^FS")) // 20 + 30 + 10 } + + @Test func printWidthIsDerivedFromTheEnvironmentByDefault() throws { + let narrowEnvironment = ZPLEnvironment(stock: Stock.Preset.label2x1, device: Device.Preset.ZD620) + let zpl = try ListLayout().makeLabel([.item("eggs")], environment: narrowEnvironment).zpl() + #expect(zpl.contains("^PW600")) + } + + @Test func explicitPrintWidthOverridesTheEnvironment() throws { + var layout = ListLayout() + layout.printWidthDots = 800 + let narrowEnvironment = ZPLEnvironment(stock: Stock.Preset.label2x1, device: Device.Preset.ZD620) + let zpl = try layout.makeLabel([.item("eggs")], environment: narrowEnvironment).zpl() + #expect(zpl.contains("^PW800")) + } } diff --git a/Tests/LabelPrintTests/ParseLinesTests.swift b/Tests/LabelPrintTests/ParseLinesTests.swift index eda5cc2..69d2f71 100644 --- a/Tests/LabelPrintTests/ParseLinesTests.swift +++ b/Tests/LabelPrintTests/ParseLinesTests.swift @@ -36,4 +36,21 @@ struct ParseLinesTests { @Test func emptyInputYieldsNoLines() { #expect(parseLines("") == []) } + + @Test func crlfHeaderLineIsClassifiedAsHeader() { + #expect(parseLines("Costco:\r\n") == [.header("Costco:")]) + } + + @Test func crlfDocumentParsesSameAsItsLFEquivalent() { + let lf = "Costco:\neggs\n\nDraegers:\nmilk\n" + let crlf = "Costco:\r\neggs\r\n\r\nDraegers:\r\nmilk\r\n" + #expect(parseLines(crlf) == parseLines(lf)) + #expect(parseLines(crlf) == [ + .header("Costco:"), .item("eggs"), .blank, .header("Draegers:"), .item("milk"), + ]) + } + + @Test func loneCarriageReturnLineEndingsAlsoSplit() { + #expect(parseLines("a\rb\rc") == [.item("a"), .item("b"), .item("c")]) + } } diff --git a/docs/specs/2026-08-30-examples-reorganization-design.md b/docs/specs/2026-08-30-examples-reorganization-design.md index 21fe9c1..f73531c 100644 --- a/docs/specs/2026-08-30-examples-reorganization-design.md +++ b/docs/specs/2026-08-30-examples-reorganization-design.md @@ -69,7 +69,7 @@ Two criteria carry most of the weight: Snippets/ Tier 1 — auto-discovered, no Package.swift entry PrintToStdout.swift PreviewInITerm2.swift - PrintToNetwork.swift + PrintAList.swift RenderATemplate.swift ChooseStockAndDevice.swift