@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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +114 -0
- package/package.json +36 -0
- package/src/analyze.d.ts +18 -0
- package/src/analyze.js +268 -0
- package/src/balance.d.ts +7 -0
- package/src/balance.js +104 -0
- package/src/calibration.d.ts +13 -0
- package/src/calibration.js +42 -0
- package/src/chart.d.ts +37 -0
- package/src/chart.js +255 -0
- package/src/geometry.d.ts +12 -0
- package/src/geometry.js +45 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +13 -0
- package/src/measure.d.ts +25 -0
- package/src/measure.js +274 -0
- package/src/plan.d.ts +17 -0
- package/src/plan.js +216 -0
- package/src/presets.d.ts +5 -0
- package/src/presets.js +26 -0
- package/src/profile.d.ts +37 -0
- package/src/profile.js +213 -0
- package/src/types.d.ts +41 -0
- package/src/types.js +6 -0
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 {};
|