@segnavia/render 0.5.0 → 0.6.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.
@@ -0,0 +1,42 @@
1
+ //#region src/browser-jpeg.ts
2
+ function browserJpegDecoder(tab) {
3
+ return async (bytes) => {
4
+ const encoded = Buffer.from(bytes).toString("base64");
5
+ const decoded = await tab.evaluate(async (base64) => {
6
+ const binary = atob(base64);
7
+ const source = Uint8Array.from(binary, (value) => value.charCodeAt(0));
8
+ const bitmap = await createImageBitmap(new Blob([source], { type: "image/jpeg" }), {
9
+ colorSpaceConversion: "none",
10
+ premultiplyAlpha: "none"
11
+ });
12
+ try {
13
+ const context = new OffscreenCanvas(bitmap.width, bitmap.height).getContext("2d");
14
+ if (!context) throw new Error("Chromium could not create a 2D canvas for JPEG decoding");
15
+ context.drawImage(bitmap, 0, 0);
16
+ const rgba = context.getImageData(0, 0, bitmap.width, bitmap.height).data;
17
+ const rgb = new Uint8Array(bitmap.width * bitmap.height * 3);
18
+ for (let pixel = 0; pixel < bitmap.width * bitmap.height; pixel++) {
19
+ rgb[pixel * 3] = rgba[pixel * 4] ?? 0;
20
+ rgb[pixel * 3 + 1] = rgba[pixel * 4 + 1] ?? 0;
21
+ rgb[pixel * 3 + 2] = rgba[pixel * 4 + 2] ?? 0;
22
+ }
23
+ let value = "";
24
+ for (let offset = 0; offset < rgb.length; offset += 32768) value += String.fromCharCode(...rgb.subarray(offset, Math.min(rgb.length, offset + 32768)));
25
+ return {
26
+ width: bitmap.width,
27
+ height: bitmap.height,
28
+ rgb: btoa(value)
29
+ };
30
+ } finally {
31
+ bitmap.close();
32
+ }
33
+ }, encoded);
34
+ return {
35
+ width: decoded.width,
36
+ height: decoded.height,
37
+ rgb: new Uint8Array(Buffer.from(decoded.rgb, "base64"))
38
+ };
39
+ };
40
+ }
41
+ //#endregion
42
+ export { browserJpegDecoder };
@@ -2,7 +2,7 @@ import { checked } from "./types.js";
2
2
  import { D50, labToXyz, matrix3, tableDecode, tableEncode, tryInvert3, xyzToLab } from "./spaces.js";
3
3
  //#region src/color/black-point.ts
4
4
  function detectSourceBlackPoint(colorSpace, pcs, major, relativeA2b, perceptualB2a) {
5
- if (colorSpace !== "CMYK") return [
5
+ if (colorSpace !== "CMYK" && colorSpace !== "GRAY") return [
6
6
  0,
7
7
  0,
8
8
  0
@@ -19,11 +19,12 @@ function detectSourceBlackPoint(colorSpace, pcs, major, relativeA2b, perceptualB
19
19
  checked(result, 2, "black-point A2B result")
20
20
  ]);
21
21
  const lab = pcs === "Lab " ? returned : xyzToLab(returned);
22
- return [
22
+ const black = [
23
23
  Math.min(50, lab[0]),
24
24
  0,
25
25
  0
26
26
  ];
27
+ return pcs === "Lab " ? black : labToXyz(black);
27
28
  }
28
29
  function quadraticRoot(x, y) {
29
30
  if (x.length < 4) return 0;
@@ -61,14 +62,15 @@ function quadraticRoot(x, y) {
61
62
  return discriminant <= 0 ? 0 : Math.max(0, Math.min(50, (-b + Math.sqrt(discriminant)) / (2 * a)));
62
63
  }
63
64
  function destinationBlackPoint(colorSpace, pcs, major, relativeA2b, relativeB2a, sourceBlack) {
64
- if (colorSpace !== "CMYK") return [...sourceBlack];
65
+ if (colorSpace !== "CMYK" && colorSpace !== "GRAY") return [...sourceBlack];
66
+ const sourceLab = pcs === "Lab " ? sourceBlack : xyzToLab(sourceBlack);
65
67
  const inputRamp = [], outputRamp = [];
66
68
  for (let index = 0; index < 256; index++) {
67
69
  const lightness = index * 100 / 255;
68
70
  const lab = [
69
71
  lightness,
70
- Math.max(-50, Math.min(50, sourceBlack[1])),
71
- Math.max(-50, Math.min(50, sourceBlack[2]))
72
+ Math.max(-50, Math.min(50, sourceLab[1])),
73
+ Math.max(-50, Math.min(50, sourceLab[2]))
72
74
  ];
73
75
  const value = pcs === "Lab " ? lab : labToXyz(lab);
74
76
  const device = relativeB2a.run(tableEncode(pcs, relativeB2a.encoding, major, value, false));
@@ -97,11 +99,12 @@ function destinationBlackPoint(colorSpace, pcs, major, relativeA2b, relativeB2a,
97
99
  y.push(normalized);
98
100
  }
99
101
  }
100
- return [
102
+ const result = [
101
103
  x.length < 3 ? 0 : quadraticRoot(x, y),
102
- sourceBlack[1],
103
- sourceBlack[2]
104
+ sourceLab[1],
105
+ sourceLab[2]
104
106
  ];
107
+ return pcs === "Lab " ? result : labToXyz(result);
105
108
  }
106
109
  function compensateBlackPoint(profile, lab, reverse) {
107
110
  const point = reverse ? profile.sourceBlackPoint : profile.destinationBlackPoint;
@@ -1,4 +1,4 @@
1
- import { clamp, color4, refuse } from "./types.js";
1
+ import { clamp, color1, color4, refuse } from "./types.js";
2
2
  import { labToXyz, srgbToLab, tableEncode } from "./spaces.js";
3
3
  import { compensateBlackPoint } from "./black-point.js";
4
4
  //#region src/color/engine.ts
@@ -14,5 +14,12 @@ function toPress(profile, rgb) {
14
14
  clamp(black)
15
15
  ];
16
16
  }
17
+ function toGray(profile, rgb) {
18
+ if (profile.colorSpace !== "GRAY") refuse(`destination color space ${profile.colorSpace}`, "GRAY");
19
+ const lab = compensateBlackPoint(profile, srgbToLab(rgb), false);
20
+ const pcs = profile.pcs === "Lab " ? lab : labToXyz(lab);
21
+ const [gray] = color1(profile.b2a.run(tableEncode(profile.pcs, profile.b2a.encoding, profile.majorVersion, pcs, true)), "B2A result");
22
+ return [clamp(gray)];
23
+ }
17
24
  //#endregion
18
- export { toPress };
25
+ export { toGray, toPress };
@@ -34,6 +34,16 @@ function multilinear(grid, points, outputs, input) {
34
34
  }
35
35
  return result;
36
36
  }
37
+ function linearGrid(grid, points, outputs, input) {
38
+ const count = checked(points, 0, "CLUT dimensions");
39
+ const position = clamp(checked(input, 0, "CLUT input")) * (count - 1);
40
+ const low = Math.min(count - 2, Math.floor(position));
41
+ const fraction = position - low;
42
+ return Array.from({ length: outputs }, (_, channel) => {
43
+ const lower = grid.get(low * outputs + channel);
44
+ return lower + (grid.get((low + 1) * outputs + channel) - lower) * fraction;
45
+ });
46
+ }
37
47
  function tetrahedral4(grid, points, outputs, input) {
38
48
  const { base, fractions, stride } = layout(points, outputs, input);
39
49
  const order = [
@@ -71,4 +81,4 @@ function matrixInput(values) {
71
81
  ];
72
82
  }
73
83
  //#endregion
74
- export { interpolate, matrixInput, multilinear, tetrahedral4 };
84
+ export { interpolate, linearGrid, matrixInput, multilinear, tetrahedral4 };
@@ -1,7 +1,7 @@
1
1
  import { REVERSE_LUT, checked, refuse } from "./types.js";
2
2
  import { destinationBlackPoint, detectSourceBlackPoint } from "./black-point.js";
3
3
  import { fixed, need, signature, u32 } from "./binary.js";
4
- import { matrixTables, parseMab, parseMft } from "./tables.js";
4
+ import { grayTables, matrixTables, parseMab, parseMft } from "./tables.js";
5
5
  //#region src/color/profile.ts
6
6
  const MAX_PROFILE_BYTES = 33554432;
7
7
  function header(bytes, view, bounds) {
@@ -12,7 +12,7 @@ function header(bytes, view, bounds) {
12
12
  const profileClass = signature(bytes, 12, bounds);
13
13
  if (profileClass !== "prtr" && profileClass !== "mntr") refuse(`profile class ${JSON.stringify(profileClass)}`, "prtr or mntr");
14
14
  const colorSpace = signature(bytes, 16, bounds);
15
- if (colorSpace !== "CMYK" && colorSpace !== "RGB ") refuse(`color space ${JSON.stringify(colorSpace)}`, "CMYK or RGB");
15
+ if (colorSpace !== "CMYK" && colorSpace !== "GRAY" && colorSpace !== "RGB ") refuse(`color space ${JSON.stringify(colorSpace)}`, "CMYK, GRAY, or RGB");
16
16
  const pcs = signature(bytes, 20, bounds);
17
17
  if (pcs !== "Lab " && pcs !== "XYZ ") refuse(`PCS ${JSON.stringify(pcs)}`, "Lab or XYZ");
18
18
  u32(view, 0, bounds);
@@ -55,6 +55,13 @@ function table(bytes, view, all, names, inputs, outputs) {
55
55
  if (!record) throw new Error(`ICC profile is missing ${name} after its tag set was validated`);
56
56
  return recordTable(bytes, view, record, inputs, outputs);
57
57
  }
58
+ function optionalTable(bytes, view, all, names, inputs, outputs) {
59
+ const name = names.find((candidate) => all[candidate]);
60
+ if (!name) return void 0;
61
+ const record = all[name];
62
+ if (!record) throw new Error(`ICC profile is missing ${name} after its tag set was validated`);
63
+ return recordTable(bytes, view, record, inputs, outputs);
64
+ }
58
65
  function readProfile(bytes) {
59
66
  if (bytes.length > 33554432) refuse(`profile length ${bytes.length}`, `at most ${MAX_PROFILE_BYTES} bytes`);
60
67
  const bounds = {
@@ -63,12 +70,15 @@ function readProfile(bytes) {
63
70
  }, view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
64
71
  const parsed = header(bytes, view, bounds), all = records(bytes, view, bounds);
65
72
  if (all.wtpt?.type !== "XYZ ") refuse(`white point tag type ${JSON.stringify(all.wtpt?.type ?? "missing")}`, "XYZ ");
66
- const channels = parsed.colorSpace === "CMYK" ? 4 : 3;
73
+ const channels = parsed.colorSpace === "CMYK" ? 4 : parsed.colorSpace === "GRAY" ? 1 : 3;
67
74
  const matrix = parsed.colorSpace === "RGB " ? matrixTables(bytes, view, all) : void 0;
68
- const a2b = matrix?.a2b ?? table(bytes, view, all, ["A2B1", "A2B0"], channels, 3);
69
- const b2a = matrix?.b2a ?? table(bytes, view, all, ["B2A1", "B2A0"], 3, channels);
70
- const perceptualA2b = matrix?.a2b ?? table(bytes, view, all, ["A2B0", "A2B1"], channels, 3);
71
- const perceptualB2a = matrix?.b2a ?? table(bytes, view, all, ["B2A0", "B2A1"], 3, channels);
75
+ const trcRecord = all.kTRC;
76
+ const gray = parsed.colorSpace === "GRAY" && trcRecord ? grayTables(bytes, view, trcRecord, parsed.pcs) : void 0;
77
+ const select = (names, inputs, outputs, fallback) => optionalTable(bytes, view, all, names, inputs, outputs) ?? fallback ?? table(bytes, view, all, names, inputs, outputs);
78
+ const a2b = matrix?.a2b ?? select(["A2B1", "A2B0"], channels, 3, gray?.a2b);
79
+ const b2a = matrix?.b2a ?? select(["B2A1", "B2A0"], 3, channels, gray?.b2a);
80
+ const perceptualA2b = matrix?.a2b ?? select(["A2B0", "A2B1"], channels, 3, gray?.a2b);
81
+ const perceptualB2a = matrix?.b2a ?? select(["B2A0", "B2A1"], 3, channels, gray?.b2a);
72
82
  const gamutRecord = all.gamt, gamut = gamutRecord ? recordTable(bytes, view, gamutRecord, 3, 1) : void 0;
73
83
  const white = all.wtpt;
74
84
  if (!white) throw new Error("ICC profile is missing wtpt after its tag set was validated");
@@ -1,7 +1,7 @@
1
1
  import { REVERSE_LUT, checked, clamp, refuse } from "./types.js";
2
2
  import { invert3, matrix3 } from "./spaces.js";
3
3
  import { align4, fixed, need, signature, u16, u32 } from "./binary.js";
4
- import { interpolate, matrixInput, multilinear, tetrahedral4 } from "./interpolation.js";
4
+ import { interpolate, linearGrid, matrixInput, multilinear, tetrahedral4 } from "./interpolation.js";
5
5
  //#region src/color/tables.ts
6
6
  function packed(bytes, view, offset, length, width, bounds) {
7
7
  need(offset, length * width, bounds);
@@ -73,6 +73,16 @@ function parseCurve(bytes, view, offset, bounds) {
73
73
  size: 12 + count * 4
74
74
  };
75
75
  }
76
+ function invertCurve(curve, target) {
77
+ let low = 0, high = 1;
78
+ const descending = curve(0) > curve(1);
79
+ for (let index = 0; index < 24; index++) {
80
+ const middle = (low + high) / 2;
81
+ if (descending ? curve(middle) > target : curve(middle) < target) low = middle;
82
+ else high = middle;
83
+ }
84
+ return (low + high) / 2;
85
+ }
76
86
  function curvesAt(bytes, view, offset, count, bounds) {
77
87
  const curves = [];
78
88
  let cursor = offset;
@@ -132,7 +142,7 @@ function parseMft(bytes, view, record, type, inputs, outputs) {
132
142
  ];
133
143
  current = matrix3(fixedMatrix, matrixInput(current));
134
144
  }
135
- current = inputs === 3 ? multilinear(grid, points, outputs, current) : tetrahedral4(grid, points, outputs, current);
145
+ current = inputs === 1 ? linearGrid(grid, points, outputs, current) : inputs === 3 ? multilinear(grid, points, outputs, current) : tetrahedral4(grid, points, outputs, current);
136
146
  return current.map((value, channel) => interpolate(checked(outputTables, channel, `${type} output tables`), value));
137
147
  }
138
148
  };
@@ -179,7 +189,7 @@ function parseMab(bytes, view, record, type, inputs, outputs) {
179
189
  2
180
190
  ].map((row) => checked(matrix, row * 3, `${type} matrix`) * x + checked(matrix, row * 3 + 1, `${type} matrix`) * y + checked(matrix, row * 3 + 2, `${type} matrix`) * z + checked(matrix, 9 + row, `${type} matrix`));
181
191
  };
182
- const clut = (values) => !grid ? [...values] : inputs === 3 ? multilinear(grid, points, outputs, values) : tetrahedral4(grid, points, outputs, values);
192
+ const clut = (values) => !grid ? [...values] : inputs === 1 ? linearGrid(grid, points, outputs, values) : inputs === 3 ? multilinear(grid, points, outputs, values) : tetrahedral4(grid, points, outputs, values);
183
193
  return {
184
194
  inputChannels: inputs,
185
195
  outputChannels: outputs,
@@ -227,15 +237,6 @@ function matrixTables(bytes, view, records) {
227
237
  checked(checked(columns, 2, "colorants"), 2, "blue")
228
238
  ];
229
239
  const inverse = invert3(matrix);
230
- const invertCurve = (curve, target) => {
231
- let low = 0, high = 1;
232
- for (let index = 0; index < 24; index++) {
233
- const middle = (low + high) / 2;
234
- if (curve(middle) < target) low = middle;
235
- else high = middle;
236
- }
237
- return (low + high) / 2;
238
- };
239
240
  return {
240
241
  a2b: {
241
242
  inputChannels: 3,
@@ -251,5 +252,38 @@ function matrixTables(bytes, view, records) {
251
252
  }
252
253
  };
253
254
  }
255
+ function grayTables(bytes, view, record, pcs) {
256
+ const curve = parseCurve(bytes, view, record.offset, record).curve;
257
+ const scale = 32768 / 65535;
258
+ return {
259
+ a2b: {
260
+ inputChannels: 1,
261
+ outputChannels: 3,
262
+ encoding: "mAB ",
263
+ run(input) {
264
+ const luminance = curve(checked(input, 0, "gray input"));
265
+ if (pcs === "XYZ ") return [
266
+ luminance * .9642 * scale,
267
+ luminance * scale,
268
+ luminance * .8249 * scale
269
+ ];
270
+ return [
271
+ luminance,
272
+ 128 / 255,
273
+ 128 / 255
274
+ ];
275
+ }
276
+ },
277
+ b2a: {
278
+ inputChannels: 3,
279
+ outputChannels: 1,
280
+ encoding: REVERSE_LUT,
281
+ run(input) {
282
+ const luminance = pcs === "XYZ " ? checked(input, 1, "gray PCS input") / scale : checked(input, 0, "gray PCS input");
283
+ return [invertCurve(curve, luminance)];
284
+ }
285
+ }
286
+ };
287
+ }
254
288
  //#endregion
255
- export { matrixTables, parseCurve, parseMab, parseMft };
289
+ export { grayTables, invertCurve, matrixTables, parseCurve, parseMab, parseMft };
@@ -8,6 +8,9 @@ function checked(values, index, structure) {
8
8
  if (value === void 0) throw new Error(`${structure} index ${index} is outside length ${values.length}`);
9
9
  return value;
10
10
  }
11
+ function color1(values, structure) {
12
+ return [checked(values, 0, structure)];
13
+ }
11
14
  function color4(values, structure) {
12
15
  return [
13
16
  checked(values, 0, structure),
@@ -18,4 +21,4 @@ function color4(values, structure) {
18
21
  }
19
22
  const clamp = (value) => Math.max(0, Math.min(1, value));
20
23
  //#endregion
21
- export { REVERSE_LUT, checked, clamp, color4, refuse };
24
+ export { REVERSE_LUT, checked, clamp, color1, color4, refuse };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EffectsReport } from "./effects/types.js";
2
- import { PressImage, PressReport } from "./press/types.js";
2
+ import { PressDestination, PressImage, PressReport } from "./press/types.js";
3
3
  import { Font, ImageOptions, RenderLimits, RenderOptions } from "./options.js";
4
4
  import { RenderRefusedError, RendererUnavailableError } from "./errors.js";
5
5
  import { BookInput, Deck, ImpositionInput, PageInput } from "@segnavia/format";
@@ -33,4 +33,4 @@ export declare function fontFaces(fonts?: Font[]): string;
33
33
  export declare function captureImage(tab: Page$1, options: Pick<ImageOptions, 'format' | 'quality' | 'limits'>, fullPage: boolean): Promise<Uint8Array>;
34
34
  export declare function openRenderer(options: RendererOptions): Promise<Renderer>;
35
35
  //#endregion
36
- export { type EffectsReport, type Font, type ImageOptions, type PressImage, type PressReport, type RenderLimits, type RenderOptions, RenderRefusedError, RendererUnavailableError };
36
+ export { type EffectsReport, type Font, type ImageOptions, type PressDestination, type PressImage, type PressReport, type RenderLimits, type RenderOptions, RenderRefusedError, RendererUnavailableError };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { RenderRefusedError, RendererUnavailableError } from "./errors.js";
2
2
  import { renderBrowserBookPdf } from "./book-render.js";
3
+ import { browserJpegDecoder } from "./browser-jpeg.js";
3
4
  import { containFixedPageOverflow } from "./overflow-containment.js";
4
5
  import { renderDeckPdf } from "./deck-pdf.js";
5
6
  import { assertNeutralTolerance } from "./press/color-policy.js";
@@ -55,7 +56,7 @@ async function enforce(pdf, limits) {
55
56
  }
56
57
  return pdf;
57
58
  }
58
- async function finishPdf(pdf, options) {
59
+ async function finishPdf(pdf, options, decodeJpeg) {
59
60
  if (options.limits?.maxBytes !== void 0 && pdf.byteLength > options.limits.maxBytes) throw new RenderRefusedError(`The PDF is ${pdf.byteLength} bytes; the limit is ${options.limits.maxBytes}.`);
60
61
  let output = pdf;
61
62
  if (options.effects !== false) {
@@ -66,7 +67,7 @@ async function finishPdf(pdf, options) {
66
67
  if (options.press) {
67
68
  const neutralTolerance = options.press.neutralTolerance ?? 3;
68
69
  assertNeutralTolerance(neutralTolerance);
69
- const converted = await rewritePressPdf(output, options.press.profile, neutralTolerance);
70
+ const converted = await rewritePressPdf(output, options.press.profile, neutralTolerance, decodeJpeg);
70
71
  output = converted.bytes;
71
72
  options.press.report?.(converted.report);
72
73
  }
@@ -205,6 +206,18 @@ async function openRenderer(options) {
205
206
  reportHeight: false
206
207
  });
207
208
  }
209
+ async function finishWithBrowser(pdf, options) {
210
+ if (!options.press || new TextDecoder().decode(options.press.profile.subarray(16, 20)) !== "GRAY") return finishPdf(pdf, options);
211
+ return withTab({
212
+ viewport: {
213
+ width: 1,
214
+ height: 1
215
+ },
216
+ scripts: true,
217
+ html: "<!doctype html>",
218
+ waitForReady: false
219
+ }, (tab) => finishPdf(pdf, options, browserJpegDecoder(tab)));
220
+ }
208
221
  function pageViewport(page, width, printing = false) {
209
222
  if (page.print) return printViewport(page.print, printing);
210
223
  if (page.size === "fluid") return {
@@ -275,11 +288,11 @@ async function openRenderer(options) {
275
288
  return renderBrowserBookPdf(input, opts, {
276
289
  render: (setup, pdf) => withTab(setup, (tab) => tab.pdf(pdf)),
277
290
  fontFaces: (options) => fontFaces(options.fonts),
278
- finish: finishPdf
291
+ finish: finishWithBrowser
279
292
  });
280
293
  },
281
294
  async pagePdf(input, opts = {}) {
282
- return finishPdf(await renderPagePdf(Page.parse(input), opts), opts);
295
+ return finishWithBrowser(await renderPagePdf(Page.parse(input), opts), opts);
283
296
  },
284
297
  async pageImage(input, opts = {}) {
285
298
  const page = Page.parse(input);
@@ -303,7 +316,7 @@ async function openRenderer(options) {
303
316
  pagePdf: renderPagePdf,
304
317
  piecePdf: renderPiecePdf,
305
318
  fontFaces: (options) => fontFaces(options.fonts),
306
- finish: finishPdf
319
+ finish: finishWithBrowser
307
320
  });
308
321
  },
309
322
  deckPdf(deck, opts = {}) {
@@ -313,7 +326,7 @@ async function openRenderer(options) {
313
326
  waitForReady: false
314
327
  }, (tab) => tab.pdf(pdf)),
315
328
  fontFaces: (options) => fontFaces(options.fonts),
316
- finish: finishPdf
329
+ finish: finishWithBrowser
317
330
  });
318
331
  },
319
332
  async slideImage(deck, index, opts = {}) {