bug: Tray selection picklist is empty due to empty-array coalescing bug and missing vendor PPD keywords (Epson/Canon) #2

Closed
opened 2026-09-07 22:21:34 +01:00 by gronod · 0 comments
Owner

Summary

In the TargetPrint Inspector sidebar, the "Tray" selection picklist (trayPop) is completely empty with 0 menu items when an OEM printer (such as Epson XP-55 or Canon Pro series) is selected, rendering a blank dropdown control.


Investigation & Root Cause Analysis

1. Swift Nil-Coalescing Bug on Empty Arrays in InspectorView

In Sources/Views/InspectorView.swift (line 188):

private func refreshDependent() {
    let q = selectedQueue
    refill(mediaPop, q?.mediaSizes.isEmpty == false ? q!.mediaSizes : Geometry.papers.map { $0.name })
    refill(typePop, q?.mediaTypes ?? ["Plain"])
    refill(trayPop, q?.trays ?? ["Auto"])
    refill(resolutionPop, q?.resolutions ?? [])
    updateAirPrint()
}

Notice the difference between mediaPop and trayPop:

  • mediaPop correctly checks q?.mediaSizes.isEmpty == false ? ... : Geometry.papers.map { $0.name }.
  • trayPop uses q?.trays ?? ["Auto"].

Because q is a non-nil PrinterQueue, q?.trays evaluates to Optional([]). In Swift, the nil-coalescing operator ?? only evaluates the right-hand expression if the left-hand operand is nil. Since Optional([]) != nil, q?.trays ?? ["Auto"] evaluates to [].

Inside refill(_:_:):

private func refill(_ pop: NSPopUpButton, _ items: [String]) {
    let current = pop.titleOfSelectedItem
    pop.removeAllItems()
    items.forEach { pop.addItem(withTitle: $0) }
    if let current = current { pop.selectItem(withTitle: current) }
}

pop.removeAllItems() clears all existing items, and items.forEach iterates 0 times. Consequently, trayPop is left with 0 items, presenting a completely blank/empty control.

2. Missing Vendor-Specific PPD Keywords for Tray / Paper Source

In Sources/Printing/CUPSManager.swift (line 104):

static func discoverPPDOptions(_ ppdText: String) -> PPDOptions {
    var out = PPDOptions()
    out.pageSizes = optionChoices(in: ppdText, keyword: "PageSize")
    out.mediaTypes = optionChoices(in: ppdText, keyword: "MediaType")
    out.trays = optionChoices(in: ppdText, keyword: "InputSlot")
    out.resolutions = optionChoices(in: ppdText, keyword: "Resolution")

discoverPPDOptions hardcodes keyword: "InputSlot". While generic PostScript and network laser printers use *InputSlot, major inkjet manufacturers use proprietary keywords:

  • Epson inkjet drivers (e.g. Epson_XP_55_LPD.ppd, XP-600/700/800/900 series):
    *OpenUI *EPIJ_FdSo/Paper Source: PickOne
    *EPIJ_FdSo 2/Cassette 1: ""
    *EPIJ_FdSo 3/Cassette 2: ""
    *EPIJ_FdSo 12/Rear Paper Feed Slot: ""
    *CloseUI: *EPIJ_FdSo
    
  • Canon inkjet drivers (e.g. CanonIJPro9500IIseries.ppd):
    *OpenUI *CNIJMediaSupply/Paper Source: PickOne
    *CNIJMediaSupply 7/Rear Tray: ""
    *CNIJMediaSupply 33/Manual Feed: ""
    *CloseUI: *CNIJMediaSupply
    

Because neither vendor PPD contains *InputSlot, optionChoices(in: ppdText, keyword: "InputSlot") returns []. Combined with the nil-coalescing bug above, trayPop becomes completely empty.

3. Dropping Human-Readable Display Names

Even if EPIJ_FdSo or CNIJMediaSupply were queried with optionChoices(in:keyword:):

let token = rest.split(whereSeparator: { $0 == "/" || $0 == ":" || $0 == " " }).first

optionChoices extracts only the choice token prior to / ("2", "3", "12"), discarding the human-readable display titles ("Cassette 1", "Cassette 2", "Rear Paper Feed Slot").

4. PrintEngine PPD Injection Keyword Mismatch

In Sources/Printing/PrintEngine.swift (line 92):

if let paperSource = paperSource { extras["InputSlot"] = paperSource }

PrintEngine hardcodes extras["InputSlot"]. When printing to Epson or Canon queues, setting InputSlot in NSPrintInfo print settings is ignored by the driver because the driver filter listens for EPIJ_FdSo or CNIJMediaSupply.


Steps to Reproduce

  1. Configure an Epson or Canon printer queue (e.g. Epson_XP_55_LPD).
  2. Launch TargetPrint.
  3. In the Inspector sidebar, observe the "Tray" dropdown menu.
  4. Observed: The dropdown contains 0 items and displays a blank selector.
  5. Expected: The dropdown displays the available paper sources (e.g. Cassette 1, Cassette 2, Rear Paper Feed Slot), or defaults to Auto if the printer has a single fixed feed.

Implementation Plan for Fix

Phase 1: Fix Inspector Empty Array Fallback (InspectorView.swift)

  • Modify refreshDependent() to check .isEmpty:
    let trays = (q?.trays.isEmpty == false) ? q!.trays : ["Auto"]
    refill(trayPop, trays)
    
  • Ensure trayPop always contains at least ["Auto"] rather than clearing to 0 items when no trays are discovered.

Phase 2: Expand PPD Keyword Discovery (CUPSManager.swift)

  1. Detect tray options across standard and vendor keywords:
    • Check InputSlot, EPIJ_FdSo (Epson), CNIJMediaSupply (Canon), or dynamically inspect *OpenUI *<Keyword>/Paper Source: / /Media Source:.
  2. Store the discovered tray option keyword on PrinterQueue (e.g. trayKeyword: String?) so PrintEngine can inject the appropriate key.
  3. Parse both the choice code (name, e.g. "2", "12") and human-readable label (title, e.g. "Cassette 1", "Rear Paper Feed Slot"), decoding any PPD hex escapes (<2F>, <2E>).

Phase 3: Update Inspector UI Selection Logic (InspectorView.swift)

  1. Populate trayPop items with title = choice.title and representedObject = choice.name.
  2. Update select(trayPop, ...) to find matching items by either representedObject as? String == value or title == value.
  3. In push(into engine:), set engine.paperSource to trayPop.selectedItem?.representedObject as? String ?? trayPop.titleOfSelectedItem.

Phase 4: Dynamic PPD Key Injection (PrintEngine.swift)

  1. Update applyOptionalPPDKeys(to:):
    • Lookup the active queue's trayKeyword (defaulting to "InputSlot").
    • Inject extras[trayKey] = paperSource.

Phase 5: Unit Testing & Verification

  1. Add test cases in Tests/:
    • Test PPD parsing of Epson *EPIJ_FdSo choices (Cassette 1, Cassette 2, Rear Paper Feed Slot).
    • Test PPD parsing of Canon *CNIJMediaSupply choices.
    • Test fallback to ["Auto"] when a queue has no tray options or an empty list.
    • Verify trayPop UI retains Auto rather than clearing to 0 items.
  2. Run swift test to ensure all tests pass without regression.
### Summary In the TargetPrint Inspector sidebar, the "Tray" selection picklist (`trayPop`) is completely empty with 0 menu items when an OEM printer (such as Epson XP-55 or Canon Pro series) is selected, rendering a blank dropdown control. --- ### Investigation & Root Cause Analysis #### 1. Swift Nil-Coalescing Bug on Empty Arrays in InspectorView In `Sources/Views/InspectorView.swift` (line 188): ```swift private func refreshDependent() { let q = selectedQueue refill(mediaPop, q?.mediaSizes.isEmpty == false ? q!.mediaSizes : Geometry.papers.map { $0.name }) refill(typePop, q?.mediaTypes ?? ["Plain"]) refill(trayPop, q?.trays ?? ["Auto"]) refill(resolutionPop, q?.resolutions ?? []) updateAirPrint() } ``` Notice the difference between `mediaPop` and `trayPop`: - `mediaPop` correctly checks `q?.mediaSizes.isEmpty == false ? ... : Geometry.papers.map { $0.name }`. - `trayPop` uses `q?.trays ?? ["Auto"]`. Because `q` is a non-nil `PrinterQueue`, `q?.trays` evaluates to `Optional([])`. In Swift, the nil-coalescing operator `??` only evaluates the right-hand expression if the left-hand operand is `nil`. Since `Optional([]) != nil`, `q?.trays ?? ["Auto"]` evaluates to `[]`. Inside `refill(_:_:)`: ```swift private func refill(_ pop: NSPopUpButton, _ items: [String]) { let current = pop.titleOfSelectedItem pop.removeAllItems() items.forEach { pop.addItem(withTitle: $0) } if let current = current { pop.selectItem(withTitle: current) } } ``` `pop.removeAllItems()` clears all existing items, and `items.forEach` iterates 0 times. Consequently, `trayPop` is left with 0 items, presenting a completely blank/empty control. #### 2. Missing Vendor-Specific PPD Keywords for Tray / Paper Source In `Sources/Printing/CUPSManager.swift` (line 104): ```swift static func discoverPPDOptions(_ ppdText: String) -> PPDOptions { var out = PPDOptions() out.pageSizes = optionChoices(in: ppdText, keyword: "PageSize") out.mediaTypes = optionChoices(in: ppdText, keyword: "MediaType") out.trays = optionChoices(in: ppdText, keyword: "InputSlot") out.resolutions = optionChoices(in: ppdText, keyword: "Resolution") ``` `discoverPPDOptions` hardcodes `keyword: "InputSlot"`. While generic PostScript and network laser printers use `*InputSlot`, major inkjet manufacturers use proprietary keywords: - **Epson inkjet drivers** (e.g. `Epson_XP_55_LPD.ppd`, XP-600/700/800/900 series): ```ppd *OpenUI *EPIJ_FdSo/Paper Source: PickOne *EPIJ_FdSo 2/Cassette 1: "" *EPIJ_FdSo 3/Cassette 2: "" *EPIJ_FdSo 12/Rear Paper Feed Slot: "" *CloseUI: *EPIJ_FdSo ``` - **Canon inkjet drivers** (e.g. `CanonIJPro9500IIseries.ppd`): ```ppd *OpenUI *CNIJMediaSupply/Paper Source: PickOne *CNIJMediaSupply 7/Rear Tray: "" *CNIJMediaSupply 33/Manual Feed: "" *CloseUI: *CNIJMediaSupply ``` Because neither vendor PPD contains `*InputSlot`, `optionChoices(in: ppdText, keyword: "InputSlot")` returns `[]`. Combined with the nil-coalescing bug above, `trayPop` becomes completely empty. #### 3. Dropping Human-Readable Display Names Even if `EPIJ_FdSo` or `CNIJMediaSupply` were queried with `optionChoices(in:keyword:)`: ```swift let token = rest.split(whereSeparator: { $0 == "/" || $0 == ":" || $0 == " " }).first ``` `optionChoices` extracts only the choice token prior to `/` (`"2"`, `"3"`, `"12"`), discarding the human-readable display titles (`"Cassette 1"`, `"Cassette 2"`, `"Rear Paper Feed Slot"`). #### 4. PrintEngine PPD Injection Keyword Mismatch In `Sources/Printing/PrintEngine.swift` (line 92): ```swift if let paperSource = paperSource { extras["InputSlot"] = paperSource } ``` `PrintEngine` hardcodes `extras["InputSlot"]`. When printing to Epson or Canon queues, setting `InputSlot` in `NSPrintInfo` print settings is ignored by the driver because the driver filter listens for `EPIJ_FdSo` or `CNIJMediaSupply`. --- ### Steps to Reproduce 1. Configure an Epson or Canon printer queue (e.g. `Epson_XP_55_LPD`). 2. Launch `TargetPrint`. 3. In the Inspector sidebar, observe the "Tray" dropdown menu. 4. **Observed:** The dropdown contains 0 items and displays a blank selector. 5. **Expected:** The dropdown displays the available paper sources (e.g. `Cassette 1`, `Cassette 2`, `Rear Paper Feed Slot`), or defaults to `Auto` if the printer has a single fixed feed. --- ### Implementation Plan for Fix #### Phase 1: Fix Inspector Empty Array Fallback (`InspectorView.swift`) - Modify `refreshDependent()` to check `.isEmpty`: ```swift let trays = (q?.trays.isEmpty == false) ? q!.trays : ["Auto"] refill(trayPop, trays) ``` - Ensure `trayPop` always contains at least `["Auto"]` rather than clearing to 0 items when no trays are discovered. #### Phase 2: Expand PPD Keyword Discovery (`CUPSManager.swift`) 1. Detect tray options across standard and vendor keywords: - Check `InputSlot`, `EPIJ_FdSo` (Epson), `CNIJMediaSupply` (Canon), or dynamically inspect `*OpenUI *<Keyword>/Paper Source:` / `/Media Source:`. 2. Store the discovered tray option keyword on `PrinterQueue` (e.g. `trayKeyword: String?`) so `PrintEngine` can inject the appropriate key. 3. Parse both the choice code (`name`, e.g. `"2"`, `"12"`) and human-readable label (`title`, e.g. `"Cassette 1"`, `"Rear Paper Feed Slot"`), decoding any PPD hex escapes (`<2F>`, `<2E>`). #### Phase 3: Update Inspector UI Selection Logic (`InspectorView.swift`) 1. Populate `trayPop` items with `title = choice.title` and `representedObject = choice.name`. 2. Update `select(trayPop, ...)` to find matching items by either `representedObject as? String == value` or `title == value`. 3. In `push(into engine:)`, set `engine.paperSource` to `trayPop.selectedItem?.representedObject as? String ?? trayPop.titleOfSelectedItem`. #### Phase 4: Dynamic PPD Key Injection (`PrintEngine.swift`) 1. Update `applyOptionalPPDKeys(to:)`: - Lookup the active queue's `trayKeyword` (defaulting to `"InputSlot"`). - Inject `extras[trayKey] = paperSource`. #### Phase 5: Unit Testing & Verification 1. Add test cases in `Tests/`: - Test PPD parsing of Epson `*EPIJ_FdSo` choices (`Cassette 1`, `Cassette 2`, `Rear Paper Feed Slot`). - Test PPD parsing of Canon `*CNIJMediaSupply` choices. - Test fallback to `["Auto"]` when a queue has no tray options or an empty list. - Verify `trayPop` UI retains `Auto` rather than clearing to 0 items. 2. Run `swift test` to ensure all tests pass without regression.
gronod added the Kind/Bug label 2026-09-07 22:21:34 +01:00
Sign in to join this conversation.