@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/balance.js ADDED
@@ -0,0 +1,104 @@
1
+ // Turning a gray-ramp reading into a cast correction.
2
+ //
3
+ // The gray chart sends neutral and the printer returns something that is not,
4
+ // measured against the bare card so the camera and the light are already divided
5
+ // out (see ./measure). What is left is the printer's own cast, and this is where
6
+ // it becomes a correction the render can apply.
7
+ //
8
+ // The method is an inversion, not a fit to a formula. Each step's measured curve
9
+ // says what the printer did with a level; asking that curve what level it WOULD
10
+ // have printed as neutral gives the level to send instead. Doing that at every
11
+ // step and reading off the exponent that connects them is the whole of it.
12
+ import { assessCalibration } from "./calibration.js";
13
+ const median = (xs) => {
14
+ const s = [...xs].sort((a, b) => a - b);
15
+ const mid = s.length >> 1;
16
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
17
+ };
18
+ // The neutral steps of a reading, one entry per level sent, repeats averaged and
19
+ // in order. Averaging the repeats is what the three passes across the card were
20
+ // for: the cast is what they agree on, the disagreement is the sheet.
21
+ function neutralSteps(reading) {
22
+ const byLevel = new Map();
23
+ for (const p of reading.patches) {
24
+ if (p.role !== "measure")
25
+ continue;
26
+ if (p.sent[0] !== p.sent[1] || p.sent[1] !== p.sent[2])
27
+ continue;
28
+ const at = byLevel.get(p.sent[0]) ?? { sum: [0, 0, 0], n: 0 };
29
+ at.sum[0] += p.measured[0];
30
+ at.sum[1] += p.measured[1];
31
+ at.sum[2] += p.measured[2];
32
+ at.n += 1;
33
+ byLevel.set(p.sent[0], at);
34
+ }
35
+ return [...byLevel.entries()]
36
+ .map(([level, at]) => ({
37
+ level,
38
+ measured: [at.sum[0] / at.n, at.sum[1] / at.n, at.sum[2] / at.n],
39
+ }))
40
+ .sort((a, b) => a.level - b.level);
41
+ }
42
+ // The level that would have printed `value` on this channel, by interpolating
43
+ // between the two measured steps that bracket it. Null when the channel never
44
+ // printed anything that dark or that light — the correction cannot be derived
45
+ // past the ends of what was measured, and guessing there is how a profile starts
46
+ // inventing density the ribbon does not have.
47
+ function inputFor(steps, channel, value) {
48
+ for (let i = 1; i < steps.length; i++) {
49
+ const lo = steps[i - 1];
50
+ const hi = steps[i];
51
+ const a = lo.measured[channel];
52
+ const b = hi.measured[channel];
53
+ if (value < Math.min(a, b) || value > Math.max(a, b))
54
+ continue;
55
+ if (a === b)
56
+ return lo.level;
57
+ return lo.level + ((value - a) / (b - a)) * (hi.level - lo.level);
58
+ }
59
+ return null;
60
+ }
61
+ // Levels this close to the ends are left out of the fit. The exponent comes from
62
+ // a ratio of logarithms, and ln(level/255) goes to zero at white and to negative
63
+ // infinity at black — a level or two of camera noise there swings the answer
64
+ // wildly, while the midtones carry the same information cleanly.
65
+ const FIT_MIN = 24;
66
+ const FIT_MAX = 232;
67
+ export function fitChannelBalance(reading, options = {}) {
68
+ if (!options.allowUnsafe && !assessCalibration(reading).usable)
69
+ return null;
70
+ const steps = neutralSteps(reading);
71
+ if (steps.length < 4)
72
+ return null;
73
+ const exponents = [0, 1, 2].map((channel) => {
74
+ const ks = [];
75
+ for (const step of steps) {
76
+ if (step.level < FIT_MIN || step.level > FIT_MAX)
77
+ continue;
78
+ // The neutral this printer can actually make at this level: the average of
79
+ // what its three channels did. Correcting toward that costs no density,
80
+ // where correcting toward the level that was SENT would ask the ribbon for
81
+ // ink it does not have and clip instead.
82
+ const target = (step.measured[0] + step.measured[1] + step.measured[2]) / 3;
83
+ const input = inputFor(steps, channel, target);
84
+ if (input === null || input <= 0 || input >= 255)
85
+ continue;
86
+ ks.push(Math.log(input / 255) / Math.log(step.level / 255));
87
+ }
88
+ // Median, not mean: one patch spoiled by a speck or a highlight should not
89
+ // move the correction the other fifteen agree on.
90
+ return ks.length ? median(ks) : 1;
91
+ });
92
+ return {
93
+ r: round(exponents[0]),
94
+ g: round(exponents[1]),
95
+ b: round(exponents[2]),
96
+ };
97
+ }
98
+ const round = (v) => Math.round(v * 1000) / 1000;
99
+ // Whether a balance is worth applying at all. A reading always produces some
100
+ // number; below this the correction is smaller than the noise it was measured
101
+ // through, and storing it would dress up a rounding error as a measurement.
102
+ export function isBalanceMeaningful(balance) {
103
+ return [balance.r, balance.g, balance.b].some((k) => Math.abs(k - 1) >= 0.01);
104
+ }
@@ -0,0 +1,13 @@
1
+ import { type ChartReading } from "./measure.js";
2
+ export type CalibrationBlocker = "stock-clipped" | "patches-missed" | "uneven-lighting" | "uneven-print" | "not-enough-gray-steps";
3
+ export type CalibrationAssessment = {
4
+ usable: boolean;
5
+ blockers: CalibrationBlocker[];
6
+ metrics: {
7
+ graySteps: number;
8
+ missedPatches: number;
9
+ lightSpread: number;
10
+ repeatSpread: number;
11
+ };
12
+ };
13
+ export declare function assessCalibration(reading: ChartReading): CalibrationAssessment;
@@ -0,0 +1,42 @@
1
+ // Whether a chart reading is evidence good enough to turn into a printer
2
+ // correction. This is deliberately library policy rather than a UI convention:
3
+ // callers other than the chart tool must not be able to save a confident-looking
4
+ // profile from a blown-out or uneven photograph.
5
+ import { repeatSpread } from "./measure.js";
6
+ const MIN_GRAY_STEPS = 4;
7
+ const MAX_REPEAT_SPREAD = 4;
8
+ // Assess a chart independently of any particular UI. A little stock variation
9
+ // is normal camera noise; when it is both material and comparable to the
10
+ // repeated-patch variation, it means the light—not the printer—accounts for the
11
+ // measurement. A steady light with wide repeated-patch variation is instead a
12
+ // printer consistency problem. Neither should become a profile.
13
+ export function assessCalibration(reading) {
14
+ const graySteps = new Set(reading.patches
15
+ .filter((p) => p.role === "measure" &&
16
+ p.sent[0] === p.sent[1] &&
17
+ p.sent[1] === p.sent[2])
18
+ .map((p) => p.sent[0])).size;
19
+ const spread = repeatSpread(reading);
20
+ const lightBound = reading.lightSpread >= Math.max(2, spread * 0.6);
21
+ const blockers = [];
22
+ if (reading.stockClipped)
23
+ blockers.push("stock-clipped");
24
+ if (reading.missed.length > 0)
25
+ blockers.push("patches-missed");
26
+ if (graySteps < MIN_GRAY_STEPS)
27
+ blockers.push("not-enough-gray-steps");
28
+ if (lightBound)
29
+ blockers.push("uneven-lighting");
30
+ else if (spread >= MAX_REPEAT_SPREAD)
31
+ blockers.push("uneven-print");
32
+ return {
33
+ usable: blockers.length === 0,
34
+ blockers,
35
+ metrics: {
36
+ graySteps,
37
+ missedPatches: reading.missed.length,
38
+ lightSpread: reading.lightSpread,
39
+ repeatSpread: spread,
40
+ },
41
+ };
42
+ }
package/src/chart.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { type GroupNode } from "@freshcoat-js/engine";
2
+ export type RGB = [number, number, number];
3
+ export type Box = {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ };
9
+ export type PatchRole = "measure" | "stock" | "fiducial";
10
+ export type ChartPatch = Box & {
11
+ id: string;
12
+ rgb: RGB;
13
+ role: PatchRole;
14
+ repeatOf?: string;
15
+ };
16
+ export type ChartSpec = {
17
+ id: string;
18
+ name: string;
19
+ purpose: string;
20
+ width: number;
21
+ height: number;
22
+ patches: ChartPatch[];
23
+ registration: [Point, Point, Point, Point];
24
+ };
25
+ export type Point = {
26
+ x: number;
27
+ y: number;
28
+ };
29
+ export declare function layoutGrid(count: number, area: Box, gutter?: number): Box[];
30
+ export declare function hslToRgb(h: number, s: number, l: number): RGB;
31
+ export declare function grayBalanceChart(stepCount?: number): ChartSpec;
32
+ export declare function toneWedgeChart(stepCount?: number): ChartSpec;
33
+ export declare function hueSweepChart(hueCount?: number, lightnesses?: number[]): ChartSpec;
34
+ export declare function chromaRampChart(hues?: number[], stepCount?: number): ChartSpec;
35
+ export declare function latticeCharts(size?: number, perCard?: number): ChartSpec[];
36
+ export declare const DIAGNOSTIC_CHARTS: () => ChartSpec[];
37
+ export declare function chartScene(spec: ChartSpec): GroupNode;
package/src/chart.js ADDED
@@ -0,0 +1,255 @@
1
+ // Measurement charts — the printed half of characterizing a card printer.
2
+ //
3
+ // A chart is a SPEC first: patch geometry plus the RGB that was sent to the
4
+ // printer for each one. The spec is what makes a captured photo readable, so it
5
+ // is shared by both ends — `chartScene` renders it to a card, `readChart`
6
+ // (./measure) samples the same patches back out of a photograph. Nothing about
7
+ // the chart's layout is written down twice.
8
+ //
9
+ // Building a freshcoat node tree is data, not rasterizing: for-print still owns
10
+ // no canvas. The caller paints the tree.
11
+ import { createFrame, createRect, } from "@freshcoat-js/engine";
12
+ import { CR80_LONG, CR80_SHORT } from "./geometry.js";
13
+ // 3mm margin, a 2.5mm fiducial and a hairline gutter, all at 300dpi. The patch
14
+ // field starts inside the fiducial band so a corner mark is never half a patch.
15
+ const MARGIN = 36;
16
+ const FIDUCIAL = 30;
17
+ const GUTTER = 6;
18
+ function innerArea(width, height) {
19
+ const inset = MARGIN + FIDUCIAL + GUTTER;
20
+ return {
21
+ x: inset,
22
+ y: inset,
23
+ width: width - inset * 2,
24
+ height: height - inset * 2,
25
+ };
26
+ }
27
+ // Lay `count` cells into `area`, choosing the column count whose cell aspect sits
28
+ // closest to square. Cells fill the area, so patches are as large as the card
29
+ // allows — a bigger patch survives a phone camera's noise and a slight misclick.
30
+ export function layoutGrid(count, area, gutter = GUTTER) {
31
+ if (count <= 0)
32
+ return [];
33
+ let best = { cols: 1, score: Number.POSITIVE_INFINITY };
34
+ for (let cols = 1; cols <= count; cols++) {
35
+ const rows = Math.ceil(count / cols);
36
+ const w = (area.width - gutter * (cols - 1)) / cols;
37
+ const h = (area.height - gutter * (rows - 1)) / rows;
38
+ if (w <= 0 || h <= 0)
39
+ continue;
40
+ // Distance from square, plus a nudge against layouts that leave a ragged
41
+ // final row — a half-empty row wastes patch size for nothing.
42
+ const score = Math.abs(Math.log(w / h)) + (cols * rows - count) / count;
43
+ if (score < best.score)
44
+ best = { cols, score };
45
+ }
46
+ const cols = best.cols;
47
+ const rows = Math.ceil(count / cols);
48
+ const w = (area.width - gutter * (cols - 1)) / cols;
49
+ const h = (area.height - gutter * (rows - 1)) / rows;
50
+ return Array.from({ length: count }, (_, i) => ({
51
+ x: area.x + (i % cols) * (w + gutter),
52
+ y: area.y + Math.floor(i / cols) * (h + gutter),
53
+ width: w,
54
+ height: h,
55
+ }));
56
+ }
57
+ const hex = ([r, g, b]) => `#${[r, g, b]
58
+ .map((v) => Math.max(0, Math.min(255, Math.round(v)))
59
+ .toString(16)
60
+ .padStart(2, "0"))
61
+ .join("")}`;
62
+ // HSL → RGB, the generator for the hue and chroma sweeps. Chart colors are
63
+ // authored in HSL because the sweeps vary one axis at a time by definition.
64
+ export function hslToRgb(h, s, l) {
65
+ const c = (1 - Math.abs(2 * l - 1)) * s;
66
+ const hp = (((h % 360) + 360) % 360) / 60;
67
+ const x = c * (1 - Math.abs((hp % 2) - 1));
68
+ const [r, g, b] = hp < 1
69
+ ? [c, x, 0]
70
+ : hp < 2
71
+ ? [x, c, 0]
72
+ : hp < 3
73
+ ? [0, c, x]
74
+ : hp < 4
75
+ ? [0, x, c]
76
+ : hp < 5
77
+ ? [x, 0, c]
78
+ : [c, 0, x];
79
+ const m = l - c / 2;
80
+ return [
81
+ Math.round((r + m) * 255),
82
+ Math.round((g + m) * 255),
83
+ Math.round((b + m) * 255),
84
+ ];
85
+ }
86
+ function fiducials(width, height) {
87
+ const corners = [
88
+ ["tl", MARGIN, MARGIN],
89
+ ["tr", width - MARGIN - FIDUCIAL, MARGIN],
90
+ ["br", width - MARGIN - FIDUCIAL, height - MARGIN - FIDUCIAL],
91
+ ["bl", MARGIN, height - MARGIN - FIDUCIAL],
92
+ ];
93
+ const patches = corners.map(([id, x, y]) => ({
94
+ id: `fid-${id}`,
95
+ rgb: [0, 0, 0],
96
+ role: "fiducial",
97
+ x,
98
+ y,
99
+ width: FIDUCIAL,
100
+ height: FIDUCIAL,
101
+ }));
102
+ const center = (p) => ({
103
+ x: p.x + p.width / 2,
104
+ y: p.y + p.height / 2,
105
+ });
106
+ return {
107
+ patches,
108
+ registration: [
109
+ center(patches[0]),
110
+ center(patches[1]),
111
+ center(patches[2]),
112
+ center(patches[3]),
113
+ ],
114
+ };
115
+ }
116
+ // Assemble a chart from a list of colors. Stock patches are spliced in at both
117
+ // ends and the middle rather than parked in one corner: the white reference has
118
+ // to sample the same lighting the measured patches do, and a single corner patch
119
+ // under a gradient of window light would bias every reading against it.
120
+ function build(id, name, purpose, colors, stockCount = 4, width = CR80_LONG, height = CR80_SHORT) {
121
+ const withStock = [];
122
+ const every = Math.max(1, Math.floor(colors.length / Math.max(1, stockCount - 1)));
123
+ let stock = 0;
124
+ colors.forEach((c, i) => {
125
+ if (stock < stockCount && i % every === 0) {
126
+ withStock.push({
127
+ id: `stock-${stock}`,
128
+ rgb: [255, 255, 255],
129
+ role: "stock",
130
+ });
131
+ stock++;
132
+ }
133
+ withStock.push({ ...c, role: "measure" });
134
+ });
135
+ while (stock < stockCount) {
136
+ withStock.push({
137
+ id: `stock-${stock}`,
138
+ rgb: [255, 255, 255],
139
+ role: "stock",
140
+ });
141
+ stock++;
142
+ }
143
+ const boxes = layoutGrid(withStock.length, innerArea(width, height));
144
+ const marks = fiducials(width, height);
145
+ return {
146
+ id,
147
+ name,
148
+ purpose,
149
+ width,
150
+ height,
151
+ patches: [
152
+ ...withStock.map((c, i) => ({ ...c, ...boxes[i] })),
153
+ ...marks.patches,
154
+ ],
155
+ registration: marks.registration,
156
+ };
157
+ }
158
+ const steps = (n, f) => Array.from({ length: n }, (_, i) => f(n === 1 ? 0 : i / (n - 1)));
159
+ // ── The four diagnostics ───────────────────────────────────────────────────
160
+ //
161
+ // None of these needs a reference target. Each is read against the bare stock in
162
+ // the same photograph, or against the other patches on the same card, so an
163
+ // uncalibrated phone answers all four.
164
+ // Neutral in, neutral out — the chart that finds a color cast. Every step is
165
+ // printed three times across the card, so an uneven sheet is visible as
166
+ // disagreement between the repeats rather than being read as a cast.
167
+ export function grayBalanceChart(stepCount = 16) {
168
+ const base = steps(stepCount, (t) => {
169
+ const v = Math.round(t * 255);
170
+ return [v, v, v];
171
+ });
172
+ const colors = [0, 1, 2].flatMap((pass) => base.map((rgb, i) => ({
173
+ id: `gray-${i}-p${pass}`,
174
+ rgb,
175
+ ...(pass > 0 ? { repeatOf: `gray-${i}-p0` } : {}),
176
+ })));
177
+ return build("gray-balance", "Gray balance", "Neutral steps printed three times across the card. Any color in these is the printer's cast; disagreement between the three passes is the sheet being uneven.", colors);
178
+ }
179
+ // Per-channel ramps — the printer's transfer curve, one primary at a time.
180
+ export function toneWedgeChart(stepCount = 9) {
181
+ const axes = [
182
+ ["r", (t) => [Math.round(t * 255), 0, 0]],
183
+ ["g", (t) => [0, Math.round(t * 255), 0]],
184
+ ["b", (t) => [0, 0, Math.round(t * 255)]],
185
+ ["c", (t) => [0, Math.round(t * 255), Math.round(t * 255)]],
186
+ ["m", (t) => [Math.round(t * 255), 0, Math.round(t * 255)]],
187
+ ["y", (t) => [Math.round(t * 255), Math.round(t * 255), 0]],
188
+ ];
189
+ const colors = axes.flatMap(([name, f]) => steps(stepCount, f).map((rgb, i) => ({ id: `${name}-${i}`, rgb })));
190
+ return build("tone-wedge", "Tone wedges", "Each primary and secondary ramped on its own. Read where a ramp stops getting darker — that is the channel's real transfer curve, not the one the file assumes.", colors);
191
+ }
192
+ // Constant saturation and lightness, hue swept the whole way round. Whichever
193
+ // hues come back rotated are the ones a correction has to hold.
194
+ export function hueSweepChart(hueCount = 24, lightnesses = [0.4, 0.55, 0.7]) {
195
+ const colors = lightnesses.flatMap((l, li) => Array.from({ length: hueCount }, (_, i) => ({
196
+ id: `hue-${Math.round((i * 360) / hueCount)}-l${li}`,
197
+ rgb: hslToRgb((i * 360) / hueCount, 0.75, l),
198
+ })));
199
+ return build("hue-sweep", "Hue sweep", "One saturation, one lightness, every hue. Hues that come back turned are where the ribbon bends color — this is the chart that shows an indigo drifting toward magenta.", colors);
200
+ }
201
+ // Saturation ramps per hue. The step where a ramp stops getting more saturated is
202
+ // the ribbon's gamut edge for that hue — the number a correction cannot guess.
203
+ export function chromaRampChart(hues = [250, 270, 210, 0, 120, 40], stepCount = 10) {
204
+ const colors = hues.flatMap((h) => steps(stepCount, (t) => hslToRgb(h, t, 0.5)).map((rgb, i) => ({
205
+ id: `chroma-${h}-${i}`,
206
+ rgb,
207
+ })));
208
+ return build("chroma-ramp", "Chroma ramps", "Saturation ramped per hue. Where a ramp stops changing is that hue's gamut edge — how much saturation the ribbon can actually hold.", colors);
209
+ }
210
+ // ── The lattice ────────────────────────────────────────────────────────────
211
+ // An N×N×N sample of the RGB cube, split across as many cards as it takes. This
212
+ // is the input to a 3D LUT; the diagnostics above are for deciding whether you
213
+ // need one. `perCard` caps patch count so patches stay big enough to photograph.
214
+ export function latticeCharts(size = 5, perCard = 100) {
215
+ const colors = [];
216
+ const at = (i) => Math.round((i / (size - 1)) * 255);
217
+ for (let r = 0; r < size; r++) {
218
+ for (let g = 0; g < size; g++) {
219
+ for (let b = 0; b < size; b++) {
220
+ colors.push({
221
+ id: `lat-${r}-${g}-${b}`,
222
+ rgb: [at(r), at(g), at(b)],
223
+ });
224
+ }
225
+ }
226
+ }
227
+ const pages = Math.ceil(colors.length / perCard);
228
+ return Array.from({ length: pages }, (_, p) => build(`lattice-${size}-${p + 1}`, `Lattice ${size}³ — card ${p + 1} of ${pages}`, `Part ${p + 1} of an even ${size}×${size}×${size} sample of the RGB cube. Print and read every card; together they are the measurement a 3D LUT is built from.`, colors.slice(p * perCard, (p + 1) * perCard)));
229
+ }
230
+ export const DIAGNOSTIC_CHARTS = () => [
231
+ grayBalanceChart(),
232
+ toneWedgeChart(),
233
+ hueSweepChart(),
234
+ chromaRampChart(),
235
+ ];
236
+ // ── Rendering ──────────────────────────────────────────────────────────────
237
+ // The spec as a freshcoat scene. Deliberately flat: solid rects, no gradients,
238
+ // no antialiased edges to average into a patch, and a white ground so unprinted
239
+ // card reads as unprinted card.
240
+ export function chartScene(spec) {
241
+ return createFrame({
242
+ pos: { x: 0, y: 0 },
243
+ size: { width: spec.width, height: spec.height },
244
+ background: createRect({
245
+ pos: { x: 0, y: 0 },
246
+ size: { width: spec.width, height: spec.height },
247
+ fills: [{ kind: "solid", color: "#ffffff" }],
248
+ }),
249
+ children: spec.patches.map((p) => createRect({
250
+ pos: { x: p.x, y: p.y },
251
+ size: { width: p.width, height: p.height },
252
+ fills: [{ kind: "solid", color: hex(p.rgb) }],
253
+ })),
254
+ });
255
+ }
@@ -0,0 +1,12 @@
1
+ import type { CardOrientation, CropRegion } from "./types.js";
2
+ export declare const CR80_LONG = 1012;
3
+ export declare const CR80_SHORT = 638;
4
+ export declare const CR80_ASPECT: number;
5
+ export declare function defaultCropRegion(): CropRegion;
6
+ export declare function cr80Dimensions(orientation: CardOrientation): {
7
+ width: number;
8
+ height: number;
9
+ };
10
+ export declare function cr80CropAspect(orientation: CardOrientation): number;
11
+ export declare function detectOrientation(imgWidth: number, imgHeight: number): CardOrientation;
12
+ export declare function fitCr80CropToImage(imgWidth: number, imgHeight: number, orientation?: CardOrientation): CropRegion;
@@ -0,0 +1,45 @@
1
+ // CR80 card dimensions at 300dpi
2
+ export const CR80_LONG = 1012; // 3.375"
3
+ export const CR80_SHORT = 638; // 2.125"
4
+ export const CR80_ASPECT = CR80_LONG / CR80_SHORT; // ~1.586
5
+ export function defaultCropRegion() {
6
+ return { x: 0, y: 0, width: 1, height: 1, orientation: "landscape" };
7
+ }
8
+ // Get CR80 output dimensions for a given orientation
9
+ export function cr80Dimensions(orientation) {
10
+ return orientation === "landscape"
11
+ ? { width: CR80_LONG, height: CR80_SHORT }
12
+ : { width: CR80_SHORT, height: CR80_LONG };
13
+ }
14
+ // Get the crop aspect ratio for a given orientation
15
+ export function cr80CropAspect(orientation) {
16
+ return orientation === "landscape" ? CR80_ASPECT : 1 / CR80_ASPECT;
17
+ }
18
+ // Auto-detect orientation from image aspect ratio
19
+ export function detectOrientation(imgWidth, imgHeight) {
20
+ return imgWidth >= imgHeight ? "landscape" : "portrait";
21
+ }
22
+ // Fit a CR80 rectangle centered within the image
23
+ export function fitCr80CropToImage(imgWidth, imgHeight, orientation) {
24
+ const orient = orientation ?? detectOrientation(imgWidth, imgHeight);
25
+ const targetAspect = cr80CropAspect(orient);
26
+ const imgAspect = imgWidth / imgHeight;
27
+ if (imgAspect > targetAspect) {
28
+ const cropWidth = targetAspect / imgAspect;
29
+ return {
30
+ x: (1 - cropWidth) / 2,
31
+ y: 0,
32
+ width: cropWidth,
33
+ height: 1,
34
+ orientation: orient,
35
+ };
36
+ }
37
+ const cropHeight = imgAspect / targetAspect;
38
+ return {
39
+ x: 0,
40
+ y: (1 - cropHeight) / 2,
41
+ width: 1,
42
+ height: cropHeight,
43
+ orientation: orient,
44
+ };
45
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { analyzePixels, analyzeStats, buildRecommendation, correctionMatrix, type ImageStats, measureGamut, } from "./analyze.js";
2
+ export { type FitChannelBalanceOptions, fitChannelBalance, isBalanceMeaningful, } from "./balance.js";
3
+ export { assessCalibration, type CalibrationAssessment, type CalibrationBlocker, } from "./calibration.js";
4
+ export { type Box, type ChartPatch, type ChartSpec, chartScene, chromaRampChart, DIAGNOSTIC_CHARTS, grayBalanceChart, hslToRgb, hueSweepChart, latticeCharts, layoutGrid, type PatchRole, type Point, type RGB, toneWedgeChart, } from "./chart.js";
5
+ export { CR80_ASPECT, CR80_LONG, CR80_SHORT, cr80CropAspect, cr80Dimensions, defaultCropRegion, detectOrientation, fitCr80CropToImage, } from "./geometry.js";
6
+ export { applyHomography, type ChartReading, grayCast, type Homography, homographyFrom, type PatchReading, readChart, readingToCsv, repeatSpread, } from "./measure.js";
7
+ export { analyzeScene, classifyIntent, type ImageSampler, type LayerIntent, type LayerIntentResolver, type PlanPolicy, planScene, printAdjust, } from "./plan.js";
8
+ export { NO_PROCESSING, YMCKO_FINISH, YMCKO_PRESET } from "./presets.js";
9
+ export { createPrintProfile, type PrintProfile, type PrintProfileConditions, type PrintProfileCreation, type PrintProfileDetails, parsePrintProfile, profileCacheKey, UNMEASURED_PROFILE, withProfile, } from "./profile.js";
10
+ export type { CardOrientation, ChannelBalance, CropRegion, GamutReport, ImageAnalysis, PixelData, PrintOptimizeOptions, } from "./types.js";
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ // @freshcoat-js/for-print — an ANALYSIS module. Image statistics + print intent in,
2
+ // freshcoat adjustments out. It holds no canvas, no rasterizer, and no pixel
3
+ // output: freshcoat applies the per-layer `Adjust`s and the whole-frame
4
+ // `FrameFinish` this module recommends. Pure and engine-agnostic throughout.
5
+ export { analyzePixels, analyzeStats, buildRecommendation, correctionMatrix, measureGamut, } from "./analyze.js";
6
+ export { fitChannelBalance, isBalanceMeaningful, } from "./balance.js";
7
+ export { assessCalibration, } from "./calibration.js";
8
+ export { chartScene, chromaRampChart, DIAGNOSTIC_CHARTS, grayBalanceChart, hslToRgb, hueSweepChart, latticeCharts, layoutGrid, toneWedgeChart, } from "./chart.js";
9
+ export { CR80_ASPECT, CR80_LONG, CR80_SHORT, cr80CropAspect, cr80Dimensions, defaultCropRegion, detectOrientation, fitCr80CropToImage, } from "./geometry.js";
10
+ export { applyHomography, grayCast, homographyFrom, readChart, readingToCsv, repeatSpread, } from "./measure.js";
11
+ export { analyzeScene, classifyIntent, planScene, printAdjust, } from "./plan.js";
12
+ export { NO_PROCESSING, YMCKO_FINISH, YMCKO_PRESET } from "./presets.js";
13
+ export { createPrintProfile, parsePrintProfile, profileCacheKey, UNMEASURED_PROFILE, withProfile, } from "./profile.js";
@@ -0,0 +1,25 @@
1
+ import type { ChartPatch, ChartSpec, Point, RGB } from "./chart.js";
2
+ import type { PixelData } from "./types.js";
3
+ export type Homography = number[];
4
+ export declare function homographyFrom(from: [Point, Point, Point, Point], to: [Point, Point, Point, Point]): Homography | null;
5
+ export declare function applyHomography(h: Homography, p: Point): Point;
6
+ export type PatchReading = {
7
+ id: string;
8
+ role: ChartPatch["role"];
9
+ sent: RGB;
10
+ raw: RGB;
11
+ measured: RGB;
12
+ repeatOf?: string;
13
+ };
14
+ export type ChartReading = {
15
+ chartId: string;
16
+ patches: PatchReading[];
17
+ stock: RGB;
18
+ stockClipped: boolean;
19
+ lightSpread: number;
20
+ missed: string[];
21
+ };
22
+ export declare function readChart(image: PixelData, spec: ChartSpec, corners: [Point, Point, Point, Point]): ChartReading | null;
23
+ export declare function grayCast(reading: ChartReading): RGB;
24
+ export declare function repeatSpread(reading: ChartReading): number;
25
+ export declare function readingToCsv(reading: ChartReading): string;