@segnavia/render 0.4.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.
@@ -1,9 +1,9 @@
1
1
  import { RenderRefusedError } from "../errors.js";
2
2
  import { groupAction, shadingAction } from "./policy.js";
3
3
  import { inspectContent } from "./content.js";
4
- import { decodeContent, dictionaryOf, nameOf, numberOf, resolvedColorSpace } from "./pdf-objects.js";
4
+ import { decodeContent, dictionaryOf, nameOf, numberOf, rawStreamOf, resolvedColorSpace } from "./pdf-objects.js";
5
5
  import { walkPressGraph } from "./visitor.js";
6
- import { PDFDict, PDFDocument, PDFName, PDFRef } from "pdf-lib";
6
+ import { PDFArray, PDFDict, PDFDocument, PDFName, PDFRef } from "pdf-lib";
7
7
  //#region src/press/report.ts
8
8
  const key = (name) => PDFName.of(name);
9
9
  const refuse = (found, supported) => {
@@ -15,6 +15,7 @@ function inspectImage(state, image) {
15
15
  if (state.images.has(image)) return;
16
16
  state.images.add(image);
17
17
  const space = resolvedColorSpace(state.document, resolved(state, image.dict.get(key("ColorSpace"))));
18
+ if (state.report.destination === "gray" && (space.kind === "rgb" || space.kind === "cmyk")) refuse(`image color space ${space.label}`, "DeviceGray or Indexed over DeviceGray");
18
19
  if (space.kind !== "rgb") return;
19
20
  const width = requiredDimension(numberOf(image.dict, "Width"), "Width");
20
21
  const height = requiredDimension(numberOf(image.dict, "Height"), "Height");
@@ -24,18 +25,19 @@ function inspectImage(state, image) {
24
25
  colorSpace: space.label
25
26
  });
26
27
  }
27
- function inspectFunction(state, fn, policy) {
28
+ function inspectFunction(state, fn, policy, _mask) {
28
29
  if (policy.action === "refuse") refuse(policy.found, policy.supported);
29
30
  if (fn && numberOf(fn, "FunctionType") === 2) state.report.gradientStops += 2;
30
31
  }
31
- function inspectShading(state, shading) {
32
+ function inspectShading(state, shading, _mask) {
32
33
  if (state.shadings.has(shading)) return false;
33
34
  state.shadings.add(shading);
34
35
  const type = numberOf(shading, "ShadingType");
35
36
  const fn = dictionaryOf(resolved(state, shading.get(key("Function"))));
36
37
  const functionType = fn ? numberOf(fn, "FunctionType") : void 0;
37
38
  const space = resolvedColorSpace(state.document, resolved(state, shading.get(key("ColorSpace"))));
38
- const policy = shadingAction(space.kind, type, functionType);
39
+ const destination = state.report.destination;
40
+ const policy = shadingAction(space.kind, type, functionType, destination);
39
41
  if (policy.action !== "keep") {
40
42
  if (type === 1) refuse(`ShadingType 1 FunctionType ${functionType ?? "missing"} color space ${space.label}`, policy.supported);
41
43
  refuse(`ShadingType ${type ?? "missing"} color space ${space.label}`, policy.supported);
@@ -46,42 +48,52 @@ function inspectShading(state, shading) {
46
48
  }
47
49
  return true;
48
50
  }
49
- function inspectGroup(state, owner, maskSource) {
51
+ function inspectGroup(state, owner, mask) {
50
52
  const group = owner.lookupMaybe(key("Group"), PDFDict);
51
53
  if (!group || nameOf(group.get(key("S"))) !== "Transparency" || state.groups.has(group)) return;
52
54
  state.groups.add(group);
53
55
  const space = resolvedColorSpace(state.document, resolved(state, group.get(key("CS"))));
54
- const policy = groupAction(space.kind, space.label, maskSource);
56
+ const destination = state.report.destination;
57
+ const policy = groupAction(space.kind, space.label, mask !== "none", destination);
55
58
  if (policy.action !== "keep") refuse(`transparency group color space ${space.label}`, policy.supported);
56
- if (space.kind === "cmyk") state.report.groups++;
59
+ if (space.kind === destination) state.report.groups++;
57
60
  }
58
- async function inspectPressPdf(bytes) {
61
+ function outputDestination(document) {
62
+ const intent = document.catalog.lookupMaybe(key("OutputIntents"), PDFArray)?.lookup(0, PDFDict);
63
+ const profile = rawStreamOf(intent?.get(key("DestOutputProfile")) ? document.context.lookup(intent.get(key("DestOutputProfile"))) : void 0);
64
+ return profile && numberOf(profile.dict, "N") === 1 ? "gray" : "cmyk";
65
+ }
66
+ async function inspectPressPdf(bytes, requested) {
59
67
  const document = await PDFDocument.load(bytes, { updateMetadata: false });
68
+ const destination = requested ?? outputDestination(document);
60
69
  const state = {
61
70
  budget: { decodedBytes: 0 },
62
71
  document,
63
72
  groups: /* @__PURE__ */ new WeakSet(),
64
73
  images: /* @__PURE__ */ new WeakSet(),
65
74
  report: {
75
+ destination,
66
76
  images: [],
77
+ convertedImages: 0,
67
78
  fills: 0,
68
79
  strokes: 0,
69
80
  gradientStops: 0,
70
81
  sampledShadings: 0,
71
- groups: 0
82
+ groups: 0,
83
+ neutralColors: 0
72
84
  },
73
85
  shadings: /* @__PURE__ */ new WeakSet()
74
86
  };
75
87
  walkPressGraph(document, {
76
88
  content: (_object, stream) => {
77
- const counts = inspectContent(decodeContent(stream, state.budget));
89
+ const counts = inspectContent(decodeContent(stream, state.budget), destination);
78
90
  state.report.fills += counts.fills;
79
91
  state.report.strokes += counts.strokes;
80
92
  },
81
- function: (fn, decision) => inspectFunction(state, fn, decision),
82
- group: (owner, _stream, maskSource) => inspectGroup(state, owner, maskSource),
93
+ function: (fn, decision, mask) => inspectFunction(state, fn, decision, mask),
94
+ group: (owner, _stream, mask) => inspectGroup(state, owner, mask),
83
95
  image: (image) => inspectImage(state, image),
84
- shading: (shading) => inspectShading(state, shading)
96
+ shading: (shading, _pattern, mask) => inspectShading(state, shading, mask)
85
97
  });
86
98
  return state.report;
87
99
  }
@@ -10,7 +10,6 @@ const MAX_SHADING_SAMPLES = 1048576;
10
10
  const MAX_DOCUMENT_SHADING_SAMPLES = 4 * MAX_SHADING_SAMPLES;
11
11
  const MAX_DOCUMENT_CALCULATOR_OPERATIONS = 8 * MAX_SHADING_SAMPLES * 52;
12
12
  const MAX_SAMPLING_COLOR_CACHE = 4096;
13
- const SAMPLE_CHANNELS = 4;
14
13
  const SAMPLE_BYTES = 1;
15
14
  function refuse(found, supported) {
16
15
  throw new RenderRefusedError(`The PDF has unsupported ${found}; supported: ${supported}.`);
@@ -186,12 +185,12 @@ function domainValue(min, max, index, size) {
186
185
  if (size <= 1) return min;
187
186
  return min + (max - min) * index / (size - 1);
188
187
  }
189
- function sampleShadingToCmyk(program, domain, size, convert) {
188
+ function sampleShading(program, domain, size, convert, channels) {
190
189
  const x0 = domain[0] ?? 0;
191
190
  const x1 = domain[1] ?? 0;
192
191
  const y0 = domain[2] ?? 0;
193
192
  const y1 = domain[3] ?? 0;
194
- const samples = new Uint8Array(size.n * size.m * SAMPLE_CHANNELS * SAMPLE_BYTES);
193
+ const samples = new Uint8Array(size.n * size.m * channels * SAMPLE_BYTES);
195
194
  const toCmyk = createSamplingColorConverter(convert).convert;
196
195
  let offset = 0;
197
196
  for (let j = 0; j < size.m; j++) {
@@ -213,8 +212,8 @@ function sampleShadingToCmyk(program, domain, size, convert) {
213
212
  }
214
213
  return samples;
215
214
  }
216
- function chargeShadingSamples(budget, samples, operations) {
217
- const bytes = samples * SAMPLE_CHANNELS * SAMPLE_BYTES;
215
+ function chargeShadingSamples(budget, samples, operations, channels = 4) {
216
+ const bytes = samples * channels * SAMPLE_BYTES;
218
217
  if (!Number.isFinite(samples) || samples < 0 || !Number.isFinite(bytes) || bytes < 0) refuse(`document shading samples ${samples}`, "a finite non-negative sample count and byte charge");
219
218
  if (!Number.isFinite(operations) || operations < 0) refuse(`document calculator operations ${operations}`, "a finite non-negative operator count");
220
219
  const totalSamples = (budget.shadingSamples ?? 0) + samples;
@@ -228,7 +227,7 @@ function chargeShadingSamples(budget, samples, operations) {
228
227
  budget.calculatorOperations = totalOperations;
229
228
  }
230
229
  const resolved = (document, object) => object instanceof PDFRef ? document.context.lookup(object) : object;
231
- function replaceType1Function(document, shading, pattern, convert, budget, painted, sampleDomain, sampleSize) {
230
+ function replaceType1Function(document, shading, pattern, convert, budget, painted, sampleDomain, sampleSize, channels = 4) {
232
231
  const existing = shading.get(PDFName.of("Function"));
233
232
  const stream = rawStreamOf(resolved(document, existing));
234
233
  if (!stream) return refuse("shading FunctionType 4 stream", "a FunctionType 4 stream");
@@ -239,21 +238,12 @@ function replaceType1Function(document, shading, pattern, convert, budget, paint
239
238
  const size = sampleSize ?? shadingSampleGrid(domain, matrix);
240
239
  const program = parseCalculator(new TextDecoder("latin1").decode(decodeContent(stream, budget)));
241
240
  const samples = size.n * size.m;
242
- chargeShadingSamples(budget, samples, samples * staticOperatorCount(program));
243
- const data = sampleShadingToCmyk(program, domain, size, convert);
241
+ chargeShadingSamples(budget, samples, samples * staticOperatorCount(program), channels);
242
+ const data = sampleShading(program, domain, size, convert, channels);
244
243
  const replacement = document.context.stream(deflateSync(data), {
245
244
  FunctionType: 0,
246
245
  Domain: domain,
247
- Range: [
248
- 0,
249
- 1,
250
- 0,
251
- 1,
252
- 0,
253
- 1,
254
- 0,
255
- 1
256
- ],
246
+ Range: Array.from({ length: channels }, () => [0, 1]).flat(),
257
247
  Size: [size.n, size.m],
258
248
  BitsPerSample: 8,
259
249
  Order: 1,
@@ -263,4 +253,4 @@ function replaceType1Function(document, shading, pattern, convert, budget, paint
263
253
  return size;
264
254
  }
265
255
  //#endregion
266
- export { MAX_DOCUMENT_CALCULATOR_OPERATIONS, MAX_DOCUMENT_SHADING_SAMPLES, MAX_SAMPLING_COLOR_CACHE, MAX_SHADING_SAMPLES, SHADING_SAMPLE_SPACING_PT, chargeShadingSamples, createSamplingColorConverter, domainValue, matrixOf, packedRgb8, replaceType1Function, sampleDomainForPainted, sampleDomainForUses, sampleShadingToCmyk, shadingDeviceMatrix, shadingDomainOf, shadingSampleGrid, shadingSampleGridForUses, unionSampleDomain, unpackRgb8 };
256
+ export { MAX_DOCUMENT_CALCULATOR_OPERATIONS, MAX_DOCUMENT_SHADING_SAMPLES, MAX_SAMPLING_COLOR_CACHE, MAX_SHADING_SAMPLES, SHADING_SAMPLE_SPACING_PT, chargeShadingSamples, createSamplingColorConverter, domainValue, matrixOf, packedRgb8, replaceType1Function, sampleDomainForPainted, sampleDomainForUses, sampleShading, shadingDeviceMatrix, shadingDomainOf, shadingSampleGrid, shadingSampleGridForUses, unionSampleDomain, unpackRgb8 };
@@ -4,15 +4,22 @@ export interface PressImage {
4
4
  height: number;
5
5
  colorSpace: 'DeviceRGB' | 'CalRGB' | 'ICCBased' | 'Indexed';
6
6
  }
7
+ export type PressDestination = 'cmyk' | 'gray';
7
8
  export interface PressReport {
9
+ /** Device color space written for press-ready vector and generated-image output. */
10
+ destination: PressDestination;
8
11
  /** Authored raster images left in RGB, read back from the finished PDF. */
9
12
  images: PressImage[];
10
- /** CMYK painting and group counts read back from the finished PDF. */
13
+ /** Supplied raster image objects converted to DeviceGray. */
14
+ convertedImages: number;
15
+ /** Destination-space painting and group counts read back from the finished PDF. */
11
16
  fills: number;
12
17
  strokes: number;
13
18
  gradientStops: number;
14
- /** Function-based shadings rewritten as DeviceCMYK sampled functions. */
19
+ /** Function-based shadings rewritten as sampled functions in the destination space. */
15
20
  sampledShadings: number;
16
21
  groups: number;
22
+ /** Color conversions snapped to black ink by the configured tolerance but not by the exact-neutral rule. */
23
+ neutralColors: number;
17
24
  }
18
25
  //#endregion
@@ -13,7 +13,7 @@ function rememberRef(state, object) {
13
13
  state.references.add(label);
14
14
  return false;
15
15
  }
16
- function visitContent(state, object, replace, enclosing, replay = false, nestResources = true) {
16
+ function visitContent(state, object, replace, enclosing, replay = false, nestResources = true, context = "none") {
17
17
  if (!object) return;
18
18
  const seenRef = rememberRef(state, object);
19
19
  const found = stream(state, object);
@@ -23,9 +23,10 @@ function visitContent(state, object, replace, enclosing, replay = false, nestRes
23
23
  const seen = seenRef || seenStream;
24
24
  if (seen && !replay) return;
25
25
  const own = found.dict.lookupMaybe(key("Resources"), PDFDict);
26
- if (!seen) state.actions?.group(found.dict, found, state.maskSources.has(found));
27
- state.actions?.content(object, found, replace, own ?? enclosing);
28
- if (nestResources && !seen) visitResources(state, own);
26
+ const mask = state.maskSources.get(found) ?? context;
27
+ if (!seen) state.actions?.group(found.dict, found, mask);
28
+ state.actions?.content(object, found, replace, own ?? enclosing, mask);
29
+ if (nestResources && !seen) visitResources(state, own, mask);
29
30
  }
30
31
  function visitSoftMask(state, object, enclosing) {
31
32
  const graphicsState = dictionary(state, object);
@@ -34,8 +35,9 @@ function visitSoftMask(state, object, enclosing) {
34
35
  const source = stream(state, sourceObject);
35
36
  if (!graphicsState || !mask || !source || !sourceObject) return;
36
37
  state.actions?.softMask?.(graphicsState, mask, source, source.dict.lookupMaybe(key("Resources"), PDFDict) ?? enclosing);
37
- state.maskSources.add(source);
38
- visitContent(state, sourceObject, (replacement) => mask?.set(key("G"), replacement), enclosing);
38
+ const context = nameOf(mask.get(key("S"))) === "Alpha" ? "alpha" : "luminosity";
39
+ state.maskSources.set(source, context);
40
+ visitContent(state, sourceObject, (replacement) => mask?.set(key("G"), replacement), enclosing, false, true, context);
39
41
  }
40
42
  function visitAppearances(state, page, enclosing) {
41
43
  const annotations = resolved(state, page.get(key("Annots")));
@@ -57,69 +59,69 @@ function visitAppearances(state, page, enclosing) {
57
59
  }
58
60
  }
59
61
  }
60
- function visitFunction(state, object) {
62
+ function visitFunction(state, object, context) {
61
63
  const fn = dictionary(state, object);
62
64
  if (fn && state.functions.has(fn)) return;
63
65
  if (fn) state.functions.add(fn);
64
66
  const type = fn ? numberOf(fn, "FunctionType") : void 0;
65
67
  const functions = type === 3 ? fn?.lookupMaybe(key("Functions"), PDFArray) : void 0;
66
68
  const decision = shadingFunctionAction(type, Boolean(fn), type !== 3 || Boolean(functions));
67
- state.actions?.function(fn, decision);
68
- for (let index = 0; index < (functions?.size() ?? 0); index++) visitFunction(state, functions?.get(index));
69
+ state.actions?.function(fn, decision, context);
70
+ for (let index = 0; index < (functions?.size() ?? 0); index++) visitFunction(state, functions?.get(index), context);
69
71
  }
70
- function visitShading(state, shading, pattern) {
71
- if (state.actions?.shading(shading, pattern)) visitFunction(state, shading.get(key("Function")));
72
+ function visitShading(state, shading, pattern, context) {
73
+ if (state.actions?.shading(shading, pattern, context)) visitFunction(state, shading.get(key("Function")), context);
72
74
  }
73
- function visitShadings(state, resources) {
75
+ function visitShadings(state, resources, context) {
74
76
  const shadings = resources.lookupMaybe(key("Shading"), PDFDict);
75
77
  for (const [, object] of shadings?.entries() ?? []) {
76
78
  const found = dictionary(state, object);
77
- if (found) visitShading(state, found);
79
+ if (found) visitShading(state, found, void 0, context);
78
80
  }
79
81
  }
80
- function visitPatterns(state, resources) {
82
+ function visitPatterns(state, resources, context) {
81
83
  const patterns = resources.lookupMaybe(key("Pattern"), PDFDict);
82
84
  for (const [name, object] of patterns?.entries() ?? []) {
83
85
  const found = dictionary(state, object);
84
86
  if (!found) continue;
85
87
  if (numberOf(found, "PatternType") === 2) {
86
88
  const shading = dictionary(state, found.get(key("Shading")));
87
- if (shading) visitShading(state, shading, found);
88
- } else visitContent(state, object, (replacement) => patterns?.set(name, replacement), found.lookupMaybe(key("Resources"), PDFDict) ?? resources);
89
- visitResources(state, found.lookupMaybe(key("Resources"), PDFDict));
89
+ if (shading) visitShading(state, shading, found, context);
90
+ } else visitContent(state, object, (replacement) => patterns?.set(name, replacement), found.lookupMaybe(key("Resources"), PDFDict) ?? resources, false, true, context);
91
+ visitResources(state, found.lookupMaybe(key("Resources"), PDFDict), context);
90
92
  }
91
93
  }
92
94
  function visitGraphicsStates(state, resources) {
93
95
  const graphicsStates = resources.lookupMaybe(key("ExtGState"), PDFDict);
94
96
  for (const [, object] of graphicsStates?.entries() ?? []) visitSoftMask(state, object, resources);
95
97
  }
96
- function visitXObjects(state, resources) {
98
+ function visitXObjects(state, resources, context) {
97
99
  const xObjects = resources.lookupMaybe(key("XObject"), PDFDict);
98
100
  for (const [name, object] of xObjects?.entries() ?? []) {
99
101
  const found = stream(state, object);
100
102
  if (!found) continue;
101
- if (nameOf(found.dict.get(key("Subtype"))) === "Image") state.actions?.image(found);
102
- else visitContent(state, object, (replacement) => xObjects?.set(name, replacement), found.dict.lookupMaybe(key("Resources"), PDFDict) ?? resources);
103
+ if (nameOf(found.dict.get(key("Subtype"))) === "Image") state.actions?.image(found, context, (replacement) => xObjects?.set(name, replacement));
104
+ else visitContent(state, object, (replacement) => xObjects?.set(name, replacement), found.dict.lookupMaybe(key("Resources"), PDFDict) ?? resources, false, true, context);
103
105
  }
104
106
  }
105
- function visitFonts(state, resources) {
107
+ function visitFonts(state, resources, context) {
106
108
  const fonts = resources.lookupMaybe(key("Font"), PDFDict);
107
109
  for (const [, object] of fonts?.entries() ?? []) {
108
110
  const font = dictionary(state, object);
109
111
  if (!font || nameOf(font.get(key("Subtype"))) !== "Type3") continue;
110
112
  const charProcs = font.lookupMaybe(key("CharProcs"), PDFDict);
111
- for (const [name, glyph] of charProcs?.entries() ?? []) visitContent(state, glyph, (replacement) => charProcs?.set(name, replacement), font.lookupMaybe(key("Resources"), PDFDict) ?? resources);
112
- visitResources(state, font.lookupMaybe(key("Resources"), PDFDict));
113
+ for (const [name, glyph] of charProcs?.entries() ?? []) visitContent(state, glyph, (replacement) => charProcs?.set(name, replacement), font.lookupMaybe(key("Resources"), PDFDict) ?? resources, false, true, context);
114
+ visitResources(state, font.lookupMaybe(key("Resources"), PDFDict), context);
113
115
  }
114
116
  }
115
- function visitResources(state, resources) {
117
+ function visitResources(state, resources, context = "none") {
116
118
  if (!resources || state.resources.has(resources)) return;
117
119
  state.resources.add(resources);
118
120
  visitGraphicsStates(state, resources);
119
- visitXObjects(state, resources);
120
- visitFonts(state, resources);
121
- visitShadings(state, resources);
122
- visitPatterns(state, resources);
121
+ visitXObjects(state, resources, context);
122
+ visitFonts(state, resources, context);
123
+ visitShadings(state, resources, context);
124
+ visitPatterns(state, resources, context);
123
125
  }
124
126
  function visitPageContents(state, page, contents, pageResources) {
125
127
  const replay = Boolean(state.actions?.replayPageContents);
@@ -147,7 +149,7 @@ function traverse(document, maskSources, actions) {
147
149
  streams: /* @__PURE__ */ new WeakSet()
148
150
  };
149
151
  for (const page of document.getPages()) {
150
- actions?.group(page.node, void 0, false);
152
+ actions?.group(page.node, void 0, "none");
151
153
  const pageResources = page.node.Resources();
152
154
  visitPageContents(state, page.node, page.node.Contents(), pageResources);
153
155
  visitResources(state, pageResources);
@@ -155,7 +157,7 @@ function traverse(document, maskSources, actions) {
155
157
  }
156
158
  }
157
159
  function walkPressGraph(document, actions) {
158
- const maskSources = /* @__PURE__ */ new WeakSet();
160
+ const maskSources = /* @__PURE__ */ new WeakMap();
159
161
  traverse(document, maskSources);
160
162
  traverse(document, maskSources, actions);
161
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segnavia/render",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,8 +26,9 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@segnavia/format": "0.4.0",
30
- "@segnavia/frame": "0.4.0",
29
+ "@segnavia/format": "0.6.0",
30
+ "@segnavia/frame": "0.6.0",
31
+ "@segnavia/html": "0.6.0",
31
32
  "playwright-core": "1.55.1",
32
33
  "pdf-lib": "^1.17.1"
33
34
  }