diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift index 9b49fd3..214772e 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -174,8 +174,13 @@ public enum CupsParsers { /// PPD `* /:` lines → `id → label` map. /// Language-qualified forms (`*en_US. id/Label:`) also match. + /// Precedence is deterministic, not positional (#181): unqualified + /// `*Key` > `en_US.` > `en.` > first-qualified-seen, so a trailing + /// locale block (Canon `th.CNIJMediaType`) can never overwrite the + /// base English labels — and a qualified-only id still gets its + /// first-seen qualified label (R9). public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] { - var map: [String: String] = [:] + var hits: [String: [(qualifier: String?, label: String)]] = [:] for rawLine in ppd.split(separator: "\n") { var line = rawLine.trimmingCharacters(in: .whitespaces) guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue } @@ -183,7 +188,9 @@ public enum CupsParsers { // Optional locale qualifier: `en_US.InputSlot` → `InputSlot`. // Only strip when the part before the first `.` looks like // a locale (short `xx`/`xx_YY`); real keys containing dots - // are left alone. + // are left alone. The qualifier is recorded for precedence + // rather than dropped (#181). + var qualifier: String? if let dot = line.firstIndex(of: ".") { let prefix = line[../` — human label after the last `/`. guard let slash = rest.firstIndex(of: "/") else { continue } - let id = String(rest[..` hex escapes → the literal byte (`<2F>` → `/`, + /// `<20>` → space). Anything that is not `<` + two hex digits + + /// `>` passes through untouched (#181). + private static func ppdUnescape(_ text: String) -> String { + var result = "" + var index = text.startIndex + while index < text.endIndex { + guard text[index] == "<", + let hexEnd = text.index( + index, offsetBy: 3, limitedBy: text.endIndex), + hexEnd < text.endIndex, text[hexEnd] == ">", + let byte = UInt8( + text[text.index(after: index).. String? { let url = ppdDir.appendingPathComponent("\(queue).ppd") - return try? String(contentsOf: url, encoding: .utf8) + // UTF-8 first — the Canon Thai labels are UTF-8 and a blanket + // Latin-1 read would mojibake them (#181, R10). Latin-1 only + // when UTF-8 decoding fails outright. + return (try? String(contentsOf: url, encoding: .utf8)) + ?? (try? String(contentsOf: url, encoding: .isoLatin1)) } // MARK: - Spawn diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift index 062c1f4..dd6a526 100644 --- a/Tests/ICCeryCoreTests/CupsParserTests.swift +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -246,4 +246,177 @@ final class CupsParserTests: XCTestCase { XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off") XCTAssertNil(pair(["PageSize"])) } + + // MARK: - #181 Canon media locale precedence + PPD encoding + + /// The 18 Canon Pro9500 media types named in the issue — the ids + /// are the numeric codes the driver enumerates via `lpoptions -l`. + private let canonMedia: [(id: String, label: String)] = [ + ("0", "Plain Paper"), + ("1", "Photo Paper Plus Glossy II"), + ("2", "Photo Paper Pro Platinum N"), + ("3", "Photo Paper Pro Platinum"), + ("4", "Photo Paper Pro Luster"), + ("5", "Photo Paper Plus Semi-gloss"), + ("6", "Matte Photo Paper"), + ("7", "Fine Art \"Photo Rag\""), + ("8", "Fine Art \"Museum Etching\""), + ("9", "Photo Paper Pro Premium Matte"), + ("10", "Fine Art Premium Matte"), + ("11", "Other Fine Art Paper"), + ("12", "Canvas"), + ("13", "Board Paper"), + ("14", "Ink Jet Hagaki"), + ("15", "Hagaki"), + ("16", "Printable disc"), + ("17", "Printable disc (bleed-proof)"), + ] + + /// Canon Pro9500-shaped fragment: the unqualified base block comes + /// early and the `th.` block trails at the end — the ordering that + /// let Thai overwrite English under last-write-wins (#181). + private var canonPPD: String { + var lines = [ + "*OpenUI *CNIJMediaType/Media Type: PickOne", + "*DefaultCNIJMediaType: 0", + ] + for media in canonMedia { + lines.append( + "*CNIJMediaType \(media.id)/\(media.label): \"\"") + } + lines.append("*CloseUI: *CNIJMediaType") + for media in canonMedia { + lines.append( + "*th.CNIJMediaType \(media.id)/กระดาษ\(media.id): \"\"") + } + return lines.joined(separator: "\n") + } + + func testPpdLabelsUnqualifiedSurvivesTrailingThai() { + let labels = CupsParsers.ppdChoiceLabels( + canonPPD, key: "CNIJMediaType") + XCTAssertEqual(labels["0"], "Plain Paper") + XCTAssertEqual(labels["17"], "Printable disc (bleed-proof)") + } + + func testPpdLabelsUnqualifiedWinsRegardlessOfOrder() { + // `th.` block first — precedence is deterministic, not + // positional (#181, E3). + let ppd = """ + *th.CNIJMediaType 0/กระดาษธรรมดา: "" + *CNIJMediaType 0/Plain Paper: "" + """ + let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") + XCTAssertEqual(labels["0"], "Plain Paper") + } + + func testPpdLabelsQualifiedFallbackOrder() { + // en_US > en > first-qualified-seen (#181, E3). + let ppd = """ + *en.CNIJMediaType 1/English Label: "" + *en_US.CNIJMediaType 1/US English Label: "" + *th.CNIJMediaType 1/กระดาษ: "" + *fr.CNIJMediaType 2/Français: "" + *de.CNIJMediaType 2/Deutsch: "" + """ + let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") + XCTAssertEqual(labels["1"], "US English Label") + // A qualified-only id still gets its first-seen qualified + // label — never left unlabeled (R9). + XCTAssertEqual(labels["2"], "Français") + } + + func testPpdLabelsHexEscapeDecoding() { + let ppd = """ + *CNIJMediaType 3/Photo Paper Plus Glossy<2F>Matte: "" + *CNIJMediaType 4/Plain<20>Paper: "" + *CNIJMediaType 5/BadEscape: "" + """ + let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") + XCTAssertEqual(labels["3"], "Photo Paper Plus Glossy/Matte") + XCTAssertEqual(labels["4"], "Plain Paper") + XCTAssertEqual(labels["5"], "BadEscape") + } + + /// Every `CNIJMediaType` choice enumerated by `lpoptions -l` gets a + /// non-Thai label (E1 — the true count is the hardware gate's, so + /// no count is hardcoded here); the 18 named AC labels are + /// spot-checked. + func testCapabilitiesCanonMediaAllNonThai() { + var choices = canonMedia.map(\.id) + choices[0] = "*\(choices[0])" + let listings = CupsParsers.lpoptionsList( + "CNIJMediaType/Media Type: \(choices.joined(separator: " "))\n") + let caps = CupsService().capabilities(from: listings, ppd: canonPPD) + + XCTAssertEqual(caps.mediaTypes.count, canonMedia.count) + for type in caps.mediaTypes { + XCTAssertFalse(type.name.unicodeScalars.contains { + (0x0E00...0x0E7F).contains($0.value) + }, "Thai label leaked into \(type.id): \(type.name)") + } + for media in canonMedia { + XCTAssertEqual( + caps.mediaTypes.first { $0.id == media.id }?.name, + media.label) + } + } + + /// UTF-8 PPD carrying Thai labels decodes intact — the English + /// base block wins precedence and no mojibake leaks through (#181, + /// R10). Exercises `loadPPD` through `capabilities(for:)`. + func testLoadPPDUtf8ThaiSurvivesDecode() async throws { + let (service, root) = try makeCupsService( + ppdData: Data(canonPPD.utf8), + listing: "CNIJMediaType/Media Type: *0 1") + defer { try? FileManager.default.removeItem(at: root) } + + let caps = try await service.capabilities(for: "Canon_Test") + XCTAssertEqual(caps.mediaTypes.map(\.name), + ["Plain Paper", "Photo Paper Plus Glossy II"]) + } + + /// A PPD that is not valid UTF-8 (lone `0xE9` for `é`) falls back + /// to ISO-Latin-1 instead of yielding nil → raw ids (#181, R10). + func testLoadPPDLatin1Fallback() async throws { + let ppd = "*CNIJMediaType 0/Papier Couché: \"\"\n" + let (service, root) = try makeCupsService( + ppdData: ppd.data(using: .isoLatin1)!, + listing: "CNIJMediaType/Media Type: *0") + defer { try? FileManager.default.removeItem(at: root) } + + let caps = try await service.capabilities(for: "Canon_Test") + XCTAssertEqual(caps.mediaTypes, + [PrinterMediaType(id: "0", name: "Papier Couché")]) + } + + /// Fixture `lpoptions` + `ppdDir` so `capabilities(for:)` reaches + /// the private `loadPPD` — same mock style as + /// `MediaLibraryViewModelTests.installMockCups`. + private func makeCupsService( + ppdData: Data, + listing: String, + queue: String = "Canon_Test" + ) throws -> (CupsService, URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ppd-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let ppdDir = root.appendingPathComponent("ppd") + for dir in [bin, ppdDir] { + try FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true) + } + let lpoptions = """ + #!/bin/sh + printf '%s\\n' '\(listing)' + """ + let scriptURL = bin.appendingPathComponent("lpoptions") + try lpoptions.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + try ppdData.write(to: ppdDir.appendingPathComponent("\(queue).ppd")) + return (CupsService( + processManager: ProcessManager(), + binaryDir: bin, ppdDir: ppdDir), root) + } }