print-check-cli 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +194 -0
  3. package/dist/index.js +974 -0
  4. package/package.json +52 -0
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025-2026, Ryan Calacsan
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # print-check-cli
2
+
3
+ [![CI](https://github.com/ryancalacsan/print-check-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/ryancalacsan/print-check-cli/actions/workflows/ci.yml)
4
+
5
+ A Node.js + TypeScript CLI tool that validates print-ready PDF files. Runs eight checks and reports pass/warn/fail results in the terminal.
6
+
7
+ ## Checks
8
+
9
+ | Check | What it validates |
10
+ |---|---|
11
+ | **Bleed & Trim** | TrimBox/BleedBox presence and minimum bleed dimensions |
12
+ | **Fonts** | Font embedding status (embedded, subset, or missing) |
13
+ | **Color Space** | CMYK compliance, RGB detection, spot color reporting |
14
+ | **Resolution** | Raster image DPI against a configurable minimum |
15
+ | **PDF/X Compliance** | PDF/X standard detection (OutputIntents, version, output condition) — info only |
16
+ | **Total Ink Coverage** | Maximum ink density (C+M+Y+K %) against configurable limit |
17
+ | **Transparency** | Detects unflattened transparency (groups, soft masks, blend modes) |
18
+ | **Page Size** | Verifies consistent page dimensions and optional expected size match |
19
+
20
+ ## Usage
21
+
22
+ ```
23
+ print-check <file.pdf ...> [options]
24
+
25
+ Options:
26
+ --min-dpi <number> Minimum acceptable DPI (default: 300)
27
+ --color-space <mode> Expected color space: cmyk | any (default: cmyk)
28
+ --bleed <mm> Required bleed in mm (default: 3)
29
+ --max-tac <percent> Maximum total ink coverage % (default: 300)
30
+ --page-size <WxH> Expected page size in mm (e.g. 210x297)
31
+ --checks <list> Comma-separated checks to run (default: all)
32
+ --severity <overrides> Per-check severity: check:level,... (fail|warn|off)
33
+ --profile <name> Print profile: standard | magazine | newspaper | large-format
34
+ --verbose Show detailed per-page results
35
+ --format <type> Output format: text | json (default: text)
36
+ -V, --version Output version
37
+ -h, --help Show help
38
+ ```
39
+
40
+ ### Examples
41
+
42
+ ```bash
43
+ # Run all checks with defaults
44
+ print-check flyer.pdf
45
+
46
+ # Verbose output with custom DPI threshold
47
+ print-check flyer.pdf --verbose --min-dpi 150
48
+
49
+ # Run only font and bleed checks
50
+ print-check flyer.pdf --checks fonts,bleed
51
+
52
+ # Skip color space enforcement
53
+ print-check flyer.pdf --color-space any
54
+
55
+ # JSON output for CI pipelines
56
+ print-check flyer.pdf --format json
57
+
58
+ # Use a built-in profile
59
+ print-check flyer.pdf --profile magazine
60
+
61
+ # Profile with explicit override
62
+ print-check flyer.pdf --profile newspaper --min-dpi 300
63
+
64
+ # Check multiple files at once
65
+ print-check flyer.pdf poster.pdf brochure.pdf
66
+
67
+ # Use shell globbing to check all PDFs in a directory
68
+ print-check *.pdf
69
+
70
+ # Multiple files with JSON output (outputs an array of reports)
71
+ print-check *.pdf --format json
72
+ ```
73
+
74
+ ### Profiles
75
+
76
+ Built-in profiles provide preset thresholds for common print scenarios. Explicit CLI flags override profile defaults.
77
+
78
+ | Profile | minDpi | colorSpace | bleedMm | maxTac | Use case |
79
+ |---------|--------|------------|---------|--------|----------|
80
+ | `standard` | 300 | cmyk | 3 | 300 | General commercial print (default) |
81
+ | `magazine` | 300 | cmyk | 5 | 300 | Magazine / perfect-bound |
82
+ | `newspaper` | 150 | any | 0 | 240 | Newsprint / low-fidelity |
83
+ | `large-format` | 150 | cmyk | 5 | 300 | Banners, posters, signage |
84
+
85
+ ### Exit codes
86
+
87
+ - `0` — all checks passed (or warned)
88
+ - `1` — one or more checks failed
89
+
90
+ ### Severity Overrides
91
+
92
+ Override the default severity for any check using `--severity`:
93
+
94
+ ```bash
95
+ # Downgrade font failures to warnings (exit 0)
96
+ print-check flyer.pdf --severity fonts:warn
97
+
98
+ # Skip transparency check entirely
99
+ print-check flyer.pdf --severity transparency:off
100
+
101
+ # Multiple overrides
102
+ print-check flyer.pdf --severity fonts:warn,transparency:off
103
+ ```
104
+
105
+ | Level | Behavior |
106
+ |-------|----------|
107
+ | `fail` | Default — no change to check result |
108
+ | `warn` | Downgrade any `fail` result to `warn` (exit 0) |
109
+ | `off` | Skip the check entirely |
110
+
111
+ Available check names: `bleed`, `fonts`, `colorspace`, `resolution`, `pdfx`, `tac`, `transparency`, `pagesize`.
112
+
113
+ ## Configuration
114
+
115
+ Create a config file to set default options for your project:
116
+
117
+ ### `.printcheckrc` / `.printcheckrc.json`
118
+ ```json
119
+ {
120
+ "minDpi": 300,
121
+ "colorSpace": "cmyk",
122
+ "bleed": 5,
123
+ "maxTac": 300,
124
+ "checks": "bleed,fonts,colorspace",
125
+ "profile": "magazine",
126
+ "severity": {
127
+ "fonts": "warn",
128
+ "transparency": "off"
129
+ }
130
+ }
131
+ ```
132
+
133
+ ### `printcheck.config.js`
134
+ ```js
135
+ export default {
136
+ minDpi: 150,
137
+ colorSpace: "any",
138
+ bleed: 0,
139
+ profile: "newspaper",
140
+ };
141
+ ```
142
+
143
+ Config files are auto-discovered from the current directory upward.
144
+ CLI flags always override config file values.
145
+
146
+ ## Tech Stack
147
+
148
+ | Package | Purpose |
149
+ |---|---|
150
+ | [mupdf](https://www.npmjs.com/package/mupdf) (mupdf.js) | PDF engine — WASM-powered, deep PDF object traversal |
151
+ | [pdf-lib](https://www.npmjs.com/package/pdf-lib) | Supplemental — reading page boxes (TrimBox, BleedBox, etc.) |
152
+ | [commander](https://www.npmjs.com/package/commander) | CLI framework |
153
+ | [picocolors](https://www.npmjs.com/package/picocolors) | Terminal colors |
154
+ | [zod](https://www.npmjs.com/package/zod) | CLI option validation |
155
+ | [tsup](https://www.npmjs.com/package/tsup) | TypeScript build |
156
+ | [vitest](https://www.npmjs.com/package/vitest) | Testing |
157
+
158
+ ## Project Structure
159
+
160
+ ```
161
+ src/
162
+ ├── index.ts # CLI entry point (Commander setup)
163
+ ├── types.ts # Shared interfaces (CheckResult, CheckOptions, etc.)
164
+ ├── checks/
165
+ │ ├── index.ts # Re-exports all checks
166
+ │ ├── bleed-trim.ts # Page box validation (pdf-lib)
167
+ │ ├── fonts.ts # Font embedding check (mupdf)
168
+ │ ├── colorspace.ts # Color space detection (mupdf)
169
+ │ ├── resolution.ts # Image DPI check (mupdf)
170
+ │ ├── pdfx-compliance.ts # PDF/X standard detection (mupdf)
171
+ │ ├── tac.ts # Total ink coverage check (mupdf)
172
+ │ ├── transparency.ts # Transparency detection check (mupdf)
173
+ │ └── page-size.ts # Page size consistency check (pdf-lib)
174
+ ├── engine/
175
+ │ ├── pdf-engine.ts # Unified PDF document loader (mupdf + pdf-lib)
176
+ │ └── pdf-utils.ts # Safe wrappers for mupdf PDFObject API
177
+ └── reporter/
178
+ ├── console.ts # Terminal output formatter
179
+ └── json.ts # JSON output formatter (--format json)
180
+ ```
181
+
182
+ ## Development
183
+
184
+ ```bash
185
+ npm install # Install dependencies
186
+ npm run dev -- <file> # Run via tsx (no build needed)
187
+ npm run build # Build to dist/
188
+ npm test # Run vitest
189
+ ```
190
+
191
+ ## Known Limitations (MVP)
192
+
193
+ - **mupdf PDFObject nulls** — mupdf.js returns PDFObject wrappers with `.isNull() === true` rather than JavaScript `null`. All mupdf access goes through `src/engine/pdf-utils.ts` safe wrappers to handle this.
194
+
package/dist/index.js ADDED
@@ -0,0 +1,974 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+ import { z as z2 } from "zod";
6
+ import * as fs3 from "fs";
7
+ import * as path2 from "path";
8
+
9
+ // src/checks/bleed-trim.ts
10
+ var PT_TO_MM = 25.4 / 72;
11
+ var checkBleedTrim = async (engines, options) => {
12
+ const { pdfLib } = engines;
13
+ const pages = pdfLib.getPages();
14
+ const details = [];
15
+ let worstStatus = "pass";
16
+ for (let i = 0; i < pages.length; i++) {
17
+ const page = pages[i];
18
+ const pageNum = i + 1;
19
+ const mediaBox = page.getMediaBox();
20
+ const trimBox = page.getTrimBox();
21
+ const bleedBox = page.getBleedBox();
22
+ const hasTrimBox = trimBox.x !== mediaBox.x || trimBox.y !== mediaBox.y || trimBox.width !== mediaBox.width || trimBox.height !== mediaBox.height;
23
+ const hasBleedBox = bleedBox.x !== mediaBox.x || bleedBox.y !== mediaBox.y || bleedBox.width !== mediaBox.width || bleedBox.height !== mediaBox.height;
24
+ if (!hasTrimBox && !hasBleedBox) {
25
+ details.push({
26
+ page: pageNum,
27
+ message: "No TrimBox or BleedBox defined (only MediaBox found)",
28
+ status: "warn"
29
+ });
30
+ if (worstStatus === "pass") worstStatus = "warn";
31
+ continue;
32
+ }
33
+ if (!hasTrimBox) {
34
+ details.push({
35
+ page: pageNum,
36
+ message: "No TrimBox defined",
37
+ status: "warn"
38
+ });
39
+ if (worstStatus === "pass") worstStatus = "warn";
40
+ continue;
41
+ }
42
+ const refBox = hasBleedBox ? bleedBox : mediaBox;
43
+ const bleedLeft = (trimBox.x - refBox.x) * PT_TO_MM;
44
+ const bleedBottom = (trimBox.y - refBox.y) * PT_TO_MM;
45
+ const bleedRight = (refBox.x + refBox.width - (trimBox.x + trimBox.width)) * PT_TO_MM;
46
+ const bleedTop = (refBox.y + refBox.height - (trimBox.y + trimBox.height)) * PT_TO_MM;
47
+ const minBleed = Math.min(bleedLeft, bleedBottom, bleedRight, bleedTop);
48
+ const requiredMm = options.bleedMm;
49
+ if (minBleed < requiredMm) {
50
+ const sides = [];
51
+ if (bleedLeft < requiredMm) sides.push(`left: ${bleedLeft.toFixed(1)}mm`);
52
+ if (bleedRight < requiredMm)
53
+ sides.push(`right: ${bleedRight.toFixed(1)}mm`);
54
+ if (bleedTop < requiredMm) sides.push(`top: ${bleedTop.toFixed(1)}mm`);
55
+ if (bleedBottom < requiredMm)
56
+ sides.push(`bottom: ${bleedBottom.toFixed(1)}mm`);
57
+ details.push({
58
+ page: pageNum,
59
+ message: `Insufficient bleed (need ${requiredMm}mm): ${sides.join(", ")}`,
60
+ status: "fail"
61
+ });
62
+ worstStatus = "fail";
63
+ } else {
64
+ details.push({
65
+ page: pageNum,
66
+ message: `Bleed OK (min ${minBleed.toFixed(1)}mm)`,
67
+ status: "pass"
68
+ });
69
+ }
70
+ }
71
+ const summary = worstStatus === "pass" ? `All pages have ${options.bleedMm}mm+ bleed` : worstStatus === "warn" ? "TrimBox or BleedBox missing on some pages" : "Insufficient bleed on some pages";
72
+ return { check: "Bleed & Trim", status: worstStatus, summary, details };
73
+ };
74
+
75
+ // src/engine/pdf-utils.ts
76
+ function safeResolve(obj) {
77
+ if (!obj || obj.isNull()) return void 0;
78
+ return obj.resolve();
79
+ }
80
+ function safeGet(obj, key) {
81
+ if (!obj || obj.isNull()) return void 0;
82
+ const val = obj.get(key);
83
+ if (!val || val.isNull()) return void 0;
84
+ return val;
85
+ }
86
+ function safeGetResolved(obj, key) {
87
+ const val = safeGet(obj, key);
88
+ return val ? safeResolve(val) : void 0;
89
+ }
90
+ function safeName(obj) {
91
+ if (!obj || obj.isNull()) return void 0;
92
+ if (obj.isName()) return obj.asName();
93
+ return void 0;
94
+ }
95
+ function safeString(obj) {
96
+ if (!obj || obj.isNull()) return void 0;
97
+ if (obj.isString()) return obj.asString();
98
+ if (obj.isName()) return obj.asName();
99
+ return void 0;
100
+ }
101
+ function safeForEach(obj, callback) {
102
+ if (!obj || obj.isNull()) return;
103
+ obj.forEach(callback);
104
+ }
105
+
106
+ // src/checks/fonts.ts
107
+ function collectFonts(fontDict, pageNum, seen) {
108
+ const fonts = [];
109
+ safeForEach(fontDict, (value, key) => {
110
+ const font = safeResolve(value);
111
+ if (!font) return;
112
+ const baseFontName = safeName(safeGet(font, "BaseFont")) ?? key;
113
+ if (seen.has(baseFontName)) return;
114
+ seen.add(baseFontName);
115
+ const subtype = safeName(safeGet(font, "Subtype"));
116
+ if (subtype === "Type0") {
117
+ const descendants = safeGet(font, "DescendantFonts");
118
+ if (descendants) {
119
+ for (let i = 0; i < descendants.length; i++) {
120
+ const cidFont = safeResolve(descendants.get(i));
121
+ if (!cidFont) continue;
122
+ const cidName = safeName(safeGet(cidFont, "BaseFont")) ?? baseFontName;
123
+ const descriptor2 = safeGetResolved(cidFont, "FontDescriptor");
124
+ const embedded2 = hasEmbeddedFile(descriptor2);
125
+ const subset2 = /^[A-Z]{6}\+/.test(cidName);
126
+ fonts.push({ name: cidName, embedded: embedded2, subset: subset2, page: pageNum });
127
+ }
128
+ }
129
+ return;
130
+ }
131
+ const descriptor = safeGetResolved(font, "FontDescriptor");
132
+ const embedded = hasEmbeddedFile(descriptor);
133
+ const subset = /^[A-Z]{6}\+/.test(baseFontName);
134
+ fonts.push({ name: baseFontName, embedded, subset, page: pageNum });
135
+ });
136
+ return fonts;
137
+ }
138
+ function hasEmbeddedFile(descriptor) {
139
+ if (!descriptor) return false;
140
+ return !!(safeGet(descriptor, "FontFile") || safeGet(descriptor, "FontFile2") || safeGet(descriptor, "FontFile3"));
141
+ }
142
+ var checkFonts = async (engines) => {
143
+ const { mupdf: doc } = engines;
144
+ const details = [];
145
+ const seen = /* @__PURE__ */ new Set();
146
+ const allFonts = [];
147
+ const pageCount = doc.countPages();
148
+ for (let i = 0; i < pageCount; i++) {
149
+ const pageObj = doc.findPage(i);
150
+ const resources = safeGetResolved(pageObj, "Resources");
151
+ if (!resources) continue;
152
+ const fontDict = safeGetResolved(resources, "Font");
153
+ if (!fontDict) continue;
154
+ const pageFonts = collectFonts(fontDict, i + 1, seen);
155
+ allFonts.push(...pageFonts);
156
+ }
157
+ let worstStatus = "pass";
158
+ const notEmbedded = allFonts.filter((f) => !f.embedded);
159
+ const subsetOnly = allFonts.filter((f) => f.embedded && f.subset);
160
+ for (const font of notEmbedded) {
161
+ details.push({
162
+ page: font.page,
163
+ message: `Font "${font.name}" is not embedded`,
164
+ status: "fail"
165
+ });
166
+ worstStatus = "fail";
167
+ }
168
+ for (const font of subsetOnly) {
169
+ details.push({
170
+ page: font.page,
171
+ message: `Font "${font.name}" is subset-embedded`,
172
+ status: "warn"
173
+ });
174
+ if (worstStatus === "pass") worstStatus = "warn";
175
+ }
176
+ for (const font of allFonts.filter((f) => f.embedded && !f.subset)) {
177
+ details.push({
178
+ page: font.page,
179
+ message: `Font "${font.name}" is fully embedded`,
180
+ status: "pass"
181
+ });
182
+ }
183
+ const embeddedCount = allFonts.filter((f) => f.embedded).length;
184
+ const subsetCount = subsetOnly.length;
185
+ const summary = worstStatus === "fail" ? `${notEmbedded.length} font(s) not embedded` : `${embeddedCount} fonts embedded (${subsetCount} subset)`;
186
+ return { check: "Fonts", status: worstStatus, summary, details };
187
+ };
188
+
189
+ // src/checks/colorspace.ts
190
+ import * as mupdf from "mupdf";
191
+ var checkColorSpace = async (engines, options) => {
192
+ if (options.colorSpace === "any") {
193
+ return {
194
+ check: "Color Space",
195
+ status: "pass",
196
+ summary: "Color space check skipped (--color-space any)",
197
+ details: []
198
+ };
199
+ }
200
+ const { mupdf: doc } = engines;
201
+ const details = [];
202
+ let worstStatus = "pass";
203
+ const rgbPages = [];
204
+ const spotColors = [];
205
+ const trailer = doc.getTrailer();
206
+ const root = safeGetResolved(trailer, "Root");
207
+ const outputIntents = safeGet(root, "OutputIntents");
208
+ if (outputIntents) {
209
+ for (let i = 0; i < outputIntents.length; i++) {
210
+ const intent = safeResolve(outputIntents.get(i));
211
+ if (!intent) continue;
212
+ const subtype = safeName(safeGet(intent, "S"));
213
+ const condition = safeString(safeGet(intent, "OutputConditionIdentifier"));
214
+ details.push({
215
+ message: `OutputIntent: ${subtype} \u2014 ${condition ?? "unknown"}`,
216
+ status: "pass"
217
+ });
218
+ }
219
+ }
220
+ const pageCount = doc.countPages();
221
+ for (let i = 0; i < pageCount; i++) {
222
+ const pageNum = i + 1;
223
+ const page = doc.loadPage(i);
224
+ const device = new mupdf.Device({
225
+ fillPath(_path, _evenOdd, _ctm, colorspace, _color, _alpha) {
226
+ classifyColorSpace(colorspace, pageNum, "fill path", _color);
227
+ },
228
+ strokePath(_path, _stroke, _ctm, colorspace, _color, _alpha) {
229
+ classifyColorSpace(colorspace, pageNum, "stroke path", _color);
230
+ },
231
+ fillText(_text, _ctm, colorspace, _color, _alpha) {
232
+ classifyColorSpace(colorspace, pageNum, "fill text", _color);
233
+ },
234
+ strokeText(_text, _stroke, _ctm, colorspace, _color, _alpha) {
235
+ classifyColorSpace(colorspace, pageNum, "stroke text", _color);
236
+ },
237
+ fillImage(image, _ctm, _alpha) {
238
+ const cs = image.getColorSpace();
239
+ if (cs) {
240
+ const w = image.getWidth();
241
+ const h = image.getHeight();
242
+ classifyColorSpace(cs, pageNum, `image (${w}\xD7${h}px)`);
243
+ }
244
+ },
245
+ fillImageMask(_image, _ctm, colorspace, _color, _alpha) {
246
+ classifyColorSpace(colorspace, pageNum, "image mask", _color);
247
+ }
248
+ });
249
+ page.runPageContents(device, mupdf.Matrix.identity);
250
+ }
251
+ function classifyColorSpace(cs, pageNum, source, color) {
252
+ if (cs.isRGB()) {
253
+ if (color && color.length >= 3) {
254
+ const [r, g, b] = color;
255
+ if (r === g && g === b) return;
256
+ }
257
+ if (!rgbPages.includes(pageNum)) rgbPages.push(pageNum);
258
+ details.push({
259
+ page: pageNum,
260
+ message: `RGB ${source}`,
261
+ status: "fail"
262
+ });
263
+ worstStatus = "fail";
264
+ } else if (cs.isDeviceN()) {
265
+ const name = cs.getName();
266
+ if (!spotColors.includes(name)) spotColors.push(name);
267
+ } else if (cs.getType() === "Separation") {
268
+ const name = cs.getName();
269
+ if (!spotColors.includes(name)) spotColors.push(name);
270
+ } else if (cs.isGray() || cs.isCMYK()) {
271
+ } else {
272
+ details.push({
273
+ page: pageNum,
274
+ message: `Unknown color space "${cs.getName()}" in ${source}`,
275
+ status: "warn"
276
+ });
277
+ if (worstStatus === "pass") worstStatus = "warn";
278
+ }
279
+ }
280
+ if (spotColors.length > 0) {
281
+ details.push({
282
+ message: `Spot colors found: ${spotColors.join(", ")}`,
283
+ status: "pass"
284
+ });
285
+ }
286
+ const summary = worstStatus === "pass" ? "All color spaces are CMYK-compatible" : rgbPages.length > 0 ? `RGB detected on pages ${[...new Set(rgbPages)].join(", ")}` : "Non-CMYK color spaces detected";
287
+ return { check: "Color Space", status: worstStatus, summary, details };
288
+ };
289
+
290
+ // src/checks/resolution.ts
291
+ import * as mupdf2 from "mupdf";
292
+ var checkResolution = async (engines, options) => {
293
+ const { mupdf: doc } = engines;
294
+ const details = [];
295
+ let worstStatus = "pass";
296
+ const pageCount = doc.countPages();
297
+ for (let i = 0; i < pageCount; i++) {
298
+ const pageNum = i + 1;
299
+ const page = doc.loadPage(i);
300
+ const placements = [];
301
+ const device = new mupdf2.Device({
302
+ fillImage(image, ctm, _alpha) {
303
+ placements.push({ image, ctm });
304
+ },
305
+ fillImageMask(image, ctm, _colorspace, _color, _alpha) {
306
+ placements.push({ image, ctm });
307
+ }
308
+ });
309
+ page.run(device, mupdf2.Matrix.identity);
310
+ for (const { image, ctm } of placements) {
311
+ const pixelWidth = image.getWidth();
312
+ const pixelHeight = image.getHeight();
313
+ if (!pixelWidth || !pixelHeight) continue;
314
+ const [a, b, c, d] = ctm;
315
+ const renderedWidthPt = Math.sqrt(a * a + b * b);
316
+ const renderedHeightPt = Math.sqrt(c * c + d * d);
317
+ const renderedWidthIn = renderedWidthPt / 72;
318
+ const renderedHeightIn = renderedHeightPt / 72;
319
+ const dpiX = renderedWidthIn > 0 ? pixelWidth / renderedWidthIn : 0;
320
+ const dpiY = renderedHeightIn > 0 ? pixelHeight / renderedHeightIn : 0;
321
+ const effectiveDpi = Math.min(dpiX, dpiY);
322
+ const roundedDpi = Math.round(effectiveDpi);
323
+ const label = `${pixelWidth}\xD7${pixelHeight}px image`;
324
+ const threshold = options.minDpi;
325
+ const warnThreshold = threshold * 0.9;
326
+ if (effectiveDpi < warnThreshold) {
327
+ details.push({
328
+ page: pageNum,
329
+ message: `${label}: ${roundedDpi} DPI (min: ${threshold})`,
330
+ status: "fail"
331
+ });
332
+ worstStatus = "fail";
333
+ } else if (effectiveDpi < threshold) {
334
+ details.push({
335
+ page: pageNum,
336
+ message: `${label}: ${roundedDpi} DPI (near threshold: ${threshold})`,
337
+ status: "warn"
338
+ });
339
+ if (worstStatus === "pass") worstStatus = "warn";
340
+ } else {
341
+ details.push({
342
+ page: pageNum,
343
+ message: `${label}: ${roundedDpi} DPI`,
344
+ status: "pass"
345
+ });
346
+ }
347
+ }
348
+ }
349
+ if (details.length === 0) {
350
+ return {
351
+ check: "Resolution",
352
+ status: "pass",
353
+ summary: "No raster images found",
354
+ details: []
355
+ };
356
+ }
357
+ const failedDetails = details.filter((d) => d.status === "fail");
358
+ const summary = worstStatus === "pass" ? `All images meet ${options.minDpi} DPI minimum` : worstStatus === "warn" ? `Some images near DPI threshold (${options.minDpi})` : failedDetails.map((d) => `Page ${d.page}: ${d.message}`).slice(0, 3).join("; ");
359
+ return { check: "Resolution", status: worstStatus, summary, details };
360
+ };
361
+
362
+ // src/checks/pdfx-compliance.ts
363
+ var checkPdfxCompliance = async (engines, _options) => {
364
+ const details = [];
365
+ const doc = engines.mupdf;
366
+ const trailer = doc.getTrailer();
367
+ const root = safeGetResolved(trailer, "Root");
368
+ const outputIntents = safeGetResolved(root, "OutputIntents");
369
+ let pdfxVersion;
370
+ let pdfxCondition;
371
+ const infoDict = safeGetResolved(trailer, "Info");
372
+ if (infoDict) {
373
+ const versionStr = safeString(safeGet(infoDict, "GTS_PDFXVersion"));
374
+ if (versionStr) {
375
+ pdfxVersion = versionStr;
376
+ details.push({
377
+ message: `PDF/X version: ${versionStr}`,
378
+ status: "pass"
379
+ });
380
+ }
381
+ }
382
+ if (outputIntents && outputIntents.isArray()) {
383
+ const len = outputIntents.length;
384
+ for (let i = 0; i < len; i++) {
385
+ const raw = outputIntents.get(i);
386
+ const intent = raw && !raw.isNull() ? safeResolve(raw) : void 0;
387
+ if (!intent) continue;
388
+ const subtype = safeName(safeGet(intent, "S")) ?? "unknown";
389
+ const conditionId = safeString(safeGet(intent, "OutputConditionIdentifier")) ?? "";
390
+ const info = safeString(safeGet(intent, "Info"));
391
+ const registryName = safeString(safeGet(intent, "RegistryName"));
392
+ const isPdfx = subtype.startsWith("GTS_PDFX");
393
+ const parts = [subtype];
394
+ if (conditionId) parts.push(conditionId);
395
+ if (info) parts.push(info);
396
+ if (registryName) parts.push(registryName);
397
+ const label = isPdfx ? "PDF/X OutputIntent" : "OutputIntent";
398
+ details.push({
399
+ message: `${label}: ${parts.join(" \u2014 ")}`,
400
+ status: "pass"
401
+ });
402
+ if (isPdfx && conditionId) {
403
+ pdfxCondition = conditionId;
404
+ }
405
+ }
406
+ }
407
+ if (details.length === 0) {
408
+ return {
409
+ check: "PDF/X Compliance",
410
+ status: "pass",
411
+ summary: "No PDF/X compliance detected",
412
+ details: [{ message: "No PDF/X compliance detected", status: "pass" }]
413
+ };
414
+ }
415
+ let summary;
416
+ if (pdfxVersion && pdfxCondition) {
417
+ summary = `${pdfxVersion} (${pdfxCondition})`;
418
+ } else if (pdfxVersion) {
419
+ summary = pdfxVersion;
420
+ } else if (pdfxCondition) {
421
+ summary = `PDF/X (${pdfxCondition})`;
422
+ } else {
423
+ summary = "No PDF/X compliance detected";
424
+ }
425
+ return {
426
+ check: "PDF/X Compliance",
427
+ status: "pass",
428
+ summary,
429
+ details
430
+ };
431
+ };
432
+
433
+ // src/checks/tac.ts
434
+ import * as mupdf3 from "mupdf";
435
+ var checkTac = async (engines, options) => {
436
+ const { mupdf: doc } = engines;
437
+ const details = [];
438
+ let worstStatus = "pass";
439
+ let globalMaxTac = 0;
440
+ let globalMaxPage = 1;
441
+ const maxTac = options.maxTac;
442
+ const warnThreshold = maxTac * 0.9;
443
+ const pageCount = doc.countPages();
444
+ for (let i = 0; i < pageCount; i++) {
445
+ let recordTac2 = function(tac) {
446
+ if (tac > pageMaxTac) pageMaxTac = tac;
447
+ }, handleVectorColor2 = function(colorspace, color) {
448
+ if (!colorspace.isCMYK()) return;
449
+ const tac = (color[0] + color[1] + color[2] + color[3]) * 100;
450
+ recordTac2(tac);
451
+ };
452
+ var recordTac = recordTac2, handleVectorColor = handleVectorColor2;
453
+ const pageNum = i + 1;
454
+ const page = doc.loadPage(i);
455
+ let pageMaxTac = 0;
456
+ const device = new mupdf3.Device({
457
+ fillPath(_path, _evenOdd, _ctm, colorspace, color, _alpha) {
458
+ handleVectorColor2(colorspace, color);
459
+ },
460
+ strokePath(_path, _stroke, _ctm, colorspace, color, _alpha) {
461
+ handleVectorColor2(colorspace, color);
462
+ },
463
+ fillText(_text, _ctm, colorspace, color, _alpha) {
464
+ handleVectorColor2(colorspace, color);
465
+ },
466
+ strokeText(_text, _stroke, _ctm, colorspace, color, _alpha) {
467
+ handleVectorColor2(colorspace, color);
468
+ },
469
+ fillImageMask(_image, _ctm, colorspace, color, _alpha) {
470
+ handleVectorColor2(colorspace, color);
471
+ },
472
+ fillImage(image, _ctm, _alpha) {
473
+ const cs = image.getColorSpace();
474
+ if (!cs || !cs.isCMYK()) return;
475
+ const pixmap = image.toPixmap();
476
+ const w = pixmap.getWidth();
477
+ const h = pixmap.getHeight();
478
+ const n = pixmap.getNumberOfComponents();
479
+ const samples = pixmap.getPixels();
480
+ const totalPixels = w * h;
481
+ const step = Math.max(1, Math.floor(totalPixels / 1e4));
482
+ const stride = n + (pixmap.getAlpha() ? 1 : 0);
483
+ let imageMaxTac = 0;
484
+ for (let px = 0; px < totalPixels; px += step) {
485
+ const offset = px * stride;
486
+ const c = samples[offset] / 255;
487
+ const m = samples[offset + 1] / 255;
488
+ const y = samples[offset + 2] / 255;
489
+ const k = samples[offset + 3] / 255;
490
+ const tac = (c + m + y + k) * 100;
491
+ if (tac > imageMaxTac) imageMaxTac = tac;
492
+ }
493
+ recordTac2(imageMaxTac);
494
+ }
495
+ });
496
+ page.runPageContents(device, mupdf3.Matrix.identity);
497
+ if (pageMaxTac > globalMaxTac) {
498
+ globalMaxTac = pageMaxTac;
499
+ globalMaxPage = pageNum;
500
+ }
501
+ if (pageMaxTac > 0) {
502
+ const tacRounded2 = Math.round(pageMaxTac);
503
+ let status;
504
+ if (pageMaxTac > maxTac) {
505
+ status = "fail";
506
+ } else if (pageMaxTac > warnThreshold) {
507
+ status = "warn";
508
+ } else {
509
+ status = "pass";
510
+ }
511
+ details.push({
512
+ page: pageNum,
513
+ message: `Max TAC: ${tacRounded2}% (limit: ${maxTac}%)`,
514
+ status
515
+ });
516
+ if (status === "fail") {
517
+ worstStatus = "fail";
518
+ } else if (status === "warn" && worstStatus === "pass") {
519
+ worstStatus = "warn";
520
+ }
521
+ }
522
+ }
523
+ const tacRounded = Math.round(globalMaxTac);
524
+ const summary = globalMaxTac === 0 ? `All content within TAC limit (${maxTac}%)` : globalMaxTac > maxTac ? `Max TAC: ${tacRounded}% on page ${globalMaxPage} (limit: ${maxTac}%)` : `All content within TAC limit (${maxTac}%)`;
525
+ return {
526
+ check: "Total Ink Coverage",
527
+ status: worstStatus,
528
+ summary,
529
+ details
530
+ };
531
+ };
532
+
533
+ // src/checks/transparency.ts
534
+ import * as mupdf4 from "mupdf";
535
+ var checkTransparency = async (engines) => {
536
+ const { mupdf: doc } = engines;
537
+ const details = [];
538
+ const pagesWithTransparency = [];
539
+ const pageCount = doc.countPages();
540
+ for (let i = 0; i < pageCount; i++) {
541
+ let recordAlpha2 = function(alpha) {
542
+ if (alpha < 1) {
543
+ info.hasAlpha = true;
544
+ if (!info.alphaValues.includes(alpha)) {
545
+ info.alphaValues.push(alpha);
546
+ }
547
+ }
548
+ };
549
+ var recordAlpha = recordAlpha2;
550
+ const pageNum = i + 1;
551
+ const page = doc.loadPage(i);
552
+ const info = {
553
+ blendModes: /* @__PURE__ */ new Set(),
554
+ hasAlpha: false,
555
+ alphaValues: [],
556
+ hasSoftMask: false
557
+ };
558
+ const device = new mupdf4.Device({
559
+ fillPath(_path, _evenOdd, _ctm, _colorspace, _color, alpha) {
560
+ recordAlpha2(alpha);
561
+ },
562
+ strokePath(_path, _stroke, _ctm, _colorspace, _color, alpha) {
563
+ recordAlpha2(alpha);
564
+ },
565
+ fillText(_text, _ctm, _colorspace, _color, alpha) {
566
+ recordAlpha2(alpha);
567
+ },
568
+ strokeText(_text, _stroke, _ctm, _colorspace, _color, alpha) {
569
+ recordAlpha2(alpha);
570
+ },
571
+ fillImageMask(_image, _ctm, _colorspace, _color, alpha) {
572
+ recordAlpha2(alpha);
573
+ },
574
+ fillImage(_image, _ctm, alpha) {
575
+ recordAlpha2(alpha);
576
+ },
577
+ beginGroup(_bbox, _colorspace, _isolated, _knockout, blendmode, alpha) {
578
+ if (blendmode !== "Normal") {
579
+ info.blendModes.add(blendmode);
580
+ }
581
+ recordAlpha2(alpha);
582
+ },
583
+ beginMask(_bbox, _luminosity, _colorspace, _color) {
584
+ info.hasSoftMask = true;
585
+ }
586
+ });
587
+ page.runPageContents(device, mupdf4.Matrix.identity);
588
+ const parts = [];
589
+ if (info.blendModes.size > 0) {
590
+ const modes = Array.from(info.blendModes).join(", ");
591
+ parts.push(`Blend mode (${modes})`);
592
+ }
593
+ if (info.hasAlpha) {
594
+ const minAlpha = Math.min(...info.alphaValues);
595
+ parts.push(`Alpha transparency (${minAlpha})`);
596
+ }
597
+ if (info.hasSoftMask) {
598
+ parts.push("soft mask");
599
+ }
600
+ if (parts.length > 0) {
601
+ pagesWithTransparency.push(pageNum);
602
+ details.push({
603
+ page: pageNum,
604
+ message: `Page ${pageNum}: ${parts.join(", ")}`,
605
+ status: "warn"
606
+ });
607
+ }
608
+ }
609
+ const status = pagesWithTransparency.length > 0 ? "warn" : "pass";
610
+ const summary = pagesWithTransparency.length > 0 ? `Transparency detected on ${pagesWithTransparency.length === 1 ? "page" : "pages"} ${pagesWithTransparency.join(", ")}` : "No transparency detected";
611
+ return {
612
+ check: "Transparency",
613
+ status,
614
+ summary,
615
+ details
616
+ };
617
+ };
618
+
619
+ // src/checks/page-size.ts
620
+ var PT_TO_MM2 = 25.4 / 72;
621
+ var TOLERANCE_MM = 0.5;
622
+ function dimToMm(pt) {
623
+ return pt * PT_TO_MM2;
624
+ }
625
+ function fmtMm(mm) {
626
+ return mm.toFixed(0);
627
+ }
628
+ function withinTolerance(a, b) {
629
+ return Math.abs(a - b) <= TOLERANCE_MM;
630
+ }
631
+ var checkPageSize = async (engines, options) => {
632
+ const { pdfLib } = engines;
633
+ const pages = pdfLib.getPages();
634
+ const details = [];
635
+ let worstStatus = "pass";
636
+ const pageDims = [];
637
+ for (let i = 0; i < pages.length; i++) {
638
+ const page = pages[i];
639
+ const mediaBox = page.getMediaBox();
640
+ const trimBox = page.getTrimBox();
641
+ const hasTrimBox = trimBox.x !== mediaBox.x || trimBox.y !== mediaBox.y || trimBox.width !== mediaBox.width || trimBox.height !== mediaBox.height;
642
+ const box = hasTrimBox ? trimBox : mediaBox;
643
+ const wMm = dimToMm(box.width);
644
+ const hMm = dimToMm(box.height);
645
+ pageDims.push({ wMm, hMm });
646
+ }
647
+ let expectedW;
648
+ let expectedH;
649
+ if (options.pageSize) {
650
+ const parts = options.pageSize.split("x");
651
+ if (parts.length === 2) {
652
+ expectedW = parseFloat(parts[0]);
653
+ expectedH = parseFloat(parts[1]);
654
+ }
655
+ }
656
+ for (let i = 0; i < pageDims.length; i++) {
657
+ const { wMm, hMm } = pageDims[i];
658
+ const pageNum = i + 1;
659
+ const sizeStr = `${fmtMm(wMm)} \xD7 ${fmtMm(hMm)} mm`;
660
+ if (expectedW !== void 0 && expectedH !== void 0) {
661
+ const matchesExpected = withinTolerance(wMm, expectedW) && withinTolerance(hMm, expectedH);
662
+ if (!matchesExpected) {
663
+ details.push({
664
+ page: pageNum,
665
+ message: `Page ${pageNum}: ${sizeStr} (expected ${expectedW}x${expectedH} mm)`,
666
+ status: "fail"
667
+ });
668
+ worstStatus = "fail";
669
+ } else {
670
+ details.push({
671
+ page: pageNum,
672
+ message: `Page ${pageNum}: ${sizeStr}`,
673
+ status: "pass"
674
+ });
675
+ }
676
+ } else {
677
+ const ref = pageDims[0];
678
+ const consistent = withinTolerance(wMm, ref.wMm) && withinTolerance(hMm, ref.hMm);
679
+ if (!consistent) {
680
+ details.push({
681
+ page: pageNum,
682
+ message: `Page ${pageNum}: ${sizeStr}`,
683
+ status: "warn"
684
+ });
685
+ if (worstStatus === "pass") worstStatus = "warn";
686
+ } else {
687
+ details.push({
688
+ page: pageNum,
689
+ message: `Page ${pageNum}: ${sizeStr}`,
690
+ status: "pass"
691
+ });
692
+ }
693
+ }
694
+ }
695
+ const refSize = pageDims[0];
696
+ const summary = worstStatus === "pass" ? `All pages are ${fmtMm(refSize.wMm)} \xD7 ${fmtMm(refSize.hMm)} mm` : worstStatus === "warn" ? "Inconsistent page sizes detected" : "Page size mismatch";
697
+ return { check: "Page Size", status: worstStatus, summary, details };
698
+ };
699
+
700
+ // src/engine/pdf-engine.ts
701
+ import * as fs from "fs";
702
+ import * as mupdf5 from "mupdf";
703
+ import { PDFDocument as PDFDocument2 } from "pdf-lib";
704
+ async function loadPdf(filePath) {
705
+ const buffer = fs.readFileSync(filePath);
706
+ const mupdfDoc = mupdf5.PDFDocument.openDocument(
707
+ buffer,
708
+ "application/pdf"
709
+ );
710
+ const pdfLibDoc = await PDFDocument2.load(buffer, {
711
+ ignoreEncryption: true
712
+ });
713
+ return { mupdf: mupdfDoc, pdfLib: pdfLibDoc };
714
+ }
715
+
716
+ // src/reporter/console.ts
717
+ import pc from "picocolors";
718
+ var STATUS_ICON = {
719
+ pass: pc.green("\u2713"),
720
+ warn: pc.yellow("\u26A0"),
721
+ fail: pc.red("\u2717")
722
+ };
723
+ var STATUS_COLOR = {
724
+ pass: pc.green,
725
+ warn: pc.yellow,
726
+ fail: pc.red
727
+ };
728
+ function printReport(fileName, results, verbose) {
729
+ console.log();
730
+ console.log(` ${pc.bold("print-check results:")} ${fileName}`);
731
+ console.log(pc.dim("\u2500".repeat(45)));
732
+ for (const result of results) {
733
+ const icon = STATUS_ICON[result.status];
734
+ const color = STATUS_COLOR[result.status];
735
+ const checkName = result.check.padEnd(16);
736
+ console.log(` ${icon} ${pc.bold(checkName)} ${color(result.summary)}`);
737
+ if (verbose && result.details.length > 0) {
738
+ for (const detail of result.details) {
739
+ const detailIcon = STATUS_ICON[detail.status];
740
+ const pagePrefix = detail.page ? `Page ${detail.page}: ` : "";
741
+ console.log(` ${detailIcon} ${pc.dim(pagePrefix)}${detail.message}`);
742
+ }
743
+ }
744
+ }
745
+ console.log(pc.dim("\u2500".repeat(45)));
746
+ const passed = results.filter((r) => r.status === "pass").length;
747
+ const warned = results.filter((r) => r.status === "warn").length;
748
+ const failed = results.filter((r) => r.status === "fail").length;
749
+ const parts = [];
750
+ if (passed > 0) parts.push(pc.green(`${passed} passed`));
751
+ if (warned > 0) parts.push(pc.yellow(`${warned} warned`));
752
+ if (failed > 0) parts.push(pc.red(`${failed} failed`));
753
+ console.log(` ${parts.join(pc.dim(" \xB7 "))}`);
754
+ console.log();
755
+ }
756
+
757
+ // src/reporter/json.ts
758
+ function buildJsonReport(fileName, results) {
759
+ return {
760
+ file: fileName,
761
+ results,
762
+ summary: {
763
+ passed: results.filter((r) => r.status === "pass").length,
764
+ warned: results.filter((r) => r.status === "warn").length,
765
+ failed: results.filter((r) => r.status === "fail").length
766
+ }
767
+ };
768
+ }
769
+
770
+ // src/profiles.ts
771
+ var PROFILE_NAMES = [
772
+ "standard",
773
+ "magazine",
774
+ "newspaper",
775
+ "large-format"
776
+ ];
777
+ var PROFILES = {
778
+ standard: { minDpi: 300, colorSpace: "cmyk", bleedMm: 3, maxTac: 300, pageSize: void 0 },
779
+ magazine: { minDpi: 300, colorSpace: "cmyk", bleedMm: 5, maxTac: 300, pageSize: void 0 },
780
+ newspaper: { minDpi: 150, colorSpace: "any", bleedMm: 0, maxTac: 240, pageSize: void 0 },
781
+ "large-format": { minDpi: 150, colorSpace: "cmyk", bleedMm: 5, maxTac: 300, pageSize: void 0 }
782
+ };
783
+
784
+ // src/config.ts
785
+ import { z } from "zod";
786
+ import * as fs2 from "fs";
787
+ import * as path from "path";
788
+ import { pathToFileURL } from "url";
789
+ var ConfigSchema = z.object({
790
+ minDpi: z.number().int().positive().optional(),
791
+ colorSpace: z.enum(["cmyk", "any"]).optional(),
792
+ bleed: z.number().nonnegative().optional(),
793
+ maxTac: z.number().positive().optional(),
794
+ pageSize: z.string().optional(),
795
+ checks: z.string().optional(),
796
+ verbose: z.boolean().optional(),
797
+ format: z.enum(["text", "json"]).optional(),
798
+ profile: z.enum(PROFILE_NAMES).optional(),
799
+ severity: z.record(z.string(), z.enum(["fail", "warn", "off"])).optional()
800
+ });
801
+ var CONFIG_FILES = [
802
+ ".printcheckrc",
803
+ ".printcheckrc.json",
804
+ "printcheck.config.js"
805
+ ];
806
+ function findConfigFile(startDir) {
807
+ let dir = path.resolve(startDir);
808
+ while (true) {
809
+ for (const name of CONFIG_FILES) {
810
+ const candidate = path.join(dir, name);
811
+ if (fs2.existsSync(candidate)) {
812
+ return candidate;
813
+ }
814
+ }
815
+ const parent = path.dirname(dir);
816
+ if (parent === dir) break;
817
+ dir = parent;
818
+ }
819
+ return null;
820
+ }
821
+ async function loadConfig() {
822
+ const filePath = findConfigFile(process.cwd());
823
+ if (!filePath) return null;
824
+ const ext = path.extname(filePath);
825
+ let raw;
826
+ if (ext === ".js") {
827
+ const module = await import(pathToFileURL(filePath).href);
828
+ raw = module.default;
829
+ } else {
830
+ const content = fs2.readFileSync(filePath, "utf-8");
831
+ raw = JSON.parse(content);
832
+ }
833
+ const parsed = ConfigSchema.safeParse(raw);
834
+ if (!parsed.success) {
835
+ console.error(`Invalid config file (${filePath}):`, parsed.error.format());
836
+ process.exit(1);
837
+ }
838
+ return { options: parsed.data, filePath };
839
+ }
840
+
841
+ // src/index.ts
842
+ function parseSeverityString(val) {
843
+ if (!val.trim()) return {};
844
+ const result = {};
845
+ for (const pair of val.split(",")) {
846
+ const [check, level] = pair.split(":").map((s) => s.trim());
847
+ if (check && level) result[check] = level;
848
+ }
849
+ return result;
850
+ }
851
+ function applySeverityOverride(result, override) {
852
+ if (!override || override === "fail") return result;
853
+ if (override === "warn" && result.status === "fail") {
854
+ return {
855
+ ...result,
856
+ status: "warn",
857
+ details: result.details.map(
858
+ (d) => d.status === "fail" ? { ...d, status: "warn" } : d
859
+ )
860
+ };
861
+ }
862
+ return result;
863
+ }
864
+ var ALL_CHECKS = {
865
+ bleed: checkBleedTrim,
866
+ fonts: checkFonts,
867
+ colorspace: checkColorSpace,
868
+ resolution: checkResolution,
869
+ pdfx: checkPdfxCompliance,
870
+ tac: checkTac,
871
+ transparency: checkTransparency,
872
+ pagesize: checkPageSize
873
+ };
874
+ var OptionsSchema = z2.object({
875
+ minDpi: z2.coerce.number().int().positive().optional(),
876
+ colorSpace: z2.enum(["cmyk", "any"]).optional(),
877
+ bleed: z2.coerce.number().nonnegative().optional(),
878
+ maxTac: z2.coerce.number().positive().optional(),
879
+ pageSize: z2.string().optional(),
880
+ checks: z2.string().default("all").transform((val) => val === "all" ? Object.keys(ALL_CHECKS) : val.split(",").map((s) => s.trim())),
881
+ verbose: z2.boolean().default(false),
882
+ format: z2.enum(["text", "json"]).default("text"),
883
+ profile: z2.enum(PROFILE_NAMES).optional(),
884
+ severity: z2.union([z2.string().transform(parseSeverityString), z2.record(z2.string(), z2.enum(["fail", "warn", "off"]))]).default({})
885
+ });
886
+ var program = new Command();
887
+ program.name("print-check").description("Validate print-ready PDF files").version("1.0.0").argument("<files...>", "PDF file(s) to check").option("--min-dpi <number>", "Minimum acceptable DPI").option("--color-space <mode>", "Expected color space: cmyk | any").option("--bleed <mm>", "Required bleed in mm").option("--max-tac <percent>", "Maximum total ink coverage %").option("--page-size <WxH>", "Expected page size in mm (e.g. 210x297)").option("--checks <list>", "Comma-separated checks to run", "all").option("--verbose", "Show detailed per-page results", false).option("--format <type>", "Output format: text | json", "text").option("--profile <name>", "Print profile: standard | magazine | newspaper | large-format").option("--severity <overrides>", "Per-check severity: check:level,... (fail|warn|off)").action(async (files, rawOpts) => {
888
+ const config = await loadConfig();
889
+ const stripped = {};
890
+ for (const [key, value] of Object.entries(rawOpts)) {
891
+ if (value !== void 0) stripped[key] = value;
892
+ }
893
+ const configSeverity = config?.options?.severity || {};
894
+ const cliSeverity = typeof stripped.severity === "string" ? parseSeverityString(stripped.severity) : {};
895
+ const mergedSeverity = { ...configSeverity, ...cliSeverity };
896
+ const merged = {
897
+ ...config?.options,
898
+ ...stripped,
899
+ severity: Object.keys(mergedSeverity).length > 0 ? mergedSeverity : void 0
900
+ };
901
+ const parsed = OptionsSchema.safeParse(merged);
902
+ if (!parsed.success) {
903
+ console.error("Invalid options:", parsed.error.format());
904
+ process.exit(1);
905
+ }
906
+ const opts = parsed.data;
907
+ if (config && opts.verbose) {
908
+ console.log(`Using config: ${config.filePath}`);
909
+ }
910
+ const base = opts.profile ? PROFILES[opts.profile] : PROFILES.standard;
911
+ const checkOptions = {
912
+ minDpi: opts.minDpi !== void 0 ? opts.minDpi : base.minDpi,
913
+ colorSpace: opts.colorSpace !== void 0 ? opts.colorSpace : base.colorSpace,
914
+ bleedMm: opts.bleed !== void 0 ? opts.bleed : base.bleedMm,
915
+ maxTac: opts.maxTac !== void 0 ? opts.maxTac : base.maxTac,
916
+ pageSize: opts.pageSize !== void 0 ? opts.pageSize : base.pageSize
917
+ };
918
+ const checksToRun = opts.checks.filter((name) => {
919
+ if (!ALL_CHECKS[name]) {
920
+ console.warn(`Unknown check: "${name}" (skipping)`);
921
+ return false;
922
+ }
923
+ if (opts.severity[name] === "off") return false;
924
+ return true;
925
+ });
926
+ if (checksToRun.length === 0) {
927
+ console.error("No valid checks to run.");
928
+ process.exit(1);
929
+ }
930
+ const allReports = [];
931
+ let anyFail = false;
932
+ for (const file of files) {
933
+ const filePath = path2.resolve(file);
934
+ if (!fs3.existsSync(filePath)) {
935
+ console.error(`File not found: ${filePath}`);
936
+ anyFail = true;
937
+ continue;
938
+ }
939
+ const engines = await loadPdf(filePath);
940
+ const results = [];
941
+ for (const name of checksToRun) {
942
+ try {
943
+ const raw = await ALL_CHECKS[name](engines, checkOptions);
944
+ results.push(applySeverityOverride(raw, opts.severity[name]));
945
+ } catch (err) {
946
+ const raw = {
947
+ check: name,
948
+ status: "fail",
949
+ summary: `Error: ${err instanceof Error ? err.message : String(err)}`,
950
+ details: []
951
+ };
952
+ results.push(applySeverityOverride(raw, opts.severity[name]));
953
+ }
954
+ }
955
+ if (results.some((r) => r.status === "fail")) anyFail = true;
956
+ if (opts.format === "json") {
957
+ allReports.push(buildJsonReport(path2.basename(filePath), results));
958
+ } else {
959
+ if (files.indexOf(file) > 0) {
960
+ console.log();
961
+ }
962
+ printReport(path2.basename(filePath), results, opts.verbose);
963
+ }
964
+ }
965
+ if (opts.format === "json") {
966
+ if (allReports.length === 1) {
967
+ console.log(JSON.stringify(allReports[0], null, 2));
968
+ } else {
969
+ console.log(JSON.stringify(allReports, null, 2));
970
+ }
971
+ }
972
+ process.exit(anyFail ? 1 : 0);
973
+ });
974
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "print-check-cli",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "print-check": "./dist/index.js"
7
+ },
8
+ "scripts": {
9
+ "build": "tsup",
10
+ "dev": "tsx src/index.ts",
11
+ "test": "vitest run"
12
+ },
13
+ "keywords": [
14
+ "pdf",
15
+ "print",
16
+ "prepress",
17
+ "cli"
18
+ ],
19
+ "author": "Ryan Calacsan (https://github.com/ryancalacsan)",
20
+ "license": "ISC",
21
+ "description": "CLI tool to validate print-ready PDF files",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/ryancalacsan/print-check-cli.git"
25
+ },
26
+ "homepage": "https://github.com/ryancalacsan/print-check-cli#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/ryancalacsan/print-check-cli/issues"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "LICENSE",
36
+ "README.md"
37
+ ],
38
+ "dependencies": {
39
+ "commander": "^14.0.2",
40
+ "mupdf": "^1.27.0",
41
+ "pdf-lib": "^1.17.1",
42
+ "picocolors": "^1.1.1",
43
+ "zod": "^4.3.6"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^25.1.0",
47
+ "tsup": "^8.5.1",
48
+ "tsx": "^4.21.0",
49
+ "typescript": "^5.9.3",
50
+ "vitest": "^4.0.18"
51
+ }
52
+ }