13 KiB
13 KiB
ArgyllCMS chartread -u Subprocess Integration Specification & License Isolation Guide
This document specifies the communication protocol, JSON payload schema, process lifecycle, and licensing isolation architecture for integrating chartread -u into external user interfaces and control software.
1. Architectural & Licensing Boundary (AGPLv3 Isolation)
ArgyllCMS is licensed under the GNU Affero General Public License (AGPL) Version 3. To ensure a separate host project (proprietary, MIT, Apache, etc.) is not tainted by the AGPLv3 copyleft terms, the integration must maintain a strict, arm's-length inter-process communication (IPC) boundary:
Core Isolation Rules
- No Library Linking: The host application must never statically or dynamically link (
#include,.so,.dylib,.dll,.a) against any ArgyllCMS C libraries (libinst,libicc,libcgats,libyajl, etc.). - Subprocess Isolation via Standard OS Pipes:
chartreadmust run strictly as a standalone, decoupled child process. - Standard IPC Only: Communication is conducted exclusively over standard POSIX / Win32 file descriptors (
stdin,stdout,stderr). - Independent Binary Distribution: The
chartreadbinary should be treated as an external utility tool invoked by the OS shell or process manager.
+-------------------------------------------------------------+
| Host Application |
| (Electron, Web App, Qt, Python, Rust, etc.) |
| |
| +-----------------------------------------------------+ |
| | Line-by-Line Subprocess Stream Reader | |
| +-----------------------------------------------------+ |
+------------------------------|------------------------------+
|
Standard Pipes | (stdin / stdout / stderr)
|
+------------------------------v------------------------------+
| Isolated Child Process |
| `chartread -u ...` |
| (AGPLv3 Licensed Binary) |
+-------------------------------------------------------------+
2. Command Invocation
Command Syntax
chartread [options] -u <target_basename>
-uFlag: Enables real-time emission of JSON records tostdout.<target_basename>: The base name of the input.ti2target file and output.ti3measurement file (without extension).
Key Command Options
| Flag | Description |
|---|---|
-u |
Required for streaming: Emits ROW_COLORS_JSON: ... payloads on stdout. |
-p |
Patch-by-patch spot mode (emits JSON after each individual patch). |
-n |
Disable spectral readings (omits spectral field from JSON). |
-c <port> |
Select communication port / instrument index. |
-d |
Display measurement mode. |
-t |
Transmission measurement mode. |
-e |
Emissive measurement mode. |
3. Stream Protocol & Framing Specification
When -u is provided, chartread outputs two types of lines to stdout:
- Human-Readable Status / Prompt Lines: Unprefixed text intended for user prompts or diagnostics (e.g.
Hit [Space] to read strip A). - Structured JSON Events: Single-line JSON objects strictly prefixed by the marker:
ROW_COLORS_JSON: <json_object>\n
Stream Guarantees
- Line-Delimited: Every JSON event is serialized as a single, uninterrupted line terminated with
\n. - Immediate Flush:
chartreadexplicitly invokesfflush(stdout)immediately after emitting each JSON line, guaranteeing low latency without OS-level output buffering. - Deterministic Ordering: Patches within a row are always indexed in canonical left-to-right strip order (bi-directional scan reversals are normalized internally before emission).
4. JSON Payload Schema
Top-Level Object Schema
ROW_COLORS_JSON: {
"event": "row_complete",
"row_id": "A",
"row_index": 0,
"total_rows": 12,
"patch_count": 21,
"patches": [
/* Array of Patch Objects */
]
}
| Field | Type | Description |
|---|---|---|
event |
string |
Event identifier. Currently always "row_complete". |
row_id |
string |
Human-readable label of the row/strip (e.g. "A", "B", "1"). |
row_index |
integer |
0-based index of the row within the target chart (0 ... total_rows - 1). |
total_rows |
integer |
Total number of rows/passes defined in the target chart. |
patch_count |
integer |
Number of patch elements contained in this row payload. |
patches |
array |
List of individual patch data objects. |
Patch Object Schema
{
"id": "1",
"loc": "A1",
"is_pad": false,
"device": [0.0, 50.0, 100.0, 0.0],
"expected": {
"XYZ": [18.4210, 20.1234, 15.6789],
"Lab": [51.98, -8.45, 12.32]
},
"measured": {
"XYZ": [18.5120, 20.0451, 15.7100],
"Lab": [51.89, -8.31, 12.15],
"spectral": {
"bands": 36,
"start_nm": 380.0,
"end_nm": 730.0,
"norm": 100.0,
"values": [0.0120, 0.0135, 0.0180, 0.0245]
}
}
}
| Field | Type | Description |
|---|---|---|
id |
string |
Patch ID string from .ti2 file. If "0", indicates a spacer/padding patch. |
loc |
string |
Physical location coordinate string (e.g. "A1", "B12"). |
is_pad |
boolean |
true if patch is an alignment/lead-in spacer patch (id == "0"). UIs should typically render these distinctly or skip them in analysis. |
device |
array[float] |
Device colorant drive values scaled to percentage 0.0 ... 100.0% (e.g. [C, M, Y, K] or [R, G, B]). |
expected |
object (optional) |
Expected reference values from .ti2 (omitted if no reference data is present). |
expected.XYZ |
array[float][3] |
Reference CIE XYZ values on reference scale 0.0 ... 100.0. |
expected.Lab |
array[float][3] |
Reference D50 L^*a^*b^* computed via standard CIE transformation. |
measured |
object |
Actual instrument readings. |
measured.XYZ |
array[float][3] |
Measured CIE XYZ values on reference scale 0.0 ... 100.0. |
measured.Lab |
array[float][3] |
Measured D50 L^*a^*b^* (L^* \in [0, 100], a^*, b^* \in [-128, 127]). |
measured.spectral |
object (optional) |
Spectral reflection/emission data (omitted if instrument is colorimeter-only or -n passed). |
measured.spectral.bands |
integer |
Number of spectral sample bands. |
measured.spectral.start_nm |
float |
Starting wavelength in nanometres (e.g. 380.0 or 400.0). |
measured.spectral.end_nm |
float |
Ending wavelength in nanometres (e.g. 700.0 or 730.0). |
measured.spectral.norm |
float |
Normalization scale factor (typically 100.0). |
measured.spectral.values |
array[float] |
Array of spectral reflectance / radiance values per band. |
5. Integration Implementation Examples
Node.js / Electron / TypeScript Integration
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as readline from 'readline';
export interface PatchColorEvent {
event: string;
row_id: string;
row_index: number;
total_rows: number;
patch_count: number;
patches: Array<{
id: string;
loc: string;
is_pad: boolean;
device: number[];
expected?: {
XYZ: [number, number, number];
Lab: [number, number, number];
};
measured: {
XYZ: [number, number, number];
Lab: [number, number, number];
spectral?: {
bands: number;
start_nm: number;
end_nm: number;
norm: number;
values: number[];
};
};
}>;
}
export class ChartreadRunner {
private process: ChildProcessWithoutNullStreams | null = null;
private readonly JSON_PREFIX = 'ROW_COLORS_JSON: ';
public startSession(
targetBasename: string,
onRowData: (data: PatchColorEvent) => void,
onConsoleMessage: (msg: string) => void,
onError: (err: string) => void
): void {
// Spawn isolated process over standard pipes (No library linking)
this.process = spawn('chartread', ['-u', targetBasename]);
// Parse stdout line by line
const rlOut = readline.createInterface({ input: this.process.stdout });
rlOut.on('line', (line: string) => {
const trimmed = line.trim();
if (trimmed.startsWith(this.JSON_PREFIX)) {
try {
const jsonStr = trimmed.substring(this.JSON_PREFIX.length);
const payload: PatchColorEvent = JSON.parse(jsonStr);
onRowData(payload);
} catch (err) {
onError(`Failed to parse JSON row payload: ${err}`);
}
} else if (trimmed.length > 0) {
onConsoleMessage(trimmed);
}
});
// Capture stderr for warnings / errors
const rlErr = readline.createInterface({ input: this.process.stderr });
rlErr.on('line', (errLine: string) => {
onError(errLine);
});
this.process.on('close', (code) => {
onConsoleMessage(`chartread exited with code ${code}`);
this.process = null;
});
}
/**
* Send user keyboard triggers or confirmations to chartread (e.g. Spacebar or Enter)
*/
public sendInput(input: string): void {
if (this.process && this.process.stdin.writable) {
this.process.stdin.write(input);
}
}
/**
* Abort measurement session
*/
public abort(): void {
if (this.process) {
this.sendInput('q\n'); // Send standard quit character
setTimeout(() => {
if (this.process) {
this.process.kill('SIGTERM');
}
}, 500);
}
}
}
Python Subprocess Integration
import subprocess
import json
import threading
class ChartreadClient:
JSON_PREFIX = "ROW_COLORS_JSON: "
def __init__(self, target_basename: str, on_row_callback, on_status_callback):
self.target_basename = target_basename
self.on_row_callback = on_row_callback
self.on_status_callback = on_status_callback
self.process = None
def start(self):
# Arms-length subprocess invocation maintaining AGPL isolation
self.process = subprocess.Popen(
["chartread", "-u", self.target_basename],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1 # Line buffered
)
threading.Thread(target=self._read_stdout, daemon=True).start()
threading.Thread(target=self._read_stderr, daemon=True).start()
def _read_stdout(self):
for line in iter(self.process.stdout.readline, ''):
line_str = line.strip()
if line_str.startswith(self.JSON_PREFIX):
payload_str = line_str[len(self.JSON_PREFIX):]
try:
data = json.loads(payload_str)
self.on_row_callback(data)
except json.JSONDecodeError as ex:
print(f"JSON decode error: {ex}")
elif line_str:
self.on_status_callback(line_str)
def _read_stderr(self):
for line in iter(self.process.stderr.readline, ''):
if line.strip():
print(f"[chartread stderr] {line.strip()}")
def send_key(self, key: str):
if self.process and self.process.stdin:
self.process.stdin.write(f"{key}\n")
self.process.stdin.flush()
def terminate(self):
if self.process:
self.process.terminate()
6. Real-Time UI Visualisation Best Practices
- Rendering Device Colors:
- For RGB targets: scale
device[0..2]from0..100to0..255(rgb(r%, g%, b%)). - For CMYK targets: use device simulation or convert
measured.Lab/expected.Labto sRGB for color swatch display.
- For RGB targets: scale
- Delta E Calculation (
\Delta E_{00}or\Delta E_{ab}):- When
expected.Labandmeasured.Labare both present, calculate the color difference\Delta E. - The basic Euclidean distance (
\Delta E_{ab}) is simple to implement directly:\Delta E_{ab} = \sqrt{(L_m^* - L_e^*)^2 + (a_m^* - a_e^*)^2 + (b_m^* - b_e^*)^2} - Recommendation: For professional color work, use the modern
\Delta E_{00}(CIEDE2000) formula. Due to its complexity, it is highly recommended to use an established color math library (e.g.,colorjs.ioin JavaScript, orcolormathin Python) rather than implementing it from scratch. - Display green/amber/red indicator lights next to each patch in real-time based on your accepted
\Delta Etolerance.
- When
- Handling Alignment / Spacer Patches (
is_pad == true):- Omit
is_pad == truepatches from quality score computations, or render them with dashed neutral borders to maintain layout grid accuracy without skewing statistics.
- Omit