diff --git a/Examples/ReminderList/ReminderList.swift b/Examples/ReminderList/ReminderList.swift index 6b9e1ba..f1e3dac 100644 --- a/Examples/ReminderList/ReminderList.swift +++ b/Examples/ReminderList/ReminderList.swift @@ -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 diff --git a/Package.swift b/Package.swift index b16c373..702b387 100644 --- a/Package.swift +++ b/Package.swift @@ -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: [ diff --git a/Sources/LabelKit/Labels/ListLayout.swift b/Sources/LabelKit/Labels/ListLayout.swift index 82afcb0..e4c0de2 100644 --- a/Sources/LabelKit/Labels/ListLayout.swift +++ b/Sources/LabelKit/Labels/ListLayout.swift @@ -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 } @@ -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 } } diff --git a/Tests/LabelKitTests/ListLayoutTests.swift b/Tests/LabelKitTests/ListLayoutTests.swift index 1cb9ffa..6e0142c 100644 --- a/Tests/LabelKitTests/ListLayoutTests.swift +++ b/Tests/LabelKitTests/ListLayoutTests.swift @@ -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 { @@ -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) @@ -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 { diff --git a/Tests/ReminderListTests/GenerateLabelTests.swift b/Tests/ReminderListTests/GenerateLabelTests.swift new file mode 100644 index 0000000..ff6c2ec --- /dev/null +++ b/Tests/ReminderListTests/GenerateLabelTests.swift @@ -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")) + } +}