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.
- package/LICENSE +15 -0
- package/README.md +194 -0
- package/dist/index.js +974 -0
- 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
|
+
[](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
|
+
}
|