@freshcoat-js/for-print 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/profile.js ADDED
@@ -0,0 +1,213 @@
1
+ // What was measured about one printer.
2
+ //
3
+ // A print profile is DATA, deliberately. `YMCKO_PRESET` is a set of hand-tuned
4
+ // constants compiled into every build, which is the right shape for a starting
5
+ // guess and the wrong shape for a measurement: what a gray ramp reports belongs
6
+ // to one printer, one ribbon batch, one card stock and roughly one set of room
7
+ // conditions. Re-measuring after a ribbon change should be a data change, not a
8
+ // deploy, and two printers should be able to disagree.
9
+ //
10
+ // So a profile carries only the parts a per-image analysis cannot know. It does
11
+ // NOT override saturation, contrast or gamma — those are chosen per image from
12
+ // the art itself, and a profile that pinned them would throw that away.
13
+ import { fitChannelBalance } from "./balance.js";
14
+ import { assessCalibration, } from "./calibration.js";
15
+ // A profile with nothing measured. Named so a render can always say which profile
16
+ // it used, including when the answer is "none".
17
+ export const UNMEASURED_PROFILE = {
18
+ version: 1,
19
+ name: "unmeasured",
20
+ };
21
+ // The one sanctioned route from a chart reading to profile data. Fitting and
22
+ // assessment happen together, so callers cannot accidentally save a balance
23
+ // while discarding the evidence that says whether it was safe to make.
24
+ export function createPrintProfile(reading, details) {
25
+ const assessment = assessCalibration(reading);
26
+ if (!assessment.usable) {
27
+ return { ok: false, reason: "unsafe-reading", assessment };
28
+ }
29
+ if (details.name.trim() === "") {
30
+ return { ok: false, reason: "missing-name", assessment };
31
+ }
32
+ const conditions = parseConditions(details.conditions);
33
+ if (conditions instanceof Error) {
34
+ return { ok: false, reason: "invalid-conditions", assessment };
35
+ }
36
+ const balance = fitChannelBalance(reading);
37
+ // An assessment that accepts a gray chart should fit; retain the guard so a
38
+ // future fitting algorithm can become stricter without emitting partial data.
39
+ if (!balance)
40
+ return { ok: false, reason: "unsafe-reading", assessment };
41
+ return {
42
+ ok: true,
43
+ assessment,
44
+ profile: {
45
+ version: 1,
46
+ name: details.name.trim(),
47
+ balance,
48
+ assessment,
49
+ ...(details.measuredAt ? { measuredAt: details.measuredAt } : {}),
50
+ ...(details.notes ? { notes: details.notes } : {}),
51
+ ...(conditions ? { conditions } : {}),
52
+ },
53
+ };
54
+ }
55
+ // Fold a profile into a correction. The profile wins on the fields it owns and
56
+ // leaves every analysis-derived field alone.
57
+ export function withProfile(options, profile) {
58
+ if (!profile?.balance)
59
+ return options;
60
+ return { ...options, balance: profile.balance };
61
+ }
62
+ // Identity for a render cache. A correction is part of what produced a PNG, so a
63
+ // profile change has to miss the cache — otherwise re-measuring a printer quietly
64
+ // keeps serving cards corrected the old way, which is the failure this whole
65
+ // pipeline exists to avoid. Deliberately built from the fields that CHANGE THE
66
+ // PIXELS: renaming a profile or editing its notes is not a re-render.
67
+ export function profileCacheKey(profile) {
68
+ const b = profile?.balance;
69
+ return b ? `${b.r}/${b.g}/${b.b}` : "none";
70
+ }
71
+ const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
72
+ // Exponents outside this are not a cast, they are a mistake — a fit off bad picks
73
+ // or a hand-typed digit. Rejected rather than clamped, because a profile that
74
+ // silently became something else is worse than one that refused to load.
75
+ const MIN_EXPONENT = 0.2;
76
+ const MAX_EXPONENT = 5;
77
+ function parseBalance(value) {
78
+ if (value === undefined || value === null)
79
+ return undefined;
80
+ if (typeof value !== "object")
81
+ return new Error("balance must be an object");
82
+ const b = value;
83
+ const out = {};
84
+ for (const channel of ["r", "g", "b"]) {
85
+ const k = b[channel];
86
+ if (!isFiniteNumber(k)) {
87
+ return new Error(`balance.${channel} must be a number`);
88
+ }
89
+ if (k < MIN_EXPONENT || k > MAX_EXPONENT) {
90
+ return new Error(`balance.${channel} is ${k}, outside the ${MIN_EXPONENT}–${MAX_EXPONENT} a cast correction can plausibly be`);
91
+ }
92
+ out[channel] = k;
93
+ }
94
+ return out;
95
+ }
96
+ function parseConditions(value) {
97
+ if (value === undefined || value === null)
98
+ return undefined;
99
+ if (typeof value !== "object" || Array.isArray(value)) {
100
+ return new Error("conditions must be an object");
101
+ }
102
+ const raw = value;
103
+ const conditions = {};
104
+ for (const field of ["printer", "ribbon", "stock"]) {
105
+ const item = raw[field];
106
+ if (item === undefined)
107
+ continue;
108
+ if (typeof item !== "string" || item.trim() === "") {
109
+ return new Error(`conditions.${field} must be a non-empty string`);
110
+ }
111
+ conditions[field] = item.trim();
112
+ }
113
+ return conditions;
114
+ }
115
+ const CALIBRATION_BLOCKERS = [
116
+ "stock-clipped",
117
+ "patches-missed",
118
+ "uneven-lighting",
119
+ "uneven-print",
120
+ "not-enough-gray-steps",
121
+ ];
122
+ function parseAssessment(value) {
123
+ if (value === undefined || value === null)
124
+ return undefined;
125
+ if (typeof value !== "object" || Array.isArray(value)) {
126
+ return new Error("assessment must be an object");
127
+ }
128
+ const raw = value;
129
+ if (typeof raw.usable !== "boolean") {
130
+ return new Error("assessment.usable must be a boolean");
131
+ }
132
+ if (!Array.isArray(raw.blockers) ||
133
+ !raw.blockers.every((b) => typeof b === "string" &&
134
+ CALIBRATION_BLOCKERS.includes(b))) {
135
+ return new Error("assessment.blockers must contain known calibration blockers");
136
+ }
137
+ if (raw.usable !== (raw.blockers.length === 0)) {
138
+ return new Error("assessment.usable must agree with assessment.blockers");
139
+ }
140
+ if (typeof raw.metrics !== "object" ||
141
+ raw.metrics === null ||
142
+ Array.isArray(raw.metrics)) {
143
+ return new Error("assessment.metrics must be an object");
144
+ }
145
+ const metrics = raw.metrics;
146
+ const parsed = {};
147
+ for (const field of [
148
+ "graySteps",
149
+ "missedPatches",
150
+ "lightSpread",
151
+ "repeatSpread",
152
+ ]) {
153
+ if (!isFiniteNumber(metrics[field]) || metrics[field] < 0) {
154
+ return new Error(`assessment.metrics.${field} must be a non-negative number`);
155
+ }
156
+ parsed[field] = metrics[field];
157
+ }
158
+ return {
159
+ usable: raw.usable,
160
+ blockers: raw.blockers,
161
+ metrics: parsed,
162
+ };
163
+ }
164
+ // Parse a profile from whatever a deploy handed over — an env var, a settings
165
+ // row, a pasted export. Returns the profile or an Error explaining what is wrong
166
+ // with it, never a partially-applied one: a correction that half-loaded would
167
+ // print cards nobody could account for.
168
+ export function parsePrintProfile(input) {
169
+ let value = input;
170
+ if (typeof value === "string") {
171
+ const text = value.trim();
172
+ if (text === "")
173
+ return new Error("profile is empty");
174
+ try {
175
+ value = JSON.parse(text);
176
+ }
177
+ catch (e) {
178
+ return new Error(`profile is not valid JSON: ${e instanceof Error ? e.message : "parse failed"}`);
179
+ }
180
+ }
181
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
182
+ return new Error("profile must be a JSON object");
183
+ }
184
+ const raw = value;
185
+ const version = raw.version === undefined ? 1 : raw.version;
186
+ if (version !== 1)
187
+ return new Error("profile version must be 1");
188
+ const name = raw.name;
189
+ if (typeof name !== "string" || name.trim() === "") {
190
+ return new Error("profile needs a name saying which printer it describes");
191
+ }
192
+ const balance = parseBalance(raw.balance);
193
+ if (balance instanceof Error)
194
+ return balance;
195
+ const conditions = parseConditions(raw.conditions);
196
+ if (conditions instanceof Error)
197
+ return conditions;
198
+ const assessment = parseAssessment(raw.assessment);
199
+ if (assessment instanceof Error)
200
+ return assessment;
201
+ const profile = { version: 1, name: name.trim() };
202
+ if (balance)
203
+ profile.balance = balance;
204
+ if (typeof raw.measuredAt === "string")
205
+ profile.measuredAt = raw.measuredAt;
206
+ if (typeof raw.notes === "string")
207
+ profile.notes = raw.notes;
208
+ if (conditions)
209
+ profile.conditions = conditions;
210
+ if (assessment)
211
+ profile.assessment = assessment;
212
+ return profile;
213
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ export type CardOrientation = "landscape" | "portrait";
2
+ export interface CropRegion {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ orientation: CardOrientation;
8
+ }
9
+ export interface ChannelBalance {
10
+ r: number;
11
+ g: number;
12
+ b: number;
13
+ }
14
+ export interface PrintOptimizeOptions {
15
+ saturation: number;
16
+ contrast: number;
17
+ sharpness: number;
18
+ gamma: number;
19
+ darkness: number;
20
+ balance?: ChannelBalance;
21
+ }
22
+ export interface GamutReport {
23
+ clipped: number;
24
+ pullback: number;
25
+ }
26
+ export interface ImageAnalysis {
27
+ avgBrightness: number;
28
+ avgSaturation: number;
29
+ contrast: number;
30
+ detectedOrientation: CardOrientation;
31
+ imageWidth: number;
32
+ imageHeight: number;
33
+ recommendation: PrintOptimizeOptions;
34
+ gamut: GamutReport;
35
+ notes: Array<string>;
36
+ }
37
+ export interface PixelData {
38
+ readonly data: Uint8ClampedArray | Uint8Array;
39
+ readonly width: number;
40
+ readonly height: number;
41
+ }
package/src/types.js ADDED
@@ -0,0 +1,6 @@
1
+ // ---------------------------------------------------------------------------
2
+ // for-print is an ANALYSIS module: image statistics + print intent in, freshcoat
3
+ // adjustments out. It holds no canvas, no rasterizer, and no pixel-output code —
4
+ // freshcoat owns all rendering. These types are the analysis vocabulary.
5
+ // ---------------------------------------------------------------------------
6
+ export {};