Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +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
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
```

Expand Down
83 changes: 83 additions & 0 deletions Examples/Advanced/batch-badges/BatchBadges.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// 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) }

var seenKeys = Set<String>()
for key in keys where !seenKeys.insert(key).inserted {
throw ValidationError("Duplicate column '\(key)' in CSV header.")
}

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))
}
}
}
89 changes: 89 additions & 0 deletions Examples/Advanced/compare-renderers/CompareRenderers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//
// 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)

// 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<R: ImageRenderer>(
_ 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)? {
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)
| (UInt32(bytes[offset + 2]) << 8) | UInt32(bytes[offset + 3])
}
return (Int(readUInt32(at: 16)), Int(readUInt32(at: 20)))
}
}
124 changes: 0 additions & 124 deletions Examples/LabelCLI/LabelCLI.swift

This file was deleted.

4 changes: 4 additions & 0 deletions Examples/Resources/badges.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
name,title
Ada Lovelace,First Programmer
Grace Hopper,Rear Admiral
Alan Turing,Cryptanalyst
7 changes: 7 additions & 0 deletions Examples/Resources/templates.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading