From f7ae814d91bdaa903b2eebb56da9a7265a380bbc Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:41:13 +0000 Subject: [PATCH] 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. --- .github/workflows/pdf.yml | 14 +- Cargo.lock | 12 + Cargo.toml | 1 + crates/plate-test/Cargo.toml | 20 + crates/plate-test/README.md | 139 ++++ .../plate-test/scenes/D01-spot-control.json | 78 +++ .../plate-test/scenes/D04-spot-opacity.json | 113 +++ .../scenes/D06-stroke-fill-overprint.json | 264 +++++++ crates/plate-test/scenes/D08-feather.json | 208 ++++++ crates/plate-test/scenes/D10-k100-text.json | 147 ++++ .../plate-test/scenes/M1-mask-backdrop.json | 126 ++++ .../plate-test/scenes/M2-mask-transfer.json | 128 ++++ crates/plate-test/scenes/N1-devicen-fill.json | 98 +++ .../scenes/S01-black-overprint.json | 146 ++++ .../plate-test/scenes/S02-black-knockout.json | 133 ++++ crates/plate-test/scenes/S03-opm.json | 155 +++++ .../plate-test/scenes/S04-spot-overprint.json | 107 +++ crates/plate-test/scenes/S05-spot-normal.json | 98 +++ crates/plate-test/scenes/S06-k50-opacity.json | 104 +++ .../plate-test/scenes/S07-spot-multiply.json | 160 +++++ .../scenes/S08-nonseparable-spot.json | 137 ++++ crates/plate-test/scenes/S09-knockout.json | 142 ++++ .../scenes/S10-isolated-multiply.json | 142 ++++ .../scenes/S11-shadow-over-spot.json | 269 ++++++++ .../scenes/S12-white-overprint.json | 134 ++++ .../plate-test/scenes/S13-registration.json | 132 ++++ .../scenes/S14-duotone-gradients.json | 232 +++++++ crates/plate-test/scenes/S15-placed-pdf.json | 140 ++++ .../plate-test/scenes/S16-gray-overprint.json | 134 ++++ crates/plate-test/src/gs.rs | 106 +++ crates/plate-test/src/judge.rs | 311 +++++++++ crates/plate-test/src/lib.rs | 42 ++ crates/plate-test/src/reference.rs | 653 ++++++++++++++++++ crates/plate-test/src/report.rs | 246 +++++++ crates/plate-test/src/scene.rs | 546 +++++++++++++++ crates/plate-test/src/structure.rs | 447 ++++++++++++ crates/plate-test/src/tiff.rs | 219 ++++++ crates/plate-test/tests/ghostscript.rs | 37 + crates/plate-test/tests/scenes.rs | 123 ++++ scripts/plate-check.sh | 12 + 40 files changed, 6453 insertions(+), 2 deletions(-) create mode 100644 crates/plate-test/Cargo.toml create mode 100644 crates/plate-test/README.md create mode 100644 crates/plate-test/scenes/D01-spot-control.json create mode 100644 crates/plate-test/scenes/D04-spot-opacity.json create mode 100644 crates/plate-test/scenes/D06-stroke-fill-overprint.json create mode 100644 crates/plate-test/scenes/D08-feather.json create mode 100644 crates/plate-test/scenes/D10-k100-text.json create mode 100644 crates/plate-test/scenes/M1-mask-backdrop.json create mode 100644 crates/plate-test/scenes/M2-mask-transfer.json create mode 100644 crates/plate-test/scenes/N1-devicen-fill.json create mode 100644 crates/plate-test/scenes/S01-black-overprint.json create mode 100644 crates/plate-test/scenes/S02-black-knockout.json create mode 100644 crates/plate-test/scenes/S03-opm.json create mode 100644 crates/plate-test/scenes/S04-spot-overprint.json create mode 100644 crates/plate-test/scenes/S05-spot-normal.json create mode 100644 crates/plate-test/scenes/S06-k50-opacity.json create mode 100644 crates/plate-test/scenes/S07-spot-multiply.json create mode 100644 crates/plate-test/scenes/S08-nonseparable-spot.json create mode 100644 crates/plate-test/scenes/S09-knockout.json create mode 100644 crates/plate-test/scenes/S10-isolated-multiply.json create mode 100644 crates/plate-test/scenes/S11-shadow-over-spot.json create mode 100644 crates/plate-test/scenes/S12-white-overprint.json create mode 100644 crates/plate-test/scenes/S13-registration.json create mode 100644 crates/plate-test/scenes/S14-duotone-gradients.json create mode 100644 crates/plate-test/scenes/S15-placed-pdf.json create mode 100644 crates/plate-test/scenes/S16-gray-overprint.json create mode 100644 crates/plate-test/src/gs.rs create mode 100644 crates/plate-test/src/judge.rs create mode 100644 crates/plate-test/src/lib.rs create mode 100644 crates/plate-test/src/reference.rs create mode 100644 crates/plate-test/src/report.rs create mode 100644 crates/plate-test/src/scene.rs create mode 100644 crates/plate-test/src/structure.rs create mode 100644 crates/plate-test/src/tiff.rs create mode 100644 crates/plate-test/tests/ghostscript.rs create mode 100644 crates/plate-test/tests/scenes.rs create mode 100755 scripts/plate-check.sh diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index e0d9fcf1..5a6c0e70 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 6471411f..a618b628 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 868e568d..67efc912 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ repository = "https://github.com/storytold/craft-libs" # internal: add each crate here as `craft- = { path = "crates/" }` 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"] } diff --git a/crates/plate-test/Cargo.toml b/crates/plate-test/Cargo.toml new file mode 100644 index 00000000..f17bbce4 --- /dev/null +++ b/crates/plate-test/Cargo.toml @@ -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 } diff --git a/crates/plate-test/README.md b/crates/plate-test/README.md new file mode 100644 index 00000000..11e071a9 --- /dev/null +++ b/crates/plate-test/README.md @@ -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/-.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 `). 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. diff --git a/crates/plate-test/scenes/D01-spot-control.json b/crates/plate-test/scenes/D01-spot-control.json new file mode 100644 index 00000000..578a16ec --- /dev/null +++ b/crates/plate-test/scenes/D01-spot-control.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/D04-spot-opacity.json b/crates/plate-test/scenes/D04-spot-opacity.json new file mode 100644 index 00000000..dc3813b8 --- /dev/null +++ b/crates/plate-test/scenes/D04-spot-opacity.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/D06-stroke-fill-overprint.json b/crates/plate-test/scenes/D06-stroke-fill-overprint.json new file mode 100644 index 00000000..b181bedd --- /dev/null +++ b/crates/plate-test/scenes/D06-stroke-fill-overprint.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/D08-feather.json b/crates/plate-test/scenes/D08-feather.json new file mode 100644 index 00000000..d914b4e9 --- /dev/null +++ b/crates/plate-test/scenes/D08-feather.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/D10-k100-text.json b/crates/plate-test/scenes/D10-k100-text.json new file mode 100644 index 00000000..af0474bc --- /dev/null +++ b/crates/plate-test/scenes/D10-k100-text.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/M1-mask-backdrop.json b/crates/plate-test/scenes/M1-mask-backdrop.json new file mode 100644 index 00000000..d28bc48d --- /dev/null +++ b/crates/plate-test/scenes/M1-mask-backdrop.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/M2-mask-transfer.json b/crates/plate-test/scenes/M2-mask-transfer.json new file mode 100644 index 00000000..0ecc5eac --- /dev/null +++ b/crates/plate-test/scenes/M2-mask-transfer.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/N1-devicen-fill.json b/crates/plate-test/scenes/N1-devicen-fill.json new file mode 100644 index 00000000..df09fb82 --- /dev/null +++ b/crates/plate-test/scenes/N1-devicen-fill.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S01-black-overprint.json b/crates/plate-test/scenes/S01-black-overprint.json new file mode 100644 index 00000000..f4b7bf3d --- /dev/null +++ b/crates/plate-test/scenes/S01-black-overprint.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S02-black-knockout.json b/crates/plate-test/scenes/S02-black-knockout.json new file mode 100644 index 00000000..26bdd1bf --- /dev/null +++ b/crates/plate-test/scenes/S02-black-knockout.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S03-opm.json b/crates/plate-test/scenes/S03-opm.json new file mode 100644 index 00000000..61f3967d --- /dev/null +++ b/crates/plate-test/scenes/S03-opm.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S04-spot-overprint.json b/crates/plate-test/scenes/S04-spot-overprint.json new file mode 100644 index 00000000..fed84768 --- /dev/null +++ b/crates/plate-test/scenes/S04-spot-overprint.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S05-spot-normal.json b/crates/plate-test/scenes/S05-spot-normal.json new file mode 100644 index 00000000..2067efa9 --- /dev/null +++ b/crates/plate-test/scenes/S05-spot-normal.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S06-k50-opacity.json b/crates/plate-test/scenes/S06-k50-opacity.json new file mode 100644 index 00000000..76ebcbf5 --- /dev/null +++ b/crates/plate-test/scenes/S06-k50-opacity.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S07-spot-multiply.json b/crates/plate-test/scenes/S07-spot-multiply.json new file mode 100644 index 00000000..cebbf10a --- /dev/null +++ b/crates/plate-test/scenes/S07-spot-multiply.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S08-nonseparable-spot.json b/crates/plate-test/scenes/S08-nonseparable-spot.json new file mode 100644 index 00000000..1c559310 --- /dev/null +++ b/crates/plate-test/scenes/S08-nonseparable-spot.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S09-knockout.json b/crates/plate-test/scenes/S09-knockout.json new file mode 100644 index 00000000..9834e88e --- /dev/null +++ b/crates/plate-test/scenes/S09-knockout.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S10-isolated-multiply.json b/crates/plate-test/scenes/S10-isolated-multiply.json new file mode 100644 index 00000000..aa28b11a --- /dev/null +++ b/crates/plate-test/scenes/S10-isolated-multiply.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S11-shadow-over-spot.json b/crates/plate-test/scenes/S11-shadow-over-spot.json new file mode 100644 index 00000000..eccff425 --- /dev/null +++ b/crates/plate-test/scenes/S11-shadow-over-spot.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S12-white-overprint.json b/crates/plate-test/scenes/S12-white-overprint.json new file mode 100644 index 00000000..02935d11 --- /dev/null +++ b/crates/plate-test/scenes/S12-white-overprint.json @@ -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." +} diff --git a/crates/plate-test/scenes/S13-registration.json b/crates/plate-test/scenes/S13-registration.json new file mode 100644 index 00000000..35d2254b --- /dev/null +++ b/crates/plate-test/scenes/S13-registration.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/S14-duotone-gradients.json b/crates/plate-test/scenes/S14-duotone-gradients.json new file mode 100644 index 00000000..704e622f --- /dev/null +++ b/crates/plate-test/scenes/S14-duotone-gradients.json @@ -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." +} diff --git a/crates/plate-test/scenes/S15-placed-pdf.json b/crates/plate-test/scenes/S15-placed-pdf.json new file mode 100644 index 00000000..c1e3981f --- /dev/null +++ b/crates/plate-test/scenes/S15-placed-pdf.json @@ -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." +} diff --git a/crates/plate-test/scenes/S16-gray-overprint.json b/crates/plate-test/scenes/S16-gray-overprint.json new file mode 100644 index 00000000..3a6e015f --- /dev/null +++ b/crates/plate-test/scenes/S16-gray-overprint.json @@ -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": [] + } +} diff --git a/crates/plate-test/src/gs.rs b/crates/plate-test/src/gs.rs new file mode 100644 index 00000000..a659647c --- /dev/null +++ b/crates/plate-test/src/gs.rs @@ -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 { + 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, 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::>().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 { + 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); + } +} diff --git a/crates/plate-test/src/judge.rs b/crates/plate-test/src/judge.rs new file mode 100644 index 00000000..693106cb --- /dev/null +++ b/crates/plate-test/src/judge.rs @@ -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, + pub expected: BTreeMap, + pub tol: f32, + /// Ink percentage per plate, rounded to 0.1; every expected and every separated plate. + pub measured: BTreeMap, + pub verdict: Verdict, + /// Plates outside tolerance. + pub off: Vec, + /// How the value was found, or why it wasn't. + pub how: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Judgement { + /// The plates the separation produced. + pub plates: Vec, + /// Expected plates the separation lacks. + pub missing_plates: Vec, + /// Plates the separation produced that the scene doesn't expect. + pub extra_plates: Vec, + pub points: Vec, +} + +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, res: f32, text_drawn: bool) -> Judgement { + let expected = &scene.expect.plates; + let mut names: Vec = 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 = 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, 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::>()).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); + +/// The pixels a point samples; `Err` says why there are none. +fn point_pixels(point: &SamplePoint, plates: &BTreeMap, res: f32) -> Result { + 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 { + 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 { + 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 = 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)); + } +} diff --git a/crates/plate-test/src/lib.rs b/crates/plate-test/src/lib.rs new file mode 100644 index 00000000..4a651c92 --- /dev/null +++ b/crates/plate-test/src/lib.rs @@ -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), +} diff --git a/crates/plate-test/src/reference.rs b/crates/plate-test/src/reference.rs new file mode 100644 index 00000000..78e98fb7 --- /dev/null +++ b/crates/plate-test/src/reference.rs @@ -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>>, +} + +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, + /// What was left out, e.g. text without a font. + pub notes: Vec, +} + +/// Write `scene` as a one-page PDF. +pub fn write(scene: &Scene, ctx: &Context<'_>) -> Result { + 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 { + write_nested(scene, ctx, 0, true) +} + +fn write_nested(scene: &Scene, ctx: &Context<'_>, depth: usize, partial: bool) -> Result { + 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 { + 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> { + 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, + stroke: Option, + }, + Text { + at: Point, + size: f32, + text: String, + font: Font, + fill: Option, + stroke: Option, + }, + Image { + image: Image, + rect: [f32; 4], + }, + Pdf { + doc: PdfDocument, + rect: [f32; 4], + }, + Group { + options: GroupOptions, + children: Vec, + }, + /// Outermost first: blend mode, soft mask, opacity group, overprint. + Wrapped { + blend: Option, + mask: Option, + opacity: Option, + overprint: Option, + inner: Box, + }, +} + +struct SoftMask { + kind: MaskType, + backdrop: Option, + transfer: Option, + content: Vec, +} + +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, + font: Option, +} + +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, 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, 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::>>(); + 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, 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, 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, Option), 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 { + 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 { + 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 { + 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) -> Result { + 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 + Clone) -> Result { + 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 { + 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| -> Vec { + 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), 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> { + 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 { + 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 { + // 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, + 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(); + } + } + } + } +} diff --git a/crates/plate-test/src/report.rs b/crates/plate-test/src/report.rs new file mode 100644 index 00000000..405d754e --- /dev/null +++ b/crates/plate-test/src/report.rs @@ -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, + pub status: Status, + /// What the writer left out or skipped. + pub notes: Vec, + /// Why the scene was not written or judged. + pub error: Option, + pub judgement: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Report { + pub ghostscript: String, + pub resolution: f32, + pub scenes: Vec, +} + +/// 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 { + 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::>().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 { + 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 { + let mut names: Vec = inks.keys().cloned().collect(); + judge::sort_plates(&mut names); + let parts: Vec = 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 { + let mut names: Vec = inks.iter().filter(|(_, v)| **v >= 1.0).map(|(k, _)| k.clone()).collect(); + judge::sort_plates(&mut names); + let parts: Vec = names.iter().filter_map(|name| Some(format!("{}{:.0}", short(name), inks.get(name)?))).collect(); + if parts.is_empty() { "paper".into() } else { parts.join(" ") } +} diff --git a/crates/plate-test/src/scene.rs b/crates/plate-test/src/scene.rs new file mode 100644 index 00000000..f4e4968a --- /dev/null +++ b/crates/plate-test/src/scene.rs @@ -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, + #[serde(default)] + pub settings: Settings, + pub objects: Vec, + 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, + #[serde(default)] + pub note: Option, +} + +/// 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, + /// 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, +} + +#[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, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub image: Option, + /// pdf: id of the scene to place. + #[serde(default)] + pub scene: Option, + /// group: children, drawn in order. + #[serde(default)] + pub objects: Vec, + #[serde(default)] + pub isolated: bool, + #[serde(default)] + pub knockout: bool, + #[serde(default)] + pub fill: Option, + #[serde(default)] + pub stroke: Option, + /// Whole-object opacity, 0–1 (a transparency group). + #[serde(default)] + pub opacity: Option, + #[serde(default)] + pub blend: Option, + #[serde(default)] + pub overprint: Option, + #[serde(default)] + pub mask: Option, + #[serde(default)] + pub note: Option, +} + +/// 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, + }, + 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, +} + +#[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, +} + +#[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, + /// `/BC`: the color outside the mask content (luminosity masks). + #[serde(default)] + pub backdrop: Option, + /// `/TR`: a sampled transfer function, values 0–1 over the input range. + #[serde(default)] + pub transfer: Option>, +} + +#[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, + pub points: Vec, +} + +#[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, + /// Expected ink per plate; plates not listed expect 0. + #[serde(default)] + pub inks: BTreeMap, + /// Allowed difference in ink percentage points. + pub tol: f32, + /// Measured and reported, not judged. + #[serde(default)] + pub info: bool, + #[serde(default)] + pub note: Option, +} + +#[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, 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 { + 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 { + 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 { + 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) { + 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() +} diff --git a/crates/plate-test/src/structure.rs b/crates/plate-test/src/structure.rs new file mode 100644 index 00000000..d551cb67 --- /dev/null +++ b/crates/plate-test/src/structure.rs @@ -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, + pub ext_g_states: Vec, + /// 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, + /// 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, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Group { + /// `page 1`, `form` or `soft mask`. + pub at: String, + pub s: String, + pub cs: Option, + 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, + /// `/op` (fill overprint). + #[serde(rename = "op", skip_serializing_if = "Option::is_none")] + pub op_fill: Option, + #[serde(rename = "OPM", skip_serializing_if = "Option::is_none")] + pub opm: Option, + #[serde(rename = "BM", skip_serializing_if = "Option::is_none")] + pub bm: Option, + /// The soft mask's `/S`, when the state sets a mask. + #[serde(rename = "SMask", skip_serializing_if = "Option::is_none")] + pub smask: Option, + /// 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 { + 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::>(pdf.xref().root_id()) + && let Some(intents) = catalog.get::>(b"OutputIntents".as_slice()) + { + for intent in intents.iter::>() { + walk.summary.output_intents.push(intent.get::>(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::>(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::>(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, +} + +impl Walk { + /// True the first time an indirect object is visited. + fn first_visit(&mut self, id: Option) -> 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::>(b"S".as_slice()).map(|n| n.as_str().to_owned()).unwrap_or_default(), + cs: group.get::>(b"CS".as_slice()).map(|cs| describe(&cs, 0)), + i: group.get::(b"I".as_slice()).unwrap_or(false), + k: group.get::(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::>(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::>(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::>(key.as_ref()) { + self.shading(&shading); + } + } + for key in res.patterns.keys() { + let Some(pattern) = res.patterns.get::>(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::>(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::>(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::(b"OP".as_slice()), + op_fill: gs.get::(b"op".as_slice()), + opm: gs.get::(b"OPM".as_slice()), + bm: gs.get::>(b"BM".as_slice()).and_then(|bm| match bm { + Object::Name(name) => Some(name.as_str().to_owned()), + Object::Array(names) => names.iter::>().next().map(|n| n.as_str().to_owned()), + _ => None, + }), + ..Default::default() + }; + if let Some(mask) = gs.get::>(b"SMask".as_slice()) { + state.smask = Some(mask.get::>(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::>(b"TR".as_slice()).is_some_and(|tr| !matches!(&tr, Object::Name(n) if n.as_str() == "Identity")); + if let Some(source) = mask.get::>(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::>(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::>(b"Subtype".as_slice()).as_ref().map(Name::as_str) { + Some("Image") => { + if !dict.get::(b"ImageMask".as_slice()).unwrap_or(false) + && let Some(cs) = dict.get::>(b"ColorSpace".as_slice()) + { + self.summary.color_spaces.insert(describe(&cs, 0)); + } + } + Some("Form") => { + if let Some(group) = dict.get::>(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::>(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> = array.iter::>().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 = match items.get(1) { + Some(Object::Array(names)) => names.iter::>().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::(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, + /// The set of `/CS` values of form and soft-mask groups; `"none"` stands for a group + /// without `/CS`. + #[serde(default)] + pub group_cs: Option>, + /// Some group has `/K true`. + #[serde(default)] + pub knockout: Option, + /// Some group (page groups excluded) has `/I true`. + #[serde(default)] + pub isolated: Option, + /// Some graphics state sets `/OP` or `/op` true. + #[serde(default)] + pub overprint: Option, + /// Some graphics state sets a soft mask. + #[serde(default)] + pub smask: Option, + /// The set of blend modes other than Normal. + #[serde(default)] + pub blend_modes: Option>, + /// Each entry must match some graphics state on every key it gives. + #[serde(default)] + pub ext_g_states: Option>, + /// The exact set of painting color space families. + #[serde(default)] + pub color_spaces: Option>, + /// Families that must be among the painting color spaces. + #[serde(default)] + pub color_spaces_include: Option>, + #[serde(default)] + pub device_rgb: Option, + #[serde(default)] + pub output_intents: Option>, +} + +/// 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, + #[serde(rename = "op", default)] + pub op_fill: Option, + #[serde(rename = "OPM", default)] + pub opm: Option, + #[serde(rename = "BM", default)] + pub bm: Option, + #[serde(rename = "SMask", default)] + pub smask: Option, + #[serde(rename = "BC", default)] + pub smask_bc: Option, + #[serde(rename = "TR", default)] + pub smask_tr: Option, +} + +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 { + 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 = page_groups.iter().map(|g| g.cs.clone().unwrap_or_else(|| "no-cs".to_owned())).collect(); + all.into_iter().collect::>().join(" | ") + }; + differ("page_group", want.clone(), got); + } + if let Some(want) = &self.group_cs { + let got: BTreeSet = 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 = 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 + } +} diff --git a/crates/plate-test/src/tiff.rs b/crates/plate-test/src/tiff.rs new file mode 100644 index 00000000..0d673106 --- /dev/null +++ b/crates/plate-test/src/tiff.rs @@ -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, +} + +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) -> Option { + (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 { + 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 { + 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>, 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| -> Result { + 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 { + 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 { + 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> { + 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 { + 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 = (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")); + } +} diff --git a/crates/plate-test/tests/ghostscript.rs b/crates/plate-test/tests/ghostscript.rs new file mode 100644 index 00000000..f0ca9b71 --- /dev/null +++ b/crates/plate-test/tests/ghostscript.rs @@ -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 = 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)); +} diff --git a/crates/plate-test/tests/scenes.rs b/crates/plate-test/tests/scenes.rs new file mode 100644 index 00000000..fc2bc793 --- /dev/null +++ b/crates/plate-test/tests/scenes.rs @@ -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::load_dir(&scene::scenes_dir()).unwrap() +} + +#[test] +fn every_scene_parses_and_is_consistent() { + let scenes = scenes(); + assert!(!scenes.is_empty()); + let problems: Vec = 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:?}"); +} diff --git a/scripts/plate-check.sh b/scripts/plate-check.sh new file mode 100755 index 00000000..1050e867 --- /dev/null +++ b/scripts/plate-check.sh @@ -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"