Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8678586bd | ||
|
|
c9b423b229 |
@@ -174,8 +174,13 @@ public enum CupsParsers {
|
|||||||
|
|
||||||
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
||||||
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
/// Language-qualified forms (`*en_US.<key> 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] {
|
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") {
|
for rawLine in ppd.split(separator: "\n") {
|
||||||
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
||||||
@@ -183,7 +188,9 @@ public enum CupsParsers {
|
|||||||
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
||||||
// Only strip when the part before the first `.` looks like
|
// Only strip when the part before the first `.` looks like
|
||||||
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
// 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: ".") {
|
if let dot = line.firstIndex(of: ".") {
|
||||||
let prefix = line[..<dot]
|
let prefix = line[..<dot]
|
||||||
let looksLikeLocale = (2...5).contains(prefix.count)
|
let looksLikeLocale = (2...5).contains(prefix.count)
|
||||||
@@ -191,6 +198,7 @@ public enum CupsParsers {
|
|||||||
&& (prefix.count == 2 || prefix.contains("_"))
|
&& (prefix.count == 2 || prefix.contains("_"))
|
||||||
let candidate = line[line.index(after: dot)...]
|
let candidate = line[line.index(after: dot)...]
|
||||||
if looksLikeLocale && candidate.hasPrefix(key) {
|
if looksLikeLocale && candidate.hasPrefix(key) {
|
||||||
|
qualifier = prefix.lowercased()
|
||||||
line = String(candidate)
|
line = String(candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,15 +209,50 @@ public enum CupsParsers {
|
|||||||
rest = String(rest[..<colon])
|
rest = String(rest[..<colon])
|
||||||
// `<id>/<Human label>` — human label after the last `/`.
|
// `<id>/<Human label>` — human label after the last `/`.
|
||||||
guard let slash = rest.firstIndex(of: "/") else { continue }
|
guard let slash = rest.firstIndex(of: "/") else { continue }
|
||||||
let id = String(rest[..<slash])
|
let id = ppdUnescape(String(rest[..<slash])
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces))
|
||||||
let human = String(rest[rest.index(after: slash)...])
|
let human = ppdUnescape(String(rest[rest.index(after: slash)...])
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces))
|
||||||
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
|
guard !id.isEmpty else { continue }
|
||||||
|
hits[id, default: []].append(
|
||||||
|
(qualifier, human.isEmpty ? id : human))
|
||||||
|
}
|
||||||
|
var map: [String: String] = [:]
|
||||||
|
for (id, candidates) in hits {
|
||||||
|
// First occurrence wins inside each qualifier class, in
|
||||||
|
// file order — same as the old sequential behaviour.
|
||||||
|
map[id] = candidates.first { $0.qualifier == nil }?.label
|
||||||
|
?? candidates.first { $0.qualifier == "en_us" }?.label
|
||||||
|
?? candidates.first { $0.qualifier == "en" }?.label
|
||||||
|
?? candidates[0].label
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PPD `<XX>` 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)..<hexEnd], radix: 16)
|
||||||
|
else {
|
||||||
|
result.append(text[index])
|
||||||
|
index = text.index(after: index)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.append(Character(UnicodeScalar(byte)))
|
||||||
|
index = text.index(after: hexEnd)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Detection (docs/11)
|
// MARK: - Detection (docs/11)
|
||||||
|
|
||||||
/// Media-type option key in preference order — used both to read a
|
/// Media-type option key in preference order — used both to read a
|
||||||
|
|||||||
@@ -187,7 +187,11 @@ public struct CupsService: Sendable {
|
|||||||
|
|
||||||
private func loadPPD(for queue: String) -> String? {
|
private func loadPPD(for queue: String) -> String? {
|
||||||
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
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
|
// MARK: - Spawn
|
||||||
|
|||||||
@@ -246,4 +246,177 @@ final class CupsParserTests: XCTestCase {
|
|||||||
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
||||||
XCTAssertNil(pair(["PageSize"]))
|
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/Bad<ZZ>Escape: ""
|
||||||
|
"""
|
||||||
|
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||||
|
XCTAssertEqual(labels["3"], "Photo Paper Plus Glossy/Matte")
|
||||||
|
XCTAssertEqual(labels["4"], "Plain Paper")
|
||||||
|
XCTAssertEqual(labels["5"], "Bad<ZZ>Escape")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user