Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 13 additions & 32 deletions Examples/ReminderList/ReminderList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,40 +25,21 @@ struct PrinterOptions: ParsableArguments {
var port: UInt16 = ProcessInfo.processInfo.environment["LABELKIT_PRINTER_PORT"].flatMap(UInt16.init) ?? PrinterDefaults.port
}

/// Lays the reminders out as a single long list label.
///
/// The geometry and the ZPL itself come from ``ListLayout``, so this example
/// inherits its UTF-8 declaration, one-shot `^CF` font and `^FH` escaping
/// rather than hand-rolling a second, weaker copy of them. Reminder titles are
/// arbitrary user text, so the escaping is what keeps a title containing `^XZ`
/// from terminating the label.
public func generate_label(reminders: [ReminderSummary]) -> ZPLLabel {
let fontSize = 50
let gap = 20
let lineHeight = fontSize + gap

let topMargin = 20 // space before first line
let bottomMargin = 318 // empirically needed for ZD620 + cutter @ 300 dpi

var y = topMargin

let todoItems = reminders
.map { reminder -> String in
y += lineHeight
return "^A0N,\(fontSize),\(fontSize)^FO80,\(y)^FD\(reminder.title)^FS"
}
.joined(separator: "\n")
// Reminders have no section headings; every row is a plain item.
// itemIndent 80 keeps the historical left margin for this example.
let layout = ListLayout(itemIndent: 80)
let environment = ZPLEnvironment(stock: Stock.Preset.label4x,
device: Device.Preset.ZD620)

// 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)\(todoItems)^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
return layout.makeLabel(reminders.map { .item($0.title) }, environment: environment)
}

@main
Expand Down
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ let package = Package(
path: "Examples/ReminderList",
sources: ["ReminderList.swift", "Reminders.swift"]
),
.testTarget(
name: "ReminderListTests",
dependencies: ["ReminderList", "LabelKit"],
path: "Tests/ReminderListTests"
),
.executableTarget(
name: "labelprint",
dependencies: [
Expand Down
39 changes: 31 additions & 8 deletions Sources/LabelKit/Labels/ListLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,14 @@ public struct ListLayout: Sendable {
switch line {
case .header(let text):
y += headerFontSize + gap
// Headers are the only rows that deviate from the ^CF default,
// so they alone carry a font command. ^A applies to its own
// field only; the next item falls back to ^CF.
rows.append("^A0N,\(headerFontSize),\(headerFontSize)"
+ "^FO\(headerIndent),\(y)^FD\(Self.sanitized(text))^FS")
+ "^FO\(headerIndent),\(y)^FH^FD\(Self.escaped(text))^FS")
case .item(let text):
y += itemFontSize + gap
rows.append("^A0N,\(itemFontSize),\(itemFontSize)"
+ "^FO\(itemIndent),\(y)^FD\(Self.sanitized(text))^FS")
rows.append("^FO\(itemIndent),\(y)^FH^FD\(Self.escaped(text))^FS")
case .blank:
y += (itemFontSize + gap) / 2
}
Expand All @@ -87,18 +89,39 @@ public struct ListLayout: Sendable {

// ^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\(width)^LH0,0^LS0^LL\(length)\(body)^XZ"
// ^CI28 selects UTF-8; without it any non-ASCII input prints as garbage.
// ^CF sets the item font once, so ordinary rows need no ^A of their own.
let zpl = "^XA^CI28^PW\(width)^LH0,0^LS0"
+ "^CF0,\(itemFontSize),\(itemFontSize)^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.
/// Hex-escapes the characters that a `^FH` field cannot carry literally.
///
/// 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 != "~" }
/// be parsed as the start of a command rather than printed. Emitting them
/// as `_5E`/`_7E` under `^FH` prints the real character instead of dropping
/// it, so the user's text survives intact.
///
/// `_` is the `^FH` escape introducer itself and so must escape to `_5F`;
/// left alone it would silently swallow the next two characters.
///
/// Escaping is per-`Character`, so multi-byte UTF-8 passes through
/// untouched for the `^CI28` printer to decode.
private static func escaped(_ text: String) -> String {
var out = ""
out.reserveCapacity(text.count)
for character in text {
switch character {
case "^": out += "_5E"
case "~": out += "_7E"
case "_": out += "_5F"
default: out.append(character)
}
}
return out
}
}
75 changes: 61 additions & 14 deletions Tests/LabelKitTests/ListLayoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,78 @@ struct ListLayoutTests {
ZPLEnvironment(stock: Stock.Preset.label4x, device: Device.Preset.ZD620)
}

@Test func itemUsesItemFontAndIndentAtFirstBaseline() throws {
@Test func itemIsPlacedAtFirstBaselineUsingTheDefaultFont() 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"))
#expect(zpl.contains("^FO100,90^FH^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"))
#expect(zpl.contains("^A0N,60,60^FO60,100^FH^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"))
#expect(zpl.contains("^FO100,90^FH^FDa^FS"))
#expect(zpl.contains("^FO100,195^FH^FDb^FS"))
}

// MARK: - Encoding and font declaration

@Test func utf8EncodingIsDeclaredBeforeAnyFieldData() throws {
let zpl = try ListLayout().makeLabel([.item("café piña")], environment: environment).zpl()
let ci = try #require(zpl.range(of: "^CI28"))
let firstField = try #require(zpl.range(of: "^FD"))
#expect(ci.lowerBound < firstField.lowerBound)
}

@Test func nonASCIITextSurvivesVerbatim() throws {
// ^CI28 puts the printer in UTF-8; the bytes must reach ^FD unmangled.
let zpl = try ListLayout().makeLabel([.item("café piña jalapeño")], environment: environment).zpl()
#expect(zpl.contains("^FDcafé piña jalapeño^FS"))
}

@Test func defaultFontIsDeclaredOnceRatherThanPerItem() throws {
let zpl = try ListLayout()
.makeLabel([.item("a"), .item("b"), .item("c")], environment: environment).zpl()
#expect(zpl.contains("^CF0,50,50"))
// The whole point: no per-item font command survives.
#expect(!zpl.contains("^A0N,50,50"))
}

@Test func defaultFontDeclarationTracksTheItemFontSize() throws {
var layout = ListLayout()
layout.itemFontSize = 30
let zpl = try layout.makeLabel([.item("small")], environment: environment).zpl()
#expect(zpl.contains("^CF0,30,30"))
}

// MARK: - ^FH hex escaping

@Test func commandIntroducersAreHexEscapedRatherThanStripped() throws {
let zpl = try ListLayout()
.makeLabel([.item("bad^XZand~JAmore")], environment: environment).zpl()
#expect(zpl.contains("^FH^FDbad_5EXZand_7EJAmore^FS"))
// Exactly one ^XZ: the label terminator, not one smuggled in via text.
#expect(zpl.components(separatedBy: "^XZ").count - 1 == 1)
}

@Test func hexEscapeIntroducerIsItselfEscaped() throws {
// With ^FH active, a literal '_' would otherwise start an escape and
// silently eat the next two characters.
let zpl = try ListLayout()
.makeLabel([.item("snake_case_name")], environment: environment).zpl()
#expect(zpl.contains("^FH^FDsnake_5Fcase_5Fname^FS"))
}

@Test func headerTextIsEscapedToo() throws {
let zpl = try ListLayout()
.makeLabel([.header("A^B~C_D:")], environment: environment).zpl()
#expect(zpl.contains("^FH^FDA_5EB_7EC_5FD:^FS"))
}

@Test func labelLengthIsLastBaselinePlusBottomMargin() throws {
Expand All @@ -45,14 +99,6 @@ struct ListLayoutTests {
#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)
Expand All @@ -64,7 +110,8 @@ struct ListLayoutTests {
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
#expect(zpl.contains("^CF0,30,30"))
#expect(zpl.contains("^FO40,60^FH^FDsmall^FS")) // 20 + 30 + 10
}

@Test func printWidthIsDerivedFromTheEnvironmentByDefault() throws {
Expand Down
59 changes: 59 additions & 0 deletions Tests/ReminderListTests/GenerateLabelTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// GenerateLabelTests.swift
// ReminderListTests
//

import Testing
import LabelKit
@testable import ReminderList

@Suite("ReminderList label generation")
struct GenerateLabelTests {

private func reminder(_ title: String) -> ReminderSummary {
ReminderSummary(title: title, priority: 0, dueDate: nil, isCompleted: false)
}

@Test func utf8EncodingIsDeclared() throws {
let zpl = try generate_label(reminders: [reminder("café")]).zpl()
#expect(zpl.contains("^CI28"))
}

@Test func defaultFontIsDeclaredOnceRatherThanPerReminder() throws {
let zpl = try generate_label(reminders: [reminder("a"), reminder("b")]).zpl()
#expect(zpl.contains("^CF0,50,50"))
#expect(!zpl.contains("^A0N,50,50"))
}

@Test func commandIntroducersInReminderTitlesAreEscaped() throws {
// Reminder titles are arbitrary user text from Reminders.app.
let zpl = try generate_label(reminders: [reminder("pay ^XZ rent ~JA now_ok")]).zpl()
#expect(zpl.contains("^FH^FDpay _5EXZ rent _7EJA now_5Fok^FS"))
// Exactly one ^XZ: the label terminator, not one smuggled in via a title.
#expect(zpl.components(separatedBy: "^XZ").count - 1 == 1)
}

@Test func remindersAreLaidOutOneRowPerLine() throws {
let zpl = try generate_label(reminders: [reminder("first"), reminder("second")]).zpl()
// topMargin 20 + (fontSize 50 + gap 20) per row, indented to 80.
#expect(zpl.contains("^FO80,90^FH^FDfirst^FS"))
#expect(zpl.contains("^FO80,160^FH^FDsecond^FS"))
}

@Test func labelLengthAndPublishedGeometryAgree() throws {
let label = generate_label(reminders: [reminder("only")])
#expect(try label.zpl().contains("^LL408")) // 90 + 318 cutter clearance
#expect(label.environment.options.geometry.heightDots == 408)
}

@Test func printWidthMatchesTheFourInchStock() throws {
let zpl = try generate_label(reminders: [reminder("only")]).zpl()
#expect(zpl.contains("^PW1200"))
}

@Test func emptyReminderListStillProducesAWellFormedLabel() throws {
let zpl = try generate_label(reminders: []).zpl()
#expect(zpl.hasPrefix("^XA"))
#expect(zpl.hasSuffix("^XZ"))
}
}
Loading