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

25 KiB
Raw Permalink Blame History

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
  2. Architecture Overview
  3. Target Environment & Constraints
  4. Invocation Contract & IPC
  5. JSON Job Schema (TargetJob)
  6. High-Fidelity Rendering Engine
  7. Colour Management Bypass
  8. Multi-Page Pagination
  9. User Interface (Standalone Mode)
  10. CUPS / PPD Integration
  11. Project Layout
  12. Build System
  13. Error Handling, Logging & Diagnostics
  14. Security & Entitlements
  15. Verification & Acceptance Tests
  16. Tauri / Rust Integration Contract
  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

// 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)

/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)

{
  "$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)

guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
      let image = CGImageSourceCreateImageAtIndex(source, 0, [
          kCGImageSourceShouldCache: true,
          kCGImageSourceShouldAllowFloat: false
      ] as CFDictionary) else { /* error */ }

Extract DPI:

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)

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)

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:

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.


8. Multi-Page Pagination

TargetCanvasView (or a dedicated NSDocument / print operation data source) must implement:

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

// 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)

#!/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 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:

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.