plate-test: app-neutral print color scenes, checked by PDF structure and Ghostscript plates

craft-plate-test writes JSON scenes through craft-pdf and checks the written PDF's
structure (CI, under a second) and, locally only, its separated plates through
Ghostscript tiffsep (scripts/plate-check.sh). Apps can run the same scenes through
their own export as a dev-dependency.
This commit is contained in:
aiongg committed 2026-10-20 03:41:13 +00:00
1 parent eedd0246df
commit f7ae814d91
40 files changed
+6453 -2

No files matched your search

+12 -2
View File
@@ -3,9 +3,9 @@ name: Shared PDF
on:
push:
branches: [main]
paths: ["crates/pdf/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
paths: ["crates/pdf/**", "crates/plate-test/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
pull_request:
paths: ["crates/pdf/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
paths: ["crates/pdf/**", "crates/plate-test/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
workflow_dispatch:
permissions:
@@ -86,3 +86,13 @@ jobs:
cargo install wasm-bindgen-cli --version "$runner_version" --locked
- run: cargo test -p craft-pdf --all-features --locked --target wasm32-unknown-unknown
- run: cargo test --manifest-path crates/pdf/read-only-contract/Cargo.toml --locked --target wasm32-unknown-unknown
plate-test:
name: Plate test scenes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: craft-plate-test
- run: cargo test -p craft-plate-test --locked
Generated
+12
View File
@@ -634,6 +634,18 @@ dependencies = [
"wasm-bindgen-test",
]
[[package]]
name = "craft-plate-test"
version = "0.1.0"
dependencies = [
"craft-cms",
"craft-pdf",
"hayro-syntax",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
+1
View File
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/craft-libs"
# internal: add each crate here as `craft-<name> = { path = "crates/<name>" }`
craft-ai-core = { path = "crates/ai-core" }
craft-pdf = { path = "crates/pdf", version = "=0.1.0" }
craft-plate-test = { path = "crates/plate-test" }
krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] }
hayro = { version = "=0.7.1", default-features = false }
hayro-interpret = { version = "=0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "craft-plate-test"
description = "Print-color test scenes for Craft apps: reference PDFs and PDF structure checks"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
publish = false
[lints]
workspace = true
[dependencies]
craft-cms = { workspace = true }
craft-pdf = { workspace = true, features = ["write-pdf", "write-simple-text"] }
hayro-syntax = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+139
View File
@@ -0,0 +1,139 @@
# Craft plate test
`craft-plate-test` holds print-color test scenes for the Craft apps and the tools to check PDFs
against them. A scene is an app-neutral JSON description of one page (spot inks, CMYK fills,
transparency, overprint, soft masks, placed PDFs), the ink each plate must carry at sample points,
and the PDF structure a correct print export has.
- `scene`: serde types for the scene files in `scenes/`, and `Scene::validate` for the checks a
schema can't make (declared inks, geometry fields per object type, plates named by points).
- `reference`: writes a scene to PDF with krilla through craft-pdf. It tests the shared writer on
its own. A feature the writer can't express yet returns `Error::Unsupported`.
- `structure`: summarizes a PDF with hayro-syntax (page and form transparency groups with `/S /CS
/I /K`; graphics states with `OP op OPM BM SMask`; color space families; DeviceRGB use;
output intents) and checks the summary against a scene's `structure` block.
- `gs`, `tiff`, `judge`, `report`: the local plate check (below).
`tests/scenes.rs` parses every scene, writes every scene without `pending`, and checks the
structure. It runs in CI in seconds, with the unit tests of the TIFF reader and the judge.
## Scene format
Coordinates are points from the page's top-left corner; rectangles are `[x, y, width, height]`.
Ink values are percentages (0 = no ink, 100 = solid). Plates use separation names: `Cyan`,
`Magenta`, `Yellow`, `Black` and the spot names.
| Field | Meaning |
|---|---|
| `id`, `title`, `source`, `note` | Identity and origin (research case, app regression) |
| `page` | `[width, height]` in pt |
| `inks` | Spot inks, each `{"name", "cmyk": [c, m, y, k]}` or `"lab": [L, a, b]`; their order is the DeviceN colorant order after the process inks |
| `settings` | `group_cs` (`"rgb"` or `"cmyk"`: the transparency blending space), `output_intent` (`"fogra39"`: writes the scene with craft-pdf `write::print` settings and craft-cms's `COATED_FOGRA39_ICC`; `tests/scenes.rs` also writes every non-pending scene this way) |
| `objects` | Drawn in order (below) |
| `expect` | `plates`: every plate the separation must produce, process plates included, and no others. `points`: `{at, kind: "area" or "text", region (text), plate (text, default Black), inks: {plate: value or [lo, hi]}, tol, info, note}`; unlisted plates expect 0, `info` points are measured but not judged |
| `structure` | Expected PDF structure (below) |
| `pending` | The task that makes the scene writable, e.g. `"T4"`; pending scenes are parsed but not written |
Objects have a `type`: `rect` and `ellipse` (`rect`), `text` (`at` = baseline start, `size`,
`text`), `image` (`rect`, `image`), `pdf` (`rect`, `scene` = id of a scene placed as a page),
`group` (`objects`, `isolated`, `knockout`). Any object can carry `opacity` (whole object, as a
transparency group), `blend` (`multiply`, `color`, … in kebab case), `overprint` (`{fill, stroke,
mode}`, mode = OPM, default 1) and `mask` (`{kind: "luminosity" or "alpha", content: [objects],
backdrop, transfer}`; `backdrop` is a process or gray paint, `transfer` a list of samples 0–1). Shapes and text take `fill` and `stroke` (`{paint, width}`).
Paints:
| Paint | Example |
|---|---|
| Process | `{"cmyk": [100, 0, 0, 0]}` |
| Gray (black ink %, written as DeviceGray) | `{"gray": 50}` |
| RGB (0–255) | `{"rgb": [255, 0, 0]}` |
| Spot | `{"spot": "Gold", "tint": 50}` (tint defaults to 100) |
| Several inks (DeviceN) | `{"inks": {"Cyan": 60, "Black": 40}}` |
| Registration (`/All`) | `"registration"` |
| Linear gradient | `{"linear": {"from": [x, y], "to": [x, y], "stops": [{"at": 0, "paint": …}, …]}}` |
Images are synthetic: `{"px": [w, h], "ramp": [paint, paint]}` (left to right),
`"split": [paint, paint]` (left and right halves) or `"feather": pt` (a gray soft-mask image,
white inside and fading to black at the edges). Shadows and feathers are luminosity masks over
such an image.
Tolerances: ±2 for device, spot and overprint values; ±6 for blends and ICC; ±10 for shadows and
gradients.
### The `structure` block
Each field is optional; only the fields present are checked.
| Field | Check |
|---|---|
| `page_group` | The page group's `/CS` family; `"none"` = no page group, `"no-cs"` = a group without `/CS` |
| `group_cs` | The set of `/CS` values of form and soft-mask groups (`"none"` = no `/CS`) |
| `knockout`, `isolated` | Some group has `/K true` / `/I true` (page groups excluded for `isolated`) |
| `overprint`, `smask` | Some graphics state sets `OP` or `op` true / a soft mask |
| `blend_modes` | The set of blend modes other than Normal |
| `ext_g_states` | Each entry (keys `OP op OPM BM SMask BC TR`) matches some graphics state |
| `color_spaces`, `color_spaces_include` | The exact set / a subset of color space families, e.g. `DeviceCMYK`, `Separation(Gold, alt DeviceCMYK)` |
| `device_rgb` | Some painting color space, alternates included, is DeviceRGB |
| `output_intents` | The `/S` of each output intent |
A failing check prints the full summary as JSON, which is also the quickest way to write a new
block.
## Adding a scene
1. Add `scenes/<ID>-<slug>.json`. Reuse the 360 × 216 pt page and the `Gold` spot unless the
case needs otherwise. Use fonts and images that the harness makes; never commit fonts, GWG
files or other third-party test files.
2. Add `pending` when the writer can't express the scene yet, and remove it in the change that
adds the feature.
3. Run `cargo test -p craft-plate-test`.
Text uses `Inter-Regular.ttf` from `CRAFT_PDF_TEST_FONT_DIR`
(`python scripts/fetch-pdf-test-fonts.py <dir outside the checkout>`). Without it, text objects
are skipped with a printed note.
## Use from an app
Add the crate as a dev-dependency and write an adapter in the app's tests: build each scene as an
app document, export it through the app's own print path, and check the result with
`structure::summarize` and the scene's `structure` block. Scenes the app can't build yet are
skipped by the adapter, not edited.
```rust
let scenes = craft_plate_test::scene::load_dir(&craft_plate_test::scene::scenes_dir())?;
for scene in scenes.iter().filter(|s| s.pending.is_none()) {
let pdf = my_app_export(scene)?; // the app's adapter
let summary = craft_plate_test::structure::summarize(&pdf)?;
assert!(scene.structure.check(&summary).is_empty());
}
```
## Plate check (local only)
```sh
scripts/plate-check.sh
```
The check writes every scene, separates it with Ghostscript's `tiffsep` device at 72 dpi with
overprint simulated, and judges the plates:
- Area points: the median of the 5 × 5 pixels around the point, per plate.
- Text points: the median over the glyph's pixels, those in `region` with at least half the
darkest ink on the point's `plate`.
- Each point's plates within `tol` of the expected value (or inside the `[lo, hi]` range).
- The separation produces exactly the scene's `plates`.
Pending scenes are written without the features they wait for (`reference::write_partial`) and
are expected to fail; the report shows what the writer prints today. A pending scene that passes
is reported as such: its plates don't show the missing feature under Ghostscript, so its
`structure` block is the check that does. The test fails when a scene without `pending` fails or
the harness errors.
The results go to `plate-test/report.md` and `results.json` in Cargo's target directory
(`CARGO_TARGET_DIR` is respected). The script sets `CRAFT_PDF_TEST_FONT_DIR` to
`~/.cache/craft-pdf-test-fonts` when the font is there; without the font, text points are skipped.
Ghostscript is AGPL. It runs only as an external program on developer machines (`gs` on `PATH`, or
`CRAFT_PLATE_TEST_GS`), never in CI. Don't link it, copy its code, or commit its output. Without
Ghostscript the test returns with a note.
@@ -0,0 +1,78 @@
{
"id": "D01",
"title": "Gold alone, no transparency",
"source": "DesignCraft DC01",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
}
],
"objects": [
{
"type": "rect",
"rect": [
36,
36,
108,
144
],
"fill": {
"spot": "Gold",
"tint": 100
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
90,
108
],
"inks": {
"Gold": 100
},
"tol": 2,
"note": "spot fill"
},
{
"at": [
300,
108
],
"inks": {},
"tol": 2,
"note": "paper"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": false,
"smask": false,
"blend_modes": [],
"color_spaces": [
"Separation(Gold, alt DeviceCMYK)"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,113 @@
{
"id": "D04",
"title": "Gold at 50% opacity over C100",
"source": "DesignCraft DC04",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
}
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
36,
36,
180,
144
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
144,
36,
180,
144
],
"fill": {
"spot": "Gold",
"tint": 100
},
"opacity": 0.5
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
90,
108
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
},
{
"at": [
180,
108
],
"inks": {
"Cyan": 50,
"Gold": 50
},
"tol": 6,
"note": "half the spot, half the cyan"
},
{
"at": [
290,
108
],
"inks": {
"Gold": 50
},
"tol": 6,
"note": "spot 50% over paper"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"group_cs": [
"none"
],
"color_spaces": [
"DeviceCMYK",
"Separation(Gold, alt DeviceCMYK)"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,264 @@
{
"id": "D06",
"title": "Overprint stroke and overprint fill, process and spot",
"source": "DesignCraft DC06",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
}
],
"objects": [
{
"type": "rect",
"rect": [
20,
20,
150,
100
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
40,
40,
110,
60
],
"stroke": {
"paint": {
"spot": "Gold",
"tint": 100
},
"width": 10
},
"overprint": {
"stroke": true
}
},
{
"type": "rect",
"rect": [
190,
20,
150,
100
],
"fill": {
"cmyk": [
0,
0,
100,
0
]
}
},
{
"type": "rect",
"rect": [
210,
40,
110,
60
],
"stroke": {
"paint": {
"cmyk": [
0,
100,
0,
0
]
},
"width": 10
},
"overprint": {
"stroke": true
}
},
{
"type": "rect",
"rect": [
20,
130,
320,
70
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
40,
140,
120,
50
],
"fill": {
"cmyk": [
0,
100,
0,
0
]
},
"overprint": {
"fill": true
}
},
{
"type": "rect",
"rect": [
200,
140,
120,
50
],
"fill": {
"spot": "Gold",
"tint": 100
},
"overprint": {
"fill": true
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
40,
70
],
"inks": {
"Gold": 100,
"Cyan": 100
},
"tol": 2,
"note": "spot stroke over cyan"
},
{
"at": [
95,
70
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "inside the spot-stroked frame"
},
{
"at": [
210,
70
],
"inks": {
"Magenta": 100,
"Yellow": 100
},
"tol": 2,
"note": "magenta stroke over yellow"
},
{
"at": [
265,
70
],
"inks": {
"Yellow": 100
},
"tol": 2,
"note": "inside the magenta-stroked frame"
},
{
"at": [
100,
165
],
"inks": {
"Magenta": 100,
"Cyan": 100
},
"tol": 2,
"note": "magenta fill over cyan"
},
{
"at": [
260,
165
],
"inks": {
"Gold": 100,
"Cyan": 100
},
"tol": 2,
"note": "spot fill over cyan"
},
{
"at": [
180,
165
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": true,
"smask": false,
"blend_modes": [],
"color_spaces": [
"DeviceCMYK",
"Separation(Gold, alt DeviceCMYK)"
],
"ext_g_states": [
{
"OP": true
},
{
"op": true
}
],
"device_rgb": false,
"output_intents": []
}
}
+208
View File
@@ -0,0 +1,208 @@
{
"id": "D08",
"title": "Feathered Gold (10 pt soft-mask feather), alone and in a group with cyan",
"source": "DesignCraft DC08",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
}
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
36,
36,
120,
144
],
"fill": {
"spot": "Gold",
"tint": 100
},
"mask": {
"kind": "luminosity",
"backdrop": {
"gray": 100
},
"content": [
{
"type": "image",
"rect": [
36,
36,
120,
144
],
"image": {
"px": [
120,
144
],
"feather": 10
}
}
]
}
},
{
"type": "group",
"objects": [
{
"type": "rect",
"rect": [
204,
36,
120,
144
],
"fill": {
"spot": "Gold",
"tint": 100
},
"mask": {
"kind": "luminosity",
"backdrop": {
"gray": 100
},
"content": [
{
"type": "image",
"rect": [
204,
36,
120,
144
],
"image": {
"px": [
120,
144
],
"feather": 10
}
}
]
}
},
{
"type": "rect",
"rect": [
300,
170,
40,
36
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
}
]
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
96,
108
],
"inks": {
"Gold": 100
},
"tol": 2,
"note": "feathered spot, center"
},
{
"at": [
38,
108
],
"inks": {
"Gold": [
3,
70
]
},
"tol": 10,
"note": "2 pt inside the edge: partial spot, no process ink"
},
{
"at": [
264,
108
],
"inks": {
"Gold": 100
},
"tol": 2,
"note": "feathered spot in a group, center"
},
{
"at": [
206,
108
],
"inks": {
"Gold": [
3,
70
]
},
"tol": 10,
"note": "2 pt inside the edge: partial spot, no process ink"
},
{
"at": [
330,
195
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"smask": true,
"ext_g_states": [
{
"SMask": "Luminosity"
}
],
"color_spaces_include": [
"Separation(Gold, alt DeviceCMYK)",
"DeviceGray"
],
"device_rgb": false,
"output_intents": []
}
}
+147
View File
@@ -0,0 +1,147 @@
{
"id": "D10",
"title": "K100 text (overprint) on a page with transparency, over 50% cyan and over paper",
"source": "DesignCraft DC10",
"page": [
360,
216
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
20,
20,
180,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
},
"opacity": 0.5
},
{
"type": "text",
"at": [
30,
100
],
"size": 60,
"text": "HHH",
"fill": {
"cmyk": [
0,
0,
0,
100
]
},
"overprint": {
"fill": true
}
},
{
"type": "text",
"at": [
210,
100
],
"size": 60,
"text": "HHH",
"fill": {
"cmyk": [
0,
0,
0,
100
]
},
"overprint": {
"fill": true
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
100,
80
],
"kind": "text",
"region": [
30,
56.2,
133.2,
43.8
],
"inks": {
"Black": 100,
"Cyan": 50
},
"tol": 2,
"note": "K text over 50% cyan: overprint keeps the cyan"
},
{
"at": [
280,
80
],
"kind": "text",
"region": [
210,
56.2,
133.2,
43.8
],
"inks": {
"Black": 100
},
"tol": 2,
"note": "K text over paper: K only, no rich black"
},
{
"at": [
100,
160
],
"inks": {
"Cyan": 50
},
"tol": 6,
"note": "cyan at 50% opacity"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"overprint": true,
"ext_g_states": [
{
"op": true
}
],
"group_cs": [
"none"
],
"color_spaces": [
"DeviceCMYK"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,126 @@
{
"id": "M1",
"title": "M100 under a luminosity mask whose content is a black hole, /BC white",
"source": "new",
"page": [
360,
216
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
20,
20,
320,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
60,
40,
240,
136
],
"fill": {
"cmyk": [
0,
100,
0,
0
]
},
"mask": {
"kind": "luminosity",
"backdrop": {
"gray": 0
},
"content": [
{
"type": "rect",
"rect": [
140,
70,
80,
76
],
"fill": {
"gray": 100
}
}
]
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
80,
60
],
"inks": {
"Magenta": 100
},
"tol": 2,
"note": "outside the mask content: /BC white shows the object"
},
{
"at": [
180,
108
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "inside the black hole: masked out"
},
{
"at": [
30,
30
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"smask": true,
"ext_g_states": [
{
"SMask": "Luminosity",
"BC": true
}
],
"color_spaces_include": [
"DeviceCMYK"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,128 @@
{
"id": "M2",
"title": "M100 under a luminosity mask with an inverting /TR: the white mask content becomes a hole",
"source": "new",
"page": [
360,
216
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
20,
20,
320,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
60,
40,
240,
136
],
"fill": {
"cmyk": [
0,
100,
0,
0
]
},
"mask": {
"kind": "luminosity",
"transfer": [
1,
0
],
"content": [
{
"type": "rect",
"rect": [
140,
70,
80,
76
],
"fill": {
"gray": 0
}
}
]
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
80,
60
],
"inks": {
"Magenta": 100
},
"tol": 2,
"note": "outside the mask content: default black backdrop, inverted to fully visible"
},
{
"at": [
180,
108
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "white mask content, inverted: masked out"
},
{
"at": [
30,
30
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"smask": true,
"ext_g_states": [
{
"SMask": "Luminosity",
"BC": false,
"TR": true
}
],
"color_spaces_include": [
"DeviceCMYK"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,98 @@
{
"id": "N1",
"title": "DeviceN [Cyan Black] 60/40 fill overprinting Y100",
"source": "GWG 19 idea",
"page": [
360,
216
],
"objects": [
{
"type": "rect",
"rect": [
20,
20,
320,
176
],
"fill": {
"cmyk": [
0,
0,
100,
0
]
}
},
{
"type": "rect",
"rect": [
60,
60,
240,
96
],
"fill": {
"inks": {
"Cyan": 60,
"Black": 40
}
},
"overprint": {
"fill": true
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
180,
108
],
"inks": {
"Yellow": 100,
"Cyan": 60,
"Black": 40
},
"tol": 2,
"note": "DeviceN overprint leaves unnamed colorants alone"
},
{
"at": [
30,
30
],
"inks": {
"Yellow": 100
},
"tol": 2,
"note": "yellow only"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": true,
"smask": false,
"blend_modes": [],
"color_spaces": [
"DeviceCMYK",
"DeviceN(Cyan,Black, alt DeviceCMYK)"
],
"ext_g_states": [
{
"op": true
}
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,146 @@
{
"id": "S01",
"title": "[Black] rect and text over C100, overprint on",
"source": "research S1, DesignCraft DC06",
"page": [
360,
216
],
"objects": [
{
"type": "rect",
"rect": [
20,
20,
300,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
40,
40,
100,
60
],
"fill": {
"cmyk": [
0,
0,
0,
100
]
},
"overprint": {
"fill": true
}
},
{
"type": "text",
"at": [
40,
160
],
"size": 60,
"text": "HHH",
"fill": {
"cmyk": [
0,
0,
0,
100
]
},
"overprint": {
"fill": true
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
90,
70
],
"inks": {
"Cyan": 100,
"Black": 100
},
"tol": 2,
"note": "K rect"
},
{
"at": [
110,
140
],
"kind": "text",
"region": [
40,
116.2,
133.2,
43.8
],
"inks": {
"Cyan": 100,
"Black": 100
},
"tol": 2,
"note": "K text"
},
{
"at": [
250,
70
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
},
{
"at": [
340,
108
],
"inks": {},
"tol": 2,
"note": "paper"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": true,
"smask": false,
"blend_modes": [],
"color_spaces": [
"DeviceCMYK"
],
"ext_g_states": [
{
"op": true
}
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,133 @@
{
"id": "S02",
"title": "[Black] rect and text over C100, overprint off",
"source": "research S2",
"page": [
360,
216
],
"objects": [
{
"type": "rect",
"rect": [
20,
20,
300,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
40,
40,
100,
60
],
"fill": {
"cmyk": [
0,
0,
0,
100
]
}
},
{
"type": "text",
"at": [
40,
160
],
"size": 60,
"text": "HHH",
"fill": {
"cmyk": [
0,
0,
0,
100
]
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
90,
70
],
"inks": {
"Black": 100
},
"tol": 2,
"note": "K rect"
},
{
"at": [
110,
140
],
"kind": "text",
"region": [
40,
116.2,
133.2,
43.8
],
"inks": {
"Black": 100
},
"tol": 2,
"note": "K text"
},
{
"at": [
250,
70
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
},
{
"at": [
340,
108
],
"inks": {},
"tol": 2,
"note": "paper"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": false,
"smask": false,
"blend_modes": [],
"color_spaces": [
"DeviceCMYK"
],
"device_rgb": false,
"output_intents": []
}
}
+155
View File
@@ -0,0 +1,155 @@
{
"id": "S03",
"title": "C50 Y50 under M100 with fill overprint, OPM 1 (left) and OPM 0 (right)",
"source": "research S3 (GWG 1.1 idea)",
"page": [
360,
216
],
"objects": [
{
"type": "rect",
"rect": [
20,
20,
150,
176
],
"fill": {
"cmyk": [
50,
0,
50,
0
]
}
},
{
"type": "rect",
"rect": [
40,
60,
110,
96
],
"fill": {
"cmyk": [
0,
100,
0,
0
]
},
"overprint": {
"fill": true,
"mode": 1
}
},
{
"type": "rect",
"rect": [
190,
20,
150,
176
],
"fill": {
"cmyk": [
50,
0,
50,
0
]
}
},
{
"type": "rect",
"rect": [
210,
60,
110,
96
],
"fill": {
"cmyk": [
0,
100,
0,
0
]
},
"overprint": {
"fill": true,
"mode": 0
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
95,
108
],
"inks": {
"Cyan": 50,
"Magenta": 100,
"Yellow": 50
},
"tol": 2,
"note": "OPM 1: zero components leave the backdrop"
},
{
"at": [
265,
108
],
"inks": {
"Magenta": 100
},
"tol": 2,
"note": "OPM 0: every DeviceCMYK component paints"
},
{
"at": [
30,
30
],
"inks": {
"Cyan": 50,
"Yellow": 50
},
"tol": 2,
"note": "backdrop"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": true,
"smask": false,
"blend_modes": [],
"color_spaces": [
"DeviceCMYK"
],
"ext_g_states": [
{
"op": true,
"OPM": 1
},
{
"op": true,
"OPM": 0
}
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,107 @@
{
"id": "S04",
"title": "Gold 50 over C100, overprint on",
"source": "research S4 (GWG 2.0 idea)",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
}
],
"objects": [
{
"type": "rect",
"rect": [
20,
20,
320,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
100,
60,
160,
96
],
"fill": {
"spot": "Gold",
"tint": 50
},
"overprint": {
"fill": true
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
180,
108
],
"inks": {
"Cyan": 100,
"Gold": 50
},
"tol": 2,
"note": "spot over cyan"
},
{
"at": [
40,
108
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": true,
"smask": false,
"blend_modes": [],
"color_spaces": [
"DeviceCMYK",
"Separation(Gold, alt DeviceCMYK)"
],
"ext_g_states": [
{
"op": true
}
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,98 @@
{
"id": "S05",
"title": "Gold 50 over C100, overprint off",
"source": "research S5",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
}
],
"objects": [
{
"type": "rect",
"rect": [
20,
20,
320,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
100,
60,
160,
96
],
"fill": {
"spot": "Gold",
"tint": 50
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
180,
108
],
"inks": {
"Gold": 50
},
"tol": 2,
"note": "spot over cyan"
},
{
"at": [
40,
108
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": false,
"smask": false,
"blend_modes": [],
"color_spaces": [
"DeviceCMYK",
"Separation(Gold, alt DeviceCMYK)"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,104 @@
{
"id": "S06",
"title": "K100 at 50% opacity over C100",
"source": "research S6 (the krilla RGB-group regression)",
"page": [
360,
216
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
20,
20,
200,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
120,
20,
220,
176
],
"fill": {
"cmyk": [
0,
0,
0,
100
]
},
"opacity": 0.5
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
60,
108
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
},
{
"at": [
170,
108
],
"inks": {
"Cyan": 50,
"Black": 50
},
"tol": 6,
"note": "CMYK blending: C50 K50, no M or Y"
},
{
"at": [
300,
108
],
"inks": {
"Black": 50
},
"tol": 6,
"note": "K 50% over paper"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"group_cs": [
"none"
],
"color_spaces": [
"DeviceCMYK"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,160 @@
{
"id": "S07",
"title": "Gold 100 Multiply over Teal 100 and over C100",
"source": "research S7, DesignCraft DC02",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
},
{
"name": "Teal",
"cmyk": [
80,
0,
30,
0
]
}
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
20,
20,
150,
176
],
"fill": {
"spot": "Teal",
"tint": 100
}
},
{
"type": "rect",
"rect": [
190,
20,
150,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
60,
60,
240,
96
],
"fill": {
"spot": "Gold",
"tint": 100
},
"blend": "multiply"
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold",
"Teal"
],
"points": [
{
"at": [
40,
108
],
"inks": {
"Teal": 100
},
"tol": 2,
"note": "Teal only"
},
{
"at": [
100,
108
],
"inks": {
"Teal": 100,
"Gold": 100
},
"tol": 6,
"note": "both spots kept"
},
{
"at": [
180,
108
],
"inks": {
"Gold": 100
},
"tol": 6,
"note": "Gold over paper"
},
{
"at": [
260,
108
],
"inks": {
"Cyan": 100,
"Gold": 100
},
"tol": 6,
"note": "cyan kept under the spot"
},
{
"at": [
330,
108
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"blend_modes": [
"Multiply"
],
"color_spaces": [
"DeviceCMYK",
"Separation(Gold, alt DeviceCMYK)",
"Separation(Teal, alt DeviceCMYK)"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,137 @@
{
"id": "S08",
"title": "M100 in Color blend mode over Gold and over C50",
"source": "research S8, DesignCraft DC09",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
}
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
36,
36,
180,
144
],
"fill": {
"spot": "Gold",
"tint": 100
}
},
{
"type": "rect",
"rect": [
216,
36,
124,
144
],
"fill": {
"cmyk": [
50,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
144,
72,
156,
72
],
"fill": {
"cmyk": [
0,
100,
0,
0
]
},
"blend": "color"
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
60,
108
],
"inks": {
"Gold": 100
},
"tol": 2,
"note": "spot only"
},
{
"at": [
180,
108
],
"inks": {},
"tol": 6,
"note": "spot plate composites Normal (knocked out); Color over a paper process backdrop gives paper"
},
{
"at": [
258,
108
],
"inks": {},
"tol": 6,
"info": true,
"note": "non-separable blend of process inks"
},
{
"at": [
320,
160
],
"inks": {
"Cyan": 50
},
"tol": 2,
"note": "cyan 50% only"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"blend_modes": [
"Color"
],
"color_spaces": [
"DeviceCMYK",
"Separation(Gold, alt DeviceCMYK)"
],
"device_rgb": false,
"output_intents": []
}
}
+142
View File
@@ -0,0 +1,142 @@
{
"id": "S09",
"title": "Knockout group of a 50% cyan and a 50% magenta circle over yellow",
"source": "research S9, DesignCraft DC05",
"page": [
360,
216
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
20,
20,
320,
176
],
"fill": {
"cmyk": [
0,
0,
100,
0
]
}
},
{
"type": "group",
"knockout": true,
"objects": [
{
"type": "ellipse",
"rect": [
60,
38,
140,
140
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
},
"opacity": 0.5
},
{
"type": "ellipse",
"rect": [
160,
38,
140,
140
],
"fill": {
"cmyk": [
0,
100,
0,
0
]
},
"opacity": 0.5
}
]
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
30,
108
],
"inks": {
"Yellow": 100
},
"tol": 2,
"note": "yellow only"
},
{
"at": [
100,
108
],
"inks": {
"Yellow": 50,
"Cyan": 50
},
"tol": 6,
"note": "cyan 50% over yellow"
},
{
"at": [
180,
108
],
"inks": {
"Yellow": 50,
"Magenta": 50
},
"tol": 6,
"note": "overlap: only the top circle over the backdrop (without knockout C25 M50 Y25)"
},
{
"at": [
260,
108
],
"inks": {
"Yellow": 50,
"Magenta": 50
},
"tol": 6,
"note": "magenta 50% over yellow"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"knockout": true,
"group_cs": [
"none"
],
"color_spaces": [
"DeviceCMYK"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,142 @@
{
"id": "S10",
"title": "Isolated group: M100 Multiply over Y100, both over a C100 backdrop",
"source": "research S10",
"page": [
360,
216
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
20,
20,
320,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "group",
"isolated": true,
"objects": [
{
"type": "rect",
"rect": [
60,
50,
120,
116
],
"fill": {
"cmyk": [
0,
0,
100,
0
]
}
},
{
"type": "rect",
"rect": [
120,
50,
160,
116
],
"fill": {
"cmyk": [
0,
100,
0,
0
]
},
"blend": "multiply"
}
]
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
90,
108
],
"inks": {
"Yellow": 100
},
"tol": 2,
"note": "yellow knocks out cyan"
},
{
"at": [
150,
108
],
"inks": {
"Yellow": 100,
"Magenta": 100
},
"tol": 6,
"note": "Multiply with a group member"
},
{
"at": [
250,
108
],
"inks": {
"Magenta": 100
},
"tol": 6,
"note": "Multiply over the group's transparent backdrop: no cyan (non-isolated would give C100 M100)"
},
{
"at": [
320,
108
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"isolated": true,
"group_cs": [
"none"
],
"blend_modes": [
"Multiply"
],
"color_spaces": [
"DeviceCMYK"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,269 @@
{
"id": "S11",
"title": "K drop shadows (soft mask, Multiply 75%) over Gold and over a CMYK image",
"source": "research S11, DesignCraft DC03, DC11",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
}
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "rect",
"rect": [
20,
36,
140,
144
],
"fill": {
"spot": "Gold",
"tint": 100
}
},
{
"type": "rect",
"rect": [
30,
46,
120,
120
],
"fill": {
"cmyk": [
0,
0,
0,
100
]
},
"opacity": 0.75,
"blend": "multiply",
"mask": {
"kind": "luminosity",
"backdrop": {
"gray": 100
},
"content": [
{
"type": "image",
"rect": [
50,
66,
80,
80
],
"image": {
"px": [
80,
80
],
"feather": 10
}
}
]
},
"note": "mask content is smaller than the K rect: outside it the backdrop (/BC black) hides the shadow"
},
{
"type": "image",
"rect": [
190,
36,
140,
144
],
"image": {
"px": [
140,
144
],
"split": [
{
"cmyk": [
100,
0,
0,
0
]
},
{
"cmyk": [
0,
0,
0,
50
]
}
]
}
},
{
"type": "rect",
"rect": [
210,
46,
120,
120
],
"fill": {
"cmyk": [
0,
0,
0,
100
]
},
"opacity": 0.75,
"blend": "multiply",
"mask": {
"kind": "luminosity",
"backdrop": {
"gray": 100
},
"content": [
{
"type": "image",
"rect": [
230,
66,
80,
80
],
"image": {
"px": [
80,
80
],
"feather": 10
}
}
]
},
"note": "mask content is smaller than the K rect: outside it the backdrop (/BC black) hides the shadow"
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
30,
50
],
"inks": {
"Gold": 100
},
"tol": 2,
"note": "spot only"
},
{
"at": [
90,
106
],
"inks": {
"Gold": 100,
"Black": 75
},
"tol": 10,
"note": "shadow over spot: spot kept, K follows the mask"
},
{
"at": [
35,
52
],
"inks": {
"Gold": 100
},
"tol": 10,
"note": "inside the K rect, outside the mask content: /BC hides the shadow"
},
{
"at": [
245,
106
],
"inks": {
"Cyan": 100,
"Black": 75
},
"tol": 10,
"note": "shadow over the image's C100 half"
},
{
"at": [
295,
106
],
"inks": {
"Black": 88
},
"tol": 10,
"note": "shadow (K75 Multiply) over the image's K50 half"
},
{
"at": [
205,
50
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "image C100 half"
},
{
"at": [
320,
170
],
"inks": {
"Black": 50
},
"tol": 2,
"note": "image K50 half"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"smask": true,
"blend_modes": [
"Multiply"
],
"ext_g_states": [
{
"SMask": "Luminosity",
"BC": true
}
],
"color_spaces_include": [
"DeviceCMYK",
"Separation(Gold, alt DeviceCMYK)",
"DeviceGray"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,134 @@
{
"id": "S12",
"title": "White (C0 M0 Y0 K0) fill overprint over C100, OPM 1 (left) and OPM 0 (right)",
"source": "research S12 (GWG 4.x/12.0 idea)",
"page": [
360,
216
],
"objects": [
{
"type": "rect",
"rect": [
20,
20,
320,
176
],
"fill": {
"cmyk": [
100,
0,
0,
0
]
}
},
{
"type": "rect",
"rect": [
60,
60,
100,
96
],
"fill": {
"cmyk": [
0,
0,
0,
0
]
},
"overprint": {
"fill": true,
"mode": 1
}
},
{
"type": "rect",
"rect": [
200,
60,
100,
96
],
"fill": {
"cmyk": [
0,
0,
0,
0
]
},
"overprint": {
"fill": true,
"mode": 0
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black"
],
"points": [
{
"at": [
110,
108
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "OPM 1: an all-zero overprinting white leaves the backdrop"
},
{
"at": [
250,
108
],
"inks": {},
"tol": 2,
"note": "OPM 0: white knocks out"
},
{
"at": [
40,
108
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "cyan only"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": true,
"smask": false,
"blend_modes": [],
"color_spaces": [
"DeviceCMYK"
],
"ext_g_states": [
{
"op": true,
"OPM": 1
},
{
"op": true,
"OPM": 0
}
],
"device_rgb": false,
"output_intents": []
},
"note": "The app warns about white overprint in preflight; the writer keeps the attribute exactly."
}
@@ -0,0 +1,132 @@
{
"id": "S13",
"title": "Registration marks print on every plate, spots included",
"source": "research S13",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
}
],
"objects": [
{
"type": "rect",
"rect": [
40,
40,
80,
80
],
"fill": "registration"
},
{
"type": "ellipse",
"rect": [
200,
40,
80,
80
],
"stroke": {
"paint": "registration",
"width": 6
}
},
{
"type": "rect",
"rect": [
160,
140,
60,
40
],
"fill": {
"spot": "Gold",
"tint": 100
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
80,
80
],
"inks": {
"Cyan": 100,
"Magenta": 100,
"Yellow": 100,
"Black": 100,
"Gold": 100
},
"tol": 2,
"note": "registration fill"
},
{
"at": [
240,
40
],
"inks": {
"Cyan": 100,
"Magenta": 100,
"Yellow": 100,
"Black": 100,
"Gold": 100
},
"tol": 2,
"note": "registration stroke (top of the circle)"
},
{
"at": [
240,
80
],
"inks": {},
"tol": 2,
"note": "inside the circle"
},
{
"at": [
190,
160
],
"inks": {
"Gold": 100
},
"tol": 2,
"note": "Gold"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": false,
"smask": false,
"blend_modes": [],
"color_spaces": [
"Separation(All, alt DeviceCMYK)",
"Separation(Gold, alt DeviceCMYK)"
],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,232 @@
{
"id": "S14",
"title": "DeviceN [Black Gold] duotone ramp, Gold-to-paper and Gold-to-cyan gradients",
"source": "research S14, DesignCraft DC07",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
}
],
"objects": [
{
"type": "image",
"rect": [
20,
20,
320,
80
],
"image": {
"px": [
320,
8
],
"ramp": [
{
"inks": {
"Black": 0,
"Gold": 0
}
},
{
"inks": {
"Black": 100,
"Gold": 60
}
}
]
}
},
{
"type": "rect",
"rect": [
20,
120,
150,
76
],
"fill": {
"linear": {
"from": [
20,
0
],
"to": [
170,
0
],
"stops": [
{
"at": 0,
"paint": {
"spot": "Gold",
"tint": 100
}
},
{
"at": 1,
"paint": {
"spot": "Gold",
"tint": 0
}
}
]
}
}
},
{
"type": "rect",
"rect": [
190,
120,
150,
76
],
"fill": {
"linear": {
"from": [
190,
0
],
"to": [
340,
0
],
"stops": [
{
"at": 0,
"paint": {
"spot": "Gold",
"tint": 100
}
},
{
"at": 1,
"paint": {
"cmyk": [
100,
0,
0,
0
]
}
}
]
}
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
100,
60
],
"inks": {
"Black": 25,
"Gold": 15
},
"tol": 10,
"note": "duotone at 25%"
},
{
"at": [
260,
60
],
"inks": {
"Black": 75,
"Gold": 45
},
"tol": 10,
"note": "duotone at 75%"
},
{
"at": [
57.5,
158
],
"inks": {
"Gold": 75
},
"tol": 10,
"note": "spot>paper at 25%"
},
{
"at": [
95,
158
],
"inks": {
"Gold": 50
},
"tol": 10,
"note": "spot>paper at 50%"
},
{
"at": [
132.5,
158
],
"inks": {
"Gold": 25
},
"tol": 10,
"note": "spot>paper at 75%"
},
{
"at": [
227.5,
158
],
"inks": {
"Gold": 75,
"Cyan": 25
},
"tol": 10,
"note": "spot>cyan at 25%"
},
{
"at": [
265,
158
],
"inks": {
"Gold": 50,
"Cyan": 50
},
"tol": 10,
"note": "spot>cyan at 50%"
}
]
},
"structure": {
"page_group": "none",
"smask": false,
"color_spaces_include": [
"DeviceN(Black,Gold, alt DeviceCMYK)",
"Separation(Gold, alt DeviceCMYK)"
],
"device_rgb": false,
"output_intents": []
},
"note": "DeviceN colorants follow process order (Cyan, Magenta, Yellow, Black), then the scene's inks."
}
@@ -0,0 +1,140 @@
{
"id": "S15",
"title": "S06, S02 and D01 placed as PDF pages at half size",
"source": "research S15",
"page": [
360,
216
],
"settings": {
"group_cs": "cmyk"
},
"objects": [
{
"type": "pdf",
"scene": "S06",
"rect": [
0,
0,
180,
108
]
},
{
"type": "pdf",
"scene": "S02",
"rect": [
180,
0,
180,
108
]
},
{
"type": "pdf",
"scene": "D01",
"rect": [
0,
108,
180,
108
]
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold"
],
"points": [
{
"at": [
30,
54
],
"inks": {
"Cyan": 100
},
"tol": 2,
"note": "S06 cyan only"
},
{
"at": [
85,
54
],
"inks": {
"Cyan": 50,
"Black": 50
},
"tol": 6,
"note": "S06 K50% over cyan"
},
{
"at": [
225,
35
],
"inks": {
"Black": 100
},
"tol": 2,
"note": "S02 K rect"
},
{
"at": [
235,
70
],
"kind": "text",
"region": [
200,
58,
67,
22
],
"inks": {
"Black": 100
},
"tol": 2,
"note": "S02 K text"
},
{
"at": [
45,
162
],
"inks": {
"Gold": 100
},
"tol": 2,
"note": "D01 spot"
},
{
"at": [
270,
162
],
"inks": {},
"tol": 2,
"note": "paper"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"group_cs": [
"none"
],
"color_spaces_include": [
"DeviceCMYK",
"Separation(Gold, alt DeviceCMYK)"
],
"device_rgb": false,
"output_intents": []
},
"note": "The placed pages must separate like their sources: K-only text stays K, the spot stays a spot."
}
@@ -0,0 +1,134 @@
{
"id": "S16",
"title": "DeviceGray 50% fill overprint over Gold and Teal",
"source": "research S16 (GWG 3.0 idea)",
"page": [
360,
216
],
"inks": [
{
"name": "Gold",
"cmyk": [
0,
20,
60,
20
]
},
{
"name": "Teal",
"cmyk": [
80,
0,
30,
0
]
}
],
"objects": [
{
"type": "rect",
"rect": [
20,
20,
150,
176
],
"fill": {
"spot": "Gold",
"tint": 100
}
},
{
"type": "rect",
"rect": [
190,
20,
150,
176
],
"fill": {
"spot": "Teal",
"tint": 100
}
},
{
"type": "rect",
"rect": [
60,
60,
240,
96
],
"fill": {
"gray": 50
},
"overprint": {
"fill": true
}
}
],
"expect": {
"plates": [
"Cyan",
"Magenta",
"Yellow",
"Black",
"Gold",
"Teal"
],
"points": [
{
"at": [
40,
108
],
"inks": {
"Gold": 100
},
"tol": 2,
"note": "Gold only"
},
{
"at": [
100,
108
],
"inks": {},
"tol": 2,
"info": true,
"note": "gray over Gold: devices differ (DeviceGray maps to K; overprint may keep the spot)"
},
{
"at": [
260,
108
],
"inks": {},
"tol": 2,
"info": true,
"note": "gray over Teal"
}
]
},
"structure": {
"page_group": "none",
"group_cs": [],
"overprint": true,
"smask": false,
"blend_modes": [],
"color_spaces": [
"DeviceGray",
"Separation(Gold, alt DeviceCMYK)",
"Separation(Teal, alt DeviceCMYK)"
],
"ext_g_states": [
{
"op": true
}
],
"device_rgb": false,
"output_intents": []
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Separates a PDF into plates with Ghostscript's `tiffsep` device.
//!
//! Ghostscript is AGPL: it runs here only as an external program on a developer's machine. It is
//! never linked or copied into this crate, and its output is never committed.
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::Error;
use crate::tiff::{self, Plate};
/// Overrides the Ghostscript program (default `gs` on `PATH`).
pub const GS_ENV: &str = "CRAFT_PLATE_TEST_GS";
/// Plate resolution in dpi; at 72 dpi one pixel is one scene point.
pub const RESOLUTION: f32 = 72.0;
/// The Ghostscript program to run.
pub fn program() -> OsString {
std::env::var_os(GS_ENV).unwrap_or_else(|| "gs".into())
}
/// Ghostscript's version, or `None` when the program can't be run.
pub fn version() -> Option<String> {
let output = Command::new(program()).arg("--version").output().ok()?;
output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
/// Separate the first page of `pdf` into plates, keyed by separation name (`Cyan`, …, spot
/// names). Overprint is simulated as a separating RIP would print it (`-dOverprint=/enable`).
pub fn separate(pdf: &[u8], label: &str) -> Result<BTreeMap<String, Plate>, Error> {
let work = WorkDir::new(label)?;
let input = work.0.join("in.pdf");
std::fs::write(&input, pdf).map_err(|e| gs_error(&work.0, e))?;
let res = format!("-r{RESOLUTION}");
let output_file = format!("-sOutputFile={}", work.0.join("p%d.tif").display());
let run = Command::new(program())
.args(["-q", "-dSAFER", "-dBATCH", "-dNOPAUSE", "-sDEVICE=tiffsep", &res, "-sCompression=none", "-dOverprint=/enable"])
.arg(output_file)
.arg(&input)
.output()
.map_err(|e| Error::Ghostscript(format!("{}: {e}", program().to_string_lossy())))?;
if !run.status.success() {
let stderr = String::from_utf8_lossy(&run.stderr);
let tail: Vec<&str> = stderr.lines().rev().take(5).collect();
return Err(Error::Ghostscript(format!("{label}: {}: {}", run.status, tail.into_iter().rev().collect::<Vec<_>>().join(" / "))));
}
let mut plates = BTreeMap::new();
for entry in std::fs::read_dir(&work.0).map_err(|e| gs_error(&work.0, e))? {
let path = entry.map_err(|e| gs_error(&work.0, e))?.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()).and_then(plate_name) else { continue };
let data = std::fs::read(&path).map_err(|e| gs_error(&path, e))?;
let plate = tiff::read(&data).map_err(|e| Error::Tiff(format!("{}: {e}", path.display())))?;
plates.insert(name.to_owned(), plate);
}
if plates.is_empty() {
return Err(Error::Ghostscript(format!("{label}: no plates written")));
}
Ok(plates)
}
/// `p1(Cyan).tif` → `Cyan`; the composite `p1.tif` and later pages are not plates.
pub fn plate_name(file: &str) -> Option<&str> {
file.strip_prefix("p1(")?.strip_suffix(").tif")
}
fn gs_error(path: &Path, e: std::io::Error) -> Error {
Error::Ghostscript(format!("{}: {e}", path.display()))
}
/// A fresh temporary directory, removed on drop.
struct WorkDir(PathBuf);
impl WorkDir {
fn new(label: &str) -> Result<Self, Error> {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let safe: String = label.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
let n = NEXT.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("craft-plate-test-{}-{n}-{safe}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| gs_error(&dir, e))?;
Ok(Self(dir))
}
}
impl Drop for WorkDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(test)]
mod tests {
use super::plate_name;
#[test]
fn names_plates_from_tiffsep_files() {
assert_eq!(plate_name("p1(Cyan).tif"), Some("Cyan"));
assert_eq!(plate_name("p1(PANTONE 871 C).tif"), Some("PANTONE 871 C"));
assert_eq!(plate_name("p1.tif"), None);
assert_eq!(plate_name("p2(Cyan).tif"), None);
}
}
+311
View File
@@ -0,0 +1,311 @@
//! Judges separated plates against a scene's `expect` block.
//!
//! An area point is the median of the 5 × 5 pixels around it on each plate. A text point is the
//! median over the glyph's pixels: those in its region with at least half the darkest ink on
//! the glyph plate (Black unless the point names another). Plates a point doesn't list expect 0.
use std::collections::BTreeMap;
use serde::Serialize;
use crate::scene::{Level, PROCESS, PointKind, SamplePoint, Scene, TEXT_PLATE};
use crate::tiff::Plate;
/// Below this much ink on the glyph plate a text region counts as empty.
const TEXT_MIN_INK: f32 = 20.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Verdict {
Pass,
Fail,
/// Measured, not judged.
Info,
/// Not measured: the scene's text was not drawn (no font).
Skipped,
}
#[derive(Debug, Clone, Serialize)]
pub struct PointResult {
pub at: [f32; 2],
pub kind: PointKind,
pub note: Option<String>,
pub expected: BTreeMap<String, Level>,
pub tol: f32,
/// Ink percentage per plate, rounded to 0.1; every expected and every separated plate.
pub measured: BTreeMap<String, f32>,
pub verdict: Verdict,
/// Plates outside tolerance.
pub off: Vec<String>,
/// How the value was found, or why it wasn't.
pub how: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Judgement {
/// The plates the separation produced.
pub plates: Vec<String>,
/// Expected plates the separation lacks.
pub missing_plates: Vec<String>,
/// Plates the separation produced that the scene doesn't expect.
pub extra_plates: Vec<String>,
pub points: Vec<PointResult>,
}
impl Judgement {
pub fn passed(&self) -> bool {
self.missing_plates.is_empty() && self.extra_plates.is_empty() && self.failed_points() == 0
}
pub fn failed_points(&self) -> usize {
self.points.iter().filter(|p| p.verdict == Verdict::Fail).count()
}
pub fn judged_points(&self) -> usize {
self.points.iter().filter(|p| matches!(p.verdict, Verdict::Pass | Verdict::Fail)).count()
}
}
/// Judge `plates` (separated at `res` dpi) against `scene`. `text_drawn` is false when the
/// scene's text was left out, so text points are skipped rather than failed.
pub fn judge(scene: &Scene, plates: &BTreeMap<String, Plate>, res: f32, text_drawn: bool) -> Judgement {
let expected = &scene.expect.plates;
let mut names: Vec<String> = expected.iter().chain(plates.keys()).cloned().collect();
sort_plates(&mut names);
names.dedup();
let points = scene.expect.points.iter().map(|point| judge_point(point, plates, &names, res, text_drawn)).collect();
let mut separated: Vec<String> = plates.keys().cloned().collect();
sort_plates(&mut separated);
Judgement {
missing_plates: expected.iter().filter(|p| !plates.contains_key(*p)).cloned().collect(),
extra_plates: separated.iter().filter(|p| !expected.contains(p)).cloned().collect(),
plates: separated,
points,
}
}
fn judge_point(point: &SamplePoint, plates: &BTreeMap<String, Plate>, names: &[String], res: f32, text_drawn: bool) -> PointResult {
let mut result = PointResult {
at: point.at,
kind: point.kind,
note: point.note.clone(),
expected: point.inks.clone(),
tol: point.tol,
measured: BTreeMap::new(),
verdict: Verdict::Fail,
off: Vec::new(),
how: None,
};
if point.kind == PointKind::Text && !text_drawn {
result.verdict = Verdict::Skipped;
result.how = Some("text not drawn (no font)".into());
return result;
}
let pixels = match point_pixels(point, plates, res) {
Ok((pixels, how)) => {
result.how = how;
pixels
}
Err(why) => {
result.how = Some(why);
Vec::new()
}
};
for name in names {
let value = match plates.get(name) {
Some(plate) => median(&mut pixels.iter().filter_map(|&(x, y)| plate.ink(x, y)).collect::<Vec<_>>()).unwrap_or(0.0),
None => 0.0,
};
result.measured.insert(name.clone(), (value * 10.0).round() / 10.0);
}
if pixels.is_empty() {
return result;
}
for (name, value) in &result.measured {
let ok = match point.inks.get(name).copied().unwrap_or(Level::Value(0.0)) {
Level::Value(expected) => (value - expected).abs() <= point.tol,
Level::Range([lo, hi]) => (lo..=hi).contains(value),
};
if !ok {
result.off.push(name.clone());
}
}
sort_plates(&mut result.off);
result.verdict = if point.info {
Verdict::Info
} else if result.off.is_empty() {
Verdict::Pass
} else {
Verdict::Fail
};
result
}
/// Pixels a point samples, and a note on how they were found.
type Sample = (Vec<(usize, usize)>, Option<String>);
/// The pixels a point samples; `Err` says why there are none.
fn point_pixels(point: &SamplePoint, plates: &BTreeMap<String, Plate>, res: f32) -> Result<Sample, String> {
let (width, height) = plates.values().next().map(|p| (p.width(), p.height())).ok_or("no plates")?;
let px = |v: f32| (v * res / 72.0).round();
let inside = |x: f32, y: f32| x >= 0.0 && y >= 0.0 && (x as usize) < width && (y as usize) < height;
match (point.kind, point.region) {
(PointKind::Text, Some([x, y, w, h])) => {
let glyph = point.plate.as_deref().unwrap_or(TEXT_PLATE);
let plate = plates.get(glyph).ok_or_else(|| format!("no {glyph} plate to find the text on"))?;
let (x0, y0, x1, y1) = (px(x).max(0.0), px(y).max(0.0), px(x + w), px(y + h));
let mut region = Vec::new();
let mut row = y0;
while row < y1 {
let mut col = x0;
while col < x1 {
if inside(col, row) {
let (cx, cy) = (col as usize, row as usize);
region.push(((cx, cy), plate.ink(cx, cy).unwrap_or(0.0)));
}
col += 1.0;
}
row += 1.0;
}
let darkest = region.iter().map(|(_, ink)| *ink).fold(0.0, f32::max);
if darkest < TEXT_MIN_INK {
return Err(format!("text not found on the {glyph} plate"));
}
let glyph_pixels: Vec<(usize, usize)> = region.into_iter().filter(|(_, ink)| *ink >= darkest * 0.5).map(|(at, _)| at).collect();
let how = format!("{} glyph pixels on {glyph}", glyph_pixels.len());
Ok((glyph_pixels, Some(how)))
}
_ => {
let (cx, cy) = (px(point.at[0]), px(point.at[1]));
let mut pixels = Vec::with_capacity(25);
for dy in -2..=2 {
for dx in -2..=2 {
let (x, y) = (cx + dx as f32, cy + dy as f32);
if inside(x, y) {
pixels.push((x as usize, y as usize));
}
}
}
if pixels.is_empty() { Err("outside the page".into()) } else { Ok((pixels, None)) }
}
}
}
/// The median; the mean of the middle two for an even count.
pub fn median(values: &mut [f32]) -> Option<f32> {
values.sort_by(f32::total_cmp);
let n = values.len();
match n {
0 => None,
_ if n % 2 == 1 => values.get(n / 2).copied(),
_ => Some((values.get(n / 2 - 1)? + values.get(n / 2)?) / 2.0),
}
}
/// Process plates first in press order, then spots by name.
pub fn sort_plates(names: &mut [String]) {
names.sort_by_key(|name| (PROCESS.iter().position(|p| p == name).unwrap_or(PROCESS.len()), name.clone()));
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
const W: usize = 60;
const H: usize = 40;
fn plate(paint: impl Fn(usize, usize) -> u8) -> Plate {
let ink = (0..W * H).map(|i| paint(i % W, i / W)).collect();
Plate::new(W, H, ink).unwrap()
}
fn scene(json: &str) -> Scene {
serde_json::from_str(json).unwrap()
}
/// Cyan everywhere; Gold at 50% in the right half; a "glyph" of solid Black in
/// x 10..20, y 10..30 with stray 30% black pixels around it.
fn plates(with_gold: bool, extra: Option<&str>) -> BTreeMap<String, Plate> {
let mut plates = BTreeMap::new();
plates.insert("Cyan".to_owned(), plate(|_, _| 255));
plates.insert("Magenta".to_owned(), plate(|_, _| 0));
plates.insert("Yellow".to_owned(), plate(|_, _| 0));
plates.insert(
"Black".to_owned(),
plate(|x, y| match (x, y) {
(10..20, 10..30) => 255,
(5..25, 5..35) if (x + y) % 3 == 0 => 77,
_ => 0,
}),
);
if with_gold {
plates.insert("Gold".to_owned(), plate(|x, _| if x >= 30 { 128 } else { 0 }));
}
if let Some(name) = extra {
plates.insert(name.to_owned(), plate(|_, _| 0));
}
plates
}
const SCENE: &str = r#"{
"id": "T", "title": "t", "source": "unit test", "page": [60, 40],
"inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}],
"objects": [],
"expect": {
"plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"],
"points": [
{"at": [45, 20], "inks": {"Cyan": 100, "Gold": 50}, "tol": 2},
{"at": [15, 20], "kind": "text", "region": [5, 5, 20, 30], "inks": {"Cyan": 100, "Black": 100}, "tol": 2},
{"at": [45, 20], "inks": {"Gold": [10, 20]}, "tol": 2, "info": true},
{"at": [45, 20], "inks": {"Cyan": 100, "Gold": [40, 60]}, "tol": 2}
]
}
}"#;
#[test]
fn passes_a_matching_separation() {
let judgement = judge(&scene(SCENE), &plates(true, None), 72.0, true);
let verdicts: Vec<Verdict> = judgement.points.iter().map(|p| p.verdict).collect();
assert_eq!(verdicts, [Verdict::Pass, Verdict::Pass, Verdict::Info, Verdict::Pass], "{judgement:#?}");
assert!(judgement.passed());
assert_eq!(judgement.points[0].measured["Gold"], 50.2);
assert_eq!(judgement.points[1].how.as_deref(), Some("200 glyph pixels on Black"));
assert_eq!(judgement.plates, ["Cyan", "Magenta", "Yellow", "Black", "Gold"]);
}
#[test]
fn fails_wrong_ink_and_wrong_plates() {
let mut knocked = plates(false, Some("Orange"));
knocked.insert("Cyan".to_owned(), plate(|x, _| if x >= 30 { 0 } else { 255 }));
let judgement = judge(&scene(SCENE), &knocked, 72.0, true);
assert_eq!(judgement.missing_plates, ["Gold"]);
assert_eq!(judgement.extra_plates, ["Orange"]);
let first = &judgement.points[0];
assert_eq!(first.verdict, Verdict::Fail);
assert_eq!(first.off, ["Cyan", "Gold"]);
assert_eq!(judgement.points[2].verdict, Verdict::Info, "info points are never judged");
assert!(!judgement.passed());
assert_eq!((judgement.failed_points(), judgement.judged_points()), (2, 3));
}
#[test]
fn text_points_need_the_glyph() {
let mut no_text = plates(true, None);
no_text.insert("Black".to_owned(), plate(|_, _| 0));
let judgement = judge(&scene(SCENE), &no_text, 72.0, true);
assert_eq!(judgement.points[1].verdict, Verdict::Fail);
assert_eq!(judgement.points[1].how.as_deref(), Some("text not found on the Black plate"));
let skipped = judge(&scene(SCENE), &no_text, 72.0, false);
assert_eq!(skipped.points[1].verdict, Verdict::Skipped);
assert_eq!(skipped.failed_points(), 0);
}
#[test]
fn medians() {
assert_eq!(median(&mut []), None);
assert_eq!(median(&mut [3.0, 1.0, 2.0]), Some(2.0));
assert_eq!(median(&mut [4.0, 1.0, 2.0, 3.0]), Some(2.5));
}
}
+42
View File
@@ -0,0 +1,42 @@
//! Print-color test scenes for the Craft apps.
//!
//! [`scene`] reads the app-neutral scene files in `scenes/`. [`reference`] writes a scene to
//! PDF through craft-pdf, so the shared writer is tested on its own. [`structure`] summarizes a
//! PDF's print-relevant structure (transparency groups, overprint and soft-mask graphics states,
//! color spaces, output intents) and checks it against a scene's `structure` block. Apps use
//! the scenes and [`structure`] from their own adapter, as a dev-dependency.
//!
//! On developer machines, [`report`] separates each scene's PDF into plates with Ghostscript
//! ([`gs`], an external program; [`tiff`] reads the plates) and [`judge`]s the ink at the
//! scene's sample points.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod gs;
pub mod judge;
pub mod reference;
pub mod report;
pub mod scene;
pub mod structure;
pub mod tiff;
/// Failures of the harness itself; scene results are reported, not raised.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A scene file could not be read, parsed or resolved.
#[error("scene: {0}")]
Scene(String),
/// The writer can't express this feature yet.
#[error("unsupported: {0}")]
Unsupported(String),
#[error("write: {0}")]
Write(#[from] craft_pdf::write::WriteError),
/// The PDF could not be parsed for the structure summary.
#[error("read: {0}")]
Read(String),
/// Ghostscript could not be run or failed to separate a PDF.
#[error("ghostscript: {0}")]
Ghostscript(String),
/// A plate file could not be read.
#[error("tiff: {0}")]
Tiff(String),
}
+653
View File
@@ -0,0 +1,653 @@
//! The reference writer: draws a scene with krilla through craft-pdf.
//!
//! Each scene feature maps to one plain krilla call, so the output shows what the shared writer
//! does on its own. Features the writer can't express yet return [`Error::Unsupported`] before
//! anything is drawn.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use craft_pdf::write::backend as krilla;
use craft_pdf::write::print::{self, PrintColor};
use craft_pdf::write::{self, Metadata, Writer};
use krilla::blend::{BlendMode, GroupOptions, Overprint};
use krilla::color::device_n::{self, Colorant, DeviceNSpace, Process};
use krilla::color::separation::{SeparationColorant, SeparationSpace};
use krilla::color::{RegularColor, cmyk, luma, rgb, separation};
use krilla::geom::{Path, PathBuilder, Point, Rect, Size, Transform};
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
use krilla::mask::{Mask as KrillaMask, MaskType, TransferFunction};
use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, LinearGradient, Paint as KrillaPaint, SpreadMethod, Stop, Stroke as KrillaStroke};
use krilla::pdf::{Pdf, PdfDocument};
use krilla::surface::Surface;
use krilla::text::{Font, TextDirection};
use crate::Error;
use crate::scene::{Blend, GroupCs, ImageSource, Kind, MAX_NESTING, MaskKind, Named, Object, Paint, Scene, Stroke};
/// The font used for text objects, from `scripts/fetch-pdf-test-fonts.py`.
pub const FONT_FILE: &str = "Inter-Regular.ttf";
/// Directory holding [`FONT_FILE`].
pub const FONT_DIR_ENV: &str = "CRAFT_PDF_TEST_FONT_DIR";
/// What a scene may refer to outside itself.
pub struct Context<'a> {
/// Scenes that `pdf` objects can place.
pub scenes: &'a [Scene],
/// Font data for text objects; without it text is skipped with a note.
pub font: Option<Arc<Vec<u8>>>,
}
impl<'a> Context<'a> {
/// Reads the font from [`FONT_DIR_ENV`] when it is set and the file exists.
pub fn from_env(scenes: &'a [Scene]) -> Self {
let font =
std::env::var_os(FONT_DIR_ENV).map(|dir| PathBuf::from(dir).join(FONT_FILE)).and_then(|path| std::fs::read(path).ok()).map(Arc::new);
Self { scenes, font }
}
}
/// A written scene.
#[derive(Debug)]
pub struct Output {
pub pdf: Vec<u8>,
/// What was left out, e.g. text without a font.
pub notes: Vec<String>,
}
/// Write `scene` as a one-page PDF.
pub fn write(scene: &Scene, ctx: &Context<'_>) -> Result<Output, Error> {
write_nested(scene, ctx, 0, false)
}
/// Write `scene` as far as the writer can, to see what a pending scene prints today. Features
/// the writer lacks but can leave out are left out with a note; the others still return
/// [`Error::Unsupported`].
pub fn write_partial(scene: &Scene, ctx: &Context<'_>) -> Result<Output, Error> {
write_nested(scene, ctx, 0, true)
}
fn write_nested(scene: &Scene, ctx: &Context<'_>, depth: usize, partial: bool) -> Result<Output, Error> {
let mut resolver = Resolver { scene, ctx, depth, partial, notes: Vec::new(), font: None };
let nodes = resolver.objects(&scene.objects)?;
let notes = resolver.notes;
let [width, height] = scene.page;
let page = PageSettings::from_wh(width, height).ok_or_else(|| Error::Scene(format!("{}: bad page size", scene.id)))?;
let settings = settings(scene)?;
let pdf = write::guard(|| {
let mut writer = Writer::new(settings, Metadata::default());
let mut page = writer.document_mut().start_page_with(page);
let mut surface = page.surface();
for node in &nodes {
draw(node, &mut surface);
}
surface.finish();
page.finish();
writer.finish().map_err(Error::from)
})?;
Ok(Output { pdf, notes })
}
/// The writer's defaults, or the print settings for the scene's output intent; an explicit
/// `group_cs` overrides either.
fn settings(scene: &Scene) -> Result<krilla::SerializeSettings, Error> {
let base = match scene.settings.output_intent.as_deref() {
None => krilla::SerializeSettings::default(),
Some(name) => {
let color = print_color(name).ok_or_else(|| Error::Scene(format!("{}: unknown output intent {name}", scene.id)))?;
print::settings(color)?
}
};
let group_color_space = match scene.settings.group_cs {
None => base.group_color_space,
Some(GroupCs::Rgb) => krilla::GroupColorSpace::Rgb,
Some(GroupCs::Cmyk) => krilla::GroupColorSpace::Cmyk,
};
Ok(krilla::SerializeSettings { group_color_space, ..base })
}
/// The printing condition for each name in [`OUTPUT_INTENTS`](crate::scene::OUTPUT_INTENTS).
fn print_color(name: &str) -> Option<PrintColor<'static>> {
match name {
"fogra39" => Some(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)),
_ => None,
}
}
fn unsupported(feature: &str) -> Error {
Error::Unsupported(feature.to_owned())
}
/// A scene object resolved to krilla values, drawable without failing.
enum Node {
Shape {
path: Path,
fill: Option<Fill>,
stroke: Option<KrillaStroke>,
},
Text {
at: Point,
size: f32,
text: String,
font: Font,
fill: Option<Fill>,
stroke: Option<KrillaStroke>,
},
Image {
image: Image,
rect: [f32; 4],
},
Pdf {
doc: PdfDocument,
rect: [f32; 4],
},
Group {
options: GroupOptions,
children: Vec<Node>,
},
/// Outermost first: blend mode, soft mask, opacity group, overprint.
Wrapped {
blend: Option<BlendMode>,
mask: Option<SoftMask>,
opacity: Option<NormalizedF32>,
overprint: Option<Overprint>,
inner: Box<Node>,
},
}
struct SoftMask {
kind: MaskType,
backdrop: Option<RegularColor>,
transfer: Option<TransferFunction>,
content: Vec<Node>,
}
struct Resolver<'s, 'c> {
scene: &'s Scene,
ctx: &'c Context<'c>,
depth: usize,
/// Leave out features the writer lacks instead of failing; see [`write_partial`].
partial: bool,
notes: Vec<String>,
font: Option<Font>,
}
impl Resolver<'_, '_> {
/// A feature the writer lacks: a note when writing partially, otherwise unsupported.
// Every scene feature is written today; scenes for later work (the flattener) call it again.
#[allow(dead_code)]
fn lacks(&mut self, feature: &str) -> Result<(), Error> {
if !self.partial {
return Err(unsupported(feature));
}
let note = format!("{}: {feature} left out", self.scene.id);
if !self.notes.contains(&note) {
self.notes.push(note);
}
Ok(())
}
fn objects(&mut self, objects: &[Object]) -> Result<Vec<Node>, Error> {
let mut nodes = Vec::new();
for object in objects {
if let Some(node) = self.object(object)? {
nodes.push(node);
}
}
Ok(nodes)
}
fn object(&mut self, o: &Object) -> Result<Option<Node>, Error> {
let overprint = match o.overprint {
Some(op) => Some(Overprint {
fill: op.fill,
stroke: op.stroke,
nonzero: match op.mode {
0 => false,
1 => true,
_ => return Err(self.bad("overprint mode must be 0 or 1")),
},
}),
None => None,
};
let Some(inner) = self.content(o)? else { return Ok(None) };
let blend = o.blend.filter(|b| *b != Blend::Normal).map(blend_mode);
let opacity = match o.opacity {
Some(value) if value < 1.0 => Some(NormalizedF32::new(value).ok_or_else(|| self.bad("opacity must be 0–1"))?),
_ => None,
};
let mask = match &o.mask {
Some(mask) => {
let kind = match mask.kind {
MaskKind::Luminosity => MaskType::Luminosity,
MaskKind::Alpha => MaskType::Alpha,
};
let backdrop = match &mask.backdrop {
Some(paint) => match self.color(paint)? {
krilla::color::Color::Regular(color) => Some(color),
krilla::color::Color::Special(_) => return Err(self.bad("a mask backdrop must be a process or gray color")),
},
None => None,
};
let transfer = match &mask.transfer {
Some(curve) => {
let samples = curve.iter().map(|v| NormalizedF32::new(*v)).collect::<Option<Vec<_>>>();
Some(TransferFunction::Sampled(samples.ok_or_else(|| self.bad("transfer samples must be 0–1"))?))
}
None => None,
};
Some(SoftMask { kind, backdrop, transfer, content: self.objects(&mask.content)? })
}
None => None,
};
if blend.is_none() && mask.is_none() && opacity.is_none() && overprint.is_none() {
return Ok(Some(inner));
}
Ok(Some(Node::Wrapped { blend, mask, opacity, overprint, inner: Box::new(inner) }))
}
fn content(&mut self, o: &Object) -> Result<Option<Node>, Error> {
Ok(Some(match o.kind {
Kind::Rect | Kind::Ellipse => {
let [x, y, w, h] = self.rect(o)?;
let path = if o.kind == Kind::Rect { rect_path(x, y, w, h) } else { ellipse_path(x, y, w, h) };
let path = path.ok_or_else(|| self.bad("degenerate shape"))?;
let (fill, stroke) = self.fill_stroke(o)?;
if fill.is_none() && stroke.is_none() {
return Ok(None);
}
Node::Shape { path, fill, stroke }
}
Kind::Text => {
let (Some([x, y]), Some(size), Some(text)) = (o.at, o.size, o.text.clone()) else {
return Err(self.bad("text needs at, size and text"));
};
let Some(font) = self.font()? else {
self.notes.push(format!("{}: text {text:?} skipped ({FONT_DIR_ENV} not set)", self.scene.id));
return Ok(None);
};
let (fill, stroke) = self.fill_stroke(o)?;
Node::Text { at: Point::from_xy(x, y), size, text, font, fill, stroke }
}
Kind::Image => {
let source = o.image.as_ref().ok_or_else(|| self.bad("missing image"))?;
let rect = self.rect(o)?;
Node::Image { image: self.image(source, rect)?, rect }
}
Kind::Pdf => {
let id = o.scene.as_deref().ok_or_else(|| self.bad("missing scene"))?;
let placed = self.ctx.scenes.iter().find(|s| s.id == id).ok_or_else(|| self.bad(&format!("unknown scene {id}")))?;
if self.depth >= MAX_NESTING {
return Err(self.bad("placed scenes nest too deep"));
}
let rect = self.rect(o)?;
let output = write_nested(placed, self.ctx, self.depth + 1, self.partial)?;
self.notes.extend(output.notes);
let pdf = Pdf::new(Arc::new(output.pdf)).map_err(|e| Error::Read(format!("{id}: {e:?}")))?;
Node::Pdf { doc: PdfDocument::new(Arc::new(pdf)), rect }
}
Kind::Group => Node::Group { options: GroupOptions { isolated: o.isolated, knockout: o.knockout }, children: self.objects(&o.objects)? },
}))
}
fn rect(&self, o: &Object) -> Result<[f32; 4], Error> {
o.rect.ok_or_else(|| self.bad("missing rect"))
}
fn font(&mut self) -> Result<Option<Font>, Error> {
if self.font.is_none()
&& let Some(data) = &self.ctx.font
{
self.font = Some(write::font(data, 0, None)?);
}
Ok(self.font.clone())
}
fn fill_stroke(&self, o: &Object) -> Result<(Option<Fill>, Option<KrillaStroke>), Error> {
let fill = o.fill.as_ref().map(|p| self.paint(p)).transpose()?.map(|paint| Fill { paint, ..Default::default() });
let stroke = match &o.stroke {
Some(Stroke { paint, width }) => Some(KrillaStroke { paint: self.paint(paint)?, width: *width, ..Default::default() }),
None => None,
};
Ok((fill, stroke))
}
fn paint(&self, paint: &Paint) -> Result<KrillaPaint, Error> {
match paint {
Paint::Linear { linear } => {
// krilla brings ink-based stops into one space; RGB and registration don't mix.
let space = linear.stops.first().map(|s| space_of(&s.paint));
let mixed = linear.stops.iter().any(|s| Some(space_of(&s.paint)) != space);
if mixed && linear.stops.iter().any(|s| matches!(s.paint, Paint::Rgb { .. } | Paint::Named(_))) {
return Err(unsupported("gradient between RGB or registration and other color spaces"));
}
let mut stops = Vec::new();
for stop in &linear.stops {
let offset = NormalizedF32::new(stop.at).ok_or_else(|| self.bad("gradient stop outside 0–1"))?;
stops.push(Stop { offset, color: self.color(&stop.paint)?, opacity: NormalizedF32::ONE });
}
Ok(LinearGradient {
x1: linear.from[0],
y1: linear.from[1],
x2: linear.to[0],
y2: linear.to[1],
transform: Transform::identity(),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into())
}
solid => Ok(self.color(solid)?.into()),
}
}
fn color(&self, paint: &Paint) -> Result<krilla::color::Color, Error> {
Ok(match paint {
Paint::Named(Named::Registration) => {
let all = SeparationSpace::new(SeparationColorant::AllColorants, cmyk::Color::new(255, 255, 255, 255).into());
separation::Color::new(255, all).into()
}
Paint::Cmyk { cmyk } => cmyk_color(*cmyk).into(),
Paint::Gray { gray } => luma::Color::new(byte(100.0 - gray)).into(),
Paint::Rgb { rgb } => rgb::Color::new(channel(rgb[0]), channel(rgb[1]), channel(rgb[2])).into(),
Paint::Spot { spot, tint } => {
let alternate: RegularColor = self.spot_alternate(spot)?.into();
separation::Color::new(byte(*tint), SeparationSpace::new(SeparationColorant::Custom(spot.clone()), alternate)).into()
}
Paint::Inks { inks } => self.device_n(inks)?.into(),
Paint::Linear { .. } => return Err(self.bad("gradients can't nest")),
})
}
fn spot_alternate(&self, spot: &str) -> Result<cmyk::Color, Error> {
let ink = self.scene.ink(spot).ok_or_else(|| self.bad(&format!("undeclared spot {spot}")))?;
match ink.cmyk {
Some(c) => Ok(cmyk_color(c)),
None => Err(unsupported("Lab spot alternate")),
}
}
/// A DeviceN color in the space of its inks (see [`Self::device_n_space`]).
fn device_n(&self, inks: &BTreeMap<String, f32>) -> Result<device_n::Color, Error> {
let space = self.device_n_space(inks.keys())?;
let tints = space.colorants().iter().map(|c| byte(inks.get(c.name()).copied().unwrap_or(0.0))).collect();
device_n::Color::new(tints, space).ok_or_else(|| self.bad("DeviceN tints don't match its inks"))
}
/// A DeviceN space of the named inks: process inks first, in CMYK order, then the spots in
/// the order the scene declares them.
fn device_n_space<'n>(&self, names: impl Iterator<Item = &'n String> + Clone) -> Result<DeviceNSpace, Error> {
let named = |name: &str| names.clone().any(|n| n == name);
let mut colorants = Vec::new();
for process in Process::ALL {
if named(process.name()) {
colorants.push(Colorant::Process(process));
}
}
for ink in &self.scene.inks {
if named(&ink.name) {
colorants.push(Colorant::Spot { name: ink.name.clone(), alternate: self.spot_alternate(&ink.name)? });
}
}
if let Some(name) = names.clone().find(|name| !colorants.iter().any(|c| c.name() == name.as_str())) {
return Err(self.bad(&format!("undeclared ink {name}")));
}
DeviceNSpace::new(colorants).ok_or_else(|| self.bad("bad DeviceN ink list"))
}
fn image(&self, source: &ImageSource, [_, _, w, h]: [f32; 4]) -> Result<Image, Error> {
let [px, py] = source.px;
let (width, height) = (px as usize, py as usize);
let image = if let Some(radius) = source.feather {
let mut data = Vec::with_capacity(width * height);
for y in 0..height {
for x in 0..width {
let (fx, fy) = ((x as f32 + 0.5) * w / px as f32, (y as f32 + 0.5) * h / py as f32);
let edge = fx.min(w - fx).min(fy).min(h - fy);
data.push(channel(255.0 * (edge / radius.max(f32::EPSILON)).min(1.0)));
}
}
Synthetic { data, size: (px, py), space: ImageColorspace::Luma }
} else {
let (pair, ramp) = match (&source.ramp, &source.split) {
(Some(pair), _) => (pair, true),
(_, Some(pair)) => (pair, false),
_ => return Err(self.bad("image needs ramp, split or feather")),
};
let (space, a, b) = match (ink_tints(&pair[0]), ink_tints(&pair[1])) {
// Spot and DeviceN paints: one DeviceN image of every ink either names.
(Some(a), Some(b)) => {
let space = self.device_n_space(a.keys().chain(b.keys()))?;
let samples = |inks: &BTreeMap<String, f32>| -> Vec<f32> {
space.colorants().iter().map(|c| inks.get(c.name()).copied().unwrap_or(0.0) * 2.55).collect()
};
let (a, b) = (samples(&a), samples(&b));
(ImageColorspace::DeviceN(space), a, b)
}
_ => {
let (space, a) = self.components(&pair[0])?;
let (space_b, b) = self.components(&pair[1])?;
if space != space_b {
return Err(self.bad("image paints must share a color space"));
}
(space, a, b)
}
};
let mut data = Vec::with_capacity(width * height * a.len());
for _ in 0..height {
for x in 0..width {
let t = if ramp { (x as f32 + 0.5) / px as f32 } else { f32::from(u8::from(2 * x >= width)) };
data.extend(a.iter().zip(&b).map(|(a, b)| channel(a + (b - a) * t)));
}
}
Synthetic { data, size: (px, py), space }
};
Image::from_custom(image, false).map_err(|e| self.bad(&e))
}
/// Image samples (0–255) for a solid paint.
fn components(&self, paint: &Paint) -> Result<(ImageColorspace, Vec<f32>), Error> {
Ok(match paint {
Paint::Cmyk { cmyk } => (ImageColorspace::Cmyk, cmyk.iter().map(|v| v * 2.55).collect()),
Paint::Gray { gray } => (ImageColorspace::Luma, vec![(100.0 - gray) * 2.55]),
Paint::Rgb { rgb } => (ImageColorspace::Rgb, rgb.to_vec()),
Paint::Inks { .. } | Paint::Spot { .. } => return Err(self.bad("image paints must share a color space")),
Paint::Named(_) => return Err(unsupported("registration image")),
Paint::Linear { .. } => return Err(self.bad("images take solid paints")),
})
}
fn bad(&self, message: &str) -> Error {
Error::Scene(format!("{}: {message}", self.scene.id))
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Space<'a> {
Cmyk,
Gray,
Rgb,
Spot(&'a str),
Other,
}
fn space_of(paint: &Paint) -> Space<'_> {
match paint {
Paint::Cmyk { .. } => Space::Cmyk,
Paint::Gray { .. } => Space::Gray,
Paint::Rgb { .. } => Space::Rgb,
Paint::Spot { spot, .. } => Space::Spot(spot),
_ => Space::Other,
}
}
/// Ink percentages of a spot or DeviceN paint.
fn ink_tints(paint: &Paint) -> Option<BTreeMap<String, f32>> {
match paint {
Paint::Inks { inks } => Some(inks.clone()),
Paint::Spot { spot, tint } => Some(BTreeMap::from([(spot.clone(), *tint)])),
_ => None,
}
}
fn byte(percent: f32) -> u8 {
channel(percent * 2.55)
}
fn channel(value: f32) -> u8 {
value.round().clamp(0.0, 255.0) as u8
}
fn cmyk_color([c, m, y, k]: [f32; 4]) -> cmyk::Color {
cmyk::Color::new(byte(c), byte(m), byte(y), byte(k))
}
fn blend_mode(blend: Blend) -> BlendMode {
match blend {
Blend::Normal => BlendMode::Normal,
Blend::Multiply => BlendMode::Multiply,
Blend::Screen => BlendMode::Screen,
Blend::Overlay => BlendMode::Overlay,
Blend::Darken => BlendMode::Darken,
Blend::Lighten => BlendMode::Lighten,
Blend::ColorDodge => BlendMode::ColorDodge,
Blend::ColorBurn => BlendMode::ColorBurn,
Blend::HardLight => BlendMode::HardLight,
Blend::SoftLight => BlendMode::SoftLight,
Blend::Difference => BlendMode::Difference,
Blend::Exclusion => BlendMode::Exclusion,
Blend::Hue => BlendMode::Hue,
Blend::Saturation => BlendMode::Saturation,
Blend::Color => BlendMode::Color,
Blend::Luminosity => BlendMode::Luminosity,
}
}
fn rect_path(x: f32, y: f32, w: f32, h: f32) -> Option<Path> {
let mut builder = PathBuilder::new();
builder.push_rect(Rect::from_xywh(x, y, w, h)?);
builder.finish()
}
fn ellipse_path(x: f32, y: f32, w: f32, h: f32) -> Option<Path> {
// Four cubic arcs; KAPPA places the control points for a quarter circle.
const KAPPA: f32 = 0.552_284_8;
let (rx, ry) = (w / 2.0, h / 2.0);
let (cx, cy) = (x + rx, y + ry);
let (ox, oy) = (rx * KAPPA, ry * KAPPA);
let mut b = PathBuilder::new();
b.move_to(cx + rx, cy);
b.cubic_to(cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry);
b.cubic_to(cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy);
b.cubic_to(cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry);
b.cubic_to(cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy);
b.close();
b.finish()
}
#[derive(Clone, Hash)]
struct Synthetic {
data: Vec<u8>,
size: (u32, u32),
space: ImageColorspace,
}
impl CustomImage for Synthetic {
fn color_channel(&self) -> &[u8] {
&self.data
}
fn alpha_channel(&self) -> Option<&[u8]> {
None
}
fn bits_per_component(&self) -> BitsPerComponent {
BitsPerComponent::Eight
}
fn size(&self) -> (u32, u32) {
self.size
}
fn icc_profile(&self) -> Option<&[u8]> {
None
}
fn color_space(&self) -> ImageColorspace {
self.space.clone()
}
}
fn draw(node: &Node, surface: &mut Surface<'_>) {
match node {
Node::Shape { path, fill, stroke } => {
surface.set_fill(fill.clone());
surface.set_stroke(stroke.clone());
surface.draw_path(path);
}
Node::Text { at, size, text, font, fill, stroke } => {
surface.set_fill(fill.clone());
surface.set_stroke(stroke.clone());
surface.draw_text(*at, font.clone(), *size, text, false, TextDirection::Auto);
}
Node::Image { image, rect: [x, y, w, h] } => {
if let Some(size) = Size::from_wh(*w, *h) {
surface.push_transform(&Transform::from_translate(*x, *y));
surface.draw_image(image.clone(), size);
surface.pop();
}
}
Node::Pdf { doc, rect: [x, y, w, h] } => {
if let Some(size) = Size::from_wh(*w, *h) {
surface.push_transform(&Transform::from_translate(*x, *y));
surface.draw_pdf_page(doc, size, 0);
surface.pop();
}
}
Node::Group { options, children } => {
let grouped = *options != GroupOptions::default();
if grouped {
surface.push_group(*options);
}
for child in children {
draw(child, surface);
}
if grouped {
surface.pop();
}
}
Node::Wrapped { blend, mask, opacity, overprint, inner } => {
if let Some(blend) = blend {
surface.push_blend_mode(*blend);
}
if let Some(mask) = mask {
let mut builder = surface.stream_builder();
let mut sub = builder.surface();
for child in &mask.content {
draw(child, &mut sub);
}
sub.finish();
let stream = builder.finish();
let mut krilla_mask = KrillaMask::new(stream, mask.kind);
if let Some(backdrop) = mask.backdrop {
krilla_mask = krilla_mask.with_backdrop(backdrop);
}
if let Some(transfer) = &mask.transfer {
krilla_mask = krilla_mask.with_transfer(transfer.clone());
}
surface.push_mask(krilla_mask);
}
if let Some(opacity) = opacity {
surface.push_opacity(*opacity);
}
if let Some(overprint) = overprint {
surface.push_overprint(*overprint);
}
draw(inner, surface);
for pushed in [overprint.is_some(), opacity.is_some(), mask.is_some(), blend.is_some()] {
if pushed {
surface.pop();
}
}
}
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Runs the Ghostscript plate check over scenes and writes `report.md` and `results.json`.
//!
//! Pending scenes are written as far as the writer can ([`reference::write_partial`]) and are
//! expected to fail; their results show what the writer prints today.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::judge::{self, Judgement, Verdict};
use crate::reference::{self, Context};
use crate::scene::{Level, PROCESS, Scene};
use crate::{Error, gs};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Status {
Pass,
Fail,
/// A pending scene fails, as it should until its task lands.
ExpectedFail,
/// A pending scene passes: the feature works, or the scene doesn't catch its absence.
PendingPasses,
/// A pending scene needs a feature the writer can't leave out.
NotWritten,
/// The harness failed (Ghostscript, TIFF, writer error).
Error,
}
impl Status {
/// Whether the scene result is what the scene's `pending` field says it should be.
pub fn as_expected(self) -> bool {
matches!(self, Status::Pass | Status::ExpectedFail | Status::PendingPasses | Status::NotWritten)
}
fn label(self) -> &'static str {
match self {
Status::Pass => "pass",
Status::Fail => "**FAIL**",
Status::ExpectedFail => "expected fail",
Status::PendingPasses => "pending, passes",
Status::NotWritten => "not written",
Status::Error => "**ERROR**",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SceneResult {
pub id: String,
pub title: String,
pub pending: Option<String>,
pub status: Status,
/// What the writer left out or skipped.
pub notes: Vec<String>,
/// Why the scene was not written or judged.
pub error: Option<String>,
pub judgement: Option<Judgement>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub ghostscript: String,
pub resolution: f32,
pub scenes: Vec<SceneResult>,
}
/// Write, separate and judge every scene in `ctx.scenes`.
pub fn run(ctx: &Context<'_>, ghostscript: String) -> Report {
Report { ghostscript, resolution: gs::RESOLUTION, scenes: ctx.scenes.iter().map(|scene| check_scene(scene, ctx)).collect() }
}
/// Write, separate and judge one scene.
pub fn check_scene(scene: &Scene, ctx: &Context<'_>) -> SceneResult {
let mut result = SceneResult {
id: scene.id.clone(),
title: scene.title.clone(),
pending: scene.pending.clone(),
status: Status::Error,
notes: Vec::new(),
error: None,
judgement: None,
};
let written = if scene.pending.is_some() { reference::write_partial(scene, ctx) } else { reference::write(scene, ctx) };
let output = match written {
Ok(output) => output,
Err(Error::Unsupported(feature)) if scene.pending.is_some() => {
result.status = Status::NotWritten;
result.error = Some(format!("needs {feature}"));
return result;
}
Err(e) => {
result.error = Some(e.to_string());
return result;
}
};
result.notes = output.notes;
let plates = match gs::separate(&output.pdf, &scene.id) {
Ok(plates) => plates,
Err(e) => {
result.error = Some(e.to_string());
return result;
}
};
let judgement = judge::judge(scene, &plates, gs::RESOLUTION, ctx.font.is_some());
result.status = match (scene.pending.is_some(), judgement.passed()) {
(false, true) => Status::Pass,
(false, false) => Status::Fail,
(true, false) => Status::ExpectedFail,
(true, true) => Status::PendingPasses,
};
result.judgement = Some(judgement);
result
}
/// Cargo's target directory: `CARGO_TARGET_DIR` (relative to the workspace root) or `target`.
pub fn target_dir() -> PathBuf {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let root = manifest.parent().and_then(Path::parent).unwrap_or(manifest);
match std::env::var_os("CARGO_TARGET_DIR") {
Some(dir) => root.join(dir),
None => root.join("target"),
}
}
/// Where the report goes: `plate-test/` in the target directory.
pub fn output_dir() -> PathBuf {
target_dir().join("plate-test")
}
/// Write `report.md` and `results.json` into `dir`; returns the report's path.
pub fn write(report: &Report, dir: &Path) -> Result<PathBuf, Error> {
let io = |path: &Path, e: std::io::Error| Error::Scene(format!("{}: {e}", path.display()));
std::fs::create_dir_all(dir).map_err(|e| io(dir, e))?;
let json_path = dir.join("results.json");
let json = serde_json::to_string_pretty(report).map_err(|e| Error::Scene(e.to_string()))?;
std::fs::write(&json_path, json).map_err(|e| io(&json_path, e))?;
let md_path = dir.join("report.md");
std::fs::write(&md_path, markdown(report)).map_err(|e| io(&md_path, e))?;
Ok(md_path)
}
/// The report as Markdown.
pub fn markdown(report: &Report) -> String {
let mut md = String::new();
let _ = writeln!(
md,
"# Plate check\n\nGhostscript {}, `tiffsep` at {} dpi with overprint simulated (`/enable`). Ink % per plate \
(C M Y K, spots by name); plates under 1% are left out. Pending scenes are written without the features \
they wait for and are expected to fail. Generated by `scripts/plate-check.sh`.\n",
report.ghostscript, report.resolution
);
md.push_str("| Scene | Status | Pending | Failing points | Plates |\n|---|---|---|---|---|\n");
for scene in &report.scenes {
let (points, plates) = match &scene.judgement {
Some(j) => (format!("{}/{}", j.failed_points(), j.judged_points()), plate_problems(j).unwrap_or_else(|| "ok".into())),
None => ("–".into(), "–".into()),
};
let pending = scene.pending.as_deref().unwrap_or("");
let _ = writeln!(
md,
"| [{id}](#{anchor}) | {} | {pending} | {points} | {plates} |",
scene.status.label(),
id = scene.id,
anchor = scene.id.to_lowercase()
);
}
for scene in &report.scenes {
let _ = writeln!(md, "\n## {}\n\n{}. Status: {}.", scene.id, scene.title, scene.status.label());
if let Some(task) = &scene.pending {
let _ = writeln!(md, "Pending {task}.");
}
if let Some(error) = &scene.error {
let _ = writeln!(md, "\n{error}");
}
for note in &scene.notes {
let _ = writeln!(md, "- {note}");
}
let Some(judgement) = &scene.judgement else { continue };
let _ = writeln!(md, "\nPlates: {}.{}", judgement.plates.join(", "), plate_problems(judgement).map(|p| format!(" {p}.")).unwrap_or_default());
md.push_str("\n| # | Point | Expected | Measured | Verdict |\n|---|---|---|---|---|\n");
for (i, point) in judgement.points.iter().enumerate() {
let verdict = match point.verdict {
Verdict::Pass => "pass".to_owned(),
Verdict::Info => "info".to_owned(),
Verdict::Skipped => "skipped".to_owned(),
Verdict::Fail if point.off.is_empty() => "**fail**".to_owned(),
Verdict::Fail => format!("**fail** ({})", point.off.iter().map(|p| short(p)).collect::<Vec<_>>().join(" ")),
};
let how = point.how.as_deref().map(|h| format!("; {h}")).unwrap_or_default();
let note = point.note.as_deref().map(|n| format!("{n} ")).unwrap_or_default();
let _ = writeln!(
md,
"| {} | {note}({}, {}){how} | {} ±{} | {} | {verdict} |",
i + 1,
point.at[0],
point.at[1],
expected(&point.expected),
point.tol,
measured(&point.measured),
);
}
}
md
}
fn plate_problems(judgement: &Judgement) -> Option<String> {
let mut parts = Vec::new();
if !judgement.missing_plates.is_empty() {
parts.push(format!("missing {}", judgement.missing_plates.join(", ")));
}
if !judgement.extra_plates.is_empty() {
parts.push(format!("extra {}", judgement.extra_plates.join(", ")));
}
(!parts.is_empty()).then(|| parts.join("; "))
}
/// Process plates as `C M Y K`; spots keep their names.
fn short(plate: &str) -> &str {
match PROCESS.iter().position(|p| *p == plate) {
Some(i) => ["C", "M", "Y", "K"].get(i).copied().unwrap_or(plate),
None => plate,
}
}
fn expected(inks: &std::collections::BTreeMap<String, Level>) -> String {
let mut names: Vec<String> = inks.keys().cloned().collect();
judge::sort_plates(&mut names);
let parts: Vec<String> = names
.iter()
.filter_map(|name| match inks.get(name)? {
Level::Value(v) if *v >= 1.0 => Some(format!("{}{v}", short(name))),
Level::Value(_) => None,
Level::Range([lo, hi]) => Some(format!("{}{lo}–{hi}", short(name))),
})
.collect();
if parts.is_empty() { "paper".into() } else { parts.join(" ") }
}
fn measured(inks: &std::collections::BTreeMap<String, f32>) -> String {
let mut names: Vec<String> = inks.iter().filter(|(_, v)| **v >= 1.0).map(|(k, _)| k.clone()).collect();
judge::sort_plates(&mut names);
let parts: Vec<String> = names.iter().filter_map(|name| Some(format!("{}{:.0}", short(name), inks.get(name)?))).collect();
if parts.is_empty() { "paper".into() } else { parts.join(" ") }
}
+546
View File
@@ -0,0 +1,546 @@
//! The scene format: an app-neutral description of one test page, what each plate should
//! carry at sample points, and what the PDF's structure should look like.
//!
//! Coordinates are points from the page's top-left corner. Ink values are percentages
//! (0 = no ink, 100 = solid). Rectangles are `[x, y, width, height]`.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::Error;
use crate::structure::StructureExpect;
/// How deep placed scenes may nest.
pub const MAX_NESTING: usize = 4;
/// The plate text points find their glyphs on unless they name another.
pub const TEXT_PLATE: &str = "Black";
/// The process plates, named as separation tools (Ghostscript `tiffsep`) name them.
pub const PROCESS: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"];
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Scene {
/// Short id, also the file name stem's prefix (`S01`, `D04`).
pub id: String,
pub title: String,
/// Where the scene comes from (research case or app regression).
pub source: String,
/// Page size `[width, height]` in pt.
pub page: [f32; 2],
/// Spot inks the scene may use. Their order is the colorant order after the process inks.
#[serde(default)]
pub inks: Vec<Ink>,
#[serde(default)]
pub settings: Settings,
pub objects: Vec<Object>,
pub expect: Expect,
#[serde(default)]
pub structure: StructureExpect,
/// The task that makes this scene writable, e.g. `"T4"`. Pending scenes are parsed but not
/// written or checked.
#[serde(default)]
pub pending: Option<String>,
#[serde(default)]
pub note: Option<String>,
}
/// A spot ink with its alternate (exactly one of `cmyk` or `lab`).
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Ink {
pub name: String,
#[serde(default)]
pub cmyk: Option<[f32; 4]>,
#[serde(default)]
pub lab: Option<[f32; 3]>,
}
/// Output intents a scene can name: `fogra39` is craft-cms's `COATED_FOGRA39_ICC`.
pub const OUTPUT_INTENTS: &[&str] = &["fogra39"];
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Settings {
/// Blending color space of transparency groups; absent means the writer's default.
#[serde(default)]
pub group_cs: Option<GroupCs>,
/// Name of the print output intent, one of [`OUTPUT_INTENTS`]; the scene is then written
/// with craft-pdf's print settings. Absent means the writer's defaults.
#[serde(default)]
pub output_intent: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GroupCs {
Rgb,
Cmyk,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Rect,
Ellipse,
Text,
Image,
/// Another scene, written and placed as a PDF page.
Pdf,
Group,
}
/// One drawable object. Which geometry fields apply depends on `type`; [`Scene::validate`]
/// reports missing or stray ones.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Object {
#[serde(rename = "type")]
pub kind: Kind,
/// rect, ellipse (bounding box), image, pdf.
#[serde(default)]
pub rect: Option<[f32; 4]>,
/// text: baseline start.
#[serde(default)]
pub at: Option<[f32; 2]>,
/// text: font size in pt.
#[serde(default)]
pub size: Option<f32>,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub image: Option<ImageSource>,
/// pdf: id of the scene to place.
#[serde(default)]
pub scene: Option<String>,
/// group: children, drawn in order.
#[serde(default)]
pub objects: Vec<Object>,
#[serde(default)]
pub isolated: bool,
#[serde(default)]
pub knockout: bool,
#[serde(default)]
pub fill: Option<Paint>,
#[serde(default)]
pub stroke: Option<Stroke>,
/// Whole-object opacity, 0–1 (a transparency group).
#[serde(default)]
pub opacity: Option<f32>,
#[serde(default)]
pub blend: Option<Blend>,
#[serde(default)]
pub overprint: Option<Overprint>,
#[serde(default)]
pub mask: Option<Mask>,
#[serde(default)]
pub note: Option<String>,
}
/// A paint. Ink values are percentages.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum Paint {
/// `"registration"`: the Separation `/All` colorant (every plate).
Named(Named),
Cmyk {
cmyk: [f32; 4],
},
/// Black ink percentage, written as DeviceGray `1 - gray / 100`.
Gray {
gray: f32,
},
/// 0–255 per channel.
Rgb {
rgb: [f32; 3],
},
Spot {
spot: String,
#[serde(default = "full")]
tint: f32,
},
/// Several named inks at once (DeviceN); names are process plates or declared spots.
Inks {
inks: BTreeMap<String, f32>,
},
Linear {
linear: Gradient,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Named {
Registration,
}
fn full() -> f32 {
100.0
}
/// A linear gradient in page coordinates.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Gradient {
pub from: [f32; 2],
pub to: [f32; 2],
pub stops: Vec<GradientStop>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GradientStop {
/// 0–1 along the gradient.
pub at: f32,
pub paint: Paint,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Stroke {
pub paint: Paint,
pub width: f32,
}
/// A synthetic image filling the object's `rect` (exactly one of `ramp`, `split`, `feather`).
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImageSource {
/// Pixel size `[width, height]`.
pub px: [u32; 2],
/// Horizontal ramp from the first paint (left) to the second (right).
#[serde(default)]
pub ramp: Option<[Paint; 2]>,
/// Left half the first paint, right half the second.
#[serde(default)]
pub split: Option<[Paint; 2]>,
/// Gray image for soft masks: white inside, fading to black over this many pt at the edges.
#[serde(default)]
pub feather: Option<f32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Blend {
Normal,
Multiply,
Screen,
Overlay,
Darken,
Lighten,
ColorDodge,
ColorBurn,
HardLight,
SoftLight,
Difference,
Exclusion,
Hue,
Saturation,
Color,
Luminosity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Overprint {
#[serde(default)]
pub fill: bool,
#[serde(default)]
pub stroke: bool,
/// OPM: 1 = zero components leave the backdrop (nonzero overprint), 0 = all components paint.
#[serde(default = "opm_default")]
pub mode: u8,
}
fn opm_default() -> u8 {
1
}
/// A soft mask over the object.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Mask {
pub kind: MaskKind,
pub content: Vec<Object>,
/// `/BC`: the color outside the mask content (luminosity masks).
#[serde(default)]
pub backdrop: Option<Paint>,
/// `/TR`: a sampled transfer function, values 0–1 over the input range.
#[serde(default)]
pub transfer: Option<Vec<f32>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MaskKind {
Luminosity,
Alpha,
}
/// What a correct separation gives.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Expect {
/// Every plate the separation must produce (process plates included), and no others.
pub plates: Vec<String>,
pub points: Vec<SamplePoint>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SamplePoint {
pub at: [f32; 2],
#[serde(default)]
pub kind: PointKind,
/// text: the glyph area to sample, `[x, y, w, h]`.
#[serde(default)]
pub region: Option<[f32; 4]>,
/// text: the plate the glyphs are found on; defaults to [`TEXT_PLATE`].
#[serde(default)]
pub plate: Option<String>,
/// Expected ink per plate; plates not listed expect 0.
#[serde(default)]
pub inks: BTreeMap<String, Level>,
/// Allowed difference in ink percentage points.
pub tol: f32,
/// Measured and reported, not judged.
#[serde(default)]
pub info: bool,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PointKind {
#[default]
Area,
Text,
}
/// An expected ink value or an inclusive `[lo, hi]` range.
#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Level {
Value(f32),
Range([f32; 2]),
}
/// The directory holding this crate's scenes.
pub fn scenes_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("scenes")
}
/// Parse every `*.json` scene in `dir`, sorted by file name.
pub fn load_dir(dir: &Path) -> Result<Vec<Scene>, Error> {
let mut paths = Vec::new();
for entry in std::fs::read_dir(dir).map_err(|e| Error::Scene(format!("{}: {e}", dir.display())))? {
let path = entry.map_err(|e| Error::Scene(e.to_string()))?.path();
if path.extension().is_some_and(|ext| ext == "json") {
paths.push(path);
}
}
paths.sort();
paths.iter().map(|path| load(path)).collect()
}
/// Parse one scene file.
pub fn load(path: &Path) -> Result<Scene, Error> {
let text = std::fs::read_to_string(path).map_err(|e| Error::Scene(format!("{}: {e}", path.display())))?;
serde_json::from_str(&text).map_err(|e| Error::Scene(format!("{}: {e}", path.display())))
}
impl Scene {
pub fn ink(&self, name: &str) -> Option<&Ink> {
self.inks.iter().find(|ink| ink.name == name)
}
/// Consistency problems a JSON schema can't catch; empty when the scene is well formed.
/// `all` resolves placed scenes.
pub fn validate(&self, all: &[Scene]) -> Vec<String> {
let mut problems = Vec::new();
let mut say = |message: String| problems.push(format!("{}: {message}", self.id));
if !(self.page[0] > 0.0 && self.page[1] > 0.0) {
say("page size must be positive".into());
}
if let Some(name) = &self.settings.output_intent
&& !OUTPUT_INTENTS.contains(&name.as_str())
{
say(format!("unknown output intent {name}"));
}
for ink in &self.inks {
if ink.cmyk.is_some() == ink.lab.is_some() {
say(format!("ink {} needs exactly one of cmyk or lab", ink.name));
}
if PROCESS.contains(&ink.name.as_str()) || ink.name == "All" || ink.name == "None" {
say(format!("ink {} shadows a process or reserved colorant", ink.name));
}
}
let mut objects = Vec::new();
collect(&self.objects, &mut objects);
for object in objects {
for problem in self.object_problems(object, all) {
say(problem);
}
}
for plate in &self.expect.plates {
if !PROCESS.contains(&plate.as_str()) && !self.declares_spot(plate, all, 0) {
say(format!("plate {plate} is neither process nor a declared spot"));
}
}
for name in PROCESS {
if !self.expect.plates.iter().any(|plate| plate == name) {
say(format!("plates must list the process plate {name}"));
}
}
for point in &self.expect.points {
if point.tol <= 0.0 {
say(format!("point {:?}: tol must be positive", point.at));
}
if (point.kind == PointKind::Text) != point.region.is_some() {
say(format!("point {:?}: text points need a region, area points none", point.at));
}
if let Some(plate) = &point.plate
&& (point.kind != PointKind::Text || !self.expect.plates.contains(plate))
{
say(format!("point {:?}: plate {plate} must be a text point's plate from plates", point.at));
}
for name in point.inks.keys() {
if !self.expect.plates.contains(name) {
say(format!("point {:?}: ink {name} is not in plates", point.at));
}
}
}
problems
}
/// Declared here or in a placed scene (placements nest at most [`MAX_NESTING`] deep).
fn declares_spot(&self, name: &str, all: &[Scene], depth: usize) -> bool {
self.ink(name).is_some()
|| (depth < MAX_NESTING
&& placed(&self.objects).iter().any(|id| all.iter().any(|s| s.id == **id && s.declares_spot(name, all, depth + 1))))
}
fn object_problems(&self, object: &Object, all: &[Scene]) -> Vec<String> {
let mut out = Vec::new();
let kind = object.kind;
let needs_rect = matches!(kind, Kind::Rect | Kind::Ellipse | Kind::Image | Kind::Pdf);
if needs_rect != object.rect.is_some() {
out.push(format!("{kind:?}: rect is {}", if needs_rect { "required" } else { "not used" }));
}
if let Some([_, _, w, h]) = object.rect
&& !(w > 0.0 && h > 0.0)
{
out.push(format!("{kind:?}: rect needs a positive size"));
}
let is_text = kind == Kind::Text;
if is_text != (object.at.is_some() && object.size.is_some() && object.text.is_some()) {
out.push(format!("{kind:?}: at, size and text belong to (and are required by) text"));
}
if (kind == Kind::Image) != object.image.is_some() {
out.push(format!("{kind:?}: image belongs to (and is required by) image objects"));
}
if let Some(image) = &object.image {
let sources = [image.ramp.is_some(), image.split.is_some(), image.feather.is_some()];
if sources.iter().filter(|s| **s).count() != 1 {
out.push("image needs exactly one of ramp, split or feather".into());
}
if image.px[0] == 0 || image.px[1] == 0 || image.px[0] > 4096 || image.px[1] > 4096 {
out.push("image px must be 1–4096".into());
}
}
if (kind == Kind::Pdf) != object.scene.is_some() {
out.push(format!("{kind:?}: scene belongs to (and is required by) pdf objects"));
}
if let Some(id) = &object.scene
&& (id == &self.id || !all.iter().any(|s| &s.id == id))
{
out.push(format!("pdf: unknown scene {id}"));
}
if kind != Kind::Group && (!object.objects.is_empty() || object.isolated || object.knockout) {
out.push(format!("{kind:?}: objects, isolated and knockout belong to groups"));
}
if matches!(kind, Kind::Image | Kind::Pdf | Kind::Group) && (object.fill.is_some() || object.stroke.is_some()) {
out.push(format!("{kind:?}: fill and stroke apply to shapes and text"));
}
if let Some(opacity) = object.opacity
&& !(0.0..=1.0).contains(&opacity)
{
out.push("opacity must be 0–1".into());
}
let mut paints: Vec<&Paint> = object.fill.iter().chain(object.stroke.iter().map(|s| &s.paint)).collect();
if let Some(image) = &object.image {
paints.extend(image.ramp.iter().flatten());
paints.extend(image.split.iter().flatten());
}
if let Some(mask) = &object.mask {
paints.extend(mask.backdrop.iter());
let mut nested = Vec::new();
collect(&mask.content, &mut nested);
for inner in nested {
out.extend(self.object_problems(inner, all));
}
}
for paint in paints {
self.paint_problems(paint, &mut out);
}
out
}
fn paint_problems(&self, paint: &Paint, out: &mut Vec<String>) {
let percent = |v: &f32| (0.0..=100.0).contains(v);
match paint {
Paint::Named(_) => {}
Paint::Cmyk { cmyk } if !cmyk.iter().all(percent) => out.push("cmyk values must be 0–100".into()),
Paint::Gray { gray } if !percent(gray) => out.push("gray must be 0–100".into()),
Paint::Rgb { rgb } if !rgb.iter().all(|v| (0.0..=255.0).contains(v)) => out.push("rgb values must be 0–255".into()),
Paint::Spot { spot, tint } => {
if self.ink(spot).is_none() {
out.push(format!("spot {spot} is not declared in inks"));
}
if !percent(tint) {
out.push("tint must be 0–100".into());
}
}
Paint::Inks { inks } => {
for (name, value) in inks {
if !PROCESS.contains(&name.as_str()) && self.ink(name).is_none() {
out.push(format!("ink {name} is not process or declared"));
}
if !percent(value) {
out.push("ink values must be 0–100".into());
}
}
}
Paint::Linear { linear } => {
if linear.stops.len() < 2 {
out.push("gradients need at least two stops".into());
}
for stop in &linear.stops {
if matches!(stop.paint, Paint::Linear { .. }) {
out.push("gradient stops must be solid paints".into());
}
self.paint_problems(&stop.paint, out);
}
}
_ => {}
}
}
}
/// Every object in the tree, depth first (mask contents excluded).
fn collect<'a>(objects: &'a [Object], out: &mut Vec<&'a Object>) {
for object in objects {
out.push(object);
collect(&object.objects, out);
}
}
fn placed(objects: &[Object]) -> Vec<&String> {
let mut all = Vec::new();
collect(objects, &mut all);
all.into_iter().filter_map(|o| o.scene.as_ref()).collect()
}
+447
View File
@@ -0,0 +1,447 @@
//! A summary of a PDF's print-relevant structure, and the scene `structure` block it is checked
//! against.
//!
//! Color spaces are described as families: `DeviceCMYK`, `ICCBased(N=4)`,
//! `Separation(Gold, alt DeviceCMYK)`, `DeviceN(Cyan,Black, alt DeviceCMYK)`, `Indexed(…)`,
//! `Pattern`. Transparency group color spaces are reported per group, not in `color_spaces`.
use std::collections::{BTreeSet, HashSet};
use std::sync::Arc;
use hayro_syntax::Pdf;
use hayro_syntax::content::UntypedIter;
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, Stream};
use serde::{Deserialize, Serialize};
use crate::Error;
/// What [`summarize`] found.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct Summary {
/// Every transparency group: pages first, then form XObjects and soft-mask groups.
pub groups: Vec<Group>,
pub ext_g_states: Vec<ExtGState>,
/// Color space families used for painting: resources, images, shadings and the device
/// spaces set directly by content operators (`rg`, `k`, `g`, `cs /DeviceRGB`).
pub color_spaces: BTreeSet<String>,
/// Any painting color space mentions DeviceRGB, including Separation/DeviceN alternates.
pub device_rgb: bool,
/// `/S` of each output intent, in order.
pub output_intents: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Group {
/// `page 1`, `form` or `soft mask`.
pub at: String,
pub s: String,
pub cs: Option<String>,
pub i: bool,
pub k: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct ExtGState {
/// `/OP` (stroke overprint, and fill overprint when `/op` is absent).
#[serde(rename = "OP", skip_serializing_if = "Option::is_none")]
pub op_stroke: Option<bool>,
/// `/op` (fill overprint).
#[serde(rename = "op", skip_serializing_if = "Option::is_none")]
pub op_fill: Option<bool>,
#[serde(rename = "OPM", skip_serializing_if = "Option::is_none")]
pub opm: Option<i32>,
#[serde(rename = "BM", skip_serializing_if = "Option::is_none")]
pub bm: Option<String>,
/// The soft mask's `/S`, when the state sets a mask.
#[serde(rename = "SMask", skip_serializing_if = "Option::is_none")]
pub smask: Option<String>,
/// The soft mask has `/BC`.
#[serde(rename = "BC", skip_serializing_if = "std::ops::Not::not")]
pub smask_bc: bool,
/// The soft mask has a `/TR` other than `/Identity`.
#[serde(rename = "TR", skip_serializing_if = "std::ops::Not::not")]
pub smask_tr: bool,
}
/// Summarize `pdf`. Malformed objects are skipped rather than reported.
pub fn summarize(pdf: &[u8]) -> Result<Summary, Error> {
let pdf = Pdf::new(Arc::new(pdf.to_vec())).map_err(|e| Error::Read(format!("{e:?}")))?;
let mut walk = Walk::default();
if let Some(catalog) = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id())
&& let Some(intents) = catalog.get::<Array<'_>>(b"OutputIntents".as_slice())
{
for intent in intents.iter::<Dict<'_>>() {
walk.summary.output_intents.push(intent.get::<Name<'_>>(b"S".as_slice()).map(|n| n.as_str().to_owned()).unwrap_or_default());
}
}
for (index, page) in pdf.pages().iter().enumerate() {
let raw = page.raw();
if let Some(group) = raw.get::<Dict<'_>>(b"Group".as_slice()) {
walk.group(&format!("page {}", index + 1), &group);
}
let mut ops = page.operations();
walk.operators(&mut ops);
let res = page.resources();
walk.resources(&Resources {
ext_g_states: res.ext_g_states.clone(),
color_spaces: res.color_spaces.clone(),
x_objects: res.x_objects.clone(),
patterns: res.patterns.clone(),
shadings: res.shadings.clone(),
});
}
let mut summary = walk.summary;
summary.device_rgb = summary.color_spaces.iter().any(|cs| cs.contains("DeviceRGB"));
Ok(summary)
}
struct Resources<'a> {
ext_g_states: Dict<'a>,
color_spaces: Dict<'a>,
x_objects: Dict<'a>,
patterns: Dict<'a>,
shadings: Dict<'a>,
}
impl<'a> Resources<'a> {
fn of(dict: &Dict<'a>) -> Self {
let sub = |key: &[u8]| dict.get::<Dict<'a>>(key).unwrap_or_default();
Self {
ext_g_states: sub(b"ExtGState"),
color_spaces: sub(b"ColorSpace"),
x_objects: sub(b"XObject"),
patterns: sub(b"Pattern"),
shadings: sub(b"Shading"),
}
}
}
#[derive(Default)]
struct Walk {
summary: Summary,
seen: HashSet<ObjectIdentifier>,
}
impl Walk {
/// True the first time an indirect object is visited.
fn first_visit(&mut self, id: Option<ObjectIdentifier>) -> bool {
id.is_none_or(|id| self.seen.insert(id))
}
fn group(&mut self, at: &str, group: &Dict<'_>) {
self.summary.groups.push(Group {
at: at.to_owned(),
s: group.get::<Name<'_>>(b"S".as_slice()).map(|n| n.as_str().to_owned()).unwrap_or_default(),
cs: group.get::<Object<'_>>(b"CS".as_slice()).map(|cs| describe(&cs, 0)),
i: group.get::<bool>(b"I".as_slice()).unwrap_or(false),
k: group.get::<bool>(b"K".as_slice()).unwrap_or(false),
});
}
fn operators(&mut self, ops: &mut UntypedIter<'_>) {
while let Some(op) = ops.next() {
let family = match &**op.operator {
b"rg" | b"RG" => Some("DeviceRGB".to_owned()),
b"k" | b"K" => Some("DeviceCMYK".to_owned()),
b"g" | b"G" => Some("DeviceGray".to_owned()),
b"cs" | b"CS" => op
.operands()
.next()
.and_then(|o| o.clone().into_name())
.map(|n| n.as_str().to_owned())
.filter(|n| matches!(n.as_str(), "DeviceRGB" | "DeviceCMYK" | "DeviceGray")),
_ => None,
};
if let Some(family) = family {
self.summary.color_spaces.insert(family);
}
}
}
fn resources(&mut self, res: &Resources<'_>) {
for key in res.color_spaces.keys() {
if let Some(cs) = res.color_spaces.get::<Object<'_>>(key.as_ref()) {
self.summary.color_spaces.insert(describe(&cs, 0));
}
}
for key in res.ext_g_states.keys() {
if let Some(gs) = res.ext_g_states.get::<Dict<'_>>(key.as_ref())
&& self.first_visit(gs.obj_id())
{
self.ext_g_state(&gs);
}
}
for key in res.shadings.keys() {
if let Some(shading) = res.shadings.get::<Object<'_>>(key.as_ref()) {
self.shading(&shading);
}
}
for key in res.patterns.keys() {
let Some(pattern) = res.patterns.get::<Object<'_>>(key.as_ref()) else { continue };
let (dict, stream) = match pattern {
Object::Dict(dict) => (dict, None),
Object::Stream(stream) => (stream.dict().clone(), Some(stream)),
_ => continue,
};
if let Some(shading) = dict.get::<Object<'_>>(b"Shading".as_slice()) {
self.summary.color_spaces.insert("Pattern".to_owned());
self.shading(&shading);
}
if let Some(stream) = stream
&& self.first_visit(Some(stream.obj_id()))
{
self.summary.color_spaces.insert("Pattern".to_owned());
self.content(&stream);
}
}
for key in res.x_objects.keys() {
if let Some(xobject) = res.x_objects.get::<Stream<'_>>(key.as_ref())
&& self.first_visit(Some(xobject.obj_id()))
{
self.x_object(&xobject, "form");
}
}
}
fn ext_g_state(&mut self, gs: &Dict<'_>) {
let mut state = ExtGState {
op_stroke: gs.get::<bool>(b"OP".as_slice()),
op_fill: gs.get::<bool>(b"op".as_slice()),
opm: gs.get::<i32>(b"OPM".as_slice()),
bm: gs.get::<Object<'_>>(b"BM".as_slice()).and_then(|bm| match bm {
Object::Name(name) => Some(name.as_str().to_owned()),
Object::Array(names) => names.iter::<Name<'_>>().next().map(|n| n.as_str().to_owned()),
_ => None,
}),
..Default::default()
};
if let Some(mask) = gs.get::<Dict<'_>>(b"SMask".as_slice()) {
state.smask = Some(mask.get::<Name<'_>>(b"S".as_slice()).map(|n| n.as_str().to_owned()).unwrap_or_default());
state.smask_bc = mask.contains_key(b"BC".as_slice());
state.smask_tr = mask.get::<Object<'_>>(b"TR".as_slice()).is_some_and(|tr| !matches!(&tr, Object::Name(n) if n.as_str() == "Identity"));
if let Some(source) = mask.get::<Stream<'_>>(b"G".as_slice())
&& self.first_visit(Some(source.obj_id()))
{
self.x_object(&source, "soft mask");
}
}
self.summary.ext_g_states.push(state);
}
fn shading(&mut self, shading: &Object<'_>) {
let dict = match shading {
Object::Dict(dict) => dict.clone(),
Object::Stream(stream) => stream.dict().clone(),
_ => return,
};
if let Some(cs) = dict.get::<Object<'_>>(b"ColorSpace".as_slice()) {
self.summary.color_spaces.insert(describe(&cs, 0));
}
}
fn x_object(&mut self, xobject: &Stream<'_>, at: &str) {
let dict = xobject.dict();
match dict.get::<Name<'_>>(b"Subtype".as_slice()).as_ref().map(Name::as_str) {
Some("Image") => {
if !dict.get::<bool>(b"ImageMask".as_slice()).unwrap_or(false)
&& let Some(cs) = dict.get::<Object<'_>>(b"ColorSpace".as_slice())
{
self.summary.color_spaces.insert(describe(&cs, 0));
}
}
Some("Form") => {
if let Some(group) = dict.get::<Dict<'_>>(b"Group".as_slice()) {
self.group(at, &group);
}
self.content(xobject);
}
_ => {}
}
}
fn content(&mut self, stream: &Stream<'_>) {
if let Ok(data) = stream.decoded() {
self.operators(&mut UntypedIter::new(&data));
}
if let Some(res) = stream.dict().get::<Dict<'_>>(b"Resources".as_slice()) {
self.resources(&Resources::of(&res));
}
}
}
/// A color space's family, with the names and alternates that matter for separation.
fn describe(cs: &Object<'_>, depth: usize) -> String {
if depth > 4 {
return "?".to_owned();
}
match cs {
Object::Name(name) => name.as_str().to_owned(),
Object::Array(array) => {
let items: Vec<Object<'_>> = array.iter::<Object<'_>>().collect();
let Some(Object::Name(kind)) = items.first() else { return "?".to_owned() };
let alt = |i: usize| items.get(i).map(|o| describe(o, depth + 1)).unwrap_or_else(|| "?".to_owned());
match kind.as_str() {
"Separation" => {
let name = items.get(1).and_then(|o| o.clone().into_name()).map(|n| n.as_str().to_owned()).unwrap_or_default();
format!("Separation({name}, alt {})", alt(2))
}
"DeviceN" => {
let names: Vec<String> = match items.get(1) {
Some(Object::Array(names)) => names.iter::<Name<'_>>().map(|n| n.as_str().to_owned()).collect(),
_ => Vec::new(),
};
format!("DeviceN({}, alt {})", names.join(","), alt(2))
}
"ICCBased" => {
let n = match items.get(1) {
Some(Object::Stream(stream)) => stream.dict().get::<i32>(b"N".as_slice()).unwrap_or(0),
_ => 0,
};
format!("ICCBased(N={n})")
}
"Indexed" => format!("Indexed({})", alt(1)),
"Pattern" if items.len() > 1 => format!("Pattern({})", alt(1)),
other => other.to_owned(),
}
}
_ => "?".to_owned(),
}
}
/// A scene's `structure` block. Every field is optional; only the fields present are checked.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StructureExpect {
/// The page group's `/CS`: a family, `"none"` (no page group) or `"no-cs"` (group without
/// `/CS`). Checked on every page.
#[serde(default)]
pub page_group: Option<String>,
/// The set of `/CS` values of form and soft-mask groups; `"none"` stands for a group
/// without `/CS`.
#[serde(default)]
pub group_cs: Option<BTreeSet<String>>,
/// Some group has `/K true`.
#[serde(default)]
pub knockout: Option<bool>,
/// Some group (page groups excluded) has `/I true`.
#[serde(default)]
pub isolated: Option<bool>,
/// Some graphics state sets `/OP` or `/op` true.
#[serde(default)]
pub overprint: Option<bool>,
/// Some graphics state sets a soft mask.
#[serde(default)]
pub smask: Option<bool>,
/// The set of blend modes other than Normal.
#[serde(default)]
pub blend_modes: Option<BTreeSet<String>>,
/// Each entry must match some graphics state on every key it gives.
#[serde(default)]
pub ext_g_states: Option<Vec<ExtGStateMatch>>,
/// The exact set of painting color space families.
#[serde(default)]
pub color_spaces: Option<BTreeSet<String>>,
/// Families that must be among the painting color spaces.
#[serde(default)]
pub color_spaces_include: Option<BTreeSet<String>>,
#[serde(default)]
pub device_rgb: Option<bool>,
#[serde(default)]
pub output_intents: Option<Vec<String>>,
}
/// A partial graphics state; keys as in the PDF.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExtGStateMatch {
#[serde(rename = "OP", default)]
pub op_stroke: Option<bool>,
#[serde(rename = "op", default)]
pub op_fill: Option<bool>,
#[serde(rename = "OPM", default)]
pub opm: Option<i32>,
#[serde(rename = "BM", default)]
pub bm: Option<String>,
#[serde(rename = "SMask", default)]
pub smask: Option<String>,
#[serde(rename = "BC", default)]
pub smask_bc: Option<bool>,
#[serde(rename = "TR", default)]
pub smask_tr: Option<bool>,
}
impl ExtGStateMatch {
fn matches(&self, gs: &ExtGState) -> bool {
(self.op_stroke.is_none() || self.op_stroke == gs.op_stroke)
&& (self.op_fill.is_none() || self.op_fill == gs.op_fill)
&& (self.opm.is_none() || self.opm == gs.opm)
&& (self.bm.is_none() || self.bm == gs.bm)
&& (self.smask.is_none() || self.smask == gs.smask)
&& self.smask_bc.is_none_or(|bc| bc == gs.smask_bc)
&& self.smask_tr.is_none_or(|tr| tr == gs.smask_tr)
}
}
impl StructureExpect {
/// Differences between the expectation and `summary`; empty when it matches.
pub fn check(&self, summary: &Summary) -> Vec<String> {
let mut out = Vec::new();
let mut differ = |what: &str, want: String, got: String| {
if want != got {
out.push(format!("{what}: expected {want}, found {got}"));
}
};
let page_groups: Vec<&Group> = summary.groups.iter().filter(|g| g.at.starts_with("page")).collect();
let others: Vec<&Group> = summary.groups.iter().filter(|g| !g.at.starts_with("page")).collect();
if let Some(want) = &self.page_group {
let got = if page_groups.is_empty() {
"none".to_owned()
} else {
let all: BTreeSet<String> = page_groups.iter().map(|g| g.cs.clone().unwrap_or_else(|| "no-cs".to_owned())).collect();
all.into_iter().collect::<Vec<_>>().join(" | ")
};
differ("page_group", want.clone(), got);
}
if let Some(want) = &self.group_cs {
let got: BTreeSet<String> = others.iter().map(|g| g.cs.clone().unwrap_or_else(|| "none".to_owned())).collect();
differ("group_cs", format!("{want:?}"), format!("{got:?}"));
}
if let Some(want) = self.knockout {
differ("knockout", want.to_string(), summary.groups.iter().any(|g| g.k).to_string());
}
if let Some(want) = self.isolated {
differ("isolated", want.to_string(), others.iter().any(|g| g.i).to_string());
}
let states = &summary.ext_g_states;
if let Some(want) = self.overprint {
let got = states.iter().any(|s| s.op_stroke == Some(true) || s.op_fill == Some(true));
differ("overprint", want.to_string(), got.to_string());
}
if let Some(want) = self.smask {
differ("smask", want.to_string(), states.iter().any(|s| s.smask.is_some()).to_string());
}
if let Some(want) = &self.blend_modes {
let got: BTreeSet<String> = states.iter().filter_map(|s| s.bm.clone()).filter(|bm| bm != "Normal" && bm != "Compatible").collect();
differ("blend_modes", format!("{want:?}"), format!("{got:?}"));
}
if let Some(want) = &self.color_spaces {
differ("color_spaces", format!("{want:?}"), format!("{:?}", summary.color_spaces));
}
if let Some(want) = &self.color_spaces_include {
let missing: BTreeSet<&String> = want.difference(&summary.color_spaces).collect();
differ("color_spaces_include missing", "{}".to_owned(), format!("{missing:?}"));
}
if let Some(want) = self.device_rgb {
differ("device_rgb", want.to_string(), summary.device_rgb.to_string());
}
if let Some(want) = &self.output_intents {
differ("output_intents", format!("{want:?}"), format!("{:?}", summary.output_intents));
}
for wanted in self.ext_g_states.iter().flatten() {
if !states.iter().any(|s| wanted.matches(s)) {
out.push(format!("ext_g_states: nothing matches {wanted:?}"));
}
}
out
}
}
+219
View File
@@ -0,0 +1,219 @@
//! A minimal reader for the plates Ghostscript's `tiffsep` device writes: one uncompressed
//! 8-bit gray channel in strips, either byte order.
//!
//! `tiffsep` writes plates as BlackIsZero, so a sample of 0 is solid ink and 255 is paper.
use crate::Error;
/// One separation plate, as ink levels (0 = paper, 255 = solid ink), row-major.
#[derive(Debug, Clone, PartialEq)]
pub struct Plate {
width: usize,
height: usize,
ink: Vec<u8>,
}
impl Plate {
/// A plate from ink levels; `None` when `ink` doesn't hold `width × height` values.
pub fn new(width: usize, height: usize, ink: Vec<u8>) -> Option<Self> {
(width.checked_mul(height) == Some(ink.len())).then_some(Self { width, height, ink })
}
pub fn width(&self) -> usize {
self.width
}
pub fn height(&self) -> usize {
self.height
}
/// Ink percentage (0–100) at a pixel; `None` outside the plate.
pub fn ink(&self, x: usize, y: usize) -> Option<f32> {
if x >= self.width {
return None;
}
self.ink.get(y.checked_mul(self.width)?.checked_add(x)?).map(|level| f32::from(*level) * 100.0 / 255.0)
}
}
const WIDTH: u16 = 256;
const HEIGHT: u16 = 257;
const BITS_PER_SAMPLE: u16 = 258;
const COMPRESSION: u16 = 259;
const PHOTOMETRIC: u16 = 262;
const STRIP_OFFSETS: u16 = 273;
const SAMPLES_PER_PIXEL: u16 = 277;
const ROWS_PER_STRIP: u16 = 278;
/// Read a plate from TIFF bytes.
pub fn read(data: &[u8]) -> Result<Plate, Error> {
let bad = |what: &str| Error::Tiff(what.to_owned());
let big = match data.get(..4) {
Some(b"II*\0") => false,
Some(b"MM\0*") => true,
_ => return Err(bad("not a TIFF file")),
};
let file = File { data, big };
let ifd = file.u32(4).ok_or_else(|| bad("truncated header"))? as usize;
let count = file.u16(ifd).ok_or_else(|| bad("truncated directory"))? as usize;
let mut entries = Vec::with_capacity(count);
for i in 0..count {
let at = ifd + 2 + 12 * i;
let (Some(tag), Some(kind), Some(n)) = (file.u16(at), file.u16(at + 2), file.u32(at + 4)) else {
return Err(bad("truncated directory"));
};
entries.push(Entry { tag, kind, count: n as usize, value_at: at + 8 });
}
let get = |tag: u16| -> Result<Option<Vec<usize>>, Error> {
entries.iter().find(|e| e.tag == tag).map(|e| file.values(e).ok_or_else(|| bad(&format!("bad value for tag {tag}")))).transpose()
};
let one = |tag: u16, default: Option<usize>| -> Result<usize, Error> {
match get(tag)?.as_deref() {
Some([value]) => Ok(*value),
Some(_) => Err(bad(&format!("tag {tag} must hold one value"))),
None => default.ok_or_else(|| bad(&format!("missing tag {tag}"))),
}
};
let (width, height) = (one(WIDTH, None)?, one(HEIGHT, None)?);
if one(BITS_PER_SAMPLE, Some(1))? != 8 || one(SAMPLES_PER_PIXEL, Some(1))? != 1 {
return Err(bad("only 8-bit single-channel plates are supported"));
}
if one(COMPRESSION, Some(1))? != 1 {
return Err(bad("only uncompressed plates are supported (-sCompression=none)"));
}
let white_is_zero = match one(PHOTOMETRIC, None)? {
0 => true,
1 => false,
other => return Err(bad(&format!("photometric interpretation {other} is not gray"))),
};
let rows_per_strip = one(ROWS_PER_STRIP, Some(height))?.clamp(1, height.max(1));
let offsets = get(STRIP_OFFSETS)?.ok_or_else(|| bad("missing strip offsets"))?;
let size = width.checked_mul(height).ok_or_else(|| bad("plate too large"))?;
let mut ink = Vec::with_capacity(size);
for (strip, offset) in offsets.iter().enumerate() {
let rows = rows_per_strip.min(height.saturating_sub(strip * rows_per_strip));
let bytes = data.get(*offset..offset.saturating_add(rows * width)).ok_or_else(|| bad("strip outside the file"))?;
ink.extend(bytes.iter().map(|v| if white_is_zero { *v } else { 255 - v }));
}
Plate::new(width, height, ink).ok_or_else(|| bad("strips don't cover the plate"))
}
struct Entry {
tag: u16,
kind: u16,
count: usize,
/// Where the value (four bytes or less) or the offset to it sits.
value_at: usize,
}
struct File<'a> {
data: &'a [u8],
big: bool,
}
impl File<'_> {
fn u16(&self, at: usize) -> Option<u16> {
let b: [u8; 2] = self.data.get(at..at + 2)?.try_into().ok()?;
Some(if self.big { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) })
}
fn u32(&self, at: usize) -> Option<u32> {
let b: [u8; 4] = self.data.get(at..at + 4)?.try_into().ok()?;
Some(if self.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) })
}
/// SHORT or LONG values, inline when they fit in four bytes.
fn values(&self, entry: &Entry) -> Option<Vec<usize>> {
let size = match entry.kind {
3 => 2,
4 => 4,
_ => return None,
};
let start = if entry.count * size <= 4 { entry.value_at } else { self.u32(entry.value_at)? as usize };
(0..entry.count)
.map(|i| {
let at = start.checked_add(i.checked_mul(size)?)?;
if size == 2 { self.u16(at).map(usize::from) } else { self.u32(at).map(|v| v as usize) }
})
.collect()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
/// A gray TIFF as `tiffsep` writes it (BlackIsZero samples), in strips of `rows` rows.
fn encode(width: u16, height: u16, samples: &[u8], rows: u16, big: bool) -> Vec<u8> {
let p16 = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let p32 = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let strips: Vec<&[u8]> = samples.chunks(usize::from(width) * usize::from(rows)).collect();
let mut out = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
out.extend(p32(8));
let entries = 8u16;
let dir_end = 8 + 2 + 12 * u32::from(entries) + 4;
let offsets_at = dir_end;
let data_at = offsets_at + 4 * strips.len() as u32;
let mut dir = p16(entries).to_vec();
let mut entry = |tag: u16, kind: u16, count: u32, value: u32| {
dir.extend(p16(tag));
dir.extend(p16(kind));
dir.extend(p32(count));
if kind == 3 && count == 1 {
dir.extend(p16(value as u16));
dir.extend([0, 0]);
} else {
dir.extend(p32(value));
}
};
entry(WIDTH, 3, 1, u32::from(width));
entry(HEIGHT, 3, 1, u32::from(height));
entry(BITS_PER_SAMPLE, 3, 1, 8);
entry(COMPRESSION, 3, 1, 1);
entry(PHOTOMETRIC, 3, 1, 1);
let first = if strips.len() == 1 { data_at } else { offsets_at };
entry(STRIP_OFFSETS, 4, strips.len() as u32, first);
entry(SAMPLES_PER_PIXEL, 3, 1, 1);
entry(ROWS_PER_STRIP, 3, 1, u32::from(rows));
dir.extend(p32(0));
out.extend(dir);
let mut at = data_at;
for strip in &strips {
out.extend(p32(at));
at += strip.len() as u32;
}
for strip in strips {
out.extend(strip);
}
out
}
#[test]
fn reads_strips_in_both_byte_orders() {
let samples: Vec<u8> = (0..15).map(|i| (i * 17) as u8).collect();
for big in [false, true] {
for rows in [1, 2, 3] {
let plate = read(&encode(5, 3, &samples, rows, big)).unwrap();
assert_eq!((plate.width(), plate.height()), (5, 3));
assert_eq!(plate.ink(0, 0), Some(100.0), "sample 0 is solid ink");
assert_eq!(plate.ink(4, 2), Some((255.0 - 238.0) * 100.0 / 255.0));
assert_eq!(plate.ink(5, 0), None);
assert_eq!(plate.ink(0, 3), None);
}
}
}
#[test]
fn rejects_what_it_cannot_read() {
let good = encode(2, 2, &[0, 0, 0, 0], 2, false);
assert!(read(b"GIF89a").is_err());
assert!(read(&good[..40]).is_err());
assert!(read(&good[..good.len() - 1]).is_err(), "truncated strip");
let mut packed = good.clone();
// Compression (the fourth entry's value) set to PackBits.
packed[8 + 2 + 12 * 3 + 8] = 5;
assert!(read(&packed).unwrap_err().to_string().contains("uncompressed"));
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Local plate check: separates every scene with Ghostscript and judges its sample points.
//! Run it with `scripts/plate-check.sh`; it never runs in CI.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use craft_plate_test::reference::{self, Context};
use craft_plate_test::report::{self, Status};
use craft_plate_test::{gs, scene};
#[test]
#[ignore = "needs Ghostscript; run scripts/plate-check.sh"]
fn every_scene_separates_as_expected() {
let Some(version) = gs::version() else {
println!("note: Ghostscript ({:?}) not found; set {} or install gs", gs::program(), gs::GS_ENV);
return;
};
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
let ctx = Context::from_env(&scenes);
if ctx.font.is_none() {
println!("note: {} not set; text points are skipped", reference::FONT_DIR_ENV);
}
let report = report::run(&ctx, version);
let path = report::write(&report, &report::output_dir()).unwrap();
for result in &report.scenes {
let pending = result.pending.as_deref().map(|t| format!(" (pending {t})")).unwrap_or_default();
println!("{} {:?}{pending}", result.id, result.status);
}
println!("report: {}", path.display());
let wrong: Vec<String> = report
.scenes
.iter()
.filter(|r| !r.status.as_expected())
.map(|r| format!("{} {:?}{}", r.id, r.status, r.error.as_deref().map(|e| format!(": {e}")).unwrap_or_default()))
.collect();
assert!(wrong.is_empty(), "see {}:\n{}", path.display(), wrong.join("\n"));
assert!(report.scenes.iter().any(|r| r.status == Status::Pass));
}
+123
View File
@@ -0,0 +1,123 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use craft_plate_test::reference::{self, Context};
use craft_plate_test::scene::{self, Scene};
use craft_plate_test::{Error, structure};
fn scenes() -> Vec<Scene> {
scene::load_dir(&scene::scenes_dir()).unwrap()
}
#[test]
fn every_scene_parses_and_is_consistent() {
let scenes = scenes();
assert!(!scenes.is_empty());
let problems: Vec<String> = scenes.iter().flat_map(|s| s.validate(&scenes)).collect();
assert!(problems.is_empty(), "{}", problems.join("\n"));
let mut ids: Vec<&str> = scenes.iter().map(|s| s.id.as_str()).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), scenes.len(), "scene ids must be unique");
}
/// Non-pending scenes are written and match their `structure` block (with text left out for
/// lack of fonts, a mismatch is only a note). Pending scenes must
/// either write or stop at a feature the writer lacks, so their other fields stay valid.
#[test]
fn written_scenes_match_their_structure() {
let scenes = scenes();
let ctx = Context::from_env(&scenes);
if ctx.font.is_none() {
println!("note: {} not set; text objects are skipped", reference::FONT_DIR_ENV);
}
let mut failures = Vec::new();
let mut written = 0;
for scene in &scenes {
let result = reference::write(scene, &ctx);
if let Some(task) = &scene.pending {
match result {
Ok(_) | Err(Error::Unsupported(_)) => println!("{} pending ({task})", scene.id),
Err(e) => failures.push(format!("{} (pending {task}): {e}", scene.id)),
}
continue;
}
let output = match result {
Ok(output) => output,
Err(e) => {
failures.push(format!("{}: {e}", scene.id));
continue;
}
};
for note in &output.notes {
println!("note: {note}");
}
let summary = structure::summarize(&output.pdf).unwrap();
let problems = scene.structure.check(&summary);
// A scene whose overprint or other states sit only on text can't match without fonts.
let text_skipped = output.notes.iter().any(|note| note.contains(reference::FONT_DIR_ENV));
if !problems.is_empty() && text_skipped {
println!("note: {}: structure not checked with text skipped: {}", scene.id, problems.join("; "));
} else if !problems.is_empty() {
let json = serde_json::to_string_pretty(&summary).unwrap();
failures.push(format!("{}:\n {}\nsummary: {json}", scene.id, problems.join("\n ")));
}
written += 1;
}
println!("{written} scenes written, {} pending", scenes.len() - written);
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// The print preset (the FOGRA39 output intent) adds one `GTS_PDFX` intent and leaves the rest
/// of a scene's structure as the scene expects. Three scenes cover blending, overprint and
/// DeviceN; embedding the 700 KB profile in every scene would make this the slowest test.
#[test]
fn print_preset_adds_one_gts_pdfx_intent() {
let scenes = scenes();
let ctx = Context::from_env(&scenes);
let mut failures = Vec::new();
for scene in scenes.iter().filter(|s| ["S01", "S06", "S14"].contains(&s.id.as_str())) {
let mut print = scene.clone();
print.settings.output_intent = Some("fogra39".to_owned());
let output = reference::write(&print, &ctx).unwrap();
let summary = structure::summarize(&output.pdf).unwrap();
let mut want = scene.structure.clone();
want.output_intents = Some(vec!["GTS_PDFX".to_owned()]);
let problems = want.check(&summary);
let text_skipped = output.notes.iter().any(|note| note.contains(reference::FONT_DIR_ENV));
if !problems.is_empty() && text_skipped {
assert!(summary.output_intents.iter().filter(|i| *i == "GTS_PDFX").count() == 1, "{}: {summary:?}", scene.id);
} else if !problems.is_empty() {
failures.push(format!("{}: {}", scene.id, problems.join("; ")));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// The check catches the RGB blending regression: S06 written with the writer's default group
/// space has an RGB form group and no page group, which its `structure` block rejects.
#[test]
fn structure_check_rejects_rgb_blending() {
let scenes = scenes();
let mut s06 = scenes.iter().find(|s| s.id == "S06").unwrap().clone();
s06.settings.group_cs = None;
let pdf = reference::write(&s06, &Context { scenes: &scenes, font: None }).unwrap().pdf;
let summary = structure::summarize(&pdf).unwrap();
assert!(summary.groups.iter().any(|g| g.at == "form" && g.s == "Transparency" && g.cs.as_deref() == Some("DeviceRGB")), "{summary:?}");
assert!(summary.color_spaces.contains("DeviceCMYK"), "{summary:?}");
let problems = s06.structure.check(&summary);
assert!(problems.iter().any(|p| p.starts_with("page_group")), "{problems:?}");
assert!(problems.iter().any(|p| p.starts_with("group_cs")), "{problems:?}");
assert!(structure::summarize(b"not a pdf").is_err());
}
/// N1 waits for overprint (T4); without it, the DeviceN fill and its color spaces already
/// match the scene.
#[test]
fn device_n_scene_writes_its_color_spaces() {
let scenes = scenes();
let n1 = scenes.iter().find(|s| s.id == "N1").unwrap();
let output = reference::write_partial(n1, &Context { scenes: &scenes, font: None }).unwrap();
let summary = structure::summarize(&output.pdf).unwrap();
assert_eq!(Some(&summary.color_spaces), n1.structure.color_spaces.as_ref(), "{summary:?}");
assert!(!summary.device_rgb, "{summary:?}");
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Separate every plate-test scene with Ghostscript (local only, never CI) and judge the plates.
# Ghostscript is `gs` on PATH, or CRAFT_PLATE_TEST_GS.
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
fonts="${XDG_CACHE_HOME:-$HOME/.cache}/craft-pdf-test-fonts"
if [[ -z "${CRAFT_PDF_TEST_FONT_DIR:-}" && -f "$fonts/Inter-Regular.ttf" ]]; then
export CRAFT_PDF_TEST_FONT_DIR="$fonts"
fi
cd "$root"
cargo test -p craft-plate-test --test ghostscript -- --ignored --nocapture
echo "report: ${CARGO_TARGET_DIR:-target}/plate-test/report.md"