mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 15:49:54 +00:00
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:
1 parent
eedd0246df
commit
f7ae814d91
40 files changed
+6453
-2
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 }
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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(¬e) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(" ") }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
Executable
+12
@@ -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"
|
||||
Reference in new issue
Block a user