Files
gronod 007fb40815 Update SPEC.md
Updated build target MacOS version
2026-09-07 09:57:55 +01:00

618 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ICCery-CPU — Specification
**ICCery Colour Print Utility**
**Binary name:** `TargetPrint`
**Repository:** `ICCery-CPU`
**Version:** 0.0.1 (initial)
**Status:** Specification / Implementation Ready
This document is the authoritative technical specification for the `TargetPrint` native macOS utility. It is intended to be placed at the root of a new empty repository named **ICCery-CPU** and used as the single source of truth for implementation.
---
## Table of Contents
1. [Purpose & Scope](#1-purpose--scope)
2. [Architecture Overview](#2-architecture-overview)
3. [Target Environment & Constraints](#3-target-environment--constraints)
4. [Invocation Contract & IPC](#4-invocation-contract--ipc)
5. [JSON Job Schema (`TargetJob`)](#5-json-job-schema-targetjob)
6. [High-Fidelity Rendering Engine](#6-high-fidelity-rendering-engine)
7. [Colour Management Bypass](#7-colour-management-bypass)
8. [Multi-Page Pagination](#8-multi-page-pagination)
9. [User Interface (Standalone Mode)](#9-user-interface-standalone-mode)
10. [CUPS / PPD Integration](#10-cups--ppd-integration)
11. [Project Layout](#11-project-layout)
12. [Build System](#12-build-system)
13. [Error Handling, Logging & Diagnostics](#13-error-handling-logging--diagnostics)
14. [Security & Entitlements](#14-security--entitlements)
15. [Verification & Acceptance Tests](#15-verification--acceptance-tests)
16. [Tauri / Rust Integration Contract](#16-tauri--rust-integration-contract)
17. [Future Extensions (Non-Goals for v1)](#17-future-extensions-non-goals-for-v1)
---
## 1. Purpose & Scope
`TargetPrint` is a high-precision, compiled, native macOS utility written in Swift + AppKit (with Objective-C/Carbon bridging where required). It serves as an independent fallback companion for **ICCery**, a Rust/Tauri colour-profiling desktop application.
### Mission
Print multi-page raster colour profiling targets (`.tif` / `.tiff`) with **absolute geometric and pixel-level fidelity**, while completely disabling or strictly controlling:
- Operating-system ColourSync transformations
- Driver-level colour adjustments
- Any Quartz 2D interpolation or anti-aliasing during rendering
### Out of Scope (v1)
- PDF or vector target printing
- Windows / Linux support
- Network print queue management beyond the local CUPS system
- Colour management profile creation or editing
- Automatic printer driver installation
---
## 2. Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ ICCery (Tauri) │
│ (Rust side serialises TargetJob JSON → temp file) │
└──────────────────────────┬──────────────────────────────────┘
│ std::process::Command
│ --job /tmp/iccery_job_UUID.json
┌─────────────────────────────────────────────────────────────┐
│ TargetPrint.app │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ main.swift │→ │ AppDelegate │→ │ PreviewWindow │ │
│ │ (CLI parse) │ │ │ │ Controller │ │
│ └─────────────┘ └──────────────┘ └─────────┬─────────┘ │
│ │ │
│ ┌────────────────────────────────────────────▼──────────┐ │
│ │ TargetCanvasView (NSView) │ │
│ │ • ImageIO decode (zero interpolation) │ │
│ │ • Exact DPI geometry in PostScript points │ │
│ │ • knowsPageRange / rectForPage pagination │ │
│ └────────────────────────────────────────────┬──────────┘ │
│ │ │
│ ┌────────────────────────────────────────────▼──────────┐ │
│ │ PrintEngine │ │
│ │ • NSPrintOperation + NSPrintInfo │ │
│ │ • Carbon CorePrinting dictionary injection │ │
│ │ • UI policy (lock Color Matching pane) │ │
│ └────────────────────────────────────────────┬──────────┘ │
│ │ │
│ ┌────────────────────────────────────────────▼──────────┐ │
│ │ CUPSManager │ │
│ │ • libcups (cupsGetNamedDest / cupsGetPPD) │ │
│ │ • AirPrint detection │ │
│ │ • Vendor-specific PPD option injection │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
The process is always a full AppKit GUI application (`NSApplication`). It never runs as a pure command-line tool; even when launched with `--job` it owns its own run loop and presents UI.
---
## 3. Target Environment & Constraints
| Item | Requirement |
|------|-------------|
| **Deployment Target** | macOS 10.15 Catalina → macOS 26+ Tahoe |
| **Architectures** | Universal 2 (`x86_64` + `arm64`) |
| **Language / Runtime** | Swift 5.x (compatible with the oldest supported Xcode that still targets 10.15) |
| **UI Framework** | AppKit only (no SwiftUI for v1) |
| **Third-party dependencies** | **None**. Link exclusively against system frameworks |
| **Frameworks** | `AppKit`, `CoreGraphics`, `ImageIO`, `ApplicationServices` (CorePrinting), `Foundation`, `libcups` (via bridging header) |
| **Sandbox** | Hardened Runtime **enabled**, App Sandbox **disabled** |
| **Code Signing** | Required for distribution; development builds may use ad-hoc signing |
| **Minimum Xcode** | Xcode 10+ (for 10.15 support); recommended modern Xcode with dual-arch support |
### Bridging Header
```objc
// Bridging-Header.h
#import <cups/cups.h>
#import <cups/ppd.h>
```
---
## 4. Invocation Contract & IPC
Two mutually exclusive modes.
### Mode A — Structured Job Mode (ICCery → TargetPrint)
```bash
/Applications/TargetPrint.app/Contents/MacOS/TargetPrint --job /tmp/iccery_job_<uuid>.json
```
Alternative: file-association open of a `.targetjob` file (UTI to be registered in `Info.plist`).
#### Lifecycle Requirements
1. Process launches as a normal AppKit application.
2. Immediately parses the job file (or fails with a clear alert + non-zero exit).
3. Creates the preview window and loads the target pages.
4. Configures `NSPrintInfo` from the job.
5. Presents `NSPrintPanel` as a modal sheet on the preview window **without** requiring further user action to reach the print dialog.
6. The calling Tauri process must **not** block waiting for the print dialog; TargetPrint owns its run loop.
Exit codes:
| Code | Meaning |
|------|---------|
| 0 | Normal termination (user cancelled or print completed) |
| 1 | Invalid / missing job file |
| 2 | One or more target files unreadable |
| 3 | Printer / CUPS error |
| 4 | Internal fatal error |
### Mode B — Standalone Interactive Mode
Launched with no arguments or by double-clicking the app icon / dropping files onto it.
Behaviour:
- Empty drop-zone canvas.
- Accepts multi-file drag-and-drop of `.tif` / `.tiff`.
- Standard **File → Open…** (multi-select).
- Page thumbnail sidebar.
- Inspector for printer, media, tray, scale, colour-management mode.
- Explicit **Print…** button that shows `NSPrintPanel`.
---
## 5. JSON Job Schema (`TargetJob`)
```json
{
"$schema": "https://iccery.local/schemas/targetjob.v1.json",
"version": 1,
"jobTitle": "Epson_XP55_IlfordLustre_Target_P1-2",
"files": [
"/absolute/path/to/target_page_1.tif",
"/absolute/path/to/target_page_2.tif"
],
"printSettings": {
"printerName": "EPSON_XP_55_Series",
"mediaSize": "A4",
"mediaType": "PremiumGlossy",
"paperSource": "Auto",
"resolution": "5760x1440dpi",
"scaling": 1.0,
"centered": true,
"forceUnmanagedColor": true
},
"uiPolicy": {
"lockColorManagement": true,
"allowBasicDriverChanges": true
}
}
```
### Field Semantics
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `version` | int | yes | Currently only `1` is accepted |
| `jobTitle` | string | yes | Used as window title and CUPS job name |
| `files` | [string] | yes | Absolute paths; order defines page order |
| `printSettings.printerName` | string | yes | Exact CUPS queue name (`lpstat -a`) |
| `printSettings.mediaSize` | string | yes | e.g. `A4`, `Letter`, `4x6`, or PPD media name |
| `printSettings.mediaType` | string | no | PPD `MediaType` value |
| `printSettings.paperSource` | string | no | PPD `InputSlot` value (`Auto`, `Tray1`, …) |
| `printSettings.resolution` | string | no | Informational / PPD `Resolution` if present |
| `printSettings.scaling` | number | yes | 1.0 = 100 %. Only 1.0 is recommended for profiling |
| `printSettings.centered` | bool | yes | Centre image within imageable area |
| `printSettings.forceUnmanagedColor` | bool | yes | Master switch for colour bypass |
| `uiPolicy.lockColorManagement` | bool | yes | Hide / disable Color Matching pane |
| `uiPolicy.allowBasicDriverChanges` | bool | yes | Whether paper size / orientation remain editable |
Validation must be strict. Unknown keys are ignored; missing required keys cause an immediate fatal error with a user-visible alert.
---
## 6. High-Fidelity Rendering Engine
### 6.1 Image Loading (ImageIO)
```swift
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let image = CGImageSourceCreateImageAtIndex(source, 0, [
kCGImageSourceShouldCache: true,
kCGImageSourceShouldAllowFloat: false
] as CFDictionary) else { /* error */ }
```
Extract DPI:
```swift
let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]
let dpiX = props?[kCGImagePropertyDPIWidth] as? CGFloat ?? 72.0
let dpiY = props?[kCGImagePropertyDPIHeight] as? CGFloat ?? 72.0
let pixelWidth = CGFloat(image.width)
let pixelHeight = CGFloat(image.height)
```
### 6.2 Geometry (PostScript Points)
macOS printing uses **72 points = 1 inch**.
```
physicalWidthInches = pixelWidth / dpiX
physicalHeightInches = pixelHeight / dpiY
pointWidth = physicalWidthInches * 72.0
pointHeight = physicalHeightInches * 72.0
```
All layout calculations **must** use these point values. Never multiply by `backingScaleFactor`.
### 6.3 Colour-Space Handling
When `forceUnmanagedColor == true`:
- Create a device RGB colour space: `CGColorSpaceCreateDeviceRGB()`
- Draw the `CGImage` into a bitmap context that uses this colour space, or draw the image directly while ensuring no source profile is applied.
- Do **not** call `CGImageCreateCopyWithColorSpace` with a calibrated space.
Goal: the pixel values that leave the process and enter the CUPS raster filter are identical (within quantisation) to the values stored in the TIFF.
### 6.4 Drawing Implementation (`TargetCanvasView`)
```swift
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
context.interpolationQuality = .none
context.setShouldAntialias(false)
context.setAllowsAntialiasing(false)
// Calculate destination rect in points (1:1 with physical size)
let destRect = calculateDestinationRect(for: currentPage,
paperSize: printInfo.paperSize,
imageable: printInfo.imageablePageBounds,
centered: settings.centered,
scaling: settings.scaling)
context.draw(currentCGImage, in: destRect)
}
```
`calculateDestinationRect` must never introduce fractional pixel offsets that would cause the print pipeline to resample.
---
## 7. Colour Management Bypass
This is the most critical section. Colour translation must be prevented at **two** independent layers.
### 7.1 Quartz / ColorSync Layer (NSPrintInfo + CorePrinting)
```swift
let printInfo = NSPrintInfo.shared
var dict = printInfo.dictionary()
// Force custom (i.e. “no”) colour matching
dict[NSPrintInfo.AttributeKey(rawValue: "PMColorMatchingMode")] = "APCustomColorMatching"
dict[NSPrintInfo.AttributeKey(rawValue: "PMCustomColorMatchingProfile")] = ""
// Optional legacy keys still observed by some drivers
dict[NSPrintInfo.AttributeKey(rawValue: "com.apple.print.PrintSettings.PMColorMatchingMode")] = "APCustomColorMatching"
printInfo.dictionary().setDictionary(dict as! [AnyHashable: Any])
```
When the user (in standalone mode) explicitly requests ColorSync or Driver Managed:
| Mode | `PMColorMatchingMode` value |
|------|-----------------------------|
| Unmanaged (default for profiling) | `"APCustomColorMatching"` |
| ColorSync | `"APColorSync"` |
| Driver / Vendor | `"APPrinterExtension"` |
### 7.2 Print Panel UI Policy
When `uiPolicy.lockColorManagement == true`:
```swift
let panel = printOperation.printPanel
panel.options = [
.showsCopies,
.showsPageRange,
.showsPaperSize,
.showsOrientation,
.showsScaling,
.showsPreview
]
// Explicitly omit any colour-matching accessory
```
Further, implement `NSPrintPanelAccessorizing` (or inspect the panels accessory controllers after presentation) and remove any view controller whose title contains “Color Matching” or “Colour Matching”.
### 7.3 Driver / PPD Layer
See [Section 10](#10-cups--ppd-integration).
---
## 8. Multi-Page Pagination
`TargetCanvasView` (or a dedicated `NSDocument` / print operation data source) must implement:
```swift
override func knowsPageRange(_ range: NSRangePointer) -> Bool {
range.pointee = NSRange(location: 1, length: pages.count)
return true
}
override func rectForPage(_ pageNumber: Int) -> NSRect {
// pageNumber is 1-based
let page = pages[pageNumber - 1]
return calculatePageBounds(for: page, on: currentPrintInfo)
}
```
Each page is rendered in isolation. The graphics context origin must be reset so that page *N* never inherits a transform from page *N-1*.
Page order is exactly the order of the `files` array in the job (or the order files were added in standalone mode).
---
## 9. User Interface (Standalone Mode)
### Window Layout
```
┌──────────────────────────────────────────────────────────────┐
│ TargetPrint — <jobTitle or “Untitled”> │
├────────────┬───────────────────────────────────┬─────────────┤
│ Thumbnail │ │ Inspector │
│ Sidebar │ TargetCanvasView │ - Printer │
│ │ (drop zone when empty) │ - Media │
│ [page 1] │ │ - Tray │
│ [page 2] │ │ - Scale │
│ … │ │ - Colour │
│ │ │ Mode │
│ │ │ │
│ │ │ [Print…] │
└────────────┴───────────────────────────────────┴─────────────┘
```
- Drop zone accepts multiple TIFF files.
- Thumbnails are generated with `NSImage` at low resolution for speed; the canvas always uses the full-resolution `CGImage`.
- Colour-mode segmented control: **No Colour Management** | ColorSync | Driver Managed.
- When a job is loaded via `--job`, the inspector is read-only for locked fields.
---
## 10. CUPS / PPD Integration
### 10.1 Queue Inspection
```c
// via Bridging-Header
cups_dest_t *dest = cupsGetNamedDest(CUPS_HTTP_DEFAULT, printerName, NULL);
const char *ppdFile = cupsGetPPD(printerName);
ppd_file_t *ppd = ppdOpenFile(ppdFile);
```
### 10.2 AirPrint Detection
A queue is considered AirPrint (and therefore unsuitable for reliable unmanaged colour) when **any** of the following is true:
- Device URI begins with `apple-airprint://` or `ipps://` with AirPrint markers
- PPD contains `*APAirPrint: True` (or equivalent)
- PPD manufacturer is “Apple” and model contains “AirPrint”
In this case the preview window must show a persistent non-modal warning badge:
> **Warning:** Selected printer uses an AirPrint / driverless queue. Hardware colour management cannot be reliably disabled. Install the official OEM driver (Epson, Canon, etc.) for accurate profiling.
### 10.3 Vendor-Specific PPD Options
Inject into `NSPrintInfo.printSettings` (or the low-level dictionary) **before** the print operation runs:
| Vendor | Keys to set for “off” |
|--------|-----------------------|
| Epson | `ColorModel=RGB`, `EPSONColorControls=Off` (or `NoColorAdjustment`) |
| Canon | `CNColorMatching=None` |
| HP | `ColorModel=RGB`, `HPColorControl=Off` (where available) |
| Generic | Prefer any option whose name contains `Color` / `Colour` and whose choice is `None` / `Off` / `No` |
Discovery is performed by walking `ppd->options` and matching known patterns. Unknown printers receive only the CorePrinting colour-matching bypass.
---
## 11. Project Layout
```
ICCery-CPU/
├── README.md # Short project overview + build instructions
├── SPEC.md # This document
├── LICENSE # (to be chosen)
├── TargetPrint.xcodeproj/
├── Info.plist
├── TargetPrint.entitlements
├── Bridging-Header.h
├── Sources/
│ ├── main.swift
│ ├── AppDelegate.swift
│ ├── Models/
│ │ ├── TargetJob.swift # Codable + validation
│ │ └── TargetPage.swift # ImageIO + geometry
│ ├── Views/
│ │ ├── TargetCanvasView.swift
│ │ ├── DropZoneView.swift
│ │ ├── ThumbnailSidebarView.swift
│ │ └── InspectorView.swift
│ ├── Controllers/
│ │ └── PreviewWindowController.swift
│ └── Printing/
│ ├── PrintEngine.swift
│ └── CUPSManager.swift
├── Resources/
│ ├── Assets.xcassets
│ └── Base.lproj/
└── Scripts/
├── build_universal.sh
└── package_app.sh
```
---
## 12. Build System
### Universal Binary Script (`Scripts/build_universal.sh`)
```bash
#!/bin/bash
set -euo pipefail
CONFIGURATION=${1:-Release}
SCHEME="TargetPrint"
PROJECT="TargetPrint.xcodeproj"
BUILD_DIR="build"
# Build each architecture
xcodebuild -project "$PROJECT" -scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-arch x86_64 \
-derivedDataPath "$BUILD_DIR/x86_64" \
BUILD_DIR="$BUILD_DIR/x86_64" \
ONLY_ACTIVE_ARCH=NO
xcodebuild -project "$PROJECT" -scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-arch arm64 \
-derivedDataPath "$BUILD_DIR/arm64" \
BUILD_DIR="$BUILD_DIR/arm64" \
ONLY_ACTIVE_ARCH=NO
# Lipo
mkdir -p "$BUILD_DIR/universal"
lipo -create \
"$BUILD_DIR/x86_64/Build/Products/$CONFIGURATION/TargetPrint.app/Contents/MacOS/TargetPrint" \
"$BUILD_DIR/arm64/Build/Products/$CONFIGURATION/TargetPrint.app/Contents/MacOS/TargetPrint" \
-output "$BUILD_DIR/universal/TargetPrint"
# Re-assemble .app bundle
# … (copy resources, Info.plist, frameworks, then replace binary)
```
The final artefact is a Universal 2 `.app` bundle.
---
## 13. Error Handling, Logging & Diagnostics
- All user-facing errors surface via `NSAlert`.
- Internal diagnostics are written to:
- `os_log` (subsystem `com.iccery.TargetPrint`)
- Optionally a rotating log file under `~/Library/Logs/TargetPrint/` when launched with `--verbose`.
- Job JSON is never deleted by TargetPrint; the caller (ICCery) is responsible for cleanup. Leaving the file in `/tmp` provides an audit trail.
- On fatal parse errors the process exits with the codes defined in §4 after showing an alert.
---
## 14. Security & Entitlements
`TargetPrint.entitlements`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened Runtime -->
<key>com.apple.security.cs.allow-jit</key>
<false/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<false/>
<key>com.apple.security.cs.disable-library-validation</key>
<false/>
<!-- Explicitly no App Sandbox -->
<!-- (absence of com.apple.security.app-sandbox is required) -->
</dict>
</plist>
```
Because the binary must call `cupsGetPPD` and inspect local print queues, the App Sandbox **cannot** be enabled. Hardened Runtime remains mandatory for notarisation.
---
## 15. Verification & Acceptance Tests
| ID | Test | Pass Criterion |
|----|------|----------------|
| G1 | Geometric accuracy | 300 DPI image of 2400 × 3000 px prints at exactly 8.00 × 10.00 in (± 0.1 mm) measured on the physical print or by examining the CUPS raster bounding box |
| P1 | Pixel integrity | Adjacent pure patches (RGB 255,0,0 next to 0,255,0) show zero anti-aliased transition pixels in the CUPS raster (inspect with `cupsfilter` or by capturing the raw raster) |
| C1 | Colour bypass | With `forceUnmanagedColor: true`, the printed patches match the source TIFF values within printer gamut limits (verified by spectrophotometer or known reference) |
| I1 | IPC compliance | `TargetPrint --job valid.json` opens the preview and immediately presents a correctly pre-configured `NSPrintPanel`; Color Matching controls are absent or disabled |
| A1 | AirPrint warning | Selecting an AirPrint queue displays the permanent warning badge |
| M1 | Multi-page | Two-page job prints page 1 then page 2 with correct geometry and no coordinate bleed |
Automated unit tests should cover:
- JSON decoding + validation
- DPI extraction edge cases (missing tags → 72 dpi)
- Geometry calculation pure functions
- AirPrint detection heuristics
UI and print-pipeline tests remain manual / semi-automated.
---
## 16. Tauri / Rust Integration Contract
On the ICCery (Rust) side the only required interaction is:
```rust
use std::env;
use std::fs;
use std::process::Command;
// 1. Serialise TargetJob
let job = TargetJob { /* … */ };
let json = serde_json::to_string_pretty(&job)?;
// 2. Write to temp file
let mut path = env::temp_dir();
path.push(format!("iccery_job_{}.json", uuid::Uuid::new_v4()));
fs::write(&path, json)?;
// 3. Launch asynchronously do not wait
Command::new("/Applications/TargetPrint.app/Contents/MacOS/TargetPrint")
.arg("--job")
.arg(&path)
.spawn()?; // fire-and-forget
```
TargetPrint never signals completion back to Tauri; the user interacts solely with the native print dialog.
---
## 17. Future Extensions (Non-Goals for v1)
- Soft-proof overlay on the canvas
- Built-in target generator
- Direct CUPS job submission without `NSPrintPanel` (headless mode)
- Support for `.png` / 16-bit TIFF
- Per-page media / tray overrides
- Notarised Sparkle auto-update channel
These items are deliberately excluded from the initial implementation so that the core fidelity and colour-bypass guarantees can be stabilised first.
---
**End of Specification**
This document should be treated as the contract for all subsequent implementation work in the `ICCery-CPU` repository.