@segnavia/render 0.5.0 → 0.7.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,123 @@
1
+ //#region src/overflow.ts
2
+ /**
3
+ * Chromium's measured maximum print shrink; the 3x overflow demo check fails
4
+ * if it changes. Chromium lays a scaled print out at the narrower width P / s,
5
+ * then shrinks it by at most this factor. The solved scale searches layout
6
+ * widths for the point where rightward content reaches P, and uses this exact
7
+ * cap when it is already reached at the narrowest layout. Rightward generated
8
+ * content in an RTL root is a known limit: neither scroll extent nor element
9
+ * boxes expose it.
10
+ */
11
+ const CHROMIUM_MAX_SHRINK = 1.5;
12
+ /** Decides the next layout to measure, or the print scale, from measured widths. */
13
+ function solveScale(pageWidth, measurements) {
14
+ const measured = new Map(measurements.map((measurement) => [measurement.layoutWidth, measurement.contentWidth]));
15
+ const atPage = measured.get(pageWidth);
16
+ if (atPage === void 0) return { measure: pageWidth };
17
+ if (atPage <= pageWidth) return { scale: 1 };
18
+ const narrowest = Math.ceil(pageWidth / CHROMIUM_MAX_SHRINK);
19
+ const atNarrowest = measured.get(narrowest);
20
+ if (atNarrowest === void 0) return { measure: narrowest };
21
+ if (atNarrowest >= pageWidth) return { scale: CHROMIUM_MAX_SHRINK };
22
+ let low = narrowest;
23
+ let high = pageWidth;
24
+ let lastSatisfied = pageWidth;
25
+ while (high - low > 1) {
26
+ const middle = Math.floor((low + high) / 2);
27
+ const width = measured.get(middle);
28
+ if (width === void 0) return { measure: middle };
29
+ if (width >= pageWidth) {
30
+ high = middle;
31
+ lastSatisfied = middle;
32
+ } else low = middle;
33
+ }
34
+ return { scale: pageWidth / (measured.get(high) !== void 0 && measured.get(high) >= pageWidth ? high : lastSatisfied) };
35
+ }
36
+ async function measuredOverflow(tab, pageWidth) {
37
+ return tab.evaluate((width) => {
38
+ const root = document.documentElement;
39
+ const extent = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0);
40
+ const endOverflow = Math.max(0, extent - root.clientWidth);
41
+ const rtl = getComputedStyle(root).direction === "rtl";
42
+ const contributions = [];
43
+ let clippedStart = 0;
44
+ let rightOverflow = rtl ? 0 : endOverflow;
45
+ const locator = (element) => {
46
+ if (element.id) return `#${CSS.escape(element.id)}`;
47
+ const parts = [];
48
+ let current = element;
49
+ while (current && current !== root && parts.length < 3) {
50
+ const classes = [...current.classList].slice(0, 2).map((name) => `.${CSS.escape(name)}`).join("");
51
+ parts.unshift(`${current.localName}${classes}`);
52
+ current = current.parentElement;
53
+ }
54
+ return parts.join(" > ");
55
+ };
56
+ for (const element of document.querySelectorAll("*")) {
57
+ const box = element.getBoundingClientRect();
58
+ const elementStart = Math.max(0, rtl ? box.right - width : -box.left);
59
+ const elementEnd = Math.max(0, rtl ? -box.left : box.right - width);
60
+ clippedStart = Math.max(clippedStart, elementStart);
61
+ rightOverflow = Math.max(rightOverflow, box.right - width);
62
+ if ((elementStart > 0 || elementEnd > 0) && contributions.length < 20) contributions.push({
63
+ locator: locator(element),
64
+ clippedStart: elementStart,
65
+ clippedEnd: elementEnd
66
+ });
67
+ }
68
+ return {
69
+ overflow: {
70
+ pageWidth: width,
71
+ extent,
72
+ clippedStart,
73
+ clippedEnd: endOverflow,
74
+ elements: contributions
75
+ },
76
+ rightOverflow
77
+ };
78
+ }, pageWidth);
79
+ }
80
+ function overflowEntry(measured, scale, part, inputIndex) {
81
+ return {
82
+ part,
83
+ ...inputIndex === void 0 ? {} : { inputIndex },
84
+ ...measured.overflow,
85
+ scale
86
+ };
87
+ }
88
+ async function measureOverflow(tab, pageWidth, part, inputIndex) {
89
+ await tab.emulateMedia({ media: "print" });
90
+ return overflowEntry(await measuredOverflow(tab, pageWidth), 1, part, inputIndex);
91
+ }
92
+ async function printWithOverflow(tab, pageWidth, part, pdf, inputIndex) {
93
+ await tab.emulateMedia({ media: "print" });
94
+ const viewport = tab.viewportSize();
95
+ if (!viewport) throw new Error("The print tab has no viewport.");
96
+ const measurements = [];
97
+ let original;
98
+ let decision = solveScale(pageWidth, measurements);
99
+ while ("measure" in decision) {
100
+ await tab.setViewportSize({
101
+ width: decision.measure,
102
+ height: viewport.height
103
+ });
104
+ const measured = await measuredOverflow(tab, decision.measure);
105
+ if (decision.measure === pageWidth) original = measured;
106
+ measurements.push({
107
+ layoutWidth: decision.measure,
108
+ contentWidth: decision.measure + measured.rightOverflow
109
+ });
110
+ decision = solveScale(pageWidth, measurements);
111
+ }
112
+ await tab.setViewportSize(viewport);
113
+ if (!original) throw new Error("The page-width overflow measurement was not made.");
114
+ return {
115
+ bytes: await tab.pdf({
116
+ ...pdf,
117
+ scale: decision.scale
118
+ }),
119
+ overflow: overflowEntry(original, decision.scale, part, inputIndex)
120
+ };
121
+ }
122
+ //#endregion
123
+ export { CHROMIUM_MAX_SHRINK, measureOverflow, printWithOverflow, solveScale };
@@ -1,5 +1,6 @@
1
1
  import { RenderRefusedError } from "./errors.js";
2
2
  import { pdfBox, pdfPoint, pt } from "./pdf-geometry.js";
3
+ import { printWithOverflow } from "./overflow.js";
3
4
  import { PX_PER_MM, printBoxes, printPadding, printProperties, sheetMm } from "@segnavia/format";
4
5
  import { PDFDocument, PDFName, rgb } from "pdf-lib";
5
6
  //#region src/page-paper.ts
@@ -56,11 +57,12 @@ async function addPressBoxes(pdf, print) {
56
57
  });
57
58
  return document.save();
58
59
  }
59
- async function printPage(tab, print, preferCssPageSize) {
60
+ async function printPage(tab, print, preferCssPageSize, inputIndex) {
60
61
  if (!usesPressLayout(print)) {
61
62
  const mm = sheetMm(print);
62
63
  const margin = print.margin;
63
- return tab.pdf({
64
+ const pageWidth = Math.round(mm.width * PX_PER_MM);
65
+ return printWithOverflow(tab, pageWidth, "page", {
64
66
  width: `${mm.width}mm`,
65
67
  height: `${mm.height}mm`,
66
68
  printBackground: true,
@@ -71,10 +73,10 @@ async function printPage(tab, print, preferCssPageSize) {
71
73
  bottom: `${margin.bottom}mm`,
72
74
  left: `${margin.left}mm`
73
75
  } : void 0
74
- });
76
+ }, inputIndex);
75
77
  }
76
78
  const boxes = printBoxes(print);
77
- const pdf = await tab.pdf({
79
+ const printed = await printWithOverflow(tab, Math.round(boxes.bleedBox.width * PX_PER_MM), "page", {
78
80
  width: `${boxes.bleedBox.width}mm`,
79
81
  height: `${boxes.bleedBox.height}mm`,
80
82
  printBackground: true,
@@ -85,10 +87,13 @@ async function printPage(tab, print, preferCssPageSize) {
85
87
  bottom: "0mm",
86
88
  left: "0mm"
87
89
  }
88
- });
89
- const papers = (await PDFDocument.load(pdf)).getPageCount();
90
+ }, inputIndex);
91
+ const papers = (await PDFDocument.load(printed.bytes)).getPageCount();
90
92
  if (papers > 1) throw new RenderRefusedError(`A page with bleed or marks is one printed piece, but the content needs ${papers} papers. Shorten the content, or remove the bleed and marks.`);
91
- return addPressBoxes(pdf, print);
93
+ return {
94
+ bytes: await addPressBoxes(printed.bytes, print),
95
+ overflow: printed.overflow
96
+ };
92
97
  }
93
98
  //#endregion
94
99
  export { assertPrintOptions, printCss, printPage, printViewport };
package/dist/pages-pdf.js CHANGED
@@ -51,13 +51,16 @@ async function renderPagesPdf(inputs, opts, services) {
51
51
  if (inputs.length === 0) throw new RenderRefusedError("There is nothing to render. Pass at least one page to clear this refusal.");
52
52
  const pages = inputs.map((input) => Page.parse(input));
53
53
  if (!opts.imposition) {
54
- const rendered = await Promise.all(pages.map((page) => services.pagePdf(page, {
54
+ const rendered = await Promise.all(pages.map((page, inputIndex) => services.pagePdf(page, {
55
55
  ...opts,
56
56
  effects: false,
57
57
  limits: void 0,
58
- press: void 0
59
- })));
60
- return services.finish(await joinPdfs(rendered), opts);
58
+ press: void 0,
59
+ overflow: void 0
60
+ }, inputIndex)));
61
+ const output = await services.finish(await joinPdfs(rendered.map((part) => part.bytes)), opts);
62
+ opts.overflow?.report?.({ entries: rendered.flatMap((part) => part.overflow ? [part.overflow] : []) });
63
+ return output;
61
64
  }
62
65
  const imposition = Imposition.parse(opts.imposition);
63
66
  if (opts.preferCssPageSize) throw new RenderRefusedError("Pages in an imposition take their size from its paper. Remove preferCssPageSize, or remove the imposition, to clear this refusal.");
@@ -1,5 +1,5 @@
1
1
  import { RenderRefusedError } from "../errors.js";
2
- import { cachedPressColor, newlySnapsToNeutral, pressColor } from "./color-policy.js";
2
+ import { cachedPressColor, destinationColor, newlySnapsToNeutral, pdfMaskLuminosity, pressDestination } from "./color-policy.js";
3
3
  import { groupAction, shadingAction } from "./policy.js";
4
4
  import { rewriteContent } from "./content.js";
5
5
  import { arrayNumbers, decodeContent, dictionaryOf, nameOf, numberOf, replaceArray, resolvedColorSpace } from "./pdf-objects.js";
@@ -7,6 +7,7 @@ import { walkPressGraph } from "./visitor.js";
7
7
  import "../effects/types.js";
8
8
  import { replaceType1Function, sampleDomainForUses, shadingDeviceMatrix, shadingDomainOf, shadingSampleGridForUses } from "./sample-shading.js";
9
9
  import { readProfile } from "../color/profile.js";
10
+ import { convertSuppliedImage, convertedImageCount, createImageState } from "./image.js";
10
11
  import { createPaintedBoundsTracker, recordPatternPaintedBounds } from "./pattern-bounds.js";
11
12
  import { inspectPressPdf } from "./report.js";
12
13
  import { PDFDict, PDFDocument, PDFHexString, PDFName, PDFRef, PDFString } from "pdf-lib";
@@ -17,12 +18,14 @@ const refuse = (found, supported) => {
17
18
  throw new RenderRefusedError(`The PDF has unsupported ${found}; supported: ${supported}.`);
18
19
  };
19
20
  const resolved = (state, object) => object instanceof PDFRef ? state.document.context.lookup(object) : object;
20
- function convertFunction(state, fn, policy) {
21
+ const isGrayMask = (state, mask) => state.destination === "gray" && mask !== "none";
22
+ const converterFor = (state, mask) => isGrayMask(state, mask) ? pdfMaskLuminosity : state.convert;
23
+ function convertFunction(state, fn, policy, mask) {
21
24
  if (policy.action === "refuse") refuse(policy.found, policy.supported);
22
25
  if (!fn) return;
23
26
  if (numberOf(fn, "FunctionType") === 2) {
24
- for (const name of ["C0", "C1"]) replaceArray(fn, name, state.convert(arrayNumbers(fn, name, 3)));
25
- if (fn.has(key("Range"))) replaceArray(fn, "Range", [
27
+ for (const name of ["C0", "C1"]) replaceArray(fn, name, converterFor(state, mask)(arrayNumbers(fn, name, 3)));
28
+ if (fn.has(key("Range"))) replaceArray(fn, "Range", state.destination === "gray" ? [0, 1] : [
26
29
  0,
27
30
  1,
28
31
  0,
@@ -35,7 +38,7 @@ function convertFunction(state, fn, policy) {
35
38
  state.totals.gradientStops += 2;
36
39
  return;
37
40
  }
38
- if (fn.has(key("Range"))) replaceArray(fn, "Range", [
41
+ if (fn.has(key("Range"))) replaceArray(fn, "Range", state.destination === "gray" ? [0, 1] : [
39
42
  0,
40
43
  1,
41
44
  0,
@@ -72,78 +75,81 @@ function shadingUses(state, shading, pattern) {
72
75
  });
73
76
  return uses.filter((use) => use.painted !== void 0);
74
77
  }
75
- function convertType1Shading(state, shading, pattern) {
78
+ function convertType1Shading(state, shading, pattern, mask) {
76
79
  const uses = shadingUses(state, shading, pattern);
77
80
  const domain = sampleDomainForUses(shadingDomainOf(shading), uses);
78
81
  const matrices = uses.length > 0 ? uses.map((use) => use.matrix) : [shadingDeviceMatrix(shading, pattern)];
79
82
  const size = shadingSampleGridForUses(domain, matrices);
80
- replaceType1Function(state.document, shading, pattern, state.sampleConvert, state.budget, void 0, domain, size);
83
+ replaceType1Function(state.document, shading, pattern, isGrayMask(state, mask) ? pdfMaskLuminosity : state.sampleConvert, state.budget, void 0, domain, size, state.destination === "gray" ? 1 : 4);
81
84
  }
82
- function convertShading(state, shading, pattern) {
85
+ function convertShading(state, shading, pattern, mask) {
83
86
  if (state.seenShadings.has(shading)) return false;
84
87
  state.seenShadings.add(shading);
85
88
  const type = numberOf(shading, "ShadingType");
86
89
  const functionType = functionTypeOf(state, shading);
87
90
  const space = resolvedColorSpace(state.document, resolved(state, shading.get(key("ColorSpace"))));
88
- const policy = shadingAction(space.kind, type, functionType);
91
+ const destination = state.destination;
92
+ const policy = shadingAction(space.kind, type, functionType, destination);
89
93
  if (policy.action === "refuse") {
90
94
  if (type === 1) refuse(`ShadingType 1 FunctionType ${functionType ?? "missing"}`, policy.supported);
91
95
  refuse(`ShadingType ${type ?? "missing"} color space ${space.label}`, policy.supported);
92
96
  }
93
97
  if (policy.action === "keep") return false;
94
- if (shading.has(key("Background"))) replaceArray(shading, "Background", state.convert(arrayNumbers(shading, "Background", 3)));
95
- shading.set(key("ColorSpace"), key("DeviceCMYK"));
98
+ if (shading.has(key("Background"))) replaceArray(shading, "Background", converterFor(state, mask)(arrayNumbers(shading, "Background", 3)));
99
+ shading.set(key("ColorSpace"), key(destination === "gray" ? "DeviceGray" : "DeviceCMYK"));
96
100
  if (type === 1) {
97
- convertType1Shading(state, shading, pattern);
101
+ convertType1Shading(state, shading, pattern, mask);
98
102
  return false;
99
103
  }
100
104
  return true;
101
105
  }
102
- function convertGroup(state, owner, maskSource) {
106
+ function convertGroup(state, owner, mask) {
103
107
  const group = owner.lookupMaybe(key("Group"), PDFDict);
104
108
  if (!group || nameOf(group.get(key("S"))) !== "Transparency" || state.seenGroups.has(group)) return;
105
109
  state.seenGroups.add(group);
106
110
  const space = resolvedColorSpace(state.document, resolved(state, group.get(key("CS"))));
107
- const policy = groupAction(space.kind, space.label, maskSource);
111
+ const destination = state.destination;
112
+ const policy = groupAction(space.kind, space.label, mask !== "none", destination);
108
113
  if (policy.action === "refuse") refuse(`transparency group color space ${space.label}`, policy.supported);
109
114
  if (policy.action === "convert") {
110
- group.set(key("CS"), key("DeviceCMYK"));
115
+ group.set(key("CS"), key(destination === "gray" ? "DeviceGray" : "DeviceCMYK"));
111
116
  state.totals.groups++;
112
117
  }
113
118
  }
114
- function convertRuleCImage(state, image, width, height, dictionary) {
119
+ function convertRuleCImage(state, image, width, height, dictionary, convert) {
115
120
  const source = decodeContent(image, state.budget);
116
121
  if (source.length !== width * height * 3) refuse("effect image sample length", "three 8-bit RGB samples per pixel");
117
- const converted = new Uint8Array(width * height * 4);
122
+ const channels = state.destination === "gray" ? 1 : 4;
123
+ const converted = new Uint8Array(width * height * channels);
118
124
  for (let pixel = 0; pixel < width * height; pixel++) {
119
- const color = state.convert([
125
+ const color = convert([
120
126
  (source[pixel * 3] ?? 0) / 255,
121
127
  (source[pixel * 3 + 1] ?? 0) / 255,
122
128
  (source[pixel * 3 + 2] ?? 0) / 255
123
129
  ]);
124
- for (let channel = 0; channel < 4; channel++) converted[pixel * 4 + channel] = Math.round(Math.max(0, Math.min(1, color[channel] ?? 0)) * 255);
130
+ for (let channel = 0; channel < channels; channel++) converted[pixel * channels + channel] = Math.round(Math.max(0, Math.min(1, color[channel] ?? 0)) * 255);
125
131
  }
126
- dictionary.ColorSpace = key("DeviceCMYK");
132
+ dictionary.ColorSpace = key(state.destination === "gray" ? "DeviceGray" : "DeviceCMYK");
127
133
  const replacement = state.document.context.stream(deflateSync(converted), dictionary);
128
134
  const reference = state.document.context.getObjectRef(image);
129
135
  if (reference) state.document.context.assign(reference, replacement);
130
136
  }
131
- function convertEffectImage(state, image) {
137
+ function convertEffectImage(state, image, mask) {
132
138
  if (!image.dict.has(key("SegnaviaGeneratedEffect")) || resolvedColorSpace(state.document, resolved(state, image.dict.get(key("ColorSpace")))).kind !== "rgb") return;
133
139
  const width = numberOf(image.dict, "Width") ?? refuse("effect image Width missing", "a positive Width number");
134
140
  const height = numberOf(image.dict, "Height") ?? refuse("effect image Height missing", "a positive Height number");
135
141
  if (width <= 0 || height <= 0) refuse("effect image dimensions", "positive Width and Height numbers");
136
142
  const dictionary = streamDictionary(image);
143
+ const convert = converterFor(state, mask);
137
144
  if (!image.dict.has(key("SegnaviaEffectColor"))) {
138
- convertRuleCImage(state, image, width, height, dictionary);
145
+ convertRuleCImage(state, image, width, height, dictionary, convert);
139
146
  return;
140
147
  }
141
- const source = arrayNumbers(image.dict, "SegnaviaEffectColor", 3);
142
- const color = state.convert(source);
148
+ const color = convert(arrayNumbers(image.dict, "SegnaviaEffectColor", 3));
143
149
  const palette = color.map((value) => Math.round(Math.max(0, Math.min(1, value)) * 255));
144
150
  dictionary.ColorSpace = state.document.context.obj([
145
151
  "Indexed",
146
- "DeviceCMYK",
152
+ state.destination === "gray" ? "DeviceGray" : "DeviceCMYK",
147
153
  0,
148
154
  PDFHexString.of(Buffer.from(palette).toString("hex"))
149
155
  ]);
@@ -185,9 +191,10 @@ function recordContentBounds(state, stream, resources, budget) {
185
191
  if (state.sequence) state.sequence.feed(bytes, resolve, resolveGs, resolveShading);
186
192
  else recordPatternPaintedBounds(bytes, resolve, state.painted, resolveGs, resolveShading, state.shadingPainted);
187
193
  }
188
- function convertContent(state, object, stream, setDirect) {
194
+ function convertContent(state, object, stream, setDirect, mask) {
189
195
  const bytes = decodeContent(stream, state.budget);
190
- const rewritten = rewriteContent(bytes, state.convert);
196
+ const destination = state.destination;
197
+ const rewritten = rewriteContent(bytes, converterFor(state, mask), destination);
191
198
  state.totals.fills += rewritten.counts.fills;
192
199
  state.totals.strokes += rewritten.counts.strokes;
193
200
  const replacement = state.document.context.stream(deflateSync(rewritten.bytes), streamDictionary(stream));
@@ -211,15 +218,16 @@ function newState(document, profile, tolerance) {
211
218
  shadingSamples: 0
212
219
  },
213
220
  convert: (rgb) => {
214
- if (newlySnapsToNeutral(rgb, tolerance)) totals.neutralColors++;
221
+ if (profile.colorSpace === "CMYK" && newlySnapsToNeutral(rgb, tolerance)) totals.neutralColors++;
215
222
  return cached(rgb);
216
223
  },
224
+ destination: pressDestination(profile),
217
225
  document,
218
226
  painted: /* @__PURE__ */ new WeakMap(),
219
227
  profile,
220
228
  sampleConvert: (rgb) => {
221
- if (newlySnapsToNeutral(rgb, tolerance)) totals.neutralColors++;
222
- return pressColor(profile, rgb, tolerance);
229
+ if (profile.colorSpace === "CMYK" && newlySnapsToNeutral(rgb, tolerance)) totals.neutralColors++;
230
+ return destinationColor(profile, rgb, tolerance);
223
231
  },
224
232
  seenGroups: /* @__PURE__ */ new WeakSet(),
225
233
  seenShadings: /* @__PURE__ */ new WeakSet(),
@@ -235,7 +243,9 @@ function rewritePressDocument(document, profile, tolerance = 3) {
235
243
  beginContentsSequence: () => {
236
244
  state.sequence = createPaintedBoundsTracker(state.painted, state.shadingPainted);
237
245
  },
238
- content: (_object, stream, _replace, resources) => recordContentBounds(state, stream, resources, boundsBudget),
246
+ content: (_object, stream, _replace, resources) => {
247
+ recordContentBounds(state, stream, resources, boundsBudget);
248
+ },
239
249
  endContentsSequence: () => {
240
250
  state.sequence = void 0;
241
251
  },
@@ -246,16 +256,16 @@ function rewritePressDocument(document, profile, tolerance = 3) {
246
256
  shading: (shading, pattern) => indexShadingPattern(state, shading, pattern)
247
257
  });
248
258
  walkPressGraph(document, {
249
- content: (object, stream, replace) => convertContent(state, object, stream, replace),
250
- function: (fn, decision) => convertFunction(state, fn, decision),
251
- group: (owner, _stream, maskSource) => convertGroup(state, owner, maskSource),
252
- image: (image) => convertEffectImage(state, image),
253
- shading: (shading, pattern) => convertShading(state, shading, pattern)
259
+ content: (object, stream, replace, _resources, mask) => convertContent(state, object, stream, replace, mask),
260
+ function: (fn, decision, mask) => convertFunction(state, fn, decision, mask),
261
+ group: (owner, _stream, mask) => convertGroup(state, owner, mask),
262
+ image: (image, mask) => convertEffectImage(state, image, mask),
263
+ shading: (shading, pattern, mask) => convertShading(state, shading, pattern, mask)
254
264
  });
255
265
  return state.totals;
256
266
  }
257
- function addOutputIntent(document, profile) {
258
- const profileRef = document.context.register(document.context.stream(profile, { N: 4 }));
267
+ function addOutputIntent(document, profile, channels) {
268
+ const profileRef = document.context.register(document.context.stream(profile, { N: channels }));
259
269
  const intentRef = document.context.register(document.context.obj({
260
270
  Type: "OutputIntent",
261
271
  S: "GTS_PDFX",
@@ -264,16 +274,61 @@ function addOutputIntent(document, profile) {
264
274
  }));
265
275
  document.catalog.set(key("OutputIntents"), document.context.obj([intentRef]));
266
276
  }
267
- async function rewritePressPdf(bytes, profileBytes, tolerance = 3) {
277
+ function replaceSourceImage(document, image, uses, replacement) {
278
+ const reference = document.context.getObjectRef(image);
279
+ if (reference) document.context.assign(reference, replacement);
280
+ else for (const use of uses) use.replace(replacement);
281
+ }
282
+ async function convertImageUses(state, image, uses) {
283
+ const normal = uses.filter((use) => use.mask === "none");
284
+ const masks = uses.filter((use) => use.mask !== "none");
285
+ if (normal.length > 0) {
286
+ const replacement = await convertSuppliedImage(state, image, "none");
287
+ if (!replacement) return;
288
+ replaceSourceImage(state.document, image, normal, replacement);
289
+ }
290
+ if (masks.length === 0) return;
291
+ const replacement = await convertSuppliedImage(state, image, masks[0]?.mask ?? "luminosity");
292
+ if (!replacement) return;
293
+ if (normal.length === 0) replaceSourceImage(state.document, image, masks, replacement);
294
+ else {
295
+ const reference = state.document.context.getObjectRef(replacement) ?? state.document.context.register(replacement);
296
+ for (const use of masks) use.replace(reference);
297
+ }
298
+ }
299
+ async function convertGrayImages(document, profile, decodeJpeg) {
300
+ const state = createImageState(document, profile, decodeJpeg);
301
+ const images = [];
302
+ walkPressGraph(document, {
303
+ content: () => void 0,
304
+ function: () => void 0,
305
+ group: () => void 0,
306
+ image: (image, mask, replace) => images.push({
307
+ image,
308
+ mask,
309
+ replace
310
+ }),
311
+ shading: () => false
312
+ });
313
+ const uses = /* @__PURE__ */ new Map();
314
+ for (const found of images) uses.set(found.image, [...uses.get(found.image) ?? [], found]);
315
+ for (const [image, found] of uses) await convertImageUses(state, image, found);
316
+ return convertedImageCount(state);
317
+ }
318
+ async function rewritePressPdf(bytes, profileBytes, tolerance = 3, decodeJpeg) {
268
319
  try {
269
320
  const profile = readProfile(profileBytes);
270
- if (profile.colorSpace !== "CMYK") refuse(`press profile color space ${profile.colorSpace}`, "CMYK");
321
+ if (profile.colorSpace !== "CMYK" && profile.colorSpace !== "GRAY") refuse(`press profile color space ${profile.colorSpace}`, "CMYK or GRAY");
271
322
  const document = await PDFDocument.load(bytes, { updateMetadata: false });
272
323
  const counts = rewritePressDocument(document, profile, tolerance);
273
- addOutputIntent(document, profileBytes);
324
+ let convertedImages = 0;
325
+ const destination = pressDestination(profile);
326
+ if (destination === "gray") convertedImages = await convertGrayImages(document, profile, decodeJpeg);
327
+ addOutputIntent(document, profileBytes, destination === "gray" ? 1 : 4);
274
328
  const output = await document.save({ useObjectStreams: false });
275
- const report = await inspectPressPdf(output);
329
+ const report = await inspectPressPdf(output, destination);
276
330
  report.neutralColors = counts.neutralColors;
331
+ report.convertedImages = convertedImages;
277
332
  return {
278
333
  bytes: output,
279
334
  report
@@ -1,7 +1,13 @@
1
1
  import { RenderRefusedError } from "../errors.js";
2
- import { toPress } from "../color/engine.js";
2
+ import { toGray, toPress } from "../color/engine.js";
3
3
  //#region src/press/color-policy.ts
4
4
  const NEUTRAL_TOLERANCE = .001;
5
+ function pressDestination(profile) {
6
+ return profile.colorSpace === "GRAY" ? "gray" : "cmyk";
7
+ }
8
+ function pdfMaskLuminosity([red, green, blue]) {
9
+ return [.3 * red + .59 * green + .11 * blue];
10
+ }
5
11
  function assertNeutralTolerance(value) {
6
12
  if (!Number.isFinite(value) || value < 0 || value > 255) throw new RenderRefusedError(`The press neutral tolerance is ${String(value)}; supported: a finite number from 0 to 255.`);
7
13
  }
@@ -21,6 +27,9 @@ function pressColor(profile, rgb, tolerance = 3) {
21
27
  ];
22
28
  return toPress(profile, rgb);
23
29
  }
30
+ function destinationColor(profile, rgb, tolerance = 3) {
31
+ return profile.colorSpace === "GRAY" ? toGray(profile, rgb) : pressColor(profile, rgb, tolerance);
32
+ }
24
33
  function formatPdfNumber(value) {
25
34
  const rounded = Math.round(value * 1e6) / 1e6;
26
35
  if (Object.is(rounded, -0)) return "0";
@@ -32,10 +41,10 @@ function cachedPressColor(profile, tolerance = 3) {
32
41
  const key = `${tolerance}:${rgb.join(",")}`;
33
42
  const cached = cache.get(key);
34
43
  if (cached) return cached;
35
- const converted = pressColor(profile, rgb, tolerance);
44
+ const converted = destinationColor(profile, rgb, tolerance);
36
45
  cache.set(key, converted);
37
46
  return converted;
38
47
  };
39
48
  }
40
49
  //#endregion
41
- export { assertNeutralTolerance, cachedPressColor, formatPdfNumber, newlySnapsToNeutral, pressColor, snapsToNeutral };
50
+ export { assertNeutralTolerance, cachedPressColor, destinationColor, formatPdfNumber, newlySnapsToNeutral, pdfMaskLuminosity, pressColor, pressDestination, snapsToNeutral };
@@ -120,7 +120,7 @@ function inlineHeader(source, at) {
120
120
  }
121
121
  return refusal("truncated BI operator", "an ID and EI pair");
122
122
  }
123
- function inlineImageEnd(source, at) {
123
+ function inlineImageEnd(source, at, destination) {
124
124
  const { colorSpace, id, imageMask } = inlineHeader(source, at);
125
125
  const match = /[\0\t\n\f\r ]EI(?=[\0\t\n\f\r ()<>[\]{}/%]|$)/g;
126
126
  match.lastIndex = id;
@@ -129,6 +129,7 @@ function inlineImageEnd(source, at) {
129
129
  const name = colorSpace?.startsWith("/") ? colorSpace.slice(1) : void 0;
130
130
  const policy = inlineImageAction(name, imageMask);
131
131
  if (policy.action === "refuse") refusal(`BI operator with color space ${policy.label}`, policy.supported);
132
+ if (destination === "gray" && policy.kind === "cmyk") refusal(`BI operator with color space ${policy.label}`, "RGB or gray input for a greyscale press");
132
133
  return end.index + end[0].length;
133
134
  }
134
135
  function skippedConstruct(source, cursor) {
@@ -144,7 +145,7 @@ function numeric(operands, count, operator) {
144
145
  return operands.map((token) => Number(token.raw));
145
146
  }
146
147
  function replacement(token, operands, values) {
147
- const operator = token.raw === "rg" || token.raw === "g" ? "k" : "K";
148
+ const operator = values.length === 1 ? token.raw === token.raw.toLowerCase() ? "g" : "G" : token.raw === token.raw.toLowerCase() ? "k" : "K";
148
149
  return {
149
150
  start: operands[0]?.start ?? token.start,
150
151
  end: token.end,
@@ -169,36 +170,43 @@ function replaceGray(token, operands, convert, counts) {
169
170
  gray
170
171
  ]));
171
172
  }
172
- function validateSpace(token, operands) {
173
+ function validateSpace(token, operands, destination) {
173
174
  if (operands.length !== 1 || operands[0]?.kind !== "name") refusal(`${token.raw} operator operands`, "one color space name");
174
175
  const name = operands[0].raw.slice(1);
175
- const policy = contentOperatorAction(token.raw, false, name);
176
+ const policy = contentOperatorAction(token.raw, false, name, false, destination);
176
177
  if (policy.action === "refuse") refusal(`${token.raw} operator selecting ${colorSpaceLabel({ name })}`, policy.supported);
177
178
  }
178
- function enforceOperatorPolicy(token, operands, policy) {
179
+ function enforceOperatorPolicy(token, operands, policy, destination) {
179
180
  if (policy.action !== "refuse") return;
180
181
  const operator = token.raw;
181
182
  if (operator === "cs" || operator === "CS") {
182
- validateSpace(token, operands);
183
+ validateSpace(token, operands, destination);
183
184
  return;
184
185
  }
185
186
  if (operator === "sc" || operator === "SC" || operator === "scn" || operator === "SCN") refusal(`${operator} operator with numeric operands`, policy.supported);
186
- refusal(`${operator === "g" || operator === "G" ? "gray" : "RGB"} color operator ${operator} after conversion`, policy.supported);
187
+ refusal(`${operator === "g" || operator === "G" ? "gray" : operator === "k" || operator === "K" ? "CMYK" : "RGB"} color operator ${operator} after conversion`, policy.supported);
187
188
  }
188
- function colorOperator(token, operands, convert, counts, inspect) {
189
+ function colorOperator(token, operands, convert, counts, inspect, destination) {
189
190
  const operator = token.raw;
190
191
  if (!isContentColorOperator(operator)) return void 0;
191
192
  const patternName = operands.length === 1 && operands[0]?.kind === "name";
192
- enforceOperatorPolicy(token, operands, contentOperatorAction(operator, inspect, void 0, patternName));
193
+ enforceOperatorPolicy(token, operands, contentOperatorAction(operator, inspect, void 0, patternName, destination), destination);
193
194
  if (operator === "rg" || operator === "RG") return replaceRgb(token, operands, convert, counts);
194
- if (operator === "g" || operator === "G") return replaceGray(token, operands, convert, counts);
195
- if (operator === "cs" || operator === "CS") validateSpace(token, operands);
195
+ if (operator === "g" || operator === "G") {
196
+ if (inspect && destination === "gray") {
197
+ numeric(operands, 1, operator);
198
+ counts[operator === "g" ? "fills" : "strokes"]++;
199
+ return;
200
+ }
201
+ return replaceGray(token, operands, convert, counts);
202
+ }
203
+ if (operator === "cs" || operator === "CS") validateSpace(token, operands, destination);
196
204
  else if (operator === "k" || operator === "K") {
197
205
  numeric(operands, 4, operator);
198
206
  counts[operator === "k" ? "fills" : "strokes"]++;
199
207
  }
200
208
  }
201
- function scan(source, convert, inspect, visit, operatorVisit) {
209
+ function scan(source, convert, inspect, destination, visit, operatorVisit) {
202
210
  const counts = {
203
211
  fills: 0,
204
212
  strokes: 0
@@ -218,11 +226,11 @@ function scan(source, convert, inspect, visit, operatorVisit) {
218
226
  }
219
227
  cursor = token.end;
220
228
  if (token.raw === "BI") {
221
- cursor = inlineImageEnd(source, cursor);
229
+ cursor = inlineImageEnd(source, cursor, destination);
222
230
  operands = [];
223
231
  } else if (token.kind === "operator") {
224
232
  operatorVisit?.(token, operands);
225
- const found = colorOperator(token, operands, convert, counts, inspect);
233
+ const found = colorOperator(token, operands, convert, counts, inspect, destination);
226
234
  if (found) visit?.(found);
227
235
  operands = [];
228
236
  } else {
@@ -241,7 +249,7 @@ function visitContentOperations(bytes, visit) {
241
249
  0,
242
250
  0,
243
251
  0
244
- ], false, void 0, (operator, operands) => {
252
+ ], false, "cmyk", void 0, (operator, operands) => {
245
253
  visit({
246
254
  end: operator.end,
247
255
  operands: operands.flatMap((operand) => operand.kind === "name" || operand.kind === "number" ? [{
@@ -255,14 +263,14 @@ function visitContentOperations(bytes, visit) {
255
263
  });
256
264
  });
257
265
  }
258
- function inspectContent(bytes) {
259
- return scan(decoder.decode(bytes), () => refusal("RGB color operator after conversion", "CMYK operators"), true);
266
+ function inspectContent(bytes, destination = "cmyk") {
267
+ return scan(decoder.decode(bytes), () => refusal("RGB color operator after conversion", `${destination === "gray" ? "gray" : "CMYK"} operators`), true, destination);
260
268
  }
261
- function rewriteContent(bytes, convert) {
269
+ function rewriteContent(bytes, convert, destination = "cmyk") {
262
270
  const source = decoder.decode(bytes);
263
271
  let outputLength = bytes.length;
264
272
  let changed = false;
265
- const counts = scan(source, convert, false, (found) => {
273
+ const counts = scan(source, convert, false, destination, (found) => {
266
274
  changed = true;
267
275
  outputLength += encoder.encode(found.text).length - (found.end - found.start);
268
276
  });
@@ -273,7 +281,7 @@ function rewriteContent(bytes, convert) {
273
281
  const output = new Uint8Array(outputLength);
274
282
  let inputAt = 0;
275
283
  let outputAt = 0;
276
- scan(source, convert, false, (found) => {
284
+ scan(source, convert, false, destination, (found) => {
277
285
  const untouched = bytes.subarray(inputAt, found.start);
278
286
  output.set(untouched, outputAt);
279
287
  outputAt += untouched.length;