Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd99375b1b | ||
|
|
f24198ec7e | ||
|
|
8c8ac37300 | ||
|
|
5e901c9c17 | ||
|
|
b71e843c96 | ||
|
|
2d740acbd1 | ||
|
|
242e8a5fd5 | ||
|
|
d0aeeb0076 | ||
|
|
39ac89fc26 | ||
|
|
11740c9731 | ||
|
|
c4b87e36b2 | ||
|
|
f12428264c | ||
|
|
49e8413ebe | ||
|
|
285d94a3e6 | ||
|
|
9920156bb6 | ||
|
|
a844503e4c | ||
|
|
1c46c4c654 | ||
|
|
d396bc23af | ||
|
|
9106e344f8 | ||
|
|
52d6b9a2ba | ||
|
|
2076f82b47 | ||
|
|
a52c030963 | ||
|
|
25a194cc00 | ||
|
|
831d916d3d | ||
|
|
3d890203dd | ||
|
|
679191b4ca | ||
|
|
811ad1efd8 | ||
|
|
cccdf96cc1 | ||
|
|
7228275f1c | ||
|
|
649be860e2 | ||
|
|
4f01bc13b2 | ||
|
|
9c707b37fc | ||
|
|
6992c843b4 | ||
|
|
6780ffae60 | ||
|
|
1499834de7 | ||
|
|
8adc629055 | ||
|
|
871474f1a9 | ||
|
|
b4c39b8cb9 | ||
|
|
8939caa284 | ||
|
|
dd69402f8b | ||
|
|
96604acffb | ||
|
|
d3d6cce917 | ||
|
|
72be324732 | ||
|
|
638edec175 | ||
|
|
03ea4079f9 | ||
|
|
e94dfed775 | ||
|
|
2f75b6cd29 | ||
|
|
69dc539db5 | ||
|
|
30550f038a | ||
|
|
535220cf34 | ||
|
|
65dd757d59 | ||
|
|
088ffc844b | ||
|
|
885b7acac1 | ||
|
|
63bf2c4598 | ||
|
|
d0d934bfda | ||
|
|
258337cf7c | ||
|
|
d4c4361b4a | ||
|
|
36a6a8ae49 | ||
|
|
2b15d3234f | ||
|
|
a867c1e020 | ||
|
|
7c0a253b50 | ||
|
|
7a861ae064 | ||
|
|
466f863d00 | ||
|
|
a1deca3d13 | ||
|
|
cd8973a520 | ||
|
|
79581c7ff5 | ||
|
|
8961824876 | ||
|
|
87df4a0648 | ||
|
|
a09cbe58c8 | ||
|
|
0e4d3f6871 | ||
|
|
d7e9f2a47a | ||
|
|
1f922fcbbc | ||
|
|
1b9b37454b | ||
|
|
9779d21962 | ||
|
|
a825aed763 | ||
|
|
4e72f8c0ec |
No files matched your search
@@ -0,0 +1,48 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
@@ -37,6 +37,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
## Running and looking at the app
|
||||
|
||||
@@ -143,6 +143,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-join-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-join.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-knife.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-line-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-line.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-list-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-live-bucket.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -150,6 +151,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-mask-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-measure.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-mesh.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-mirror-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -182,6 +184,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-pucker.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-puppet.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rearrange.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rect-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rect-grid.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-reference-point.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-remove-brush.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
@@ -37,6 +37,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
## Running and looking at the app
|
||||
|
||||
Generated
+178
-28
@@ -1231,6 +1231,12 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1439,7 +1445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1455,7 +1461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1603,6 +1609,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1611,7 +1626,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1620,7 +1635,7 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2059,6 +2074,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@@ -3667,6 +3688,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spirv"
|
||||
version = "0.4.0+sdk-1.4.341.0"
|
||||
@@ -3697,6 +3724,16 @@ dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string-interner"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
@@ -4174,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
@@ -4188,12 +4225,13 @@ dependencies = [
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-ui-egui",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4205,7 +4243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4221,7 +4259,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4237,7 +4275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4249,7 +4287,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4263,7 +4301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4271,12 +4309,13 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4296,17 +4335,19 @@ dependencies = [
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-tools",
|
||||
"vectorcraft-trace",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4323,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4337,7 +4378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4349,7 +4390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4368,7 +4409,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4382,7 +4423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4394,7 +4435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4419,9 +4460,23 @@ dependencies = [
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"wasmi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4446,7 +4501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4470,7 +4525,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4487,7 +4542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4500,7 +4555,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4515,7 +4570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4528,7 +4583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4545,16 +4600,18 @@ dependencies = [
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-tools",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4719,6 +4776,99 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser 0.261.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
|
||||
dependencies = [
|
||||
"spin",
|
||||
"wasmi_collections",
|
||||
"wasmi_core",
|
||||
"wasmi_ir",
|
||||
"wasmparser 0.228.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_collections"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
|
||||
dependencies = [
|
||||
"string-interner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_core"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_ir"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
|
||||
dependencies = [
|
||||
"wasmi_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.228.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "261.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
@@ -5535,7 +5685,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+9
-2
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -32,6 +32,7 @@ vectorcraft-testkit = { path = "crates/testkit" }
|
||||
vectorcraft-cad = { path = "crates/cad" }
|
||||
vectorcraft-eps = { path = "crates/eps" }
|
||||
vectorcraft-metafile = { path = "crates/metafile" }
|
||||
vectorcraft-plugins = { path = "crates/plugins" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -43,6 +44,12 @@ log = "0.4"
|
||||
proptest = "1"
|
||||
# tests: a TCP port bound without listening (connections to it are refused)
|
||||
socket2 = "0.6"
|
||||
# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32
|
||||
# too. Without `wat` (text parsing is test-only) and memory64; `validate` keeps full module
|
||||
# validation on; the portable dispatcher can't overflow the host stack on long-running modules.
|
||||
wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] }
|
||||
# Tests: WebAssembly text -> binary for the plug-in fixtures (Apache-2.0 WITH LLVM-exception).
|
||||
wat = "1.228"
|
||||
|
||||
# rendering / formats
|
||||
vello_cpu = "0.2"
|
||||
|
||||
@@ -26,3 +26,6 @@ BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0).
|
||||
|
||||
JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is
|
||||
based in part on the work of the Independent JPEG Group.
|
||||
|
||||
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
|
||||
under the Apache License 2.0 with the LLVM exception.
|
||||
@@ -172,7 +172,7 @@ milestones, and honest time-to-parity estimates.
|
||||
**Where we are (2026-10-05):** roughly 68–74% of Illustrator's features exist and work, and about 40–55% of
|
||||
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and
|
||||
files (SVG, PDF and PDF-compatible `.ai`, EPS, DXF, raster formats, Print, Package). The scores are
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The scores are
|
||||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
@@ -187,7 +187,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0
|
||||
advanced type, then hardening and packaging for 1.0. The prioritized list is in
|
||||
[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
|
||||
the UI can be swapped without touching the engine.
|
||||
|
||||
|
||||
+20
-20
@@ -33,7 +33,7 @@ since 2026-10-01.
|
||||
|
||||
| Dimension | Estimate | Evidence and what's missing |
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type, vertical type ×3 and Print Tiling; 51 panel modules |
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type and vertical type ×3; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~68–74% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, vertical/CJK type, Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
@@ -64,7 +64,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
|
||||
generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h.
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, the Print Tiling tool, video
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
|
||||
rulers. 15–25 h.
|
||||
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
|
||||
browser QA, accessibility, packaging. 50–80 h.
|
||||
@@ -101,7 +101,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
|
||||
- **Cutting:** Eraser, Scissors, Knife.
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
@@ -109,7 +109,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
@@ -126,9 +126,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export.
|
||||
- Place and the Links panel, Package, File Info, Print with print presets and PostScript output, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
@@ -137,20 +137,20 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| # | Milestone | Status | Est. remaining, one agent (h) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; flattener presets not yet used by PDF/print; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ mostly done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, polish | 6–10 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool done, Trim View, middle-button pan; print tiling, multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux and content-sized dialogs and menus done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~335–520** |
|
||||
@@ -169,25 +169,25 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
|
||||
| Area | Weight | Done | Missing (main items) | One agent (h) |
|
||||
|---|---:|---:|---|---:|
|
||||
| Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 |
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
|
||||
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
|
||||
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 97% | flattener presets in PDF/EPS/print, raster effect reach in preview bounds | 2–4 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
|
||||
| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, print tiling, Snap to Pixel/Glyph | 10–15 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security and presets are done | 6–10 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 70% | Print Tiling tool and Show Print Tiling, flattener presets in print/PDF, print preview fidelity (Print dialog, presets, PostScript, marks and separations are done) | 5–8 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
|
||||
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
|
||||
@@ -201,7 +201,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c
|
||||
**~85–150 h** to full parity.
|
||||
|
||||
_Inventories (2026-10-05):_ 18 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except five (Touch Type, vertical type ×3, Print Tiling); 51 panel modules; Illustrator-style live
|
||||
implemented except four (Touch Type, vertical type ×3); 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
|
||||
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
|
||||
|
||||
@@ -22,6 +22,8 @@ rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
image = { workspace = true }
|
||||
webbrowser = { workspace = true }
|
||||
# The window's monitors, size and position (the same winit eframe runs on).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
clipboard-win = { workspace = true }
|
||||
|
||||
@@ -11,6 +11,7 @@ mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod printing;
|
||||
mod window;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
@@ -30,6 +31,7 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
window::track(ctx, &mut self.0.ui.window);
|
||||
if self.0.ui.status == "quit" {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
@@ -71,21 +73,32 @@ fn prefs_path_for(name: &str, lower: &str) -> Option<std::path::PathBuf> {
|
||||
base.map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut VectorcraftApp) {
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
||||
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("VECTORCRAFT_NO_PREFS").is_none()
|
||||
}
|
||||
|
||||
/// The saved UI preferences, read before the window opens (they hold its size and position).
|
||||
fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()));
|
||||
if let Some(bytes) = bytes
|
||||
&& let Ok(ui) = serde_json::from_slice::<vectorcraft_ui_egui::UiState>(&bytes)
|
||||
{
|
||||
if let Some(ui) = saved {
|
||||
app.ui = ui.sanitized();
|
||||
}
|
||||
vectorcraft_ui_egui::prefs_dialog::restore(app);
|
||||
}
|
||||
|
||||
fn save_prefs(app: &VectorcraftApp) {
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
@@ -230,11 +243,13 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let saved = read_prefs();
|
||||
let saved_window = saved.as_ref().and_then(|ui| ui.window);
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
@@ -249,7 +264,11 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services());
|
||||
load_prefs(&mut app);
|
||||
load_prefs(&mut app, saved);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
app.ui.window = Some(window::restore(w, saved_window));
|
||||
}
|
||||
// User Defined swatch and graphic style libraries live next to the preferences.
|
||||
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
|
||||
app.session.swatch_libraries.set_user_dir(swatches);
|
||||
|
||||
@@ -25,6 +25,8 @@ pub struct NativeMenu {
|
||||
generation: u64,
|
||||
/// Saved views and recent files listed (empty slots are left out, so a change rebuilds).
|
||||
slots: usize,
|
||||
/// Plug-in registry revision the Plug-ins submenus were built for.
|
||||
plugins: u64,
|
||||
}
|
||||
|
||||
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
|
||||
@@ -56,7 +58,7 @@ impl NativeMenu {
|
||||
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
|
||||
.map(|(_, _, _, c)| c.clone())
|
||||
.collect();
|
||||
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app) }
|
||||
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app), plugins: menus::plugin_revision() }
|
||||
}
|
||||
|
||||
/// Dispatch clicked items and refresh state.
|
||||
@@ -72,10 +74,11 @@ impl NativeMenu {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
// Shortcuts edited, workspaces added, saved views or recent files changed: rebuild so
|
||||
// Shortcuts edited, workspaces added, saved views, recent files or plug-ins changed: rebuild so
|
||||
// accelerators and lists are current.
|
||||
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|
||||
|| menus::listed_slots(app) != self.slots
|
||||
|| menus::plugin_revision() != self.plugins
|
||||
{
|
||||
*self = NativeMenu::install(app);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! The main window's size and position. The first launch fits the window to its monitor, opening
|
||||
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
|
||||
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
|
||||
//! onto a monitor that is still connected.
|
||||
|
||||
use vectorcraft_ui_egui::state::WindowGeometry;
|
||||
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
|
||||
|
||||
/// The first window's size, in points.
|
||||
pub const DEFAULT_SIZE: [f32; 2] = [1440.0, 900.0];
|
||||
/// The smallest window, in points.
|
||||
pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
|
||||
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
|
||||
/// say how much of the screen they take): a monitor with less room opens it maximized.
|
||||
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
|
||||
|
||||
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
pos: [i64; 2],
|
||||
size: [i64; 2],
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
|
||||
let scale = scale as f32;
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
|
||||
}
|
||||
|
||||
/// Its size in points.
|
||||
fn points(&self) -> [f32; 2] {
|
||||
self.size.map(|s| s as f32 / self.scale)
|
||||
}
|
||||
|
||||
/// Its size in points less room for a taskbar, Dock or menu bar.
|
||||
fn usable(&self) -> [f32; 2] {
|
||||
let points = self.points();
|
||||
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
|
||||
}
|
||||
|
||||
/// A window of `size` points on this monitor, in physical pixels.
|
||||
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
|
||||
size.map(|s| (s * self.scale) as i64)
|
||||
}
|
||||
|
||||
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
|
||||
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
|
||||
let px = self.pixels(size);
|
||||
let span = |i: usize| {
|
||||
let start = i64::from(pos[i]).max(self.pos[i]);
|
||||
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
|
||||
(end - start).max(0)
|
||||
};
|
||||
span(0).saturating_mul(span(1))
|
||||
}
|
||||
|
||||
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
|
||||
/// points on this monitor.
|
||||
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
|
||||
let px = self.pixels(size);
|
||||
std::array::from_fn(|i| {
|
||||
let lo = self.pos[i];
|
||||
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
|
||||
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
|
||||
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
|
||||
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
|
||||
/// doesn't list them, so nothing to check a saved geometry against) the default size.
|
||||
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
|
||||
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
|
||||
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
|
||||
return default;
|
||||
};
|
||||
let Some(saved) = saved else {
|
||||
let usable = home.usable();
|
||||
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
|
||||
return default;
|
||||
}
|
||||
// A small screen: maximized, with a size that fits once un-maximized.
|
||||
let size = fit(DEFAULT_SIZE, usable);
|
||||
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
|
||||
};
|
||||
// The monitor showing most of the window; none when it was on a monitor that is gone.
|
||||
let shown = saved.pos.and_then(|p| {
|
||||
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
|
||||
(area > 0).then_some(m)
|
||||
});
|
||||
let m = shown.unwrap_or(home);
|
||||
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
|
||||
let room = m.points();
|
||||
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
|
||||
let size = fit(saved.size, if too_big { m.usable() } else { room });
|
||||
WindowGeometry {
|
||||
// Moved fully onto its monitor, or centred on the home monitor.
|
||||
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
|
||||
size,
|
||||
maximized: saved.maximized || too_big,
|
||||
}
|
||||
}
|
||||
|
||||
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
|
||||
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
||||
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
|
||||
}
|
||||
|
||||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||||
/// geometry.
|
||||
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
|
||||
let handles: Vec<_> = window.available_monitors().collect();
|
||||
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
|
||||
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
|
||||
let g = place(saved, &monitors, home);
|
||||
if let Some([x, y]) = g.pos {
|
||||
window.set_outer_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
// Returns the new size when it applies at once; either way the window gets it.
|
||||
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
|
||||
if g.maximized {
|
||||
window.set_maximized(true);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
|
||||
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
|
||||
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
|
||||
let zoom = ctx.zoom_factor();
|
||||
ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
if v.minimized == Some(true) || v.fullscreen == Some(true) {
|
||||
return;
|
||||
}
|
||||
if v.maximized == Some(true) {
|
||||
if let Some(g) = geometry {
|
||||
g.maximized = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(inner) = v.inner_rect else { return };
|
||||
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
|
||||
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
|
||||
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
|
||||
}
|
||||
|
||||
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
|
||||
Some(WindowGeometry { pos, size, maximized })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_window_fits_its_screen() {
|
||||
// 1920 × 1080: the default size, where the system puts it.
|
||||
let big = [monitor(0, 0, 1920, 1080, 1.0)];
|
||||
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||||
// 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points): maximized, and a
|
||||
// size that fits the screen with room for the taskbar once un-maximized.
|
||||
for (m, size) in [
|
||||
(monitor(0, 0, 1600, 900, 1.0), [1440.0, 772.0]),
|
||||
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
|
||||
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
|
||||
] {
|
||||
let g = place(None, &[m], Some(0));
|
||||
assert!(g.maximized, "{m:?}");
|
||||
assert_eq!(g.size, size, "{m:?}");
|
||||
let [x, y] = g.pos.unwrap();
|
||||
let px = m.pixels(g.size);
|
||||
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
|
||||
}
|
||||
// No monitor information: the default size, whatever was saved.
|
||||
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||||
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_window_comes_back_where_it_was() {
|
||||
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
|
||||
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)]
|
||||
{
|
||||
assert_eq!(Some(place(g, &two, Some(0))), g);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_window_stays_on_a_connected_monitor() {
|
||||
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
|
||||
// Last time on a second monitor that is gone: centred on the remaining one.
|
||||
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
|
||||
// Half off the monitor's right and bottom edges: moved fully onto it.
|
||||
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
|
||||
assert_eq!(g.pos, Some([600, 200]));
|
||||
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
|
||||
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
|
||||
// A monitor left of the primary one (negative coordinates) still counts.
|
||||
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
|
||||
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
|
||||
// Nonsense sizes from a damaged preferences file are kept within the screen.
|
||||
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
|
||||
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_geometry_is_tracked_for_the_next_launch() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut geometry = None;
|
||||
let mut frame = |info: egui::ViewportInfo| {
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
|
||||
geometry
|
||||
};
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
|
||||
let normal = egui::ViewportInfo {
|
||||
native_pixels_per_point: Some(1.5),
|
||||
inner_rect: Some(rect),
|
||||
outer_rect: Some(rect),
|
||||
maximized: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
// Position in physical pixels, size in points.
|
||||
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
|
||||
assert_eq!(frame(normal.clone()), Some(g));
|
||||
// Maximized or minimized: the size to restore stays.
|
||||
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
|
||||
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
|
||||
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
|
||||
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
|
||||
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
|
||||
assert_eq!(frame(normal), Some(g));
|
||||
// Saved with the preferences; older preference files have no window.
|
||||
let mut ui = vectorcraft_ui_egui::UiState { window: Some(g), ..Default::default() };
|
||||
let back: vectorcraft_ui_egui::UiState = serde_json::from_slice(&serde_json::to_vec(&ui).unwrap()).unwrap();
|
||||
assert_eq!(back.window, Some(g));
|
||||
ui.window = None;
|
||||
let json = serde_json::to_value(&ui).unwrap();
|
||||
assert!(json.get("window").is_none());
|
||||
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back.window, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21L21 3" stroke-dasharray="2 2"/><path d="M4 4H16.5L4 16.5Z" fill="currentColor" fill-opacity="0.35"/><path d="M20 7.5V20H7.5Z" fill="currentColor" fill-opacity="0.35"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2V22" stroke-dasharray="2 2"/><path d="M12 5C7.5 5 4 8 4 12C4 16 7.5 19 12 19Z" fill="currentColor" fill-opacity="0.35"/><path d="M12 5C16.5 5 20 8 20 12C20 16 16.5 19 12 19" stroke-opacity="0.5"/></svg>
|
||||
|
After Width: | Height: | Size: 400 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="14" height="16" stroke-dasharray="2 2"/><path d="M17 6.34A6 6 0 1 0 17 17.66Z" fill="currentColor" fill-opacity="0.35"/><path d="M17 6.34A6 6 0 0 1 17 17.66" stroke-opacity="0.5"/></svg>
|
||||
|
After Width: | Height: | Size: 395 B |
+50
-20
@@ -16,6 +16,7 @@ pub mod live;
|
||||
pub mod marks;
|
||||
pub mod metadata;
|
||||
pub mod node;
|
||||
pub mod orient;
|
||||
pub mod overprint;
|
||||
pub mod pattern;
|
||||
mod pixels;
|
||||
@@ -23,6 +24,7 @@ pub mod profiles;
|
||||
pub mod range;
|
||||
pub mod rastersettings;
|
||||
mod reach;
|
||||
pub mod recolor;
|
||||
pub mod selection;
|
||||
pub mod setup;
|
||||
pub mod slices;
|
||||
@@ -61,6 +63,7 @@ pub use node::Knockout;
|
||||
pub use node::Scaling;
|
||||
pub use node::{ImageMap, ObjectAttributes};
|
||||
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
|
||||
pub use orient::OrientedBox;
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use profiles::ColorProfiles;
|
||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||
@@ -175,8 +178,11 @@ impl Unit {
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
|
||||
///
|
||||
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
|
||||
/// math typed before a field's unit suffix works.
|
||||
pub fn parse(self, s: &str) -> Option<f64> {
|
||||
parse_measure(s, self)
|
||||
parse_measure(s, self).filter(|v| v.is_finite())
|
||||
}
|
||||
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
|
||||
/// `feetInches`).
|
||||
@@ -219,14 +225,20 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
if l.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let a = parse_measure(l, default)?;
|
||||
return match op {
|
||||
'+' => Some(a + parse_measure(r, default)?),
|
||||
'-' => Some(a - parse_measure(r, default)?),
|
||||
'*' => Some(a * r.trim().parse::<f64>().ok()?),
|
||||
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
|
||||
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
|
||||
_ => {
|
||||
let d = r.trim().parse::<f64>().ok()?;
|
||||
if d == 0.0 { None } else { Some(a / d) }
|
||||
// A factor or divisor is a plain number; a unit after it is the expression's.
|
||||
let (k, unit) = number_unit(r)?;
|
||||
let a = parse_measure(l, unit.unwrap_or(default))?;
|
||||
if op == '*' {
|
||||
Some(a * k)
|
||||
} else if k == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(a / k)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -240,22 +252,40 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
{
|
||||
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
|
||||
}
|
||||
let (v, unit) = number_unit(s)?;
|
||||
Some(unit.unwrap_or(default).to_pt(v))
|
||||
}
|
||||
|
||||
/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit).
|
||||
fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
|
||||
let s = s.trim();
|
||||
let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
|
||||
let v: f64 = s[..num_end].trim().parse().ok()?;
|
||||
let unit = match s[num_end..].trim() {
|
||||
"" => default,
|
||||
"pt" => Unit::Points,
|
||||
"px" => Unit::Pixels,
|
||||
"in" | "\"" => Unit::Inches,
|
||||
"mm" => Unit::Millimeters,
|
||||
"cm" => Unit::Centimeters,
|
||||
"m" => Unit::Meters,
|
||||
"ft" | "'" => Unit::Feet,
|
||||
"yd" => Unit::Yards,
|
||||
"pc" => Unit::Picas,
|
||||
let v: f64 = s.get(..num_end)?.trim().parse().ok()?;
|
||||
let unit = match s.get(num_end..)?.trim() {
|
||||
"" => None,
|
||||
"pt" => Some(Unit::Points),
|
||||
"px" => Some(Unit::Pixels),
|
||||
"in" | "\"" => Some(Unit::Inches),
|
||||
"mm" => Some(Unit::Millimeters),
|
||||
"cm" => Some(Unit::Centimeters),
|
||||
"m" => Some(Unit::Meters),
|
||||
"ft" | "'" => Some(Unit::Feet),
|
||||
"yd" => Some(Unit::Yards),
|
||||
"p" | "pc" => Some(Unit::Picas),
|
||||
_ => return None,
|
||||
};
|
||||
Some(unit.to_pt(v))
|
||||
Some((v, unit))
|
||||
}
|
||||
|
||||
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
|
||||
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
|
||||
pub fn parse_number(s: &str) -> Option<f64> {
|
||||
if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) {
|
||||
return None;
|
||||
}
|
||||
// A leading `+` is a sign (`+5`), as a plain number read it.
|
||||
let s = s.trim();
|
||||
Unit::Points.parse(s.strip_prefix('+').unwrap_or(s))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -335,6 +335,11 @@ pub struct Node {
|
||||
/// these options.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slice: Option<Box<crate::SliceOptions>>,
|
||||
/// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its
|
||||
/// bounding box and handles stand at this angle. Transforms turn it with the object (see
|
||||
/// [`crate::orient`]); Reset Bounding Box sets it back to 0.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub bbox_angle: f64,
|
||||
}
|
||||
|
||||
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
|
||||
@@ -383,6 +388,7 @@ impl Node {
|
||||
graphic_style: None,
|
||||
attrs: None,
|
||||
slice: None,
|
||||
bbox_angle: 0.0,
|
||||
}
|
||||
}
|
||||
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
||||
@@ -572,6 +578,9 @@ impl Node {
|
||||
self.transform_scaled(a, &scaling.into());
|
||||
}
|
||||
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
|
||||
if !self.is_layer() {
|
||||
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
|
||||
}
|
||||
// Refitting an unplaced gradient only reproduces moves and uniform scales.
|
||||
if !keeps_gradient_fit(a) {
|
||||
self.pin_gradients();
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Rotated bounding boxes. Every object keeps the angle of its own axes ([`Node::bbox_angle`])
|
||||
//! through the transforms applied to it, so after a rotation its bounding box and handles stay
|
||||
//! square to it: scaling by a side handle stretches it along its own width or height. Path
|
||||
//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0.
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
/// Angles closer than this (degrees) are the same.
|
||||
const EPS_DEG: f64 = 1e-6;
|
||||
|
||||
/// A bounding box square to a frame turned by `angle`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OrientedBox {
|
||||
/// Counter-clockwise degrees in (-180, 180]; 0: square to the page.
|
||||
pub angle: f64,
|
||||
/// The box in the turned frame: [`OrientedBox::to_doc`] places it on the page.
|
||||
pub rect: Rect,
|
||||
}
|
||||
|
||||
impl OrientedBox {
|
||||
/// A box square to the page.
|
||||
pub fn aligned(rect: Rect) -> Self {
|
||||
Self { angle: 0.0, rect }
|
||||
}
|
||||
/// The turned frame → the page (a rotation about the page origin).
|
||||
pub fn to_doc(&self) -> Affine {
|
||||
if self.angle == 0.0 { Affine::IDENTITY } else { Affine::rotate(-self.angle.to_radians()) }
|
||||
}
|
||||
/// A page point in the turned frame.
|
||||
pub fn to_local(&self, p: Point) -> Point {
|
||||
self.to_doc().inverse() * p
|
||||
}
|
||||
/// `a`, a transform of the turned frame, as a page transform.
|
||||
pub fn conjugate(&self, a: Affine) -> Affine {
|
||||
if self.angle == 0.0 { a } else { self.to_doc() * a * self.to_doc().inverse() }
|
||||
}
|
||||
pub fn center(&self) -> Point {
|
||||
self.to_doc() * self.rect.center()
|
||||
}
|
||||
/// One of the 9 reference points (row-major from the box's own top-left), on the page.
|
||||
pub fn reference_point(&self, index: usize) -> Point {
|
||||
self.to_doc() * vectorcraft_geom::reference_point(self.rect, index)
|
||||
}
|
||||
/// The corners on the page: top-left, top-right, bottom-right, bottom-left of the box itself.
|
||||
pub fn corners(&self) -> [Point; 4] {
|
||||
let r = self.rect;
|
||||
[(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.to_doc() * Point::new(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle (counter-clockwise degrees) of an object's axes after `a`: where `a` takes its x axis.
|
||||
/// A mirroring `a` allows two boxes 180° apart: the one nearer the old angle (then the smaller
|
||||
/// angle) is kept, so flipping an upright object keeps it upright.
|
||||
pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
if (m0, m1, m2, m3) == (1.0, 0.0, 0.0, 1.0) {
|
||||
return angle;
|
||||
}
|
||||
let (s, c) = (-angle.to_radians()).sin_cos();
|
||||
let v = Vec2::new(m0 * c + m2 * s, m1 * c + m3 * s);
|
||||
// Degenerate (or non-finite) transforms leave the angle alone.
|
||||
let len = v.hypot();
|
||||
if !len.is_finite() || len <= 1e-12 {
|
||||
return angle;
|
||||
}
|
||||
let mut out = normalize_deg(-v.y.atan2(v.x).to_degrees());
|
||||
let dist = |x: f64| normalize_deg(x - angle).abs();
|
||||
if a.determinant() < 0.0 {
|
||||
let alt = normalize_deg(out + 180.0);
|
||||
let (d0, d1) = (dist(out), dist(alt));
|
||||
if d1 < d0 - EPS_DEG || ((d1 - d0).abs() <= EPS_DEG && alt.abs() < out.abs()) {
|
||||
out = alt;
|
||||
}
|
||||
}
|
||||
// Moves and scales along the object's own axes keep its angle exactly.
|
||||
if dist(out) <= EPS_DEG {
|
||||
angle
|
||||
} else if out.abs() <= EPS_DEG {
|
||||
0.0
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
|
||||
/// without changing it.
|
||||
pub fn bounds_in(&self, a: Affine, visual: bool) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Group { children, clip: false } => {
|
||||
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual)));
|
||||
// The group's own strokes paint around its members (as in `visual_bounds`).
|
||||
let o = if visual { self.appearance.outset() } else { 0.0 };
|
||||
b.map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
let clip = children.first()?;
|
||||
let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_));
|
||||
clip.bounds_in(a, visual && stroked)
|
||||
}
|
||||
_ => {
|
||||
let mut n = self.clone();
|
||||
n.transform(a, false);
|
||||
if visual { n.visual_bounds() } else { n.geometric_bounds() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The angle the bounding box of `ids` stands at: the `bbox_angle` they share, else 0.
|
||||
pub fn bbox_angle(&self, ids: &[NodeId]) -> f64 {
|
||||
let mut angles = ids.iter().filter_map(|id| self.node(*id)).map(|n| n.bbox_angle);
|
||||
let Some(first) = angles.next() else { return 0.0 };
|
||||
if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 }
|
||||
}
|
||||
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]):
|
||||
/// geometric bounds, or `visual` with their strokes.
|
||||
pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option<OrientedBox> {
|
||||
let angle = self.bbox_angle(ids);
|
||||
if angle == 0.0 {
|
||||
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
|
||||
}
|
||||
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
|
||||
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?;
|
||||
Some(OrientedBox { angle, rect })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Appearance;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn rot(deg: f64) -> Affine {
|
||||
Affine::rotate(-deg.to_radians())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotations_add_and_moves_keep_the_angle() {
|
||||
assert!(close(transformed_angle(0.0, rot(45.0)), 45.0));
|
||||
assert!(close(transformed_angle(170.0, rot(20.0)), -170.0));
|
||||
assert_eq!(transformed_angle(30.0, Affine::translate((5.0, 7.0))), 30.0);
|
||||
assert_eq!(transformed_angle(45.0, rot(-45.0)), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_scales_keep_the_angle_exactly() {
|
||||
let b = OrientedBox { angle: 45.0, rect: Rect::ZERO };
|
||||
assert_eq!(transformed_angle(45.0, b.conjugate(Affine::scale_non_uniform(3.0, 0.5))), 45.0);
|
||||
// A flip along the object's own axis keeps the box too.
|
||||
let b = OrientedBox { angle: 135.0, rect: Rect::ZERO };
|
||||
assert_eq!(transformed_angle(135.0, b.conjugate(Affine::scale_non_uniform(-1.0, 1.0))), 135.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_flips_mirror_the_angle() {
|
||||
let flip_h = Affine::scale_non_uniform(-1.0, 1.0);
|
||||
let flip_v = Affine::scale_non_uniform(1.0, -1.0);
|
||||
assert_eq!(transformed_angle(0.0, flip_h), 0.0);
|
||||
assert_eq!(transformed_angle(0.0, flip_v), 0.0);
|
||||
assert!(close(transformed_angle(30.0, flip_h), -30.0));
|
||||
assert!(close(transformed_angle(30.0, flip_v), -30.0));
|
||||
// Degenerate transforms leave it alone.
|
||||
assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oriented_bounds_hug_a_rotated_rectangle() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
let c = Point::new(50.0, 25.0);
|
||||
d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false);
|
||||
let b = d.oriented_bounds(&[id], false).unwrap();
|
||||
assert!(close(b.angle, 30.0));
|
||||
assert!(close(b.rect.width(), 100.0) && close(b.rect.height(), 50.0));
|
||||
assert!(b.center().distance(c) < 1e-9);
|
||||
// The page box is bigger.
|
||||
assert!(d.bounds_of(&[id], false).unwrap().width() > 100.0);
|
||||
// Grouped: the group has no angle of its own, the bounds are square to the page.
|
||||
let g = d.alloc_id();
|
||||
d.insert(Some(l), 1, Node::group(g, vec![])).unwrap();
|
||||
d.move_node(id, Some(g), 0).unwrap();
|
||||
assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
|
||||
}
|
||||
|
||||
/// A solid paint of paper white (no ink in any colour model).
|
||||
fn is_white(p: &Paint) -> bool {
|
||||
pub fn is_white(p: &Paint) -> bool {
|
||||
let Paint::Solid { color, .. } = p else { return false };
|
||||
match *color {
|
||||
Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0,
|
||||
@@ -149,6 +149,33 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any fill or stroke of `n`'s subtree overprints, characters' too.
|
||||
fn overprints_anywhere(n: &Node) -> bool {
|
||||
n.appearance.items.iter().any(|i| i.overprint())
|
||||
|| matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| overprints_anywhere(c)))
|
||||
}
|
||||
|
||||
/// Stop every fill and stroke of `a`'s subtree overprinting (characters' too), copying only the
|
||||
/// nodes on the way to them.
|
||||
pub fn clear_overprints(a: &mut Arc<Node>) {
|
||||
if !overprints_anywhere(a) {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(a);
|
||||
for it in &mut n.appearance.items {
|
||||
*it.overprint_mut() = false;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
(r.style.overprint_fill, r.style.overprint_stroke) = (false, false);
|
||||
}
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
clear_overprints(c);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The colour visitor that recolouring shares: Edit Colors, Recolor Artwork and the live colour
|
||||
//! adjustment effects walk an object's colours through [`recolor_node`] and decide what each
|
||||
//! colour, mesh point and embedded image becomes through a [`ColorVisitor`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
|
||||
use crate::{AppearanceItem, Node, NodeKind};
|
||||
|
||||
/// What a recolouring pass does to the colours it meets.
|
||||
pub trait ColorVisitor {
|
||||
/// A fill's or stroke's paint (an object's, or a text run's): true when it changed.
|
||||
fn paint(&mut self, p: &mut Paint) -> bool;
|
||||
/// One gradient-mesh point's colour.
|
||||
fn color(&mut self, c: Color) -> Color;
|
||||
/// The key of embedded image `key` recoloured, `None` when it stays as it is.
|
||||
fn image(&mut self, key: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of
|
||||
/// embedded images.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Reach {
|
||||
pub fill: bool,
|
||||
pub stroke: bool,
|
||||
pub images: bool,
|
||||
}
|
||||
|
||||
impl Reach {
|
||||
/// Every colour.
|
||||
pub const ALL: Reach = Reach { fill: true, stroke: true, images: true };
|
||||
}
|
||||
|
||||
/// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of
|
||||
/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay
|
||||
/// as they are). Members that change are replaced by copies; the others stay shared. Returns the
|
||||
/// number of paints, meshes and images that changed.
|
||||
pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize {
|
||||
let mut changed = 0;
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize,
|
||||
AppearanceItem::Stroke(l) if reach.stroke => changed += v.paint(&mut l.paint) as usize,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
if reach.fill {
|
||||
changed += v.paint(&mut r.style.fill) as usize;
|
||||
}
|
||||
if reach.stroke {
|
||||
changed += v.paint(&mut r.style.stroke) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) if reach.fill => {
|
||||
let mut ch = false;
|
||||
for p in &mut m.points {
|
||||
let c = v.color(p.color);
|
||||
ch |= c != p.color;
|
||||
p.color = c;
|
||||
}
|
||||
changed += ch as usize;
|
||||
}
|
||||
NodeKind::Image(im) if reach.images && im.link.is_none() => {
|
||||
if let Some(k) = v.image(&im.key) {
|
||||
im.key = k;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
let mut m = (**c).clone();
|
||||
let k = recolor_node(&mut m, reach, v);
|
||||
if k > 0 {
|
||||
*c = Arc::new(m);
|
||||
changed += k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Apply `f` to the solid colour or gradient stops of a paint, with their swatch link and tint
|
||||
/// ([`Paint::map_links`]); a gradient that changes is no longer its gradient swatch's. Returns
|
||||
/// whether anything changed.
|
||||
pub fn map_links(p: &mut Paint, f: &dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
|
||||
let changed = p.map_links(&mut |c, l, t| f(c, l, t));
|
||||
if changed && let Paint::Gradient(g) = p {
|
||||
g.swatch = None;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether
|
||||
/// anything changed.
|
||||
pub fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
|
||||
map_links(p, &|c, link, _| {
|
||||
let n = f(*c);
|
||||
if n == *c {
|
||||
return false;
|
||||
}
|
||||
*c = n;
|
||||
*link = None;
|
||||
true
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
//! Colour adjustment effects (Effect → Color Adjustments): Brightness/Contrast, Curves, Levels,
|
||||
//! Hue/Saturation, Shift to Color and Temperature/Tint.
|
||||
//!
|
||||
//! They recolour what an object paints with, non-destructively: its fills and strokes (solid
|
||||
//! colours and gradient stops), type, gradient meshes, embedded images and, on a group or layer,
|
||||
//! every member — through the colour visitor the Edit Colors commands use
|
||||
//! ([`vectorcraft_doc::recolor`]). Each colour keeps its colour model. An effect on one fill or
|
||||
//! stroke recolours that paint only. [`adjust`] evaluates them, innermost first and each list in
|
||||
//! stack order; the renderer draws the result (caching recoloured copies of embedded images) and
|
||||
//! [`crate::bake_document`] writes it for export, recoloured images as new embedded images.
|
||||
//! Pattern tiles, and the shadows and glows of raster effects, keep their colours.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint, keep_model};
|
||||
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_paint, recolor_node};
|
||||
use vectorcraft_doc::{Document, Effect, Node, NodeKind};
|
||||
|
||||
use crate::merged_params;
|
||||
use crate::raster::color_param;
|
||||
use crate::util::{flag, num, text};
|
||||
|
||||
/// The colour adjustment effects, in menu order.
|
||||
pub const ADJUSTMENTS: [&str; 6] =
|
||||
["adjust.brightnessContrast", "adjust.curves", "adjust.hueSaturation", "adjust.levels", "adjust.shiftToColor", "adjust.temperatureTint"];
|
||||
|
||||
/// Is `id` a colour adjustment effect?
|
||||
pub fn is_adjustment(id: &str) -> bool {
|
||||
ADJUSTMENTS.contains(&id)
|
||||
}
|
||||
|
||||
fn visible_adjustment(e: &Effect) -> bool {
|
||||
e.visible && is_adjustment(&e.id)
|
||||
}
|
||||
|
||||
/// Does `n` carry a visible colour adjustment, on the object or on one of its fills or strokes?
|
||||
pub fn has_adjustment(n: &Node) -> bool {
|
||||
n.appearance.effects.iter().any(visible_adjustment) || n.appearance.items.iter().any(|i| i.effects().iter().any(visible_adjustment))
|
||||
}
|
||||
|
||||
/// The channels a tone curve (Curves, Levels) applies to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Channel {
|
||||
All,
|
||||
One(usize),
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
fn of(p: &Value) -> Self {
|
||||
match text(p, "channel", "rgb").to_ascii_lowercase().as_str() {
|
||||
"red" | "r" => Channel::One(0),
|
||||
"green" | "g" => Channel::One(1),
|
||||
"blue" | "b" => Channel::One(2),
|
||||
_ => Channel::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries of a sampled tone curve.
|
||||
const LUT: usize = 256;
|
||||
|
||||
/// One adjustment, its parameters normalised.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Adjust {
|
||||
/// Brightness and contrast, -1..1 each.
|
||||
BrightnessContrast { brightness: f32, contrast: f32 },
|
||||
/// A tone curve (Curves, Levels) sampled at [`LUT`] inputs, on `channel`.
|
||||
Tone { channel: Channel, lut: Arc<[f32]> },
|
||||
/// Hue shift in degrees, saturation and lightness -1..1; `colorize` sets the hue instead.
|
||||
HueSaturation { hue: f32, saturation: f32, lightness: f32, colorize: bool },
|
||||
/// Move colours `amount` (0..1) of the way to `target` (sRGB), keeping their lightness when
|
||||
/// `preserve`.
|
||||
ShiftToColor { target: [f32; 3], amount: f32, preserve: bool },
|
||||
/// Warm/cool and magenta/green shifts, -1..1.
|
||||
TemperatureTint { temperature: f32, tint: f32 },
|
||||
}
|
||||
|
||||
/// The colour adjustments of an effect list, composed in order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ColorMap(Vec<Adjust>);
|
||||
|
||||
impl ColorMap {
|
||||
/// `self`, then `next`.
|
||||
pub fn then(&self, next: &ColorMap) -> ColorMap {
|
||||
ColorMap(self.0.iter().chain(&next.0).cloned().collect())
|
||||
}
|
||||
|
||||
/// Adjust display (sRGB) components 0..1.
|
||||
pub fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||||
self.0.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }))
|
||||
}
|
||||
|
||||
/// Adjust a colour, keeping its colour model (unchanged when the adjustments leave it as is).
|
||||
pub fn apply(&self, c: Color) -> Color {
|
||||
let rgb = c.to_rgb();
|
||||
let out = self.apply_rgb(rgb);
|
||||
if out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) {
|
||||
return c;
|
||||
}
|
||||
keep_model(c, Color::rgb(out[0], out[1], out[2]))
|
||||
}
|
||||
|
||||
/// Adjust an 8-bit sRGB pixel.
|
||||
pub fn apply_rgb8(&self, [r, g, b]: [u8; 3]) -> [u8; 3] {
|
||||
let out = self.apply_rgb([r, g, b].map(|v| v as f32 / 255.0));
|
||||
out.map(|v| (v * 255.0).round() as u8)
|
||||
}
|
||||
}
|
||||
|
||||
/// The visible colour adjustments of `effects` as one map, `None` when there are none.
|
||||
pub fn color_map(effects: &[Effect]) -> Option<ColorMap> {
|
||||
let v: Vec<Adjust> =
|
||||
effects.iter().filter(|e| visible_adjustment(e)).filter_map(|e| Adjust::parse(&e.id, &merged_params(&e.id, &e.params))).collect();
|
||||
(!v.is_empty()).then_some(ColorMap(v))
|
||||
}
|
||||
|
||||
/// A parameter in -100..100 as -1..1.
|
||||
fn amount(p: &Value, key: &str) -> f32 {
|
||||
(num(p, key, 0.0).clamp(-100.0, 100.0) / 100.0) as f32
|
||||
}
|
||||
|
||||
/// A level 0..255 as 0..1.
|
||||
fn level(p: &Value, key: &str, default: f64) -> f32 {
|
||||
(num(p, key, default).clamp(0.0, 255.0) / 255.0) as f32
|
||||
}
|
||||
|
||||
impl Adjust {
|
||||
fn parse(id: &str, p: &Value) -> Option<Self> {
|
||||
Some(match id {
|
||||
"adjust.brightnessContrast" => Adjust::BrightnessContrast { brightness: amount(p, "brightness"), contrast: amount(p, "contrast") },
|
||||
"adjust.levels" => {
|
||||
let (ib, iw) = (level(p, "inputBlack", 0.0), level(p, "inputWhite", 255.0));
|
||||
let (ob, ow) = (level(p, "outputBlack", 0.0), level(p, "outputWhite", 255.0));
|
||||
let gamma = num(p, "gamma", 1.0).clamp(0.1, 10.0) as f32;
|
||||
let f = |x: f32| {
|
||||
let t = if iw > ib {
|
||||
((x - ib) / (iw - ib)).clamp(0.0, 1.0)
|
||||
} else if x >= ib {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
ob + t.powf(1.0 / gamma) * (ow - ob)
|
||||
};
|
||||
Adjust::Tone { channel: Channel::of(p), lut: sample(f) }
|
||||
}
|
||||
"adjust.curves" => {
|
||||
let pts = curve_points(p.get("points"));
|
||||
Adjust::Tone { channel: Channel::of(p), lut: sample(|x| curve_at(&pts, x)) }
|
||||
}
|
||||
"adjust.hueSaturation" => Adjust::HueSaturation {
|
||||
hue: num(p, "hue", 0.0).clamp(-360.0, 360.0) as f32,
|
||||
saturation: amount(p, "saturation"),
|
||||
lightness: amount(p, "lightness"),
|
||||
colorize: flag(p, "colorize", false),
|
||||
},
|
||||
"adjust.shiftToColor" => Adjust::ShiftToColor {
|
||||
target: color_param(p, Color::rgb(1.0, 0.5, 0.0)).to_rgb(),
|
||||
amount: (num(p, "amount", 50.0).clamp(0.0, 100.0) / 100.0) as f32,
|
||||
preserve: flag(p, "preserveLightness", true),
|
||||
},
|
||||
"adjust.temperatureTint" => Adjust::TemperatureTint { temperature: amount(p, "temperature"), tint: amount(p, "tint") },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply(&self, [r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
match self {
|
||||
Adjust::BrightnessContrast { brightness, contrast } => {
|
||||
// Brightness bends the tones (black and white stay), contrast spreads them from the middle.
|
||||
let gamma = 2f32.powf(-brightness);
|
||||
let k = if *contrast >= 0.0 { 1.0 / (1.0 - contrast * 0.99) } else { 1.0 + contrast };
|
||||
[r, g, b].map(|v| (v.clamp(0.0, 1.0).powf(gamma) - 0.5) * k + 0.5)
|
||||
}
|
||||
Adjust::Tone { channel, lut } => {
|
||||
let f = |v: f32| lookup(lut, v);
|
||||
match channel {
|
||||
Channel::All => [f(r), f(g), f(b)],
|
||||
Channel::One(i) => {
|
||||
let mut c = [r, g, b];
|
||||
if let Some(v) = c.get_mut(*i) {
|
||||
*v = f(*v);
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
}
|
||||
Adjust::HueSaturation { hue, saturation, lightness, colorize } => {
|
||||
let [h, s, l] = to_hsl([r, g, b]);
|
||||
let (h, s) = if *colorize {
|
||||
(hue.rem_euclid(360.0), (saturation + 1.0) / 2.0)
|
||||
} else {
|
||||
let s = if *saturation >= 0.0 { s / (1.0 - saturation * 0.99) } else { s * (1.0 + saturation) };
|
||||
((h + hue).rem_euclid(360.0), s)
|
||||
};
|
||||
let l = if *lightness >= 0.0 { l + (1.0 - l) * lightness } else { l * (1.0 + lightness) };
|
||||
from_hsl([h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)])
|
||||
}
|
||||
Adjust::ShiftToColor { target, amount, preserve } => {
|
||||
let to = if *preserve {
|
||||
let [th, ts, _] = to_hsl(*target);
|
||||
from_hsl([th, ts, to_hsl([r, g, b])[2]])
|
||||
} else {
|
||||
*target
|
||||
};
|
||||
let mix = |a: f32, b: f32| a + (b - a) * amount;
|
||||
[mix(r, to[0]), mix(g, to[1]), mix(b, to[2])]
|
||||
}
|
||||
Adjust::TemperatureTint { temperature, tint } => {
|
||||
let (t, m) = (temperature * 0.3, tint * 0.3);
|
||||
[r * (1.0 + t), g * (1.0 - m), b * (1.0 - t)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `f` sampled at [`LUT`] inputs 0..1.
|
||||
fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> {
|
||||
(0..LUT).map(|i| f(i as f32 / (LUT - 1) as f32).clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
/// The tone curve `lut` at `v` (linear between samples).
|
||||
fn lookup(lut: &[f32], v: f32) -> f32 {
|
||||
let x = v.clamp(0.0, 1.0) * (LUT - 1) as f32;
|
||||
let i = (x.floor() as usize).min(LUT - 2);
|
||||
let (a, b) = (lut.get(i).copied().unwrap_or(v), lut.get(i + 1).copied().unwrap_or(v));
|
||||
a + (b - a) * (x - i as f32)
|
||||
}
|
||||
|
||||
/// Curves' points (0..1 each, sorted by input, one per input) from its `points` parameter:
|
||||
/// `"x,y x,y …"` or `[[x, y], …]` in 0..255. Missing or unreadable points give the identity line.
|
||||
pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> {
|
||||
let mut pts: Vec<(f64, f64)> = match v {
|
||||
Some(Value::String(s)) => s
|
||||
.split(|c: char| c.is_whitespace() || c == ';')
|
||||
.filter_map(|pair| {
|
||||
let (x, y) = pair.split_once(',')?;
|
||||
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
|
||||
})
|
||||
.collect(),
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|xy| Some((xy.get(0)?.as_f64()?, xy.get(1)?.as_f64()?))).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
pts.retain(|(x, y)| x.is_finite() && y.is_finite());
|
||||
// At most a point per level is meaningful.
|
||||
pts.truncate(256);
|
||||
let mut pts: Vec<(f32, f32)> =
|
||||
pts.into_iter().map(|(x, y)| ((x.clamp(0.0, 255.0) / 255.0) as f32, (y.clamp(0.0, 255.0) / 255.0) as f32)).collect();
|
||||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
|
||||
if pts.len() < 2 {
|
||||
return vec![(0.0, 0.0), (1.0, 1.0)];
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// Curves' curve through `pts` ([`curve_points`]) at `x` (0..1): a monotone cubic, flat beyond
|
||||
/// the end points.
|
||||
pub fn curve_at(pts: &[(f32, f32)], x: f32) -> f32 {
|
||||
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return x };
|
||||
if x <= first.0 {
|
||||
return first.1;
|
||||
}
|
||||
if x >= last.0 {
|
||||
return last.1;
|
||||
}
|
||||
let n = pts.len();
|
||||
let slope = |i: usize| -> f32 {
|
||||
match (pts.get(i), pts.get(i + 1)) {
|
||||
(Some(a), Some(b)) if b.0 > a.0 => (b.1 - a.1) / (b.0 - a.0),
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
// Tangents: the harmonic mean of the neighbouring slopes, zero at extrema, so the curve never
|
||||
// overshoots its points.
|
||||
let tangent = |i: usize| -> f32 {
|
||||
if i == 0 {
|
||||
return slope(0);
|
||||
}
|
||||
if i + 1 >= n {
|
||||
return slope(n - 2);
|
||||
}
|
||||
let (a, b) = (slope(i - 1), slope(i));
|
||||
if a * b <= 0.0 { 0.0 } else { 2.0 * a * b / (a + b) }
|
||||
};
|
||||
let k = pts.windows(2).position(|w| x >= w[0].0 && x <= w[1].0).unwrap_or(0);
|
||||
let (Some(&(x0, y0)), Some(&(x1, y1))) = (pts.get(k), pts.get(k + 1)) else { return x };
|
||||
let h = x1 - x0;
|
||||
if h <= 0.0 {
|
||||
return y0;
|
||||
}
|
||||
let t = (x - x0) / h;
|
||||
let (m0, m1) = (tangent(k) * h, tangent(k + 1) * h);
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
(2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * m0 + (-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * m1
|
||||
}
|
||||
|
||||
/// HSL with hue in degrees, saturation and lightness 0..1.
|
||||
fn to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||||
let l = (max + min) / 2.0;
|
||||
let d = max - min;
|
||||
if d <= 1e-6 {
|
||||
return [0.0, 0.0, l];
|
||||
}
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
(g - b) / d + if g < b { 6.0 } else { 0.0 }
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
[h * 60.0, s, l]
|
||||
}
|
||||
|
||||
fn from_hsl([h, s, l]: [f32; 3]) -> [f32; 3] {
|
||||
if s <= 0.0 {
|
||||
return [l; 3];
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
let hue = |t: f32| {
|
||||
let t = t.rem_euclid(1.0);
|
||||
if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
}
|
||||
};
|
||||
let h = h / 360.0;
|
||||
[hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
|
||||
}
|
||||
|
||||
/// Gives the key of an embedded image recoloured by a map (`None`: leave it as it is).
|
||||
pub type ImageHook<'a> = dyn FnMut(&str, &ColorMap) -> Option<String> + 'a;
|
||||
|
||||
/// The live visitor: every colour through `map`, embedded images through the hook.
|
||||
struct Live<'a, 'b> {
|
||||
map: &'a ColorMap,
|
||||
images: &'a mut ImageHook<'b>,
|
||||
}
|
||||
|
||||
impl ColorVisitor for Live<'_, '_> {
|
||||
fn paint(&mut self, p: &mut Paint) -> bool {
|
||||
map_paint(p, &|c| self.map.apply(c))
|
||||
}
|
||||
fn color(&mut self, c: Color) -> Color {
|
||||
self.map.apply(c)
|
||||
}
|
||||
fn image(&mut self, key: &str) -> Option<String> {
|
||||
(self.images)(key, self.map)
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with the colour adjustments in it applied, `None` when it has none: its members' first,
|
||||
/// then those of its fills and strokes on their paints, then its own followed by `outer` (the
|
||||
/// adjustments of an object `n` is the art of) on everything it paints. The adjustment effects
|
||||
/// are gone from the result; embedded images take the keys `images` gives.
|
||||
pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> Option<Node> {
|
||||
let mut m: Option<Node> = None;
|
||||
if let Some(ch) = n.children() {
|
||||
for (i, c) in ch.iter().enumerate() {
|
||||
if let Some(new) = adjust(c, None, images)
|
||||
&& let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
|
||||
{
|
||||
*slot = Arc::new(new);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, item) in n.appearance.items.iter().enumerate() {
|
||||
if let Some(map) = color_map(item.effects())
|
||||
&& let Some(it) = m.get_or_insert_with(|| n.clone()).appearance.items.get_mut(i)
|
||||
{
|
||||
map_paint(it.paint_mut(), &|c| map.apply(c));
|
||||
it.effects_mut().retain(|e| !is_adjustment(&e.id));
|
||||
}
|
||||
}
|
||||
let map = match (color_map(&n.appearance.effects), outer) {
|
||||
(Some(own), Some(outer)) => Some(own.then(outer)),
|
||||
(own, None) => own,
|
||||
(None, Some(outer)) => Some(outer.clone()),
|
||||
};
|
||||
if let Some(map) = map {
|
||||
let mm = m.get_or_insert_with(|| n.clone());
|
||||
mm.appearance.effects.retain(|e| !is_adjustment(&e.id));
|
||||
recolor_node(mm, Reach::ALL, &mut Live { map: &map, images });
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
|
||||
/// `d` (named by their content), and a symbol instance stands for its symbol's art.
|
||||
pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let art;
|
||||
let src = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
|
||||
let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone());
|
||||
art = crate::reshape::as_art(n, symbol.as_deref())?;
|
||||
&art
|
||||
}
|
||||
_ => n,
|
||||
};
|
||||
let mut hook = |key: &str, map: &ColorMap| -> Option<String> {
|
||||
let blob = d.images.get(key)?.map_rgb(|rgb| map.apply_rgb8(rgb))?;
|
||||
let k = blob.content_key();
|
||||
d.images.entry(k.clone()).or_insert(blob);
|
||||
Some(k)
|
||||
};
|
||||
adjust(src, None, &mut hook)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Appearance, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
fn map(id: &str, params: Value) -> ColorMap {
|
||||
color_map(&[Effect { id: id.into(), params, visible: true }]).unwrap()
|
||||
}
|
||||
|
||||
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_settings_change_nothing() {
|
||||
for id in ADJUSTMENTS {
|
||||
let m = color_map(&[Effect { id: id.into(), params: json!({"amount": 0}), visible: true }]).unwrap();
|
||||
for c in [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] {
|
||||
assert!(close(m.apply_rgb(c), c), "{id} {c:?} → {:?}", m.apply_rgb(c));
|
||||
}
|
||||
}
|
||||
let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4);
|
||||
assert_eq!(map("adjust.levels", json!({})).apply(cmyk), cmyk, "an untouched colour keeps its exact values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjustments_move_colours_the_expected_way() {
|
||||
let grey = [0.5, 0.5, 0.5];
|
||||
assert!(map("adjust.brightnessContrast", json!({"brightness": 50})).apply_rgb(grey)[0] > 0.6);
|
||||
assert!(map("adjust.brightnessContrast", json!({"contrast": 50})).apply_rgb([0.7; 3])[0] > 0.8);
|
||||
assert!(close(map("adjust.levels", json!({"inputBlack": 64, "inputWhite": 192})).apply_rgb([0.25, 0.5, 0.75]), [0.0, 0.5, 1.0]));
|
||||
assert!(close(map("adjust.levels", json!({"outputWhite": 128, "channel": "red"})).apply_rgb([1.0; 3]), [0.502, 1.0, 1.0]));
|
||||
// An S curve darkens the shadows and lightens the highlights, through its points exactly.
|
||||
let s = map("adjust.curves", json!({"points": "0,0 64,32 192,224 255,255"}));
|
||||
assert!(close(s.apply_rgb([64.0 / 255.0; 3]), [32.0 / 255.0; 3]));
|
||||
assert!(s.apply_rgb([0.85; 3])[0] > 0.9);
|
||||
let arr = map("adjust.curves", json!({"points": [[0, 255], [255, 0]]}));
|
||||
assert!(close(arr.apply_rgb([0.0, 1.0, 0.25]), [1.0, 0.0, 0.75]), "an inverting curve");
|
||||
assert!(close(map("adjust.hueSaturation", json!({"hue": 120})).apply_rgb([1.0, 0.0, 0.0]), [0.0, 1.0, 0.0]));
|
||||
assert!(close(map("adjust.hueSaturation", json!({"saturation": -100})).apply_rgb([1.0, 0.0, 0.0]), [0.5; 3]));
|
||||
assert!(close(map("adjust.hueSaturation", json!({"saturation": 100})).apply_rgb(grey), grey), "greys stay grey");
|
||||
assert!(close(map("adjust.hueSaturation", json!({"colorize": true, "hue": 240, "saturation": 100})).apply_rgb(grey), [0.0, 0.0, 1.0]));
|
||||
let shift = map("adjust.shiftToColor", json!({"color": "#0000ff", "amount": 100, "preserveLightness": false}));
|
||||
assert!(close(shift.apply_rgb([1.0, 1.0, 0.0]), [0.0, 0.0, 1.0]));
|
||||
let warm = map("adjust.temperatureTint", json!({"temperature": 100})).apply_rgb(grey);
|
||||
assert!(warm[0] > grey[0] && warm[2] < grey[2]);
|
||||
// Effects compose in stack order.
|
||||
let both = map("adjust.levels", json!({"outputWhite": 128})).then(&map("adjust.brightnessContrast", json!({"brightness": 100})));
|
||||
assert!(close(both.apply_rgb([1.0; 3]), [(128.0f32 / 255.0).sqrt(); 3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_recolours_paints_members_and_strips_the_effects() {
|
||||
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let leaf = |id| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), red.clone()));
|
||||
let mut g = Node::group(NodeId(1), vec![leaf(2), leaf(3)]);
|
||||
g.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
|
||||
assert!(has_adjustment(&g));
|
||||
let out = adjust(&g, None, &mut |_, _| None).unwrap();
|
||||
assert!(out.appearance.effects.is_empty());
|
||||
for c in out.children().unwrap() {
|
||||
assert_eq!(c.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
|
||||
}
|
||||
// A hidden effect does nothing; a stroke's own effect recolours just that stroke.
|
||||
g.appearance.effects[0].visible = false;
|
||||
assert!(!has_adjustment(&g) && adjust(&g, None, &mut |_, _| None).is_none());
|
||||
let mut p = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
p.appearance.items[1].effects_mut().push(Effect { id: "adjust.levels".into(), params: json!({"outputBlack": 255}), visible: true });
|
||||
let out = adjust(&p, None, &mut |_, _| None).unwrap();
|
||||
assert_eq!(out.appearance.items[1].paint().color().unwrap().to_hex(), "#ffffff");
|
||||
assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint());
|
||||
assert!(out.appearance.items[1].effects().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills,
|
||||
//! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects
|
||||
//! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
|
||||
//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images
|
||||
//! become images of their own).
|
||||
//!
|
||||
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
|
||||
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
|
||||
@@ -17,8 +19,8 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::group::{has_own_paint, paints};
|
||||
use crate::{
|
||||
GeomContext, apply_geometry_with, crop_marks_art, evaluate_container, has_crop_marks, has_geometry, has_pathfinder, is_geometry, needs_outline,
|
||||
reshape,
|
||||
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
has_pathfinder, is_geometry, needs_outline, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
@@ -37,7 +39,8 @@ fn clear_item_effects(item: &mut AppearanceItem) {
|
||||
|
||||
/// Does anything in `n`'s subtree need baking?
|
||||
pub fn needs_bake(n: &Node) -> bool {
|
||||
has_crop_marks(n)
|
||||
has_adjustment(n)
|
||||
|| has_crop_marks(n)
|
||||
|| has_pathfinder(n)
|
||||
|| has_own_paint(n)
|
||||
|| has_geometry(&n.appearance.effects)
|
||||
@@ -78,6 +81,12 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if !needs_bake(n) {
|
||||
return None;
|
||||
}
|
||||
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
|
||||
if has_adjustment(n)
|
||||
&& let Some(m) = adjust_in_document(d, n)
|
||||
{
|
||||
return Some(bake_node(d, &m).unwrap_or(m));
|
||||
}
|
||||
// Crop marks: the object and its marks.
|
||||
if let Some(m) = crop_marks_art(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
|
||||
@@ -18,11 +18,18 @@
|
||||
//! art painting its members (its raster effects then apply to the composite).
|
||||
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
|
||||
//! PDF writers.
|
||||
//! - **Plug-in effects** (`plugin.<plug-in id>`, Effect › Plug-ins) are geometry effects run by an
|
||||
//! installed WebAssembly plug-in (`vectorcraft-plugins`); [`effect_info`] describes them like
|
||||
//! built-in ones, and an effect whose plug-in isn't installed leaves the geometry as it is.
|
||||
//! - **Colour adjustments** (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color,
|
||||
//! Temperature/Tint) recolour what an object paints with: [`adjust`] evaluates them for the
|
||||
//! renderer and the exporters.
|
||||
//!
|
||||
//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash
|
||||
//! noise (`seed` parameter, default 0).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod adjust;
|
||||
mod bake;
|
||||
mod clip;
|
||||
mod distort;
|
||||
@@ -44,6 +51,7 @@ use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
|
||||
pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use group::{
|
||||
@@ -125,6 +133,8 @@ const STYLIZE: &[&str] = &["Effect", "Stylize"];
|
||||
const WARP: &[&str] = &["Effect", "Warp"];
|
||||
const BLUR: &[&str] = &["Effect", "Blur"];
|
||||
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
|
||||
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
|
||||
const ADJUST: &[&str] = &["Effect", "Color Adjustments"];
|
||||
|
||||
/// The warp styles in Illustrator's Style menu order: (id suffix, label).
|
||||
pub const WARP_STYLES: [(&str, &str); 15] = [
|
||||
@@ -254,6 +264,50 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
];
|
||||
v.extend([
|
||||
g(
|
||||
"adjust.brightnessContrast",
|
||||
"Brightness/Contrast…",
|
||||
ADJUST,
|
||||
"{brightness: -100..100 (0; bends the tones, black and white stay), contrast: -100..100 (0)} recolours the object's fills, strokes, type, meshes and embedded images (each colour keeps its model)",
|
||||
json!({"brightness": 0.0, "contrast": 0.0}),
|
||||
),
|
||||
g(
|
||||
"adjust.curves",
|
||||
"Curves…",
|
||||
ADJUST,
|
||||
"{points: \"x,y x,y …\" or [[x, y], …] (input, output 0..255; a smooth monotone curve through them, flat past the ends; \"0,0 128,128 255,255\"), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does",
|
||||
json!({"points": "0,0 128,128 255,255", "channel": "rgb"}),
|
||||
),
|
||||
g(
|
||||
"adjust.hueSaturation",
|
||||
"Hue/Saturation…",
|
||||
ADJUST,
|
||||
"{hue: deg -180..180 (0), saturation: -100..100 (0), lightness: -100..100 (0), colorize: bool (false; true: every colour takes hue `hue` (0..360) at saturation (100 + saturation) / 2 %)} recolours as Brightness/Contrast does",
|
||||
json!({"hue": 0.0, "saturation": 0.0, "lightness": 0.0, "colorize": false}),
|
||||
),
|
||||
g(
|
||||
"adjust.levels",
|
||||
"Levels…",
|
||||
ADJUST,
|
||||
"{inputBlack: 0..255 (0), inputWhite: 0..255 (255), gamma: 0.1..10 (1; above 1 lightens the midtones), outputBlack: 0..255 (0), outputWhite: 0..255 (255), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does",
|
||||
json!({"inputBlack": 0.0, "inputWhite": 255.0, "gamma": 1.0, "outputBlack": 0.0, "outputWhite": 255.0, "channel": "rgb"}),
|
||||
),
|
||||
g(
|
||||
"adjust.shiftToColor",
|
||||
"Shift to Color…",
|
||||
ADJUST,
|
||||
"{color: \"#rrggbb\" (\"#ff8000\"), amount: 0..100 % (50), preserveLightness: bool (true: colours take the target's hue and saturation and keep their lightness; false: they mix with it)} recolours as Brightness/Contrast does",
|
||||
json!({"color": "#ff8000", "amount": 50.0, "preserveLightness": true}),
|
||||
),
|
||||
g(
|
||||
"adjust.temperatureTint",
|
||||
"Temperature/Tint…",
|
||||
ADJUST,
|
||||
"{temperature: -100 (cooler) .. 100 (warmer) (0), tint: -100 (greener) .. 100 (more magenta) (0)} recolours as Brightness/Contrast does",
|
||||
json!({"temperature": 0.0, "tint": 0.0}),
|
||||
),
|
||||
]);
|
||||
for (suffix, label) in WARP_STYLES {
|
||||
let id: &'static str = match suffix {
|
||||
"arc" => "warp.arc",
|
||||
@@ -293,21 +347,47 @@ fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInf
|
||||
INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect())
|
||||
}
|
||||
|
||||
/// Catalogue entry for `id`.
|
||||
/// Catalogue entry for `id` (built-in, or an installed plug-in effect).
|
||||
pub fn effect_info(id: &str) -> Option<EffectInfo> {
|
||||
catalog_index().get(id).cloned()
|
||||
catalog_index().get(id).cloned().or_else(|| plugin_info(id))
|
||||
}
|
||||
|
||||
/// The catalogue entry of the installed effect plug-in with effect id `id` (`plugin.<id>`).
|
||||
fn plugin_info(id: &str) -> Option<EffectInfo> {
|
||||
use vectorcraft_plugins::registry::intern;
|
||||
let p = vectorcraft_plugins::effect::installed(id)?;
|
||||
let m = p.manifest();
|
||||
let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" };
|
||||
Some(EffectInfo {
|
||||
id: intern(id),
|
||||
label: intern(&format!("{}{dots}", m.name)),
|
||||
menu: PLUGINS,
|
||||
params: intern(&m.params_doc()),
|
||||
defaults: Value::Object(m.defaults()),
|
||||
raster: false,
|
||||
lengths: Lengths::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The installed effect plug-ins as catalogue entries (Effect › Plug-ins), by plug-in id.
|
||||
pub fn plugin_effects() -> Vec<EffectInfo> {
|
||||
vectorcraft_plugins::registry::list().iter().filter_map(|p| plugin_info(&vectorcraft_plugins::effect::effect_id(p.id()))).collect()
|
||||
}
|
||||
|
||||
/// Dialog defaults for `id` (`None` for unknown effects).
|
||||
pub fn default_params(id: &str) -> Option<Value> {
|
||||
catalog_index().get(id).map(|e| e.defaults.clone())
|
||||
match catalog_index().get(id) {
|
||||
Some(e) => Some(e.defaults.clone()),
|
||||
None => plugin_info(id).map(|e| e.defaults),
|
||||
}
|
||||
}
|
||||
|
||||
/// `params` layered over the defaults of `id` (unknown keys are kept).
|
||||
pub fn merged_params(id: &str, params: &Value) -> Value {
|
||||
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
|
||||
Some(Value::Object(m)) => m.clone(),
|
||||
_ => Map::new(),
|
||||
Some(_) => Map::new(),
|
||||
None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(),
|
||||
};
|
||||
if let Value::Object(p) = params {
|
||||
for (k, v) in p {
|
||||
@@ -319,7 +399,9 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
|
||||
|
||||
/// A new effect record with default parameters (overridden by `params`).
|
||||
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
|
||||
catalog_index().get(id)?;
|
||||
if !catalog_index().contains_key(id) {
|
||||
vectorcraft_plugins::effect::installed(id)?;
|
||||
}
|
||||
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
|
||||
}
|
||||
|
||||
@@ -346,9 +428,15 @@ pub fn is_raster(id: &str) -> bool {
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
|
||||
}
|
||||
|
||||
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`].)
|
||||
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour
|
||||
/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in
|
||||
/// leaves the geometry as it is).
|
||||
pub fn is_geometry(id: &str) -> bool {
|
||||
!is_raster(id) && !is_pathfinder(id) && id != CROP_MARKS && catalog_index().contains_key(id)
|
||||
!is_raster(id)
|
||||
&& !is_pathfinder(id)
|
||||
&& !is_adjustment(id)
|
||||
&& id != CROP_MARKS
|
||||
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
|
||||
}
|
||||
|
||||
/// Any visible geometry effect in the list?
|
||||
@@ -420,6 +508,9 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
|
||||
|
||||
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
|
||||
use util::*;
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone());
|
||||
}
|
||||
match id {
|
||||
"distort.freeDistort" => distort::free_distort(path, b, p),
|
||||
"distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)),
|
||||
|
||||
@@ -64,7 +64,8 @@ pub fn blend_mode(s: &str) -> BlendMode {
|
||||
BlendMode::ALL.iter().copied().find(|m| format!("{m:?}").to_ascii_lowercase() == key).unwrap_or(BlendMode::Normal)
|
||||
}
|
||||
|
||||
fn color(p: &Value, default: Color) -> Color {
|
||||
/// An effect's `color` parameter (`"#rrggbb"`, `[r, g, b]` 0..1 or a colour object).
|
||||
pub(crate) fn color_param(p: &Value, default: Color) -> Color {
|
||||
match p.get("color") {
|
||||
Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default),
|
||||
Some(Value::Array(a)) if a.len() >= 3 => {
|
||||
@@ -95,19 +96,19 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
dx: num(&p, "x", 7.0).clamp(-1e4, 1e4),
|
||||
dy: num(&p, "y", 7.0).clamp(-1e4, 1e4),
|
||||
blur,
|
||||
color: color(&p, Color::BLACK),
|
||||
color: color_param(&p, Color::BLACK),
|
||||
},
|
||||
"stylize.outerGlow" => RasterFx::OuterGlow {
|
||||
mode: blend_mode(text(&p, "mode", "screen")),
|
||||
opacity: opacity(&p),
|
||||
blur,
|
||||
color: color(&p, Color::rgb(1.0, 1.0, 0.0)),
|
||||
color: color_param(&p, Color::rgb(1.0, 1.0, 0.0)),
|
||||
},
|
||||
"stylize.innerGlow" => RasterFx::InnerGlow {
|
||||
mode: blend_mode(text(&p, "mode", "screen")),
|
||||
opacity: opacity(&p),
|
||||
blur,
|
||||
color: color(&p, Color::WHITE),
|
||||
color: color_param(&p, Color::WHITE),
|
||||
center: text(&p, "source", "edge").eq_ignore_ascii_case("center"),
|
||||
},
|
||||
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
|
||||
@@ -131,7 +131,7 @@ pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
}
|
||||
|
||||
/// [`reshape`] without its checks (no geometry effects: just the art).
|
||||
fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let fx = geometry_effects(n);
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vectorcraft-pdf = { workspace = true }
|
||||
vectorcraft-cad = { workspace = true }
|
||||
vectorcraft-eps = { workspace = true }
|
||||
vectorcraft-metafile = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
@@ -35,3 +36,4 @@ log = { workspace = true }
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
@@ -13,7 +13,9 @@ use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation};
|
||||
use vectorcraft_color::{Color, Paint, keep_model};
|
||||
use vectorcraft_doc::live::lerp_color;
|
||||
use vectorcraft_doc::pattern::PatternDef;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, ImageBlob, Node, NodeId, NodeKind};
|
||||
pub(crate) use vectorcraft_doc::recolor::map_paint;
|
||||
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node};
|
||||
use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
@@ -144,30 +146,6 @@ fn gray_level(c: Color) -> f32 {
|
||||
/// gradient stop): `(colour, link, tint)`, true when it changed them.
|
||||
pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
|
||||
|
||||
/// Apply `f` to the solid colour or gradient stops of a paint ([`Paint::map_links`]); a gradient
|
||||
/// that changes is no longer its gradient swatch's. Returns whether anything changed.
|
||||
fn map_links(p: &mut Paint, f: &LinkMap) -> bool {
|
||||
let changed = p.map_links(&mut |c, l, t| f(c, l, t));
|
||||
if changed && let Paint::Gradient(g) = p {
|
||||
g.swatch = None;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether
|
||||
/// anything changed.
|
||||
pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
|
||||
map_links(p, &|c, link, _| {
|
||||
let n = f(*c);
|
||||
if n == *c {
|
||||
return false;
|
||||
}
|
||||
*c = n;
|
||||
*link = None;
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// The identity colour map (a pass that only maps linked colours).
|
||||
fn same(c: Color) -> Color {
|
||||
c
|
||||
@@ -258,68 +236,24 @@ impl<'a> Recolor<'a> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `n` recoloured, or `None` when nothing changed.
|
||||
/// `n` recoloured (through the shared colour visitor), or `None` when nothing changed.
|
||||
fn subtree(&mut self, d: &Document, n: &Node) -> Option<Node> {
|
||||
let before = self.changed;
|
||||
let mut m = n.clone();
|
||||
self.node(d, &mut m);
|
||||
(self.changed > before).then_some(m)
|
||||
}
|
||||
|
||||
fn node(&mut self, d: &Document, n: &mut Node) {
|
||||
let Scope { fill, stroke, images, .. } = self.scope;
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(l) if fill => self.paint(d, &mut l.paint),
|
||||
AppearanceItem::Stroke(l) if stroke => self.paint(d, &mut l.paint),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
if fill {
|
||||
self.paint(d, &mut r.style.fill);
|
||||
}
|
||||
if stroke {
|
||||
self.paint(d, &mut r.style.stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) if fill => {
|
||||
let mut ch = false;
|
||||
for p in &mut m.points {
|
||||
let c = (self.f)(p.color);
|
||||
ch |= c != p.color;
|
||||
p.color = c;
|
||||
}
|
||||
self.changed += ch as usize;
|
||||
}
|
||||
NodeKind::Image(im) if images && im.link.is_none() => {
|
||||
if let Some(k) = self.image(d, &im.key) {
|
||||
im.key = k;
|
||||
self.changed += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
if let Some(new) = self.subtree(d, c) {
|
||||
*c = Arc::new(new);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut m = n.clone();
|
||||
let changed = recolor_node(&mut m, Reach { fill, stroke, images }, &mut Visit { pass: self, d });
|
||||
self.changed += changed;
|
||||
(changed > 0).then_some(m)
|
||||
}
|
||||
|
||||
fn paint(&mut self, d: &Document, p: &mut Paint) {
|
||||
let changed = match p {
|
||||
/// Recolour one paint: true when it changed.
|
||||
fn paint(&mut self, d: &Document, p: &mut Paint) -> bool {
|
||||
match p {
|
||||
Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(),
|
||||
_ => match self.links {
|
||||
Some(f) => map_links(p, f),
|
||||
None => map_paint(p, self.f),
|
||||
},
|
||||
};
|
||||
self.changed += changed as usize;
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of image `key` recoloured (one copy per pass), `None` when no pixel changes.
|
||||
@@ -370,6 +304,24 @@ impl<'a> Recolor<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pass over document `d` does to each colour the shared visitor meets.
|
||||
struct Visit<'p, 'a> {
|
||||
pass: &'p mut Recolor<'a>,
|
||||
d: &'p Document,
|
||||
}
|
||||
|
||||
impl ColorVisitor for Visit<'_, '_> {
|
||||
fn paint(&mut self, p: &mut Paint) -> bool {
|
||||
self.pass.paint(self.d, p)
|
||||
}
|
||||
fn color(&mut self, c: Color) -> Color {
|
||||
(self.pass.f)(c)
|
||||
}
|
||||
fn image(&mut self, key: &str) -> Option<String> {
|
||||
self.pass.image(self.d, key)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
run_pass(s, p, label, Recolor::new(Scope::of(p), f))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
//! Cutting tools' commands: Mirror & Cut, Line Cut and Rectangle Cut.
|
||||
//!
|
||||
//! They work on the selected paths and compound paths (inside groups too; a compound path is cut
|
||||
//! as one shape): filled shapes through `vectorcraft-pathops` booleans against a half-plane or a
|
||||
//! rectangle, open paths by splitting them where they cross the line or the rectangle's edge (the
|
||||
//! Knife and Path Eraser helpers). Each command is one undo step; the tools in
|
||||
//! `vectorcraft_tools::cut` preview it live while dragging.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Anchor, FillRule, PathData, Point, Rect, SubPath, Vec2};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::draw2::{components, erase_outline, ids_json, replace_with_pieces};
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::node_path;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"path.mirrorCut",
|
||||
"Mirror & Cut",
|
||||
[],
|
||||
None,
|
||||
"{axis?: \"vertical\"|\"horizontal\"|\"free\" (vertical), from?: [x,y], to?: [x,y] (free: the axis through both; vertical/horizontal: the axis through `from`, default the selection's centre), keep?: \"left\"|\"right\"|\"top\"|\"bottom\" (the side kept; default left, or top for a horizontal-ish axis)} cut the selected paths and compound paths at the axis, reflect the kept half across it and join both halves into one closed shape (open paths are joined where they meet the axis); art wholly on the other side is deleted, as one undo step → {ids, removed}",
|
||||
has_selection,
|
||||
mirror_cut
|
||||
),
|
||||
cmd!(
|
||||
"path.lineCut",
|
||||
"Line Cut",
|
||||
[],
|
||||
None,
|
||||
"{from: [x,y], to: [x,y]} split the selected paths and compound paths along the line through both points, extended across the art: filled shapes become their pieces on each side (holes kept), open paths split where they cross; as one undo step → {ids, removed: 0}",
|
||||
has_selection,
|
||||
line_cut
|
||||
),
|
||||
cmd!(
|
||||
"path.rectCut",
|
||||
"Rectangle Cut",
|
||||
[],
|
||||
None,
|
||||
"{rect: [x, y, width, height]} or {from: [x,y], to: [x,y]} (opposite corners) crop the selected paths and compound paths to the rectangle as real geometry (not a clipping mask): filled shapes are intersected with it, open paths keep their parts inside, art wholly outside is deleted; as one undo step → {ids, removed}",
|
||||
has_selection,
|
||||
rect_cut
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Largest coordinate the cut commands accept (keeps the half-plane polygons finite).
|
||||
const MAX_COORD: f64 = 1e7;
|
||||
/// How close an open path's end must be to the axis to be joined to its reflection.
|
||||
const AXIS_TOL: f64 = 1e-3;
|
||||
|
||||
/// A straight line through `p` along the unit vector `d`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Axis {
|
||||
p: Point,
|
||||
d: Vec2,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// The line through `a` and `b` (`None` when they coincide).
|
||||
fn through(a: Point, b: Point) -> Option<Self> {
|
||||
let v = b - a;
|
||||
let len = v.hypot();
|
||||
(len > 1e-9 && len.is_finite()).then(|| Axis { p: a, d: v / len })
|
||||
}
|
||||
|
||||
/// Signed distance of `q` from the line: positive on the side `d` points to when turned a
|
||||
/// quarter turn clockwise on screen (y pointing down).
|
||||
fn side(&self, q: Point) -> f64 {
|
||||
self.d.cross(q - self.p)
|
||||
}
|
||||
|
||||
/// `q` moved onto the line.
|
||||
fn project(&self, q: Point) -> Point {
|
||||
self.p + self.d * self.d.dot(q - self.p)
|
||||
}
|
||||
|
||||
/// The reflection across the line.
|
||||
fn reflection(&self) -> Affine {
|
||||
let (x, y) = (self.d.x, self.d.y);
|
||||
let (a, b, d) = (2.0 * x * x - 1.0, 2.0 * x * y, 2.0 * y * y - 1.0);
|
||||
let p = self.p;
|
||||
Affine::new([a, b, b, d, p.x - (a * p.x + b * p.y), p.y - (b * p.x + d * p.y)])
|
||||
}
|
||||
|
||||
/// A polygon covering everything in `around` on one side of the line (`sign` > 0: where
|
||||
/// [`Axis::side`] is positive).
|
||||
fn half_plane(&self, sign: f64, around: Rect) -> PathData {
|
||||
let r = around.width() + around.height() + self.p.distance(around.center()) + 10.0;
|
||||
let n = Vec2::new(-self.d.y, self.d.x) * sign.signum() * 2.0 * r;
|
||||
let (a, b) = (self.p - self.d * r, self.p + self.d * r);
|
||||
polygon(&[a, b, b + n, a + n])
|
||||
}
|
||||
}
|
||||
|
||||
fn polygon(pts: &[Point]) -> PathData {
|
||||
PathData::single(SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true))
|
||||
}
|
||||
|
||||
/// A point param that must be finite and within [`MAX_COORD`].
|
||||
fn point_req(p: &Value, key: &str, cmd: &str) -> Result<Point> {
|
||||
let q = point_param(p, key).ok_or_else(|| bad(cmd, format!("missing point `{key}` ([x, y])")))?;
|
||||
if q.x.abs() > MAX_COORD || q.y.abs() > MAX_COORD {
|
||||
return Err(bad(cmd, format!("`{key}` is out of range")));
|
||||
}
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
/// The editable paths and compound paths in the selection (inside groups and layers too; a
|
||||
/// compound path is one target; guides and clipping paths are left alone).
|
||||
fn cut_targets(s: &Session) -> Result<Vec<NodeId>> {
|
||||
fn visit(n: &Node, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } => out.push(n.id),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let roots = selected_roots(s)?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
if let Some(n) = doc.node(id) {
|
||||
visit(n, &mut out);
|
||||
}
|
||||
}
|
||||
out.dedup();
|
||||
out.retain(|id| doc.is_editable(*id));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a cut does to one object: its new pieces (none deletes it).
|
||||
type Plan = Vec<(NodeId, Vec<PathData>)>;
|
||||
|
||||
/// Run `cut` on every target's geometry (`None`: unchanged) and apply the result as one undo step.
|
||||
fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, FillRule) -> Option<Vec<PathData>>) -> Result<Value> {
|
||||
let targets = cut_targets(s)?;
|
||||
if targets.is_empty() {
|
||||
return Err(bad(cmd, "select paths or compound paths"));
|
||||
}
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut plan: Plan = vec![];
|
||||
for id in targets {
|
||||
let Some((pd, rule)) = doc.node(id).and_then(node_path) else { continue };
|
||||
if pd.subpaths.iter().flat_map(|sp| &sp.anchors).any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())) {
|
||||
return Err(bad(cmd, "paths contain invalid coordinates"));
|
||||
}
|
||||
if let Some(pieces) = cut(&pd, rule) {
|
||||
plan.push((id, pieces));
|
||||
}
|
||||
}
|
||||
apply(s, label, plan)
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, label: &str, plan: Plan) -> Result<Value> {
|
||||
if plan.is_empty() {
|
||||
return Ok(json!({ "ids": [], "removed": 0 }));
|
||||
}
|
||||
let (ids, removed) = s.edit(label, |d: &mut Document, sel| {
|
||||
let (mut all, mut removed) = (vec![], 0);
|
||||
for (id, pieces) in plan {
|
||||
removed += pieces.is_empty() as usize;
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
}
|
||||
sel.set(all.iter().copied());
|
||||
Ok((all, removed))
|
||||
})?;
|
||||
let mut out = ids_json(&ids);
|
||||
out["removed"] = json!(removed);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Bounds of `pd` grown a little (the reference box of the half-planes).
|
||||
fn reach(pd: &PathData) -> Rect {
|
||||
pd.control_bounds().unwrap_or_default().inflate(1.0, 1.0)
|
||||
}
|
||||
|
||||
/// `pd ∩ region`, dropping slivers.
|
||||
fn intersect(pd: &PathData, rule: FillRule, region: &PathData) -> PathData {
|
||||
let r = po::boolean(pd, rule, region, FillRule::NonZero, BoolOp::Intersect);
|
||||
if po::area(&r, FillRule::NonZero) <= 1e-6 { PathData::default() } else { r }
|
||||
}
|
||||
|
||||
// ---------- Line Cut ----------
|
||||
|
||||
fn line_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.lineCut";
|
||||
let axis = Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?;
|
||||
run_cut(s, C, "Line Cut", &|pd, rule| split_by_line(pd, rule, &axis))
|
||||
}
|
||||
|
||||
/// `pd` split by the line: the pieces on both sides, `None` when the line misses it.
|
||||
fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option<Vec<PathData>> {
|
||||
if pd.is_closed() {
|
||||
let around = reach(pd);
|
||||
let a = intersect(pd, rule, &axis.half_plane(1.0, around));
|
||||
let b = intersect(pd, rule, &axis.half_plane(-1.0, around));
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(components(&a).into_iter().chain(components(&b)).collect());
|
||||
}
|
||||
// Open paths: the parts on each side (each pass removes the other side).
|
||||
let a = erase_outline(pd, &|q| axis.side(q) < 0.0)?;
|
||||
let b = erase_outline(pd, &|q| axis.side(q) >= 0.0)?;
|
||||
Some(if pd.subpaths.len() == 1 {
|
||||
a.into_iter().chain(b).collect()
|
||||
} else {
|
||||
vec![PathData::new(a.into_iter().chain(b).flat_map(|p| p.subpaths).collect())]
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Rectangle Cut ----------
|
||||
|
||||
fn rect_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.rectCut";
|
||||
let rect = match p.get("rect") {
|
||||
Some(v) => {
|
||||
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let [x, y, w, h] = a[..] else { return Err(bad(C, "rect must be [x, y, width, height]")) };
|
||||
Rect::new(x, y, x + w, y + h).abs()
|
||||
}
|
||||
None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?),
|
||||
};
|
||||
let finite = [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
|
||||
if !finite || rect.width() <= 1e-9 || rect.height() <= 1e-9 {
|
||||
return Err(bad(C, "the rectangle must have a width and a height"));
|
||||
}
|
||||
run_cut(s, C, "Rectangle Cut", &|pd, rule| crop(pd, rule, rect))
|
||||
}
|
||||
|
||||
/// `pd` cropped to `rect`: `None` when it lies inside, no pieces when it lies outside.
|
||||
fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option<Vec<PathData>> {
|
||||
let b = pd.bounds()?;
|
||||
if rect.inflate(1e-9, 1e-9).contains_rect(b) {
|
||||
return None;
|
||||
}
|
||||
if pd.is_closed() {
|
||||
let r =
|
||||
intersect(pd, rule, &polygon(&[rect.origin(), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)]));
|
||||
return Some(if r.is_empty() { vec![] } else { vec![r] });
|
||||
}
|
||||
let inside = rect.inflate(1e-9, 1e-9);
|
||||
let pieces = erase_outline(pd, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?;
|
||||
Some(if pd.subpaths.len() == 1 || pieces.is_empty() {
|
||||
pieces
|
||||
} else {
|
||||
vec![PathData::new(pieces.into_iter().flat_map(|p| p.subpaths).collect())]
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Mirror & Cut ----------
|
||||
|
||||
fn mirror_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.mirrorCut";
|
||||
let kind = str_param(p, "axis").unwrap_or("vertical");
|
||||
let at = || -> Result<Point> {
|
||||
match p.get("from") {
|
||||
Some(_) => point_req(p, "from", C),
|
||||
None => {
|
||||
let roots = selected_roots(s)?;
|
||||
let b = s.doc()?.doc.bounds_of(&roots, false).ok_or_else(|| bad(C, "the selection has no bounds"))?;
|
||||
Ok(b.center())
|
||||
}
|
||||
}
|
||||
};
|
||||
let axis = match kind {
|
||||
"vertical" => {
|
||||
let a = at()?;
|
||||
Axis { p: a, d: Vec2::new(0.0, 1.0) }
|
||||
}
|
||||
"horizontal" => {
|
||||
let a = at()?;
|
||||
Axis { p: a, d: Vec2::new(1.0, 0.0) }
|
||||
}
|
||||
"free" => Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?,
|
||||
other => return Err(bad(C, format!("unknown axis `{other}` (vertical|horizontal|free)"))),
|
||||
};
|
||||
let sign = keep_sign(&axis, str_param(p, "keep")).map_err(|e| bad(C, e))?;
|
||||
run_cut(s, C, "Mirror & Cut", &|pd, rule| Some(mirror(pd, rule, &axis, sign)))
|
||||
}
|
||||
|
||||
/// The sign of [`Axis::side`] on the side `keep` names (default: left, or top when the axis runs
|
||||
/// more across than down).
|
||||
fn keep_sign(axis: &Axis, keep: Option<&str>) -> std::result::Result<f64, String> {
|
||||
let keep = keep.unwrap_or(if axis.d.x.abs() > axis.d.y.abs() { "top" } else { "left" });
|
||||
let toward = match keep {
|
||||
"left" => Vec2::new(-1.0, 0.0),
|
||||
"right" => Vec2::new(1.0, 0.0),
|
||||
"top" => Vec2::new(0.0, -1.0),
|
||||
"bottom" => Vec2::new(0.0, 1.0),
|
||||
other => return Err(format!("unknown side `{other}` (left|right|top|bottom)")),
|
||||
};
|
||||
let s = axis.d.cross(toward);
|
||||
if s.abs() < 1e-6 {
|
||||
return Err(format!("`{keep}` is not a side of this axis"));
|
||||
}
|
||||
Ok(s.signum())
|
||||
}
|
||||
|
||||
/// The kept half of `pd` and its reflection, joined (no pieces when nothing is on the kept side).
|
||||
fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec<PathData> {
|
||||
let m = axis.reflection();
|
||||
if pd.is_closed() {
|
||||
let kept = intersect(pd, rule, &axis.half_plane(sign, reach(pd)));
|
||||
if kept.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let joined = po::boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union);
|
||||
return if joined.is_empty() { vec![] } else { vec![joined] };
|
||||
}
|
||||
let kept = match erase_outline(pd, &|q| axis.side(q) * sign < 0.0) {
|
||||
Some(pieces) => pieces.into_iter().flat_map(|p| p.subpaths).collect(),
|
||||
None => pd.subpaths.clone(),
|
||||
};
|
||||
let subs: Vec<SubPath> = kept.iter().flat_map(|sp| mirror_open(sp, axis, m)).collect();
|
||||
if subs.is_empty() { vec![] } else { vec![PathData::new(subs)] }
|
||||
}
|
||||
|
||||
/// An open run and its reflection, joined where its ends touch the axis (closed when both do);
|
||||
/// the two apart when neither does.
|
||||
fn mirror_open(sp: &SubPath, axis: &Axis, m: Affine) -> Vec<SubPath> {
|
||||
let on = |a: Option<&Anchor>| a.is_some_and(|a| axis.side(a.p).abs() <= AXIS_TOL);
|
||||
let (start_on, end_on) = (on(sp.anchors.first()), on(sp.anchors.last()));
|
||||
if sp.closed || sp.anchors.len() < 2 || !(start_on || end_on) {
|
||||
return vec![sp.clone(), SubPath::new(sp.anchors.iter().map(|a| a.transform(m)).collect(), sp.closed)];
|
||||
}
|
||||
// The run ends on the axis; the reflection comes back from there.
|
||||
let mut run = sp.clone();
|
||||
if !end_on {
|
||||
run.reverse();
|
||||
}
|
||||
let Some((last, body)) = run.anchors.split_last() else { return vec![] };
|
||||
let q = axis.project(last.p);
|
||||
let mut anchors = body.to_vec();
|
||||
anchors.push(Anchor::with_handles(q, last.h_in, m * last.h_in));
|
||||
anchors.extend(body.iter().rev().map(|a| Anchor::with_handles(m * a.p, m * a.h_out, m * a.h_in)));
|
||||
let closed = start_on && end_on && anchors.len() > 2;
|
||||
if closed && let (Some(first), Some(back)) = (anchors.first().copied(), anchors.pop()) {
|
||||
// The start and its reflection meet on the axis: one closed shape.
|
||||
if let Some(f) = anchors.first_mut() {
|
||||
*f = Anchor::with_handles(axis.project(first.p), back.h_in, first.h_out);
|
||||
}
|
||||
}
|
||||
vec![SubPath::new(anchors, closed)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reflection_maps_across_the_axis() {
|
||||
let a = Axis::through(Point::new(0.0, 0.0), Point::new(10.0, 10.0)).unwrap();
|
||||
let q = a.reflection() * Point::new(5.0, 0.0);
|
||||
assert!((q.x - 0.0).abs() < 1e-9 && (q.y - 5.0).abs() < 1e-9, "{q:?}");
|
||||
let v = Axis { p: Point::new(100.0, 0.0), d: Vec2::new(0.0, 1.0) };
|
||||
let q = v.reflection() * Point::new(90.0, 7.0);
|
||||
assert!((q.x - 110.0).abs() < 1e-9 && (q.y - 7.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_names_a_side_of_the_axis() {
|
||||
let v = Axis { p: Point::ZERO, d: Vec2::new(0.0, 1.0) };
|
||||
let left = keep_sign(&v, Some("left")).unwrap();
|
||||
assert!(v.side(Point::new(-5.0, 0.0)) * left > 0.0);
|
||||
assert_eq!(keep_sign(&v, None).unwrap(), left);
|
||||
assert!(keep_sign(&v, Some("top")).is_err());
|
||||
let h = Axis { p: Point::ZERO, d: Vec2::new(1.0, 0.0) };
|
||||
let top = keep_sign(&h, None).unwrap();
|
||||
assert!(h.side(Point::new(0.0, -5.0)) * top > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_half_mirrors_into_a_closed_shape() {
|
||||
// An arch from the axis and back to it: its reflection closes it.
|
||||
let axis = Axis { p: Point::new(50.0, 0.0), d: Vec2::new(0.0, 1.0) };
|
||||
let sp = SubPath::new(
|
||||
vec![Anchor::corner(Point::new(50.0, 0.0)), Anchor::corner(Point::new(10.0, 50.0)), Anchor::corner(Point::new(50.0, 100.0))],
|
||||
false,
|
||||
);
|
||||
let out = mirror_open(&sp, &axis, axis.reflection());
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(out[0].closed);
|
||||
assert_eq!(out[0].anchors.len(), 4);
|
||||
assert!(out[0].anchors.iter().any(|a| (a.p.x - 90.0).abs() < 1e-9));
|
||||
// One end on the axis: one open path through it.
|
||||
let sp = SubPath::new(vec![Anchor::corner(Point::new(10.0, 0.0)), Anchor::corner(Point::new(50.0, 40.0))], false);
|
||||
let out = mirror_open(&sp, &axis, axis.reflection());
|
||||
assert_eq!((out.len(), out[0].closed, out[0].anchors.len()), (1, false, 3));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
|
||||
//! blob brush, smooth, path eraser, join).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
@@ -224,19 +226,43 @@ fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec<NodeId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of path node `id` (fresh id, no live shape) with geometry `pd`.
|
||||
/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids.
|
||||
fn compound_members(d: &mut Document, pd: PathData) -> Vec<Arc<Node>> {
|
||||
pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect()
|
||||
}
|
||||
|
||||
/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members
|
||||
/// are rebuilt, one per subpath).
|
||||
pub(super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> {
|
||||
if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) {
|
||||
let members = compound_members(d, pd);
|
||||
if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
*children = members;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
*path_mut(d, id)? = pd;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`.
|
||||
fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result<Node> {
|
||||
let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone();
|
||||
n.id = d.alloc_id();
|
||||
if let NodeKind::Path { path, live, .. } = &mut n.kind {
|
||||
*path = pd;
|
||||
*live = None;
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*path = pd;
|
||||
*live = None;
|
||||
}
|
||||
NodeKind::Compound { children, .. } => *children = compound_members(d, pd),
|
||||
_ => {}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Replace path `id` with `pieces` (first keeps the id, the rest go right above it). Returns ids.
|
||||
fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above
|
||||
/// it; none removes it). Returns ids.
|
||||
pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
let mut it = pieces.into_iter();
|
||||
let Some(first) = it.next() else {
|
||||
d.remove(id)?;
|
||||
@@ -248,16 +274,16 @@ fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> R
|
||||
let n = sibling_with(d, id, pd)?;
|
||||
ids.push(d.insert(parent, idx + 1 + k, n)?);
|
||||
}
|
||||
*path_mut(d, id)? = first;
|
||||
set_geometry(d, id, first)?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
pub(super) fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// Split a filled path into connected pieces (each outer contour with its holes).
|
||||
fn components(pd: &PathData) -> Vec<PathData> {
|
||||
pub(super) fn components(pd: &PathData) -> Vec<PathData> {
|
||||
let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect();
|
||||
if subs.len() <= 1 {
|
||||
return if pd.is_empty() { vec![] } else { vec![pd.clone()] };
|
||||
@@ -382,7 +408,7 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
|
||||
|
||||
/// Erase from an outline path (open or closed) where `remove` holds. Returns the new geometry per
|
||||
/// output object (one object per piece when the path had a single subpath), or None if unchanged.
|
||||
fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
|
||||
pub(super) fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
|
||||
let mut changed = false;
|
||||
let mut subs = vec![];
|
||||
for sp in &pd.subpaths {
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"file.new",
|
||||
"New…",
|
||||
["File"],
|
||||
Some("Cmd+N"),
|
||||
None,
|
||||
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
|
||||
always,
|
||||
super::newdoc::file_new
|
||||
@@ -117,7 +117,9 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.cancel_interaction()?;
|
||||
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
|
||||
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
|
||||
s.commit_interaction()?;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let label = e.label.clone();
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Effect",
|
||||
[],
|
||||
None,
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effects",
|
||||
[],
|
||||
None,
|
||||
"{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
|
||||
"{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin.<plug-in id>, menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -146,6 +146,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let catalog: Vec<Value> = effects::effect_catalog()
|
||||
.into_iter()
|
||||
.chain(effects::plugin_effects())
|
||||
.map(|e| {
|
||||
json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster,
|
||||
"lengths": {"always": e.lengths.always, "absolute": e.lengths.absolute}})
|
||||
@@ -321,11 +322,25 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
|
||||
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
|
||||
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
|
||||
fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
|
||||
let Some(n) = d.node(id).cloned() else { return };
|
||||
let container = is_container(&n);
|
||||
let Some(mut n) = d.node(id).cloned() else { return };
|
||||
if !expandable(&n) {
|
||||
return;
|
||||
}
|
||||
// Colour adjustments: the colours inside become the adjusted ones (an embedded image a
|
||||
// recoloured copy), then the rest of the appearance expands.
|
||||
if effects::has_adjustment(&n)
|
||||
&& let Some(m) = effects::adjust_in_document(d, &n)
|
||||
&& let Some(slot) = d.node_mut(id)
|
||||
{
|
||||
*slot = m.clone();
|
||||
out.push(id);
|
||||
n = m;
|
||||
if !expandable(&n) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// (A symbol instance has become its art, a group.)
|
||||
let container = is_container(&n);
|
||||
// Crop Marks: a group of the object and its marks, then the object expands on its own.
|
||||
if let Some(mut m) = effects::crop_marks_art(&n) {
|
||||
let mut seen = std::collections::HashSet::from([id]);
|
||||
@@ -385,7 +400,9 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush
|
||||
&& let Some(slot) = d.node_mut(id)
|
||||
{
|
||||
*slot = m;
|
||||
out.push(id);
|
||||
if out.last() != Some(&id) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
if container {
|
||||
let children: Vec<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
|
||||
|
||||
@@ -135,11 +135,7 @@ fn choice<T>(field: &str, v: Option<String>, read: impl Fn(&str) -> Option<T>, d
|
||||
/// The flattener options `p` asks for (`flattenerPreset` and `flattener` over it), built-in
|
||||
/// presets and `saved` ones; discarded overprints are dropped from the flattened art too.
|
||||
fn flattener(p: &Params, saved: &[super::super::FlattenerPreset]) -> Result<FlattenOptions> {
|
||||
let mut q = json!({ "options": p.flattener.clone().unwrap_or(Value::Null) });
|
||||
if let Some(name) = &p.flattener_preset {
|
||||
q["preset"] = json!(name);
|
||||
}
|
||||
FlattenOptions::from_params_with(&q, saved).map_err(|e| bad(C, format!("EPS flattener: {e}")))
|
||||
FlattenOptions::for_export(p.flattener_preset.as_deref(), p.flattener.as_ref(), saved).map_err(|e| bad(C, format!("EPS flattener: {e}")))
|
||||
}
|
||||
|
||||
fn params(p: &Value) -> Result<Params> {
|
||||
@@ -176,7 +172,7 @@ fn parse(p: &Value) -> Result<Settings> {
|
||||
/// `saved` it names written out, as the encoder needs it.
|
||||
pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::result::Result<Value, String> {
|
||||
let run = || -> Result<Value> {
|
||||
let q = expanded(p, saved)?.unwrap_or_else(|| p.clone());
|
||||
let q = expanded(C, p, saved)?.unwrap_or_else(|| p.clone());
|
||||
parse(&q)?;
|
||||
Ok(q)
|
||||
};
|
||||
@@ -185,10 +181,10 @@ pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::resul
|
||||
|
||||
/// `p` with a saved flattener preset it names written out as `flattener` options, for the
|
||||
/// encoder, which knows only the built-in presets; `None` when `p` names none.
|
||||
fn expanded(p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
|
||||
fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
|
||||
match str_param(p, "flattenerPreset") {
|
||||
Some(name) if FlattenOptions::preset(name).is_none() => {
|
||||
let o = flattener(¶ms(p)?, saved)?;
|
||||
Some(name) if !name.trim().is_empty() && FlattenOptions::preset(name).is_none() => {
|
||||
let o = FlattenOptions::for_export(Some(name), p.get("flattener"), saved).map_err(|e| bad(cmd, format!("flattener: {e}")))?;
|
||||
let mut q = p.clone();
|
||||
q["flattener"] = serde_json::to_value(o).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let Some(m) = q.as_object_mut() {
|
||||
@@ -299,8 +295,8 @@ fn export_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// `p` with a saved flattener preset of the session's written out (see [`expanded`]).
|
||||
pub(super) fn with_flattener<'a>(s: &Session, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
|
||||
Ok(match expanded(&p, &s.prefs.flattener_presets)? {
|
||||
pub(super) fn with_flattener<'a>(s: &Session, cmd: &str, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
|
||||
Ok(match expanded(cmd, &p, &s.prefs.flattener_presets)? {
|
||||
Some(q) => Cow::Owned(q),
|
||||
None => p,
|
||||
})
|
||||
|
||||
@@ -104,8 +104,7 @@ pub(super) fn without_artboards(p: &Value) -> Value {
|
||||
/// `p` with the saved presets it names (PDF, flattener) written out as options, for the encoders,
|
||||
/// which know only the built-in ones.
|
||||
fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
|
||||
let pdf = super::pdf::expand_preset(s, cmd, p)?;
|
||||
super::eps::with_flattener(s, pdf)
|
||||
super::pdf::expand_preset(s, cmd, p)
|
||||
}
|
||||
|
||||
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
|
||||
|
||||
@@ -623,6 +623,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.
|
||||
@@ -901,8 +902,19 @@ mod tests_pdfoutput;
|
||||
#[cfg(test)]
|
||||
mod tests_tiffbmp;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfx;
|
||||
#[cfg(test)]
|
||||
mod tests_psd;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_epsimport;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdflayers;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfwebview;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdf13;
|
||||
@@ -3,12 +3,14 @@
|
||||
//! over them, the warnings that come back, and `document.pdfSettings` (the dialog's Summary).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard, THUMBNAIL_SIZE, Thumbnail};
|
||||
|
||||
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
|
||||
use super::super::*;
|
||||
use super::{ArtboardPick, FormatOption, write_or_return};
|
||||
use crate::EngineError;
|
||||
@@ -30,8 +32,18 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
default: "\"VectorCraft Default\"",
|
||||
description: "the PDF preset the other options apply over (built-in or saved: pdf.preset.list)",
|
||||
},
|
||||
FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" },
|
||||
FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" },
|
||||
FormatOption {
|
||||
name: "standard",
|
||||
ty: "string",
|
||||
default: "\"none\"",
|
||||
description: "none | pdfA2b | pdfX1a | pdfX3 | pdfX4 (choosing one sets the compatibility it allows; see document.exportPdf)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "compatibility",
|
||||
ty: "string",
|
||||
default: "\"1.7\"",
|
||||
description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0",
|
||||
},
|
||||
FormatOption {
|
||||
name: "preserveEditing",
|
||||
ty: "boolean",
|
||||
@@ -62,7 +74,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
name: "security",
|
||||
ty: "object",
|
||||
default: "null",
|
||||
description: "passwords and permissions (the file is encrypted: RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)",
|
||||
description: "passwords and permissions (the file is encrypted: RC4 40-bit at 1.3, RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "includeNonPrinting",
|
||||
@@ -70,6 +82,24 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
default: "false",
|
||||
description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "createLayers",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "top-level layers as PDF layers (optional content groups with their visibility, print state and lock; PDF 1.5 or later)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "flattenerPreset",
|
||||
ty: "string",
|
||||
default: "\"\"",
|
||||
description: "the flattener preset PDF 1.3 files (PDF/X-1a and PDF/X-3 too) flatten transparency with: empty (High Resolution), high, medium, low or a saved one",
|
||||
},
|
||||
FormatOption {
|
||||
name: "flattener",
|
||||
ty: "object",
|
||||
default: "null",
|
||||
description: "flattener options over flattenerPreset (as object.flattenTransparency)",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -79,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export PDF",
|
||||
[],
|
||||
None,
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. document.export {format: pdf} takes the same options",
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each top-level layer but template layers as a PDF layer, an optional content group: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet c
Line truncated
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
@@ -145,9 +175,10 @@ pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
|
||||
|
||||
/// The settings `p` asks for: its preset (built-in or one of `saved`; default
|
||||
/// [`DEFAULT_PRESET`]) with `p`'s options applied over it. Choosing another standard turns
|
||||
/// Preserve Editing off unless `p` asks for it. Checked as a preset is
|
||||
/// ([`PdfSettings::check_values`]): a standard the writer doesn't produce yet passes. Keys that
|
||||
/// aren't PDF options (path, format…) are ignored.
|
||||
/// Preserve Editing off, the layers off where it has none, and sets the latest compatibility it
|
||||
/// allows, unless `p` asks for those. Checked as a preset is
|
||||
/// ([`PdfSettings::check_values`]): profiles that aren't there pass. Keys that aren't PDF options
|
||||
/// (path, format…) are ignored.
|
||||
pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
|
||||
let base = preset_settings(cmd, p, saved)?;
|
||||
if !p.is_object() {
|
||||
@@ -156,8 +187,17 @@ pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings>
|
||||
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
merge(&mut v, p);
|
||||
let mut s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?;
|
||||
if s.standard != Standard::None && s.standard != base.standard && p.get("preserveEditing").is_none_or(Value::is_null) {
|
||||
s.preserve_editing = false;
|
||||
let unasked = |key: &str| p.get(key).is_none_or(Value::is_null);
|
||||
if s.standard != base.standard {
|
||||
if s.standard != Standard::None && unasked("preserveEditing") {
|
||||
s.preserve_editing = false;
|
||||
}
|
||||
if !s.standard.allows_layers() && unasked("createLayers") {
|
||||
s.create_layers = false;
|
||||
}
|
||||
if !s.standard.allows(s.compatibility) && unasked("compatibility") {
|
||||
s.compatibility = s.standard.version();
|
||||
}
|
||||
}
|
||||
s.check_values().map_err(|e| pdf_error(cmd, e))?;
|
||||
Ok(s)
|
||||
@@ -175,10 +215,11 @@ pub fn settings(cmd: &str, p: &Value) -> Result<PdfSettings> {
|
||||
settings_with(cmd, p, &[])
|
||||
}
|
||||
|
||||
/// `p` with a saved preset it names written out as options, for the encoders, which know only the
|
||||
/// built-in presets. Params naming no preset, or a built-in one, come back as they are.
|
||||
/// `p` with the saved presets it names (a PDF preset, a flattener preset: see
|
||||
/// [`super::eps::with_flattener`]) written out as options, for the encoders, which know only the
|
||||
/// built-in presets. Params naming none come back as they are.
|
||||
pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
|
||||
match str_param(p, "preset") {
|
||||
let expanded = match str_param(p, "preset") {
|
||||
Some(name) if vectorcraft_pdf::builtin_preset(name).is_none() => {
|
||||
let set = settings_with(cmd, p, &s.prefs.pdf_presets)?;
|
||||
let mut q = p.clone();
|
||||
@@ -186,10 +227,11 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("preset");
|
||||
}
|
||||
Ok(Cow::Owned(q))
|
||||
Cow::Owned(q)
|
||||
}
|
||||
_ => Ok(Cow::Borrowed(p)),
|
||||
}
|
||||
_ => Cow::Borrowed(p),
|
||||
};
|
||||
super::eps::with_flattener(s, cmd, expanded)
|
||||
}
|
||||
|
||||
/// The full export options for `doc`: settings plus the artboards `p` picks (default all).
|
||||
@@ -214,6 +256,9 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri
|
||||
/// native document with its save options, whose pages may be left blank).
|
||||
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
let mut opts = options(cmd, doc, p)?;
|
||||
if opts.settings.thumbnails {
|
||||
opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
}
|
||||
let mut warnings = vec![];
|
||||
if opts.settings.preserve_editing {
|
||||
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
|
||||
@@ -223,14 +268,92 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
|
||||
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
|
||||
}
|
||||
}
|
||||
let r = super::super::rasterfx::export_pdf_with_report(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
warnings.extend(r.warnings);
|
||||
if opts.settings.advanced.overprint == Overprint::Preserve && doc.layers.iter().any(|l| l.has_overprint()) {
|
||||
warnings.push("overprinting objects are written without overprint: overprint is not written to PDF yet".into());
|
||||
let set = &opts.settings;
|
||||
let flat = flatten_for(cmd, doc, set, p)?;
|
||||
if flat.is_some() {
|
||||
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
|
||||
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
|
||||
}
|
||||
let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
warnings.extend(r.warnings);
|
||||
Ok((r.bytes, warnings))
|
||||
}
|
||||
|
||||
/// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty.
|
||||
pub const DEFAULT_FLATTENER: &str = "High Resolution";
|
||||
|
||||
/// `doc` with its transparency flattened for a PDF 1.3 file (asked for, or PDF/X-1a and PDF/X-3,
|
||||
/// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener`
|
||||
/// options over it; images with see-through pixels count as transparency, rasterized areas are
|
||||
/// clipped to their regions, and discarded overprints are dropped. `None` when nothing changes (or
|
||||
/// the file keeps transparency).
|
||||
fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result<Option<Document>> {
|
||||
if !set.pdf13() {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = Some(set.flattener_preset.trim()).filter(|n| !n.is_empty()).unwrap_or(DEFAULT_FLATTENER);
|
||||
let mut o = FlattenOptions::for_export(Some(name), p.get("flattener"), &[]).map_err(|e| bad(cmd, format!("PDF flattener: {e}")))?;
|
||||
o.preserve_overprints &= set.advanced.overprint == Overprint::Preserve;
|
||||
o.no_transparency = Some(see_through_images(doc));
|
||||
// Without soft masks a rasterized area can't be a rectangle with see-through pixels around
|
||||
// its art: it is clipped to its regions (opaque inside them).
|
||||
o.clip_complex_regions = true;
|
||||
flatten_document(doc, &o)
|
||||
}
|
||||
|
||||
/// The pages `opts` export of `doc` drawn on white, [`THUMBNAIL_SIZE`] pixels on their long side:
|
||||
/// the thumbnails Embed Page Thumbnails embeds. Layers the pages leave out (non-printing ones,
|
||||
/// unless asked for) are left out of them too.
|
||||
fn thumbnails(doc: &Document, opts: &PdfOptions) -> std::result::Result<Vec<Thumbnail>, PdfError> {
|
||||
let areas = vectorcraft_pdf::page_areas(doc, opts)?;
|
||||
let set = &opts.settings;
|
||||
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
|
||||
let shown = printed.map(|layers| {
|
||||
let mut d = doc.clone();
|
||||
d.layers = layers;
|
||||
d
|
||||
});
|
||||
let doc = shown.as_ref().unwrap_or(doc);
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
Ok(areas
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let img = r.render_region(doc, a, f64::from(THUMBNAIL_SIZE) / a.width().max(a.height()).max(1.0), true);
|
||||
// Drawn on white: every pixel is opaque.
|
||||
let rgb = img.pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
Thumbnail { width: img.width, height: img.height, rgb }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `layers` with the non-printing layers among them (at any depth) hidden; `None` when there are
|
||||
/// none.
|
||||
fn without_non_printing(layers: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
|
||||
let mut out: Option<Vec<Arc<Node>>> = None;
|
||||
for (i, l) in layers.iter().enumerate() {
|
||||
let NodeKind::Layer { printable, children, .. } = &l.kind else { continue };
|
||||
let new = if !printable {
|
||||
l.visible.then(|| {
|
||||
let mut n = (**l).clone();
|
||||
n.visible = false;
|
||||
Arc::new(n)
|
||||
})
|
||||
} else {
|
||||
without_non_printing(children).map(|c| {
|
||||
let mut n = (**l).clone();
|
||||
if let NodeKind::Layer { children, .. } = &mut n.kind {
|
||||
*children = c;
|
||||
}
|
||||
Arc::new(n)
|
||||
})
|
||||
};
|
||||
if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| layers.to_vec()).get_mut(i)?, new))) {
|
||||
*slot.0 = slot.1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expanded = expand_preset(s, C, p)?;
|
||||
let p = &*expanded;
|
||||
|
||||
@@ -146,7 +146,8 @@ fn open_exts_cover_every_readable_format() {
|
||||
}
|
||||
let filters: Vec<_> = open_filters().collect();
|
||||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||||
assert_eq!(filters.len(), 5 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets");
|
||||
assert_eq!(filters.len(), 6 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets, plug-ins");
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -60,13 +60,15 @@ fn export_pdf_writes_the_compatibility_header_and_the_range() {
|
||||
fn bad_options_are_refused() {
|
||||
let mut s = session(2);
|
||||
for p in [
|
||||
json!({"compatibility": "1.3"}),
|
||||
json!({"compatibility": "1.2"}),
|
||||
json!({"compatibility": "1.3", "standard": "pdfA2b"}),
|
||||
json!({"compatibility": "1.3", "flattenerPreset": "Nope"}),
|
||||
json!({"compatibility": 1.5}),
|
||||
json!({"range": "1-3"}),
|
||||
json!({"range": "0"}),
|
||||
json!({"artboards": []}),
|
||||
json!({"preset": "Nope"}),
|
||||
json!({"standard": "pdfX4"}),
|
||||
json!({"standard": "pdfX4", "compatibility": "1.7"}),
|
||||
json!({"standard": "pdfA2b", "compatibility": "2.0"}),
|
||||
json!({"compression": {"color": {"ppi": 0}}}),
|
||||
json!({"compression": "zip"}),
|
||||
@@ -105,9 +107,11 @@ fn pdf_a_refuses_pdf_2() {
|
||||
#[test]
|
||||
fn options_not_applied_yet_and_document_features_warn() {
|
||||
let mut s = session(1);
|
||||
let w = warnings(&export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}})));
|
||||
assert_eq!(w.len(), 2, "{w:?}");
|
||||
assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("fast web view")), "printer's marks are drawn: no warning");
|
||||
// Printer's marks are drawn, thumbnails embedded and the file linearised: nothing to report.
|
||||
let v = export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}}));
|
||||
assert_eq!(warnings(&v), Vec::<String>::new());
|
||||
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
|
||||
assert!(text.contains("/Thumb ") && text.contains("/Linearized 1"));
|
||||
// A pattern stroke is written as its tiles clipped to the stroke: nothing to report.
|
||||
let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [tile]})).unwrap();
|
||||
@@ -156,7 +160,7 @@ fn pdf_settings_summarise_changes_and_warnings() {
|
||||
let changed: Vec<&str> = v["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect();
|
||||
assert_eq!(changed, ["compatibility", "compression.compressText", "createLayers", "security.copy"], "key order");
|
||||
assert_eq!(v["changed"][0]["value"], "1.5");
|
||||
assert_eq!(v["warnings"].as_array().unwrap().len(), 2, "layers and permissions: {}", v["warnings"]);
|
||||
assert_eq!(v["warnings"].as_array().unwrap().len(), 1, "permissions (PDF layers are written at 1.5): {}", v["warnings"]);
|
||||
assert!(!v.to_string().contains("Password"), "passwords never come back");
|
||||
assert!(s.execute("document.pdfSettings", &json!({"compatibility": "9"})).is_err());
|
||||
// With the document: the in-memory export adds the document's own warnings.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! `document.exportPdf {compatibility: "1.3", flattenerPreset?, flattener?}`: a PDF 1.3 file has
|
||||
//! no transparency (no soft masks, blend modes, constant opacity or transparency groups): it is
|
||||
//! flattened with the chosen preset, and reads back looking like the canvas.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_render::{Rendered, Renderer};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A document with overlapping transparency: a half-opaque rectangle, a Multiply ellipse, a
|
||||
/// gradient fading out, a drop shadow and a PNG with see-through pixels, in an isolated page.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 160}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 90, "height": 70}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#2060e0"}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 50, "y": 40, "width": 90, "height": 70}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#f02010"}));
|
||||
run(&mut s, "transparency.set", json!({"opacity": 50}));
|
||||
run(&mut s, "shape.ellipse", json!({"x": 90, "y": 20, "width": 80, "height": 80}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#20c040"}));
|
||||
run(&mut s, "transparency.set", json!({"blendMode": "multiply"}));
|
||||
let shadowed = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 100, "width": 50, "height": 40}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#e0c020"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [shadowed]}));
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
run(&mut s, "file.place", json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 120]}));
|
||||
s
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: Value) -> (Vec<u8>, Vec<String>) {
|
||||
let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
(bytes, v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect())
|
||||
}
|
||||
|
||||
/// The transparency `pdf` has, in its dictionaries.
|
||||
fn transparency(pdf: &[u8]) -> Vec<&'static str> {
|
||||
let text = String::from_utf8_lossy(pdf);
|
||||
let mut found = vec![];
|
||||
for (what, there) in [
|
||||
("soft masks", text.contains("/SMask") && !text.contains("/SMask/None")),
|
||||
("blend modes", text.contains("/BM") && !text.contains("/BM/Normal")),
|
||||
("transparency groups", text.contains("/S/Transparency")),
|
||||
("opacity", text.contains("/ca 0.") || text.contains("/CA 0.")),
|
||||
] {
|
||||
if there {
|
||||
found.push(what);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn render(d: &vectorcraft_doc::Document) -> Rendered {
|
||||
Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 160.0), 1.0, true)
|
||||
}
|
||||
|
||||
/// Mean difference of the colour channels of two renders, as a share of full scale.
|
||||
fn mean_diff(a: &Rendered, b: &Rendered) -> f64 {
|
||||
let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum();
|
||||
sum as f64 / a.pixels.len() as f64 / 255.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_1_3_flattens_all_transparency_and_looks_the_same() {
|
||||
let mut s = session();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
// The same document at PDF 1.4 has transparency.
|
||||
let (pdf, _) = export(&mut s, json!({"compatibility": "1.4", "compression": {"compressText": false}, "preserveEditing": false}));
|
||||
assert!(transparency(&pdf).len() >= 3, "{:?}", transparency(&pdf));
|
||||
let (pdf, warnings) = export(&mut s, json!({"compatibility": "1.3", "compression": {"compressText": false}, "preserveEditing": false}));
|
||||
assert!(pdf.starts_with(b"%PDF-1.3"));
|
||||
assert!(String::from_utf8_lossy(&pdf).contains("<pdf:PDFVersion>1.3<"), "the metadata says 1.3 too");
|
||||
assert_eq!(transparency(&pdf), Vec::<&str>::new());
|
||||
assert!(warnings.iter().any(|w| w.contains("flattened") && w.contains("PDF 1.3")), "{warnings:?}");
|
||||
// It reads back looking like the canvas.
|
||||
let canvas = render(&before);
|
||||
let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&pdf).unwrap()));
|
||||
assert!(diff < 0.02, "read back {:.2}% off", diff * 100.0);
|
||||
// The document itself is untouched.
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// The writer refuses a document that isn't flat; a flat one with isolated page blending gets
|
||||
// no page group.
|
||||
let opts = vectorcraft_pdf::PdfOptions { settings: serde_json::from_value(json!({"compatibility": "1.3"})).unwrap(), ..Default::default() };
|
||||
let e = vectorcraft_pdf::export(&before, &opts).unwrap_err();
|
||||
assert!(matches!(&e, vectorcraft_pdf::PdfError::Unsupported(m) if m.contains("PDF 1.3")), "{e}");
|
||||
let mut flat = vectorcraft_doc::Document::new(100.0, 100.0);
|
||||
flat.page_isolate = true;
|
||||
let l = flat.default_layer().unwrap();
|
||||
let id = flat.alloc_id();
|
||||
let rect = vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0));
|
||||
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
|
||||
flat.insert(Some(l), 0, vectorcraft_doc::Node::path(id, rect, vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0)))
|
||||
.unwrap();
|
||||
assert!(transparency(&vectorcraft_pdf::export(&flat, &opts).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flattener_preset_and_options_apply() {
|
||||
let mut s = session();
|
||||
let images = |pdf: &[u8]| String::from_utf8_lossy(pdf).matches("/Subtype/Image").count();
|
||||
let p = |extra: Value| {
|
||||
let mut p = json!({"compatibility": "1.3", "preserveEditing": false});
|
||||
super::pdf::merge(&mut p, &extra);
|
||||
p
|
||||
};
|
||||
let (high, _) = export(&mut s, p(json!({})));
|
||||
let (default, _) = export(&mut s, p(json!({"flattenerPreset": "High Resolution"})));
|
||||
assert_eq!(high.len(), default.len(), "the default is High Resolution");
|
||||
let (low, _) = export(&mut s, p(json!({"flattenerPreset": "low"})));
|
||||
assert!(low.len() < high.len(), "lower resolution rasters");
|
||||
// All raster: one image a flattened group at most, and still flat.
|
||||
let (raster, _) = export(&mut s, p(json!({"flattener": {"balance": 0}})));
|
||||
assert!(images(&raster) >= 1 && transparency(&raster).is_empty());
|
||||
// A saved preset by name, through every PDF path; an unknown one is refused.
|
||||
s.execute("flattener.presets.save", &json!({"name": "All Raster", "preset": "high", "balance": 0})).unwrap();
|
||||
let (saved, _) = export(&mut s, p(json!({"flattenerPreset": "All Raster"})));
|
||||
assert_eq!(saved.len(), raster.len());
|
||||
let v = s
|
||||
.execute("document.export", &json!({"format": "pdf", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false}))
|
||||
.unwrap();
|
||||
assert_eq!(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap().len(), raster.len());
|
||||
s.execute("pdf.preset.save", &json!({"name": "Old Press", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false}))
|
||||
.unwrap();
|
||||
assert_eq!(export(&mut s, json!({"preset": "Old Press"})).0.len(), raster.len(), "a PDF preset keeps its flattener preset");
|
||||
assert!(s.execute("document.exportPdf", &p(json!({"flattenerPreset": "Nope"}))).is_err());
|
||||
// Other versions keep their transparency, whatever the preset.
|
||||
let (kept, warnings) = export(&mut s, json!({"flattenerPreset": "low", "compression": {"compressText": false}}));
|
||||
assert!(!transparency(&kept).is_empty() && !warnings.iter().any(|w| w.contains("flattened")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_1_3_files_are_encrypted_with_40_bit_rc4_and_can_be_linearised() {
|
||||
let mut s = session();
|
||||
let (pdf, warnings) = export(
|
||||
&mut s,
|
||||
json!({"compatibility": "1.3", "fastWebView": true, "thumbnails": true, "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": false}}),
|
||||
);
|
||||
assert!(pdf.starts_with(b"%PDF-1.3") && warnings.iter().all(|w| !w.contains("metadata")), "{warnings:?}");
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
assert!(text.contains("/V 1/R 2/Length 40") && text.contains("/Linearized 1") && text.contains("/Thumb "));
|
||||
for password in ["open", "owner"] {
|
||||
s.execute("document.close", &json!({"force": true})).ok();
|
||||
s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": password})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "{password}");
|
||||
}
|
||||
assert!(
|
||||
s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": "nope"})).is_err()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `document.exportPdf {createLayers}` writes PDF layers that reopen as layers, and
|
||||
//! `advanced.overprint` keeps overprinting in the file or drops it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
fn warnings(v: &Value) -> Vec<String> {
|
||||
v["warnings"].as_array().expect("warnings").iter().map(|w| w.as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
/// A document with a rectangle on "Layer 1" and one on a hidden, non-printing layer "Notes".
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let notes = s.execute("layer.new", &json!({"name": "Notes"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": notes, "visible": false, "printable": false})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_create_layers_writes_layers_that_reopen_as_layers() {
|
||||
let mut s = session();
|
||||
let v = s.execute("document.exportPdf", &json!({"createLayers": true, "preserveEditing": false})).unwrap();
|
||||
assert!(warnings(&v).is_empty(), "{:?}", warnings(&v));
|
||||
let back = vectorcraft_pdf::import(&b64(&v)).unwrap();
|
||||
let layers: Vec<(String, bool, bool)> = back
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| (l.name.clone().unwrap_or_default(), l.visible, matches!(l.kind, vectorcraft_doc::NodeKind::Layer { printable: true, .. })))
|
||||
.collect();
|
||||
assert_eq!(layers, [("Layer 1".to_string(), true, true), ("Notes".to_string(), false, false)]);
|
||||
// The default preset carries the editing data too.
|
||||
let v = s.execute("document.exportPdf", &json!({"createLayers": true})).unwrap();
|
||||
assert!(warnings(&v).is_empty(), "{:?}", warnings(&v));
|
||||
assert!(vectorcraft_pdf::editing(&b64(&v)).is_some_and(|e| e.intact));
|
||||
// At PDF 1.4 there are no PDF layers.
|
||||
let v = s.execute("document.exportPdf", &json!({"createLayers": true, "compatibility": "1.4"})).unwrap();
|
||||
assert!(warnings(&v).iter().any(|w| w.contains("PDF 1.5")), "{:?}", warnings(&v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_overprint_is_preserved_or_discarded() {
|
||||
let mut s = session();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
// The rectangles' strokes are black (their white fills would knock out: discardWhiteOverprint).
|
||||
s.execute("object.setOverprint", &json!({"stroke": true})).unwrap();
|
||||
let overprints = |s: &mut Session, p: Value| {
|
||||
let v = s.execute("document.exportPdf", &p).unwrap();
|
||||
assert!(warnings(&v).iter().all(|w| !w.contains("overprint")), "{:?}", warnings(&v));
|
||||
String::from_utf8_lossy(&b64(&v)).matches("/OP true/op true/OPM 1").count()
|
||||
};
|
||||
assert_eq!(overprints(&mut s, json!({"compression": {"compressText": false}})), 1);
|
||||
assert_eq!(overprints(&mut s, json!({"advanced": {"overprint": "discard"}})), 0);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! `document.exportPdf {thumbnails, fastWebView}`: each page's thumbnail is the page drawn small
|
||||
//! (its bleed and marks too, without the layers the page leaves out), and the file is linearised.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn export(s: &mut Session, p: Value) -> (Vec<u8>, Vec<Value>) {
|
||||
let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
(bytes, v["warnings"].as_array().unwrap().clone())
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from)
|
||||
}
|
||||
|
||||
/// The page thumbnails of `pdf`, in file order: (width, height, RGB pixels).
|
||||
fn thumbnails(pdf: &[u8]) -> Vec<(u32, u32, Vec<u8>)> {
|
||||
let mut out = vec![];
|
||||
let mut from = 0;
|
||||
while let Some(at) = find(pdf, b"<</Width ", from) {
|
||||
from = at + 1;
|
||||
let head = String::from_utf8_lossy(&pdf[at..find(pdf, b">>", at).unwrap()]).into_owned();
|
||||
if !head.contains("/ColorSpace/DeviceRGB/BitsPerComponent 8/Filter/FlateDecode/Length ") {
|
||||
continue;
|
||||
}
|
||||
let num = |key: &str| -> usize {
|
||||
let rest = &head[head.find(key).unwrap() + key.len()..];
|
||||
rest[..rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len())].parse().unwrap()
|
||||
};
|
||||
let data = find(pdf, b"stream\n", at).unwrap() + 7;
|
||||
let mut rgb = vec![];
|
||||
flate2::read::ZlibDecoder::new(&pdf[data..data + num("/Length ")]).read_to_end(&mut rgb).unwrap();
|
||||
out.push((num("/Width ") as u32, num("/Height ") as u32, rgb));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnails_show_the_pages() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect;
|
||||
// A red first page, a green second one.
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": second.x0, "y": second.y0, "width": 200, "height": 100})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
// A non-printing layer covering the first page in blue.
|
||||
let notes = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
|
||||
s.execute("layer.setProps", &json!({"id": notes, "printable": false})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
|
||||
let (pdf, warnings) = export(&mut s, json!({"thumbnails": true}));
|
||||
assert_eq!(warnings, Vec::<Value>::new());
|
||||
let thumbs = thumbnails(&pdf);
|
||||
assert_eq!(thumbs.len(), 2, "one a page");
|
||||
for ((w, h, rgb), colour) in thumbs.iter().zip([[255, 0, 0], [0, 255, 0]]) {
|
||||
assert_eq!((*w, *h), (vectorcraft_pdf::THUMBNAIL_SIZE, 53), "the page's shape, 106 px long");
|
||||
// The middle of the left half: the page's colour (the non-printing layer is left out).
|
||||
let at = ((*h / 2 * w + w / 4) * 3) as usize;
|
||||
assert_eq!(rgb[at..at + 3], colour);
|
||||
}
|
||||
// Non-printing layers included: the thumbnail shows them too.
|
||||
let (pdf, _) = export(&mut s, json!({"thumbnails": true, "includeNonPrinting": true}));
|
||||
let (w, h, rgb) = &thumbnails(&pdf)[0];
|
||||
let at = ((*h / 2 * w + w / 4) * 3) as usize;
|
||||
assert_eq!(rgb[at..at + 3], [0, 0, 255]);
|
||||
// With the bleed the page grows, and so does its thumbnail.
|
||||
let (pdf, _) = export(&mut s, json!({"thumbnails": true, "bleed": {"top": 50, "bottom": 50, "left": 0, "right": 0}}));
|
||||
assert_eq!(thumbnails(&pdf).iter().map(|t| (t.0, t.1)).collect::<Vec<_>>(), [(106, 106), (106, 106)]);
|
||||
// Off: none.
|
||||
assert!(thumbnails(&export(&mut s, json!({})).0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_web_view_through_every_pdf_path() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 3})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
let linearised = |pdf: &[u8]| String::from_utf8_lossy(&pdf[..200.min(pdf.len())]).contains("/Linearized 1");
|
||||
let (pdf, warnings) = export(&mut s, json!({"fastWebView": true, "thumbnails": true}));
|
||||
assert!(linearised(&pdf) && warnings.is_empty(), "{warnings:?}");
|
||||
// Reopened: the pages, and the document the editing data carries.
|
||||
s.execute("document.close", &json!({"force": true})).ok();
|
||||
let v = s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "web.pdf"})).unwrap();
|
||||
assert!(v.is_object());
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 3);
|
||||
assert!(s.doc().unwrap().doc.layers[0].children().unwrap().iter().any(|n| n.path_data().is_some()), "the ellipse is back");
|
||||
// document.export takes the same options; without them the file isn't linearised.
|
||||
let v = s.execute("document.export", &json!({"format": "pdf", "fastWebView": true, "range": "2"})).unwrap();
|
||||
assert!(linearised(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()));
|
||||
assert!(!linearised(&export(&mut s, json!({})).0));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! `document.exportPdf {standard}` with the PDF/X standards: choosing one sets its version and
|
||||
//! turns off what it forbids, PDF/X-1a and PDF/X-3 flatten transparency (images with see-through
|
||||
//! pixels too) before writing, PDF/X-4 keeps it, and passwords are refused.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A document with a CMYK rectangle under a half-opaque RGB one, and a PNG with see-through
|
||||
/// pixels over both.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 40, "y": 30, "width": 60, "height": 60})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": {"model": "rgb", "r": 1.0, "g": 0.2, "b": 0.0}})).unwrap();
|
||||
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
s.execute("file.place", &json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 50]})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// `document.exportPdf` with `p` (content streams readable) → (the file's text, warnings).
|
||||
fn pdf(s: &mut Session, mut p: Value) -> (String, Vec<String>) {
|
||||
p["compression"] = json!({"compressText": false});
|
||||
let v = s.execute("document.exportPdf", &p).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let warnings = v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect();
|
||||
(String::from_utf8_lossy(&bytes).into_owned(), warnings)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfx_1a_and_x3_flatten_transparency_and_x4_keeps_it() {
|
||||
let mut s = session();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for (standard, header) in [("pdfX1a", "%PDF-1.3"), ("pdfX3", "%PDF-1.3")] {
|
||||
let (text, warnings) = pdf(&mut s, json!({"standard": standard}));
|
||||
assert!(text.starts_with(header), "{standard}");
|
||||
assert!(warnings.iter().any(|w| w.contains("flattened")), "{standard}: {warnings:?}");
|
||||
for no in ["/SMask", "/S/Transparency", "/ca 0.5"] {
|
||||
assert!(!text.contains(no), "{standard}: no {no}");
|
||||
}
|
||||
assert!(text.contains("/S/GTS_PDFX") && text.contains("/GTS_PDFXVersion"), "{standard}");
|
||||
}
|
||||
// PDF/X-1a: CMYK only, the flattened colours too.
|
||||
let (text, _) = pdf(&mut s, json!({"standard": "pdfX1a"}));
|
||||
assert!(!text.contains("/DeviceRGB") && !text.contains("/ICCBased"));
|
||||
assert!(!text.split_whitespace().any(|t| t == "rg"), "no RGB fills");
|
||||
// The document itself is untouched.
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// PDF/X-4 keeps transparency (and the image's soft mask).
|
||||
let (text, warnings) = pdf(&mut s, json!({"preset": "PDF/X-4:2010"}));
|
||||
assert!(text.starts_with("%PDF-1.6"));
|
||||
assert!(text.contains("/SMask") && !warnings.iter().any(|w| w.contains("flattened")), "{warnings:?}");
|
||||
assert!(text.contains("<pdfxid:GTS_PDFXVersion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
|
||||
let mut s = session();
|
||||
let settings = |s: &mut Session, p: Value| s.execute("document.pdfSettings", &p).unwrap()["settings"].clone();
|
||||
// The default preset is PDF 1.7 and keeps editing data.
|
||||
let v = settings(&mut s, json!({"standard": "pdfX4"}));
|
||||
assert_eq!((&v["compatibility"], &v["preserveEditing"]), (&json!("1.6"), &json!(false)));
|
||||
let v = settings(&mut s, json!({"standard": "pdfX1a", "createLayers": null, "preset": "Press Quality"}));
|
||||
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.3"), &json!(false)));
|
||||
let v = settings(&mut s, json!({"standard": "pdfX4", "compatibility": "1.5", "createLayers": true}));
|
||||
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.5"), &json!(true)));
|
||||
// What is asked for explicitly is checked, not changed.
|
||||
for p in [
|
||||
json!({"standard": "pdfX4", "compatibility": "1.7"}),
|
||||
json!({"standard": "pdfX3", "compatibility": "1.6"}),
|
||||
json!({"standard": "pdfX1a", "createLayers": true}),
|
||||
json!({"standard": "pdfX3", "preserveEditing": true}),
|
||||
json!({"standard": "pdfX4", "security": {"openPassword": "x"}}),
|
||||
json!({"standard": "pdfX1a", "security": {"permissionsPassword": "x"}}),
|
||||
json!({"standard": "pdfX1a", "output": {"outputIntent": vectorcraft_color::cms::SRGB}}),
|
||||
json!({"standard": "pdfX4", "output": {"outputIntent": "No Such Press"}}),
|
||||
] {
|
||||
let e = s.execute("document.exportPdf", &p).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}");
|
||||
}
|
||||
// The built-in PDF/X presets export as they are.
|
||||
for preset in ["PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] {
|
||||
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
//! Window → Flattener Preview: the same plan, worked out without rendering, reports what flattening
|
||||
//! the document would touch ([`FlattenReport`], `flattener.preview`).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -129,6 +130,12 @@ pub struct FlattenOptions {
|
||||
/// Areas showing a single paint keep it (spot and process colours, swatches) with its
|
||||
/// overprint; off, the flattened objects lose their overprints.
|
||||
pub preserve_overprints: bool,
|
||||
/// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with
|
||||
/// see-through pixels (these keys, [`see_through_images`]) count as transparency, and
|
||||
/// rasterized areas clipped to their regions are opaque. `None` for ordinary flattening. Not a
|
||||
/// preset option.
|
||||
#[serde(skip)]
|
||||
pub no_transparency: Option<BTreeSet<String>>,
|
||||
}
|
||||
|
||||
impl Default for FlattenOptions {
|
||||
@@ -175,6 +182,7 @@ impl FlattenOptions {
|
||||
anti_alias,
|
||||
preserve_alpha: false,
|
||||
preserve_overprints: true,
|
||||
no_transparency: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +204,16 @@ impl FlattenOptions {
|
||||
Self::from_params_with(p, &[])
|
||||
}
|
||||
|
||||
/// The options an export flattens with: preset `name` (built-in or one of `saved`; default
|
||||
/// medium) with the export's `flattener` `options` over it.
|
||||
pub fn for_export(name: Option<&str>, options: Option<&Value>, saved: &[FlattenerPreset]) -> std::result::Result<Self, String> {
|
||||
let mut q = json!({ "options": options.cloned().unwrap_or(Value::Null) });
|
||||
if let Some(name) = name {
|
||||
q["preset"] = json!(name);
|
||||
}
|
||||
Self::from_params_with(&q, saved)
|
||||
}
|
||||
|
||||
/// The options `p` asks for: `preset` (built-in or one of `saved`; default medium) adjusted by
|
||||
/// the option keys given at the top level or in `options`.
|
||||
pub fn from_params_with(p: &Value, saved: &[FlattenerPreset]) -> std::result::Result<Self, String> {
|
||||
@@ -222,6 +240,11 @@ impl FlattenOptions {
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// `n` shows an image whose see-through pixels count as transparency.
|
||||
fn draws_see_through_image(&self, n: &Node) -> bool {
|
||||
self.no_transparency.as_ref().is_some_and(|keys| draws_image(n, keys))
|
||||
}
|
||||
|
||||
/// Whether a group that splits into `regions` atomic regions is rasterized whole.
|
||||
fn rasterize_all(&self, regions: usize) -> bool {
|
||||
self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0))
|
||||
@@ -792,7 +815,7 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R
|
||||
let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() };
|
||||
let plain: Vec<Node> = art.iter().map(|n| sc.plain(n)).collect();
|
||||
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
|
||||
let see_through: Vec<bool> = plain.iter().map(Node::shows_transparency).collect();
|
||||
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
|
||||
let cmyk = src.color_mode == ColorMode::Cmyk;
|
||||
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
|
||||
for g in overlapping(&reaches) {
|
||||
@@ -927,7 +950,13 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
|
||||
);
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
|
||||
if !o.preserve_alpha || !o.anti_alias {
|
||||
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
|
||||
// Without transparency the clip alone shapes the image: it is opaque, over white (its
|
||||
// edge pixels too).
|
||||
for p in img.pixels.as_chunks_mut::<4>().0 {
|
||||
p[3] = 255;
|
||||
}
|
||||
} else if !o.preserve_alpha || !o.anti_alias {
|
||||
// The art's coverage: drawn opaque, so only its own edges (and soft effects) are partial.
|
||||
let cover: Vec<Node> = art
|
||||
.iter()
|
||||
@@ -988,7 +1017,7 @@ fn opaque(n: &mut Node) {
|
||||
/// clipping path stays). `None` when that changes nothing.
|
||||
pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result<Option<Document>> {
|
||||
let outlining = o.text_to_outlines || o.strokes_to_outlines || !o.preserve_overprints;
|
||||
if !outlining && !doc.layers.iter().any(|l| l.shows_transparency()) {
|
||||
if !outlining && !doc.layers.iter().any(|l| l.shows_transparency() || o.draws_see_through_image(l)) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut roots = vec![];
|
||||
@@ -1197,6 +1226,25 @@ fn carry(from: &Node, mut to: Node) -> Node {
|
||||
to
|
||||
}
|
||||
|
||||
/// The keys of `doc`'s images that have see-through pixels (an alpha channel that isn't opaque
|
||||
/// everywhere).
|
||||
pub(crate) fn see_through_images(doc: &Document) -> BTreeSet<String> {
|
||||
let see_through = |b: &ImageBlob| {
|
||||
b.mime != "image/jpeg" && image::load_from_memory(&b.bytes).is_ok_and(|i| i.color().has_alpha() && i.to_rgba8().pixels().any(|p| p[3] < 255))
|
||||
};
|
||||
doc.images.iter().filter(|(_, b)| see_through(b)).map(|(k, _)| k.clone()).collect()
|
||||
}
|
||||
|
||||
/// `n` shows an image of `keys` (itself or inside).
|
||||
fn draws_image(n: &Node, keys: &BTreeSet<String>) -> bool {
|
||||
!keys.is_empty()
|
||||
&& n.visible
|
||||
&& match &n.kind {
|
||||
NodeKind::Image(im) => keys.contains(&im.key),
|
||||
_ => n.children().is_some_and(|ch| ch.iter().any(|c| draws_image(c, keys))),
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_fx(fx: &[Effect]) -> bool {
|
||||
fx.iter().any(|e| e.visible)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Type → Find Font: the fonts a document uses (flagging missing ones), replacing one font with
|
||||
//! another everywhere or in the selection, and selecting the text that uses a font.
|
||||
//! another everywhere or in the selection, and selecting the text that uses a font. Also the fonts
|
||||
//! available (the bundled and installed ones) and rescanning the installed fonts.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -38,6 +39,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
select_font
|
||||
),
|
||||
cmd!(
|
||||
query "text.fontList",
|
||||
"Font List",
|
||||
[],
|
||||
None,
|
||||
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
|
||||
always,
|
||||
font_list
|
||||
),
|
||||
cmd!(
|
||||
"text.rescanFonts",
|
||||
"Refresh Font List",
|
||||
[],
|
||||
None,
|
||||
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families available, installed faces found)",
|
||||
always,
|
||||
rescan
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -166,6 +185,53 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
match str_param(p, "family") {
|
||||
Some(family) => {
|
||||
if !db.has_family(family) {
|
||||
return Err(bad("text.fontList", format!("font family `{family}` is not available")));
|
||||
}
|
||||
// The name as the font spells it.
|
||||
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
|
||||
Ok(json!({ "family": name, "styles": db.styles(family) }))
|
||||
}
|
||||
None => Ok(json!({ "families": *db.family_list() })),
|
||||
}
|
||||
}
|
||||
|
||||
fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let faces = db.load_system_fonts();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let faces = 0;
|
||||
// The canvas redraws type in the fonts available now (renderers lay it out again when the
|
||||
// fonts change), and the bounds follow; that isn't an edit to save.
|
||||
for d in &mut s.docs {
|
||||
let mut texts = vec![];
|
||||
d.doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
texts.push(n.id);
|
||||
}
|
||||
});
|
||||
if !texts.is_empty() {
|
||||
let saved = !d.is_dirty();
|
||||
let doc = std::sync::Arc::make_mut(&mut d.doc);
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
if saved {
|
||||
d.mark_saved();
|
||||
}
|
||||
}
|
||||
d.revision += 1;
|
||||
}
|
||||
Ok(json!({ "families": db.family_list().len(), "faces": faces }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Object → Vector Halftone: the selected art (vectors, type, images) as vector dots, lines or
|
||||
//! shapes sized by its tone.
|
||||
//!
|
||||
//! The art is rendered offscreen and sampled once per halftone cell on a grid turned by the screen
|
||||
//! angle; each cell gets a shape whose area follows the tone there. Mono screens follow lightness
|
||||
//! in one colour; CMYK screens follow the art's ink amounts (as CMYK output separates it), one
|
||||
//! screen per ink at the classic angles, multiplied over each other. The dots can be clipped to the
|
||||
//! art's outline as real geometry.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId};
|
||||
use vectorcraft_geom::hit::fill_contains;
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::isolated_doc;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"object.vectorHalftone",
|
||||
"Vector Halftone…",
|
||||
["Object"],
|
||||
None,
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots}",
|
||||
has_selection,
|
||||
halftone
|
||||
)]
|
||||
}
|
||||
|
||||
/// Most halftone cells per screen.
|
||||
const MAX_CELLS: f64 = 100_000.0;
|
||||
/// Most pixels the tone is sampled from.
|
||||
const MAX_SAMPLE_PIXELS: f64 = 16.0e6;
|
||||
/// Cells lighter than this get no dot.
|
||||
const MIN_TONE: f64 = 0.01;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum DotShape {
|
||||
Circle,
|
||||
Ellipse,
|
||||
Square,
|
||||
Diamond,
|
||||
Line,
|
||||
}
|
||||
|
||||
impl DotShape {
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"circle" | "round" | "dot" => DotShape::Circle,
|
||||
"ellipse" => DotShape::Ellipse,
|
||||
"square" => DotShape::Square,
|
||||
"diamond" => DotShape::Diamond,
|
||||
"line" | "lines" => DotShape::Line,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The shape for tone `t` (0..1) in a cell of side `s` centred on the origin, the screen
|
||||
/// running along x.
|
||||
fn path(self, t: f64, s: f64) -> PathData {
|
||||
let square = |side: f64| shapes::rectangle(Rect::from_center_size(Point::ZERO, (side, side)));
|
||||
match self {
|
||||
DotShape::Circle => {
|
||||
let r = s * (t / std::f64::consts::PI).sqrt();
|
||||
shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.0 * r, 2.0 * r)))
|
||||
}
|
||||
DotShape::Ellipse => {
|
||||
let r = s * (t / std::f64::consts::PI).sqrt();
|
||||
shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.6 * r, 2.0 * r / 1.3)))
|
||||
}
|
||||
DotShape::Square => square(s * t.sqrt()),
|
||||
DotShape::Diamond => square(s * t.sqrt()).transformed(Affine::rotate(std::f64::consts::FRAC_PI_4)),
|
||||
// Cells along a row join into a line as thick as the tone.
|
||||
DotShape::Line => shapes::rectangle(Rect::from_center_size(Point::ZERO, (s * 1.001, s * t))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One screen: its ink and angle, and how to read its tone from a sampled pixel.
|
||||
struct Screen {
|
||||
paint: Color,
|
||||
angle: f64,
|
||||
/// Index into the sampled pixel (RGBA lightness for mono, CMYK inks for cmyk).
|
||||
channel: usize,
|
||||
}
|
||||
|
||||
/// The sampled art: `w`×`h` pixels of 4 bytes over `region` at `scale` px/pt.
|
||||
struct Samples {
|
||||
px: Vec<u8>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
region: Rect,
|
||||
scale: f64,
|
||||
/// Mono: tones are 1 − lightness of RGBA pixels; CMYK: ink amounts.
|
||||
mono: bool,
|
||||
invert: bool,
|
||||
}
|
||||
|
||||
impl Samples {
|
||||
/// The tone (0..1) of `channel` at document point `p`.
|
||||
fn tone(&self, p: Point, channel: usize) -> f64 {
|
||||
let x = ((p.x - self.region.x0) * self.scale).floor();
|
||||
let y = ((p.y - self.region.y0) * self.scale).floor();
|
||||
if !(x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h) {
|
||||
return if self.invert { 1.0 } else { 0.0 };
|
||||
}
|
||||
let i = (y as usize * self.w + x as usize) * 4;
|
||||
let byte = |k: usize| self.px.get(i + k).copied().unwrap_or(0) as f64 / 255.0;
|
||||
let t = if self.mono { 1.0 - (0.2126 * byte(0) + 0.7152 * byte(1) + 0.0722 * byte(2)) } else { byte(channel) };
|
||||
if self.invert { 1.0 - t } else { t }
|
||||
}
|
||||
|
||||
/// The mean tone over a cell of side `s` at `c` turned by `rot` (3 × 3 samples).
|
||||
fn cell_tone(&self, c: Point, s: f64, rot: Affine, channel: usize) -> f64 {
|
||||
let mut sum = 0.0;
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
let off = Point::new((i as f64 - 1.0) * s / 3.0, (j as f64 - 1.0) * s / 3.0);
|
||||
sum += self.tone(c + (rot * off).to_vec2(), channel);
|
||||
}
|
||||
}
|
||||
sum / 9.0
|
||||
}
|
||||
}
|
||||
|
||||
fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.vectorHalftone";
|
||||
let shape = match str_param(p, "shape") {
|
||||
Some(v) => DotShape::parse(v).ok_or_else(|| bad(C, format!("unknown shape `{v}` (circle|ellipse|square|diamond|line)")))?,
|
||||
None => DotShape::Circle,
|
||||
};
|
||||
let frequency = f64_or(p, "frequency", 20.0);
|
||||
if !(1.0..=300.0).contains(&frequency) {
|
||||
return Err(bad(C, "frequency must be between 1 and 300 lines per inch"));
|
||||
}
|
||||
let angle = f64_or(p, "angle", 45.0);
|
||||
if !angle.is_finite() {
|
||||
return Err(bad(C, "invalid angle"));
|
||||
}
|
||||
let mono = match str_param(p, "mode").unwrap_or("mono") {
|
||||
"mono" => true,
|
||||
"cmyk" => false,
|
||||
other => return Err(bad(C, format!("unknown mode `{other}` (mono|cmyk)"))),
|
||||
};
|
||||
let color = match p.get("color") {
|
||||
Some(v) => color_value(v).ok_or_else(|| bad(C, "invalid color"))?,
|
||||
None => Color::BLACK,
|
||||
};
|
||||
let (invert, clip, keep) = (bool_or(p, "invert", false), bool_or(p, "clip", true), bool_or(p, "keepOriginal", false));
|
||||
let cell = 72.0 / frequency;
|
||||
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "the selection has no bounds"))?;
|
||||
if b.width() <= 0.0 || b.height() <= 0.0 || !b.area().is_finite() {
|
||||
return Err(bad(C, "the selection has no area"));
|
||||
}
|
||||
// The grid turned by the screen angle covers at most (w + h)² / 2.
|
||||
if (b.width() + b.height()).powi(2) / 2.0 / (cell * cell) > MAX_CELLS {
|
||||
return Err(bad(C, "too many dots for this size: lower the frequency"));
|
||||
}
|
||||
let nodes: Vec<Node> = roots
|
||||
.iter()
|
||||
.filter_map(|id| st.doc.node(*id).cloned())
|
||||
.map(|mut n| {
|
||||
n.visible = true;
|
||||
n
|
||||
})
|
||||
.collect();
|
||||
let outline = if clip {
|
||||
vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Sample the art at about four pixels per cell.
|
||||
let scale = (4.0 / cell).min((MAX_SAMPLE_PIXELS / b.area()).sqrt()).clamp(0.01, 16.0);
|
||||
let region = b.inflate(cell, cell);
|
||||
let tmp = isolated_doc(&st.doc, nodes);
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let (w, h) = vectorcraft_render::region_pixels(region, scale);
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let px = if mono {
|
||||
r.render_region(&tmp, region, scale, true).pixels
|
||||
} else {
|
||||
r.render_region_inks(&tmp, region, scale, &vectorcraft_render::RenderOptions { skip_templates: true, ..Default::default() })
|
||||
};
|
||||
if px.len() < w * h * 4 {
|
||||
return Err(EngineError::Other("the art could not be sampled".into()));
|
||||
}
|
||||
let samples = Samples { px, w, h, region, scale, mono, invert };
|
||||
let screens: Vec<Screen> = if mono {
|
||||
vec![Screen { paint: color, angle, channel: 0 }]
|
||||
} else {
|
||||
// Bottom to top: yellow, magenta, cyan, black.
|
||||
vec![
|
||||
Screen { paint: Color::cmyk(0.0, 0.0, 1.0, 0.0), angle: angle - 45.0, channel: 2 },
|
||||
Screen { paint: Color::cmyk(0.0, 1.0, 0.0, 0.0), angle: angle + 30.0, channel: 1 },
|
||||
Screen { paint: Color::cmyk(1.0, 0.0, 0.0, 0.0), angle: angle - 30.0, channel: 0 },
|
||||
Screen { paint: Color::cmyk(0.0, 0.0, 0.0, 1.0), angle, channel: 3 },
|
||||
]
|
||||
};
|
||||
let mut layers = vec![];
|
||||
let mut dots = 0;
|
||||
for sc in &screens {
|
||||
let (path, n) = screen_dots(&samples, b, cell, sc, shape, outline.as_ref());
|
||||
dots += n;
|
||||
if !path.is_empty() {
|
||||
layers.push((path, sc.paint));
|
||||
}
|
||||
}
|
||||
if layers.is_empty() {
|
||||
return Err(EngineError::Other("the art is too light for any dots".into()));
|
||||
}
|
||||
let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?;
|
||||
let id = s.edit("Vector Halftone", |d, sel| {
|
||||
let children = layers
|
||||
.into_iter()
|
||||
.map(|(path, ink)| {
|
||||
let mut n = Node::path(d.alloc_id(), path, Appearance::basic(Paint::solid(ink), Paint::None, 0.0));
|
||||
if !mono {
|
||||
n.blend = BlendMode::Multiply;
|
||||
}
|
||||
Arc::new(n)
|
||||
})
|
||||
.collect();
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = Some("Vector Halftone".into());
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
if !keep {
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
}
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "dots": dots }))
|
||||
}
|
||||
|
||||
/// The dots of one screen over `bounds` as one path, with their count.
|
||||
fn screen_dots(samples: &Samples, bounds: Rect, cell: f64, sc: &Screen, shape: DotShape, outline: Option<&(BezPath, FillRule)>) -> (PathData, usize) {
|
||||
let rot = Affine::rotate(sc.angle.to_radians());
|
||||
let inv = rot.inverse();
|
||||
// The grid covers the bounds in screen space.
|
||||
let corners =
|
||||
[bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)].map(|q| inv * q);
|
||||
let (mut lo, mut hi) = (corners[0], corners[0]);
|
||||
for q in &corners[1..] {
|
||||
lo = Point::new(lo.x.min(q.x), lo.y.min(q.y));
|
||||
hi = Point::new(hi.x.max(q.x), hi.y.max(q.y));
|
||||
}
|
||||
let (i0, i1) = ((lo.x / cell).floor() as i64 - 1, (hi.x / cell).ceil() as i64 + 1);
|
||||
let (j0, j1) = ((lo.y / cell).floor() as i64 - 1, (hi.y / cell).ceil() as i64 + 1);
|
||||
let near = bounds.inflate(cell, cell);
|
||||
let (mut inside, mut edge) = (vec![], vec![]);
|
||||
for j in j0..=j1 {
|
||||
for i in i0..=i1 {
|
||||
let c = rot * Point::new((i as f64 + 0.5) * cell, (j as f64 + 0.5) * cell);
|
||||
if !near.contains(c) {
|
||||
continue;
|
||||
}
|
||||
let t = samples.cell_tone(c, cell, rot, sc.channel).clamp(0.0, 1.0);
|
||||
if t < MIN_TONE {
|
||||
continue;
|
||||
}
|
||||
let place = Affine::translate(c.to_vec2()) * rot;
|
||||
let dot = shape.path(t, cell).transformed(place);
|
||||
// Dots wholly inside the outline need no clipping.
|
||||
let whole = match (outline, dot.control_bounds()) {
|
||||
(Some((bp, rule)), Some(db)) => [db.origin(), Point::new(db.x1, db.y0), Point::new(db.x1, db.y1), Point::new(db.x0, db.y1)]
|
||||
.iter()
|
||||
.all(|q| fill_contains(bp, *rule, *q)),
|
||||
_ => true,
|
||||
};
|
||||
if whole { inside.push(dot) } else { edge.push(dot) }
|
||||
}
|
||||
}
|
||||
let count = inside.len() + edge.len();
|
||||
let mut subs: Vec<_> = inside.into_iter().flat_map(|p| p.subpaths).collect();
|
||||
if let Some((bp, rule)) = outline
|
||||
&& !edge.is_empty()
|
||||
{
|
||||
let edge = PathData::new(edge.into_iter().flat_map(|p| p.subpaths).collect());
|
||||
let clipped = po::boolean(&edge, FillRule::NonZero, &PathData::from_bezpath(bp), *rule, BoolOp::Intersect);
|
||||
subs.extend(clipped.subpaths);
|
||||
} else {
|
||||
subs.extend(edge.into_iter().flat_map(|p| p.subpaths));
|
||||
}
|
||||
(PathData::new(subs), count)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"object.envelope.editContents",
|
||||
"Edit Contents",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Shift+V"),
|
||||
None,
|
||||
"{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}",
|
||||
has_envelope,
|
||||
env_edit_contents
|
||||
|
||||
@@ -86,9 +86,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset Bounding Box",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{} no-op: VectorCraft bounding boxes are always axis-aligned to the document → {changed: 0}",
|
||||
"{} square the bounding box of the selected objects to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset}",
|
||||
has_selection,
|
||||
|_, _| Ok(json!({ "changed": 0 }))
|
||||
reset_bounding_box
|
||||
),
|
||||
cmd!(
|
||||
"object.rasterize",
|
||||
@@ -177,6 +177,25 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reset Bounding Box ----------
|
||||
|
||||
fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let turned: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.bbox_angle != 0.0)).collect();
|
||||
if !turned.is_empty() {
|
||||
s.edit("Reset Bounding Box", |d, _| {
|
||||
for id in &turned {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.bbox_angle = 0.0;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "changed": turned.len() }))
|
||||
}
|
||||
|
||||
// ---------- Lock / Hide ----------
|
||||
|
||||
fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod colormgmt;
|
||||
pub mod colortheme;
|
||||
mod create;
|
||||
mod css;
|
||||
mod cut;
|
||||
pub(crate) mod distortcmds;
|
||||
mod docinfo;
|
||||
mod docmenu;
|
||||
@@ -26,6 +27,7 @@ mod fonts;
|
||||
pub(crate) mod freeform;
|
||||
pub(crate) mod gradient;
|
||||
pub(crate) mod graph;
|
||||
mod halftone;
|
||||
pub mod help;
|
||||
mod layer;
|
||||
pub mod links;
|
||||
@@ -45,9 +47,12 @@ mod pathops;
|
||||
mod patterncmds;
|
||||
pub mod pdfcmds;
|
||||
mod place;
|
||||
pub mod plugin;
|
||||
pub mod prefscmds;
|
||||
pub mod print;
|
||||
pub(crate) mod printadvanced;
|
||||
pub mod printpresets;
|
||||
pub mod printtiling;
|
||||
pub mod rasterfx;
|
||||
mod recolor;
|
||||
pub mod recovery;
|
||||
@@ -241,6 +246,10 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(assets::specs());
|
||||
v.extend(css::specs());
|
||||
v.extend(printpresets::specs());
|
||||
v.extend(printtiling::specs());
|
||||
v.extend(plugin::specs());
|
||||
v.extend(cut::specs());
|
||||
v.extend(halftone::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rotate…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{angle: deg (counter-clockwise), origin?: [x,y], copy?}",
|
||||
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?} → {ids}",
|
||||
has_selection,
|
||||
rotate
|
||||
),
|
||||
@@ -175,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Bounds",
|
||||
[],
|
||||
None,
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
|
||||
has_selection,
|
||||
set_bounds
|
||||
),
|
||||
@@ -188,7 +188,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
|
||||
if let Some(o) = point_param(p, "origin") {
|
||||
return Ok(o);
|
||||
}
|
||||
s.transform_bounds(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
|
||||
s.transform_box(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
|
||||
}
|
||||
|
||||
/// What a transform command scales besides geometry: Scale Strokes & Effects and Scale Corners from
|
||||
@@ -255,8 +255,12 @@ fn about(o: Point, a: Affine) -> Affine {
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
// Illustrator angles are counter-clockwise; y is down, so negate.
|
||||
let a = about(o, Affine::rotate(-f64_or(p, "angle", 0.0).to_radians()));
|
||||
let mut angle = f64_or(p, "angle", 0.0);
|
||||
if bool_or(p, "absolute", false) {
|
||||
angle = vectorcraft_geom::normalize_deg(angle - s.doc()?.doc.bbox_angle(&ids));
|
||||
}
|
||||
// Angles are counter-clockwise; y is down, so negate.
|
||||
let a = about(o, Affine::rotate(-angle.to_radians()));
|
||||
apply_transform(s, "Rotate", ids, a, p)
|
||||
}
|
||||
|
||||
@@ -716,6 +720,11 @@ impl Session {
|
||||
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
|
||||
self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds)
|
||||
}
|
||||
/// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding
|
||||
/// box, see [`Document::oriented_bounds`]).
|
||||
pub fn transform_box(&self, ids: &[NodeId]) -> Option<OrientedBox> {
|
||||
self.doc().ok()?.doc.oriented_bounds(ids, self.prefs.use_preview_bounds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds.
|
||||
@@ -876,10 +885,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let g = d.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
|
||||
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
|
||||
// the math below runs in its frame.
|
||||
let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
|
||||
let g = frame.rect;
|
||||
// With Use Preview Bounds the values measure the visual box, whose margins around the
|
||||
// geometric box (left, top, right, bottom) scale with the strokes or stay.
|
||||
let v = if s.prefs.use_preview_bounds { d.bounds_of(&ids, true).unwrap_or(g) } else { g };
|
||||
let v = if s.prefs.use_preview_bounds { d.oriented_bounds(&ids, true).map_or(g, |b| b.rect) } else { g };
|
||||
let m = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1];
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
let rp = vectorcraft_geom::reference_point(v, refi);
|
||||
@@ -906,9 +918,10 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// box's reference point moves with the art, the margin from it to the visual one by `f`.
|
||||
let gp = vectorcraft_geom::reference_point(g, refi);
|
||||
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
|
||||
let tx = f64_or(p, "x", rp.x) - new_rp.x;
|
||||
let ty = f64_or(p, "y", rp.y) - new_rp.y;
|
||||
apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, p)
|
||||
// x, y place the reference point on the page.
|
||||
let page = frame.to_doc() * rp;
|
||||
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
|
||||
}
|
||||
|
||||
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -757,11 +757,7 @@ fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
visit(c, true, false, false, v, opts);
|
||||
}
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
if empty_text && t.plain_text().trim().is_empty() {
|
||||
v.push(n.id);
|
||||
}
|
||||
}
|
||||
NodeKind::Text(t) if empty_text && t.plain_text().trim().is_empty() => v.push(n.id),
|
||||
NodeKind::Layer { children, .. } => {
|
||||
for c in children {
|
||||
visit(c, false, false, false, v, opts);
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! `plugin.*` commands: sandboxed WebAssembly plug-ins (`vectorcraft-plugins`).
|
||||
//!
|
||||
//! Plug-ins are installed per process (`plugin.install`, or every `*.wasm` in the Additional
|
||||
//! Plug-ins Folder preference) and listed with `plugin.list` / `plugin.info`. Object filters run
|
||||
//! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a
|
||||
//! plug-in that fails leaves the document untouched); live-effect plug-ins are applied like any
|
||||
//! effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_plugins::{Kind, Plugin, effect, objects, registry};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// What a plug-in error looks like to the user.
|
||||
fn err(e: vectorcraft_plugins::Error) -> EngineError {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
|
||||
/// JSON description of an installed plug-in.
|
||||
pub fn describe(p: &Plugin) -> Value {
|
||||
let m = p.manifest();
|
||||
let mut v = json!({
|
||||
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
|
||||
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
|
||||
"size": p.size(), "source": p.source(),
|
||||
});
|
||||
if m.kind == Kind::Effect {
|
||||
v["effect"] = json!(effect::effect_id(&m.id));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The plug-in's own parameters from command params: `params` if given, else the top-level keys
|
||||
/// minus the ones the command itself uses.
|
||||
fn plugin_params(p: &Value) -> Value {
|
||||
if let Some(v) = p.get("params") {
|
||||
return v.clone();
|
||||
}
|
||||
match p {
|
||||
Value::Object(m) => {
|
||||
Value::Object(m.iter().filter(|(k, _)| !matches!(k.as_str(), "id" | "ids")).map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
}
|
||||
_ => Value::Object(Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?;
|
||||
registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into())))
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.run";
|
||||
let plugin = plugin_by_id(C, p)?;
|
||||
let m = plugin.manifest();
|
||||
if m.kind != Kind::Filter {
|
||||
return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id))));
|
||||
}
|
||||
// Bad parameters fail before anything runs.
|
||||
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
|
||||
let st = s.doc()?;
|
||||
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
|
||||
let inputs = objects::filter_targets(&st.doc, &selected);
|
||||
let parent = st.insertion_parent();
|
||||
let bounds = st.doc.bounds_of(&inputs, false);
|
||||
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
|
||||
let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
|
||||
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect))});
|
||||
let input = objects::encode_input(&st.doc, &inputs);
|
||||
let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?;
|
||||
let out = objects::decode(&plugin.run(&input, ¶ms).map_err(err)?).map_err(err)?;
|
||||
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
|
||||
let ids = objects::apply_output(d, &inputs, out, parent).map_err(err)?;
|
||||
sel.set(ids.iter().copied());
|
||||
Ok(ids)
|
||||
})?;
|
||||
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Largest base64 payload `plugin.install` accepts (the module limit, encoded).
|
||||
const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
|
||||
|
||||
/// The `.wasm` extension plug-in files have (File › Open installs them).
|
||||
pub const EXTS: &[&str] = &["wasm"];
|
||||
|
||||
fn install(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.install";
|
||||
let plugin = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
|
||||
(_, Some(data)) => {
|
||||
if data.len() > MAX_DATA_CHARS {
|
||||
return Err(bad(C, "`dataBase64` is larger than the module limit"));
|
||||
}
|
||||
let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, "`dataBase64` is not valid base64"))?;
|
||||
let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?;
|
||||
match str_param(p, "name") {
|
||||
Some(name) => plugin.with_source(name),
|
||||
None => plugin,
|
||||
}
|
||||
}
|
||||
(Some(path), None) => load_path(path)?,
|
||||
(None, None) => return Err(bad(C, "give `path` (a .wasm file) or `dataBase64` (the module)")),
|
||||
};
|
||||
if !p.get("replace").and_then(Value::as_bool).unwrap_or(true) && registry::get(plugin.id()).is_some() {
|
||||
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
|
||||
}
|
||||
Ok(describe(®istry::install(plugin)))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_path(path: &str) -> Result<Plugin> {
|
||||
registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_path(_: &str) -> Result<Plugin> {
|
||||
Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into()))
|
||||
}
|
||||
|
||||
/// The plug-in folder last loaded from the preferences, and what happened.
|
||||
static FOLDER: Mutex<Option<(String, Value)>> = Mutex::new(None);
|
||||
|
||||
/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and has changed since
|
||||
/// the last load. Runs whenever preferences are applied (startup, `prefs.set`). Native only.
|
||||
pub(crate) fn sync_prefs(s: &Session) {
|
||||
let folder = s.prefs.plugins_folder.trim();
|
||||
if folder.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_ref().is_some_and(|(f, _)| f == folder) {
|
||||
return;
|
||||
}
|
||||
*last = Some((folder.to_string(), load_folder(folder)));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
match registry::load_folder(std::path::Path::new(folder)) {
|
||||
Ok(r) => {
|
||||
json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()})
|
||||
}
|
||||
Err(e) => json!({"folder": folder, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
json!({"folder": folder, "error": "plug-in folders are not available on the web"})
|
||||
}
|
||||
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.reload";
|
||||
let folder = match p.get("path") {
|
||||
Some(v) => v.as_str().map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "`path` must be a folder"))?.to_string(),
|
||||
None => s.prefs.plugins_folder.trim().to_string(),
|
||||
};
|
||||
if folder.is_empty() {
|
||||
return Err(bad(C, "no folder: pass `path` or set Preferences › Performance & Storage › Additional Plug-ins Folder"));
|
||||
}
|
||||
let report = load_folder(&folder);
|
||||
if let Some(e) = report.get("error").and_then(Value::as_str) {
|
||||
return Err(EngineError::Other(e.to_string()));
|
||||
}
|
||||
*FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone()));
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone());
|
||||
Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder}))
|
||||
}
|
||||
|
||||
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let plugin = plugin_by_id("plugin.info", p)?;
|
||||
let mut v = describe(&plugin);
|
||||
v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null);
|
||||
v["defaults"] = Value::Object(plugin.manifest().defaults());
|
||||
v["lastError"] = json!(effect::last_error(plugin.id()));
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn remove(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
|
||||
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "plugin.list",
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
query "plugin.info",
|
||||
"Plug-in Info",
|
||||
[],
|
||||
None,
|
||||
"{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null)}",
|
||||
always,
|
||||
info
|
||||
),
|
||||
cmd!(
|
||||
"plugin.run",
|
||||
"Run Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection)} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
cmd!(
|
||||
query "plugin.install",
|
||||
"Install Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
always,
|
||||
install
|
||||
),
|
||||
cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove),
|
||||
cmd!(
|
||||
query "plugin.reload",
|
||||
"Reload Plug-ins",
|
||||
["Object", "Plug-ins"],
|
||||
None,
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
always,
|
||||
reload
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -320,6 +320,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool),
|
||||
// Native saves (`document.save {compress}`)
|
||||
p!("useCompression", "File Handling", "Files", "Use Compression", bool),
|
||||
// The W/H link of the Transform panel, the Properties panel and the Control bar
|
||||
p!("constrainProportions", "General", "Transform Panel", "Constrain Width and Height Proportions", bool),
|
||||
];
|
||||
|
||||
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
|
||||
@@ -453,6 +455,8 @@ impl Session {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
// Plug-ins in a newly set Additional Plug-ins Folder are installed.
|
||||
super::plugin::sync_prefs(self);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ use vectorcraft_pdf::{PrintOptions, PrintSettings};
|
||||
|
||||
use super::fileio::pdf::{merge, pdf_error};
|
||||
use super::fileio::{extension, write_or_return};
|
||||
use super::flatten::{FlattenOptions, flatten_document};
|
||||
use super::*;
|
||||
|
||||
const SETUP: &str = "print.setup";
|
||||
@@ -22,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Setup",
|
||||
[],
|
||||
None,
|
||||
"{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values in separations)}}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
|
||||
"{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
|
||||
has_doc,
|
||||
setup
|
||||
),
|
||||
@@ -53,7 +52,7 @@ fn saved(doc: &Document) -> Value {
|
||||
}
|
||||
|
||||
/// The settings of `doc` with `p.settings` over them, checked.
|
||||
fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSettings> {
|
||||
pub(crate) fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSettings> {
|
||||
settings_over(cmd, saved(doc), p)
|
||||
}
|
||||
|
||||
@@ -69,7 +68,7 @@ pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result<PrintS
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn to_json(set: &PrintSettings) -> Result<Value> {
|
||||
pub(crate) fn to_json(set: &PrintSettings) -> Result<Value> {
|
||||
serde_json::to_value(set).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -90,6 +89,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "print.preview";
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = settings(C, doc, p)?;
|
||||
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
|
||||
let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
|
||||
let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
v["settings"] = to_json(&set)?;
|
||||
@@ -110,25 +110,25 @@ fn to_postscript(p: &Value) -> Result<bool> {
|
||||
|
||||
fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let postscript = to_postscript(p)?;
|
||||
let flattener = if postscript {
|
||||
let preset = str_param(p, "flattenerPreset").unwrap_or("medium");
|
||||
Some(
|
||||
FlattenOptions::find_preset(preset, &s.prefs.flattener_presets)
|
||||
.ok_or_else(|| bad(PRINT, format!("flattenerPreset `{preset}`: high, medium, low or a saved one (see flattener.presets.list)")))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = settings(PRINT, doc, p)?;
|
||||
let mut set = settings(PRINT, doc, p)?;
|
||||
if postscript {
|
||||
// PostScript has no transparency: `flattenerPreset`, else the Advanced preset, else medium.
|
||||
let given = str_param(p, "flattenerPreset").filter(|n| !n.trim().is_empty());
|
||||
let preset = given.or(Some(set.advanced.flattener_preset.as_str()).filter(|n| !n.trim().is_empty())).unwrap_or("medium");
|
||||
set.advanced.flattener_preset = preset.to_string();
|
||||
}
|
||||
// Raster effects print as images at the document's raster effects resolution.
|
||||
let flat = super::rasterfx::flatten_raster_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
// Print as Bitmap and the flattener preset (Advanced) render and flatten it first.
|
||||
let prepared = super::printadvanced::prepare(PRINT, doc, &set, &s.prefs.flattener_presets)?;
|
||||
let path = str_param(p, "path");
|
||||
let Some(flattener) = flattener else {
|
||||
let r = vectorcraft_pdf::print(doc, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?;
|
||||
if !postscript {
|
||||
let r = vectorcraft_pdf::print(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() })
|
||||
.map_err(|e| pdf_error(PRINT, e))?;
|
||||
return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings }));
|
||||
};
|
||||
}
|
||||
let level = match p.get("level").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let t = super::fileio::dxf::text(v);
|
||||
@@ -136,14 +136,16 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => Level::default(),
|
||||
};
|
||||
// PostScript has no transparency.
|
||||
let flat = flatten_document(doc, &flattener)?;
|
||||
let mut warnings = vec![];
|
||||
if flat.is_some() && doc.layers.iter().any(|l| l.shows_transparency()) {
|
||||
let bitmap = set.advanced.print_as_bitmap && set.output.mode == vectorcraft_pdf::OutputMode::Composite;
|
||||
if !bitmap && doc.layers.iter().any(|l| l.shows_transparency()) {
|
||||
warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string());
|
||||
}
|
||||
if !set.color.profile.trim().is_empty() && set.output.mode == vectorcraft_pdf::OutputMode::Composite {
|
||||
warnings.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string());
|
||||
}
|
||||
let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness);
|
||||
let plan = vectorcraft_pdf::plan(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
|
||||
let plan = vectorcraft_pdf::plan(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
|
||||
.map_err(|e| pdf_error(PRINT, e))?;
|
||||
let pages: Vec<PrintPage> = plan
|
||||
.sheets
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! File → Print › Advanced, what needs the renderer and the flattener (the PDF writer does the
|
||||
//! rest): Print as Bitmap renders each printed region as one image, and a flattener preset
|
||||
//! flattens transparency, both on the document as it prints (only the layers that print,
|
||||
//! overprints discarded or simulated in composite output) before it is laid out on paper.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::marks::outset;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, LayerColor, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
use vectorcraft_pdf::{OutputMode, PrintOverprints, PrintSettings};
|
||||
use vectorcraft_render::{AntiAlias, RenderOptions};
|
||||
|
||||
use super::fileio::pdf::pdf_error;
|
||||
use super::flatten::{FlattenOptions, FlattenerPreset, flatten_document};
|
||||
use super::menucmds::{MAX_PIXELS, unique_key};
|
||||
use super::*;
|
||||
|
||||
/// The flattener options of `set`'s preset (`None`: transparency prints live); an error naming
|
||||
/// `cmd` for a preset that is neither built in nor in `saved`.
|
||||
pub(crate) fn flattener(cmd: &str, set: &PrintSettings, saved: &[FlattenerPreset]) -> Result<Option<FlattenOptions>> {
|
||||
let name = set.advanced.flattener_preset.trim();
|
||||
if name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let o = FlattenOptions::find_preset(name, saved)
|
||||
.ok_or_else(|| bad(cmd, format!("flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list)")))?;
|
||||
Ok(Some(o))
|
||||
}
|
||||
|
||||
/// `doc` as Print as Bitmap and the flattener preset of `set` print it (from `saved` presets), or
|
||||
/// `None` when neither applies. Print as Bitmap is for composite output.
|
||||
pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[FlattenerPreset]) -> Result<Option<Document>> {
|
||||
let composite = set.output.mode == OutputMode::Composite;
|
||||
let bitmap = set.advanced.print_as_bitmap && composite;
|
||||
let flattener = flattener(cmd, set, saved)?;
|
||||
if !bitmap && flattener.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let printed = vectorcraft_pdf::printed_document(doc, set);
|
||||
if bitmap {
|
||||
return bitmap_document(cmd, &printed, set).map(Some);
|
||||
}
|
||||
let Some(mut o) = flattener else { return Ok(None) };
|
||||
// Composite overprints that were discarded or simulated aren't preserved either.
|
||||
o.preserve_overprints &= !composite || set.advanced.overprints == PrintOverprints::Preserve;
|
||||
Ok(Some(flatten_document(&printed, &o)?.unwrap_or_else(|| printed.into_owned())))
|
||||
}
|
||||
|
||||
/// Leave guide paths out of `nodes` (the renderer draws them; they never print).
|
||||
fn drop_guides(nodes: &mut Vec<Arc<Node>>) {
|
||||
fn has_guides(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: true, .. }) || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c)))
|
||||
}
|
||||
nodes.retain(|n| !matches!(n.kind, NodeKind::Path { guide: true, .. }));
|
||||
for n in nodes.iter_mut().filter(|n| has_guides(n)) {
|
||||
if let Some(children) = Arc::make_mut(n).children_mut() {
|
||||
drop_guides(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` (as it prints) with its art replaced by one image of each printed region (an artboard
|
||||
/// with its bleed, or all the art), at the document's raster effects resolution; regions where
|
||||
/// nothing is drawn get none.
|
||||
fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings) -> Result<Document> {
|
||||
let regions = vectorcraft_pdf::print_regions(doc, set).map_err(|e| pdf_error(cmd, e))?;
|
||||
let bleed = set.bleed.of(doc);
|
||||
let mut art = doc.clone();
|
||||
drop_guides(&mut art.layers);
|
||||
let anti_alias = if doc.raster_effects.anti_alias { AntiAlias::Art } else { AntiAlias::None };
|
||||
let opts = RenderOptions { skip_templates: true, anti_alias, ..Default::default() };
|
||||
let mut out = doc.clone();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let mut images = vec![];
|
||||
for (_, r) in regions {
|
||||
let area = outset(r, bleed);
|
||||
// At most MAX_PIXELS, and 65535 pixels a side.
|
||||
let side = area.width().max(area.height()).max(1.0);
|
||||
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
continue;
|
||||
}
|
||||
// Whole pixels.
|
||||
let region = Rect::new(
|
||||
area.x0,
|
||||
area.y0,
|
||||
area.x0 + (area.width() * scale).ceil().max(1.0) / scale,
|
||||
area.y0 + (area.height() * scale).ceil().max(1.0) / scale,
|
||||
);
|
||||
let img = renderer.render_region_with(&art, region, scale, &opts);
|
||||
if img.pixels.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
|
||||
continue;
|
||||
}
|
||||
let key = unique_key(&out, "print-bitmap");
|
||||
out.images.insert(key.clone(), ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?));
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() };
|
||||
images.push(Arc::new(Node::new(out.alloc_id(), NodeKind::Image(image))));
|
||||
}
|
||||
let mut layer = Node::layer(out.alloc_id(), "Bitmap", LayerColor::Preset(0));
|
||||
if let Some(children) = layer.children_mut() {
|
||||
*children = images;
|
||||
}
|
||||
out.layers = vec![Arc::new(layer)];
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! View → Show Print Tiling (`view.printTiling`) and the Print Tiling tool (`print.tiling.set`):
|
||||
//! the pages of the document's print settings drawn on the canvas, from `print.preview`'s layout
|
||||
//! (`print.tiling`), and where the tool put them ([`vectorcraft_pdf::TileOrigin`], saved with
|
||||
//! the print settings).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_pdf::{TileOrigin, TilingPage};
|
||||
|
||||
use super::fileio::pdf::pdf_error;
|
||||
use super::print::{settings, to_json};
|
||||
use super::*;
|
||||
|
||||
const SET: &str = "print.tiling.set";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "view.printTiling",
|
||||
"Show Print Tiling",
|
||||
["View"],
|
||||
None,
|
||||
"{on?: bool (default: toggle)} show the pages of the document's print settings on the canvas (each document has its own setting; the Print Tiling tool shows them too) → {on}",
|
||||
has_doc,
|
||||
show
|
||||
),
|
||||
cmd!(
|
||||
"print.tiling.set",
|
||||
"Print Tiling",
|
||||
[],
|
||||
None,
|
||||
"{origin: [x, y] (document point: the top-left corner of the first page's imageable area), artboard? (0-based: the artboard it is measured from; default the printed artboard containing origin, else the first; ignored with artboards: ignore)} | {reset: true} place the printed pages as the Print Tiling tool does, saved with the print settings (tileOrigin: {placed, x, y}, from the artboard's top-left corner; it replaces the placement, and tiles start there), as one undo step; reset → the placement places them again → {tileOrigin}",
|
||||
has_doc,
|
||||
set_origin
|
||||
),
|
||||
cmd!(
|
||||
query "print.tiling",
|
||||
"Print Tiling Pages",
|
||||
[],
|
||||
None,
|
||||
"{settings?: {…print.setup settings} (over the document's)} the pages as View → Show Print Tiling draws them, from print.preview's layout → {tileOrigin, pages: [{artboard (0-based; null with artboards ignored), number (1-based tile across then down, or page), printed (tiles outside tileRange don't), page: [x0, y0, x1, y1] (the paper's edge), imageable: [x0, y0, x1, y1] (inside the unprintable margin)}] (document space; every tile when tiling, else each page once)}",
|
||||
has_doc,
|
||||
tiling
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
st.print_tiling = p.get("on").and_then(Value::as_bool).unwrap_or(!st.print_tiling);
|
||||
Ok(json!({ "on": st.print_tiling }))
|
||||
}
|
||||
|
||||
/// The pages of `doc`'s print settings in document space (what View → Show Print Tiling draws);
|
||||
/// an error when they can't print.
|
||||
pub fn pages(doc: &Document) -> Result<Vec<TilingPage>> {
|
||||
pages_with(doc, &json!({}))
|
||||
}
|
||||
|
||||
fn pages_with(doc: &Document, p: &Value) -> Result<Vec<TilingPage>> {
|
||||
const C: &str = "print.tiling";
|
||||
let set = settings(C, doc, p)?;
|
||||
let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
|
||||
Ok(vectorcraft_pdf::tiling(&pv, &set))
|
||||
}
|
||||
|
||||
fn tiling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let origin = settings("print.tiling", doc, p)?.tile_origin;
|
||||
Ok(json!({ "tileOrigin": origin, "pages": pages_with(doc, p)? }))
|
||||
}
|
||||
|
||||
/// `[x, y]` at `key` of `p`.
|
||||
fn point(p: &Value, key: &str) -> Option<Point> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
}
|
||||
|
||||
fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let current = settings(SET, doc, &json!({}))?;
|
||||
let mut set = current.clone();
|
||||
set.tile_origin = if bool_or(p, "reset", false) {
|
||||
TileOrigin::default()
|
||||
} else {
|
||||
let at = point(p, "origin").filter(|q| q.x.is_finite() && q.y.is_finite());
|
||||
let at = at.ok_or_else(|| bad(SET, "give origin: [x, y], or reset: true"))?;
|
||||
let regions = vectorcraft_pdf::print_regions(doc, &set).map_err(|e| pdf_error(SET, e))?;
|
||||
let picked = match p.get("artboard").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let i = v.as_u64().ok_or_else(|| bad(SET, "artboard must be a 0-based index"))? as usize;
|
||||
regions.iter().find(|(a, _)| a.is_none() || *a == Some(i)).ok_or_else(|| bad(SET, format!("artboard {i} doesn't print")))?
|
||||
}
|
||||
None => regions.iter().find(|(_, r)| r.contains(at)).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
|
||||
};
|
||||
let corner = picked.1.origin();
|
||||
TileOrigin { placed: true, x: at.x - corner.x, y: at.y - corner.y }
|
||||
};
|
||||
set.check().map_err(|e| pdf_error(SET, e))?;
|
||||
if set != current {
|
||||
let stored = to_json(&set)?;
|
||||
s.edit("Print Tiling", |d, _| {
|
||||
d.print_setup = Some(stored);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "tileOrigin": set.tile_origin }))
|
||||
}
|
||||
@@ -58,6 +58,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
fit_headline
|
||||
),
|
||||
cmd!(
|
||||
"text.discardEmpty",
|
||||
"Discard Empty Type",
|
||||
[],
|
||||
None,
|
||||
"{id} remove text object `id` if it has no characters (what the Type tool does with point type it placed when editing ends). When nothing but that text changed since the step that created it, the steps since are dropped instead, leaving no trace in the history → {removed}",
|
||||
has_doc,
|
||||
discard_empty
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -449,3 +458,31 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "tracking": applied}))
|
||||
}
|
||||
|
||||
fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.discardEmpty";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
if edit::runs_len(&text_ref(s, id)?.runs) > 0 {
|
||||
return Ok(json!({"removed": false}));
|
||||
}
|
||||
// The newest step whose document lacks the text created it: when the document now is that one
|
||||
// plus the text (and its id), roll back to it without a trace.
|
||||
let st = s.doc_mut()?;
|
||||
if st.interaction.is_none()
|
||||
&& let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node(id).is_none())
|
||||
&& let Some(created) = st.history.undo.get(k)
|
||||
{
|
||||
let mut now = (*st.doc).clone();
|
||||
let mut before = (*created.doc).clone();
|
||||
before.alloc_id();
|
||||
if now.remove(id).is_ok() && now == before {
|
||||
st.doc = created.doc.clone();
|
||||
st.history.undo.truncate(k);
|
||||
st.selection.prune(&st.doc);
|
||||
st.revision += 1;
|
||||
return Ok(json!({"removed": true}));
|
||||
}
|
||||
}
|
||||
s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?;
|
||||
Ok(json!({"removed": true}))
|
||||
}
|
||||
@@ -25,6 +25,10 @@ pub fn node_summary(n: &Node) -> Value {
|
||||
if n.opacity < 1.0 {
|
||||
v["opacity"] = json!(n.opacity);
|
||||
}
|
||||
// The angle of its rotated bounding box (counter-clockwise degrees).
|
||||
if n.bbox_angle != 0.0 {
|
||||
v["rotation"] = json!(n.bbox_angle);
|
||||
}
|
||||
if !n.is_container() {
|
||||
v["fill"] = json!(n.appearance.fill_paint().label());
|
||||
v["stroke"] = json!(n.appearance.stroke_paint().label());
|
||||
@@ -64,6 +68,8 @@ pub fn document(s: &Session) -> Value {
|
||||
"isolation": st.isolation.map(|l| l.0),
|
||||
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
|
||||
// The angle the selection's bounding box stands at (counter-clockwise degrees).
|
||||
"selectionRotation": d.bbox_angle(&st.selection.objects),
|
||||
// The layer, group or object targeted through the Layers panel (`layer.target`).
|
||||
"target": st.selection.target.map(|t| t.0),
|
||||
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
||||
|
||||
@@ -129,6 +129,8 @@ pub struct DocState {
|
||||
pub recovered: bool,
|
||||
/// The document's Data Recovery copy, once one was written ([`cmd::recovery`]).
|
||||
pub recovery: Option<cmd::recovery::RecoveryCopy>,
|
||||
/// View → Show Print Tiling, per document (view state: not saved, not undoable).
|
||||
pub print_tiling: bool,
|
||||
}
|
||||
|
||||
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -160,6 +162,7 @@ impl DocState {
|
||||
view,
|
||||
recovered: false,
|
||||
recovery: None,
|
||||
print_tiling: false,
|
||||
}
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
|
||||
@@ -469,6 +472,10 @@ pub struct Prefs {
|
||||
/// Preferences dialog field: resetting the preferences keeps it; `print.presets.*` edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub print_presets: Vec<cmd::printpresets::PrintPreset>,
|
||||
/// Constrain Width and Height Proportions: the link between W and H in the Transform panel,
|
||||
/// the Properties panel and the Control bar (their size fields pass `proportional` to
|
||||
/// `object.setBounds`).
|
||||
pub constrain_proportions: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -601,6 +608,7 @@ impl Default for Prefs {
|
||||
web_export_presets: vec![],
|
||||
web_export_settings: None,
|
||||
print_presets: vec![],
|
||||
constrain_proportions: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -665,6 +673,9 @@ pub struct Session {
|
||||
pub(crate) batch_stash: Option<Vec<DocState>>,
|
||||
/// Where Data Recovery keeps its copies ([`cmd::recovery`]).
|
||||
pub recovery: cmd::recovery::Recovery,
|
||||
/// While the active tool's actions run: their commands aren't "a command from outside the
|
||||
/// tool" ([`Session::after_command`]).
|
||||
pub(crate) in_tool_actions: bool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -705,6 +716,7 @@ impl Session {
|
||||
note_depth: 1,
|
||||
batch_stash: None,
|
||||
recovery: Default::default(),
|
||||
in_tool_actions: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,6 +870,9 @@ impl Session {
|
||||
let p = self.noted(params);
|
||||
self.journal.push((id.to_string(), p));
|
||||
}
|
||||
if self.depth == 0 {
|
||||
self.after_command();
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
@@ -1085,12 +1100,16 @@ impl Session {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_adjust;
|
||||
#[cfg(test)]
|
||||
mod tests_appearance;
|
||||
#[cfg(test)]
|
||||
mod tests_assets;
|
||||
#[cfg(test)]
|
||||
mod tests_attributes;
|
||||
#[cfg(test)]
|
||||
mod tests_bboxrotate;
|
||||
#[cfg(test)]
|
||||
mod tests_brushsym;
|
||||
#[cfg(test)]
|
||||
mod tests_build;
|
||||
@@ -1121,6 +1140,8 @@ mod tests_containers;
|
||||
#[cfg(test)]
|
||||
mod tests_css;
|
||||
#[cfg(test)]
|
||||
mod tests_cut;
|
||||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
#[cfg(test)]
|
||||
mod tests_distort;
|
||||
@@ -1133,6 +1154,8 @@ mod tests_editcolors;
|
||||
#[cfg(test)]
|
||||
mod tests_effectedit;
|
||||
#[cfg(test)]
|
||||
mod tests_emptytype;
|
||||
#[cfg(test)]
|
||||
mod tests_expand;
|
||||
#[cfg(test)]
|
||||
mod tests_eyedropper;
|
||||
@@ -1149,12 +1172,16 @@ mod tests_flatten;
|
||||
#[cfg(test)]
|
||||
mod tests_focal;
|
||||
#[cfg(test)]
|
||||
mod tests_fontlist;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
#[cfg(test)]
|
||||
mod tests_gradpanel;
|
||||
#[cfg(test)]
|
||||
mod tests_halftone;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_knockout;
|
||||
@@ -1209,18 +1236,24 @@ mod tests_pdfraster;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
#[cfg(test)]
|
||||
mod tests_plugins;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_previewbounds;
|
||||
#[cfg(test)]
|
||||
mod tests_print;
|
||||
#[cfg(test)]
|
||||
mod tests_printadvanced;
|
||||
#[cfg(test)]
|
||||
mod tests_printpresets;
|
||||
#[cfg(test)]
|
||||
mod tests_printpreview;
|
||||
#[cfg(test)]
|
||||
mod tests_printps;
|
||||
#[cfg(test)]
|
||||
mod tests_printtiling;
|
||||
#[cfg(test)]
|
||||
mod tests_proxyitems;
|
||||
#[cfg(test)]
|
||||
mod tests_rastersettings;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Live colour adjustments (Effect → Color Adjustments) through the commands: applied, listed,
|
||||
//! exported, expanded and kept in the native file.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn red_rect(s: &mut Session) -> NodeId {
|
||||
let id = NodeId(s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
pub(crate) fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn fill_hex(n: &Node) -> String {
|
||||
n.appearance.fill_paint().color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
/// A selected embedded 4×4 red image.
|
||||
fn red_image(s: &mut Session) -> NodeId {
|
||||
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.edit("Place", |d, sel| {
|
||||
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: vectorcraft_geom::Affine::scale(10.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_effects_are_in_the_catalog_under_color_adjustments() {
|
||||
let mut s = session();
|
||||
let v = s.execute("effect.list", &json!({})).unwrap();
|
||||
let ids: Vec<&str> =
|
||||
v["catalog"].as_array().unwrap().iter().filter(|e| e["menu"][1] == "Color Adjustments").map(|e| e["id"].as_str().unwrap()).collect();
|
||||
for id in vectorcraft_render::effects::ADJUSTMENTS {
|
||||
assert!(ids.contains(&id), "{id} in {ids:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adjustment_is_live_and_one_undo_step() {
|
||||
let mut s = session();
|
||||
let id = red_rect(&mut s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
// Non-destructive: the fill stays red, the effect sits on the object.
|
||||
let n = node(&s, id);
|
||||
assert_eq!(fill_hex(&n), "#ff0000");
|
||||
assert_eq!(n.appearance.effects.len(), 1);
|
||||
// Drawn green.
|
||||
let img =
|
||||
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true);
|
||||
assert_eq!(img.pixel(50, 50), [0, 255, 0, 255]);
|
||||
// Editing the parameters changes it live.
|
||||
s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"hue": -120}})).unwrap();
|
||||
let img =
|
||||
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true);
|
||||
assert_eq!(img.pixel(50, 50), [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_and_expand_write_the_adjusted_colours() {
|
||||
let mut s = session();
|
||||
let id = red_rect(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "adjust.levels", "params": {"outputWhite": 128, "channel": "red"}})).unwrap();
|
||||
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
|
||||
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(svg.contains("#800000") && !svg.contains("#ff0000"), "{svg}");
|
||||
// Expand Appearance makes it permanent.
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let n = node(&s, id);
|
||||
assert!(n.appearance.effects.is_empty());
|
||||
assert_eq!(fill_hex(&n), "#800000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_image_is_adjusted_for_export_without_changing_its_pixels() {
|
||||
let mut s = session();
|
||||
let id = red_image(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let baked = vectorcraft_render::effects::bake_document(&doc).unwrap();
|
||||
let NodeKind::Image(im) = &baked.node(id).unwrap().kind else { panic!("not an image") };
|
||||
assert_ne!(im.key, "pic", "a recoloured copy");
|
||||
let px = image::load_from_memory(&baked.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
assert_eq!(px.get_pixel(1, 1).0, [0, 255, 0, 255]);
|
||||
assert_eq!(doc.images.len(), 1, "the document keeps its one image");
|
||||
// Through Expand Appearance too.
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let NodeKind::Image(im) = &node(&s, id).kind else { panic!("not an image") };
|
||||
let px = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
assert_eq!(px.get_pixel(0, 0).0, [0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjustments_survive_the_native_file() {
|
||||
let mut s = session();
|
||||
red_rect(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "adjust.curves", "params": {"points": "0,0 64,32 255,255", "channel": "green"}})).unwrap();
|
||||
let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap();
|
||||
let mut found = None;
|
||||
o.doc().unwrap().doc.walk(|n| {
|
||||
if let Some(e) = n.appearance.effects.first() {
|
||||
found = Some(e.clone());
|
||||
}
|
||||
});
|
||||
let e = found.expect("the effect is kept");
|
||||
assert_eq!(e.id, "adjust.curves");
|
||||
assert_eq!(e.params["points"], "0,0 64,32 255,255");
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Rotated bounding boxes (#39): a rotation keeps the objects' angle, the bounding box and its
|
||||
//! handles turn with them, sizes are measured along their own axes, and Reset Bounding Box squares
|
||||
//! the box to the page again.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::OrientedBox;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_tools::bbox::Handle;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn angle(s: &Session, id: NodeId) -> f64 {
|
||||
s.doc().unwrap().doc.node(id).unwrap().bbox_angle
|
||||
}
|
||||
|
||||
fn selection_box(s: &Session) -> OrientedBox {
|
||||
s.transform_box(&s.doc().unwrap().selection.objects).unwrap()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// A 100 × 50 rectangle centred on (200, 200), selected and turned to 45°.
|
||||
fn turned_rect(s: &mut Session) -> NodeId {
|
||||
let a = rect(s, 150.0, 175.0, 100.0, 50.0);
|
||||
sel(s, &[a]);
|
||||
s.execute("object.rotate", &json!({"angle": 45, "absolute": true})).unwrap();
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotating_to_45_keeps_45() {
|
||||
let mut s = session();
|
||||
let n = undo_len(&s);
|
||||
let a = turned_rect(&mut s);
|
||||
assert_eq!(undo_len(&s), n + 2, "the rectangle, then one step for the rotation");
|
||||
// The panel's angle and the inspector's.
|
||||
let b = selection_box(&s);
|
||||
assert!(near(b.angle, 45.0) && near(angle(&s, a), 45.0), "{b:?}");
|
||||
assert!(near(s.execute("document.inspect", &json!({})).unwrap()["selectionRotation"].as_f64().unwrap(), 45.0));
|
||||
// The box hugs the rectangle: its own sides, its own centre.
|
||||
assert!(near(b.rect.width(), 100.0) && near(b.rect.height(), 50.0), "{b:?}");
|
||||
assert!(b.center().distance(Point::new(200.0, 200.0)) < 1e-6);
|
||||
// Absolute angles: entering 45 again changes nothing, 90 turns it by 45 more.
|
||||
s.execute("object.rotate", &json!({"angle": 45, "absolute": true})).unwrap();
|
||||
assert!(near(angle(&s, a), 45.0));
|
||||
s.execute("object.rotate", &json!({"angle": 90, "absolute": true})).unwrap();
|
||||
assert!(near(angle(&s, a), 90.0));
|
||||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||||
assert!(near(b.width(), 50.0) && near(b.height(), 100.0), "{b:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(angle(&s, a), 45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_turn_with_the_object_and_scale_along_its_axes() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
let b = selection_box(&s);
|
||||
// The Right handle sits on the turned box, 50 pt from the centre along 45° (up and right).
|
||||
let right = b.to_doc() * Handle::Right.pos(b.rect);
|
||||
let d = std::f64::consts::FRAC_1_SQRT_2 * 50.0;
|
||||
assert!(right.distance(Point::new(200.0 + d, 200.0 - d)) < 1e-6, "{right:?}");
|
||||
// Drag it 50 pt further out along the object's own x axis.
|
||||
let to = Point::new(right.x + d, right.y - d);
|
||||
let n = undo_len(&s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for (kind, p) in [(PointerKind::Down, right), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// 150 × 50 along its own axes, still at exactly 45°, the left side where it was.
|
||||
assert_eq!(angle(&s, a), b.angle);
|
||||
let nb = selection_box(&s);
|
||||
assert!(near(nb.rect.width(), 150.0) && near(nb.rect.height(), 50.0), "{nb:?}");
|
||||
let left = |b: &OrientedBox| b.to_doc() * Handle::Left.pos(b.rect);
|
||||
assert!(left(&nb).distance(left(&b)) < 1e-6);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(selection_box(&s).rect.width(), 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_panel_sizes_follow_the_turned_box() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
let n = undo_len(&s);
|
||||
s.execute("object.setBounds", &json!({"width": 200, "reference": 4})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
let b = selection_box(&s);
|
||||
assert!(near(b.rect.width(), 200.0) && near(b.rect.height(), 50.0) && near(b.angle, 45.0), "{b:?}");
|
||||
assert!(b.center().distance(Point::new(200.0, 200.0)) < 1e-6);
|
||||
// X/Y place the box's own reference point on the page.
|
||||
s.execute("object.setBounds", &json!({"x": 300, "y": 100, "reference": 0})).unwrap();
|
||||
let b = selection_box(&s);
|
||||
assert!(b.reference_point(0).distance(Point::new(300.0, 100.0)) < 1e-6, "{b:?}");
|
||||
assert!(near(angle(&s, a), 45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_squares_the_box_in_one_step() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
let geometry = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(angle(&s, a), 0.0);
|
||||
// The art stays; the box is square to the page around it.
|
||||
let b = selection_box(&s);
|
||||
assert_eq!(b.angle, 0.0);
|
||||
assert_eq!(b.rect, geometry);
|
||||
// Nothing left to reset: no step.
|
||||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(angle(&s, a), 45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_flips_keep_an_upright_box_upright() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert_eq!(angle(&s, a), 0.0);
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
|
||||
assert!(near(angle(&s, a), -30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_type_and_multiple_selections_keep_their_angle() {
|
||||
let mut s = session();
|
||||
// Objects turned together share the box.
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||||
let b = rect(&mut s, 300.0, 100.0, 100.0, 50.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
|
||||
assert!(near(selection_box(&s).angle, 20.0) && near(angle(&s, a), 20.0) && near(angle(&s, b), 20.0));
|
||||
// Mixed angles: the box is square to the page.
|
||||
let c = rect(&mut s, 100.0, 300.0, 50.0, 50.0);
|
||||
sel(&mut s, &[a, c]);
|
||||
assert_eq!(selection_box(&s).angle, 0.0);
|
||||
// A group turns as one; its members keep their own angles inside it.
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
assert_eq!(selection_box(&s).angle, 0.0);
|
||||
s.execute("object.rotate", &json!({"angle": 30, "absolute": true})).unwrap();
|
||||
assert!(near(angle(&s, g), 30.0) && near(angle(&s, a), 50.0) && near(angle(&s, c), 30.0));
|
||||
s.execute("object.ungroup", &json!({})).unwrap();
|
||||
sel(&mut s, &[c]);
|
||||
let cb = selection_box(&s);
|
||||
assert!(near(cb.angle, 30.0) && near(cb.rect.width(), 50.0) && near(cb.rect.height(), 50.0), "{cb:?}");
|
||||
// Type keeps its angle too.
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Turn"})).unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
let before = selection_box(&s).rect;
|
||||
s.execute("object.rotate", &json!({"angle": 60, "absolute": true})).unwrap();
|
||||
let tb = selection_box(&s);
|
||||
assert!(near(tb.angle, 60.0), "{tb:?}");
|
||||
assert!(near(tb.rect.width(), before.width()) && near(tb.rect.height(), before.height()), "{tb:?} vs {before:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_files_keep_the_angle() {
|
||||
let mut s = session();
|
||||
let plain = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let a = turned_rect(&mut s);
|
||||
let data = s.execute("document.save", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string();
|
||||
let text = String::from_utf8(vectorcraft_format::base64_decode(&data).unwrap()).unwrap();
|
||||
// Only turned objects write it.
|
||||
assert_eq!(text.matches("bbox_angle").count(), 1);
|
||||
s.execute("document.open", &json!({"name": "turned.vectorcraft", "dataBase64": data})).unwrap();
|
||||
assert!(near(angle(&s, a), 45.0));
|
||||
assert_eq!(angle(&s, plain), 0.0);
|
||||
sel(&mut s, &[a]);
|
||||
assert!(near(selection_box(&s).rect.width(), 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_box_rect_matches_page_bounds_when_upright() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 30.0, 40.0);
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(10.0, 20.0, 40.0, 60.0)));
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Tests for the cutting commands: Mirror & Cut, Line Cut and Rectangle Cut.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn path(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
|
||||
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
|
||||
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn geometry(s: &Session, id: NodeId) -> (PathData, FillRule) {
|
||||
let n = s.doc().unwrap().doc.node(id).cloned().unwrap();
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => (path.clone(), *rule),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
(PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()), *rule)
|
||||
}
|
||||
_ => panic!("not a path"),
|
||||
}
|
||||
}
|
||||
|
||||
fn area(s: &Session, id: NodeId) -> f64 {
|
||||
let (p, r) = geometry(s, id);
|
||||
vectorcraft_pathops::area(&p, r)
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
geometry(s, id).0.bounds().unwrap()
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 0.05)
|
||||
}
|
||||
|
||||
fn undo_depth(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_splits_a_shape_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
let before = undo_depth(&s);
|
||||
let out = s.execute("path.lineCut", &json!({"from": [30, -10], "to": [30, 10]})).unwrap();
|
||||
let pieces = ids(&out);
|
||||
assert_eq!(pieces.len(), 2);
|
||||
let mut areas: Vec<f64> = pieces.iter().map(|id| area(&s, *id)).collect();
|
||||
areas.sort_by(f64::total_cmp);
|
||||
assert!((areas[0] - 3000.0).abs() < 1.0 && (areas[1] - 7000.0).abs() < 1.0, "{areas:?}");
|
||||
// The pieces are selected, and the cut is one undo step.
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2);
|
||||
assert_eq!(undo_depth(&s), before + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((area(&s, r) - 10_000.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_keeps_compound_holes_and_splits_open_paths() {
|
||||
let mut s = session();
|
||||
let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
// Wound against the outer rectangle, so the non-zero compound has a hole.
|
||||
let hole = path(&mut s, &[(40.0, 40.0), (40.0, 60.0), (60.0, 60.0), (60.0, 40.0)], true);
|
||||
select(&mut s, &[outer, hole]);
|
||||
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
assert!((area(&s, c) - 9600.0).abs() < 1.0);
|
||||
let line = path(&mut s, &[(0.0, 200.0), (100.0, 200.0)], false);
|
||||
select(&mut s, &[c, line]);
|
||||
// A vertical line at x = 20 misses the hole: the right piece keeps it.
|
||||
let out = s.execute("path.lineCut", &json!({"from": [20, 0], "to": [20, 300]})).unwrap();
|
||||
let pieces = ids(&out);
|
||||
assert_eq!(pieces.len(), 4, "two compound pieces and two line pieces");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let compounds: Vec<NodeId> = pieces.iter().copied().filter(|id| matches!(doc.node(*id).unwrap().kind, NodeKind::Compound { .. })).collect();
|
||||
assert_eq!(compounds.len(), 2, "both pieces of a compound path stay compound paths");
|
||||
let mut areas: Vec<f64> = compounds.iter().map(|id| area(&s, *id)).collect();
|
||||
areas.sort_by(f64::total_cmp);
|
||||
assert!((areas[0] - 2000.0).abs() < 1.0 && (areas[1] - 7600.0).abs() < 1.0, "{areas:?}");
|
||||
let open: Vec<Rect> = pieces.iter().filter(|id| !compounds.contains(id)).map(|id| bounds(&s, *id)).collect();
|
||||
assert!(open.iter().any(|b| (b.x1 - 20.0).abs() < 0.01) && open.iter().any(|b| (b.x0 - 20.0).abs() < 0.01), "{open:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_that_misses_changes_nothing() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
let before = undo_depth(&s);
|
||||
let out = s.execute("path.lineCut", &json!({"from": [200, 0], "to": [200, 10]})).unwrap();
|
||||
assert!(ids(&out).is_empty());
|
||||
assert_eq!(undo_depth(&s), before);
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [5, 5], "to": [5, 5]})).is_err());
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_cut_crops_to_real_geometry() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let outside = rect(&mut s, 300.0, 300.0, 10.0, 10.0);
|
||||
let inside = rect(&mut s, 60.0, 20.0, 10.0, 10.0);
|
||||
let line = path(&mut s, &[(0.0, 50.0), (200.0, 50.0)], false);
|
||||
select(&mut s, &[a, outside, inside, line]);
|
||||
let out = s.execute("path.rectCut", &json!({"rect": [50, 0, 100, 200]})).unwrap();
|
||||
assert_eq!(out["removed"], 1);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.node(outside).is_none(), "art wholly outside is deleted");
|
||||
assert!(matches!(doc.node(a).unwrap().kind, NodeKind::Path { .. }), "no clipping mask");
|
||||
assert!(near(bounds(&s, a), Rect::new(50.0, 0.0, 100.0, 100.0)));
|
||||
assert!((area(&s, a) - 5000.0).abs() < 1.0);
|
||||
assert!(near(bounds(&s, inside), Rect::new(60.0, 20.0, 70.0, 30.0)), "art inside is untouched");
|
||||
assert!(near(bounds(&s, line), Rect::new(50.0, 50.0, 150.0, 50.0)));
|
||||
// Two corners work too.
|
||||
select(&mut s, &[a]);
|
||||
s.execute("path.rectCut", &json!({"from": [100, 100], "to": [75, 50]})).unwrap();
|
||||
assert!(near(bounds(&s, a), Rect::new(75.0, 50.0, 100.0, 100.0)));
|
||||
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 0, 10]})).is_err());
|
||||
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 10]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_cut_makes_a_symmetric_shape() {
|
||||
let mut s = session();
|
||||
// A right triangle: mirrored at x = 50 keeping the left, it becomes a house-like pentagon.
|
||||
let t = path(&mut s, &[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)], true);
|
||||
select(&mut s, &[t]);
|
||||
let out = s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap();
|
||||
assert_eq!(ids(&out), vec![t]);
|
||||
let (pd, _) = geometry(&s, t);
|
||||
assert_eq!(pd.subpaths.len(), 1, "both halves are joined into one closed path");
|
||||
assert!(pd.subpaths[0].closed);
|
||||
assert!((area(&s, t) - 7500.0).abs() < 1.0);
|
||||
assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
// Keeping the right half instead.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0], "keep": "right"})).unwrap();
|
||||
assert!((area(&s, t) - 2500.0).abs() < 1.0);
|
||||
assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_cut_axes_and_sides() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 100.0);
|
||||
let gone = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
|
||||
select(&mut s, &[a, gone]);
|
||||
// A free axis at x = 50 (drawn upward): art on the discarded side is deleted.
|
||||
let out = s.execute("path.mirrorCut", &json!({"axis": "free", "from": [50, 100], "to": [50, 0], "keep": "left"})).unwrap();
|
||||
assert_eq!(out["removed"], 1);
|
||||
assert!(s.doc().unwrap().doc.node(gone).is_none());
|
||||
// Wholly on the kept side: the shape and its reflection, one object.
|
||||
let (pd, _) = geometry(&s, a);
|
||||
assert_eq!(pd.subpaths.len(), 2);
|
||||
assert!(near(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
// A horizontal axis through the selection's centre keeps the top by default.
|
||||
let b = rect(&mut s, 0.0, 200.0, 100.0, 60.0);
|
||||
select(&mut s, &[b]);
|
||||
s.execute("path.mirrorCut", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert!(near(bounds(&s, b), Rect::new(0.0, 200.0, 100.0, 260.0)));
|
||||
assert!(s.execute("path.mirrorCut", &json!({"axis": "vertical", "keep": "top"})).is_err());
|
||||
assert!(s.execute("path.mirrorCut", &json!({"axis": "diagonal"})).is_err());
|
||||
assert!(s.execute("path.mirrorCut", &json!({"axis": "free", "from": [1, 1], "to": [1, 1]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_cut_closes_an_open_half() {
|
||||
let mut s = session();
|
||||
let p = path(&mut s, &[(50.0, 0.0), (0.0, 50.0), (50.0, 100.0)], false);
|
||||
select(&mut s, &[p]);
|
||||
s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap();
|
||||
let (pd, _) = geometry(&s, p);
|
||||
assert_eq!(pd.subpaths.len(), 1);
|
||||
assert!(pd.subpaths[0].closed);
|
||||
assert_eq!(pd.subpaths[0].anchors.len(), 4);
|
||||
assert!(near(bounds(&s, p), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_commands_need_paths() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err(), "nothing selected");
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A"})).unwrap());
|
||||
select(&mut s, &[t]);
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err());
|
||||
}
|
||||
|
||||
mod tools {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
use crate::tooling::ViewInfo;
|
||||
|
||||
fn view() -> ViewInfo {
|
||||
ViewInfo { smart_guides: false, ..Default::default() }
|
||||
}
|
||||
|
||||
fn press(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), view()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_tool_previews_live_and_commits_one_step() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
let before = undo_depth(&s);
|
||||
s.select_tool("lineCut", view()).unwrap();
|
||||
press(&mut s, PointerKind::Down, 50.0, -20.0);
|
||||
press(&mut s, PointerKind::Drag, 50.0, 40.0);
|
||||
// The preview already shows the cut.
|
||||
assert!(s.in_interaction());
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2);
|
||||
press(&mut s, PointerKind::Drag, 50.0, 120.0);
|
||||
press(&mut s, PointerKind::Up, 50.0, 120.0);
|
||||
assert!(!s.in_interaction());
|
||||
assert_eq!(undo_depth(&s), before + 1);
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.lineCut"));
|
||||
assert!((area(&s, r) - 5000.0).abs() < 1.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((area(&s, r) - 10_000.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_and_mirror_tools_cut_and_escape_restores() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
s.select_tool("rectCut", view()).unwrap();
|
||||
press(&mut s, PointerKind::Down, 50.0, 50.0);
|
||||
press(&mut s, PointerKind::Drag, 150.0, 150.0);
|
||||
assert!(near(bounds(&s, r), Rect::new(50.0, 50.0, 100.0, 100.0)));
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), view()).unwrap();
|
||||
assert!(!s.in_interaction());
|
||||
assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
// Mirror & Cut with a vertical axis placed by a click at x = 80.
|
||||
s.select_tool("mirrorCut", view()).unwrap();
|
||||
s.set_tool_option("axis", &json!("vertical"));
|
||||
press(&mut s, PointerKind::Down, 80.0, 50.0);
|
||||
press(&mut s, PointerKind::Up, 80.0, 50.0);
|
||||
assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 160.0, 100.0)));
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.mirrorCut"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Point type placed with the Type tool and left empty is discarded when editing ends, without a
|
||||
//! trace in the history; frames (dragged area type, type in or on a path) stay.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
|
||||
let v = ViewInfo::default();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
|
||||
if from != to {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
|
||||
}
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
drag(s, (x, y), (x, y));
|
||||
}
|
||||
|
||||
fn key(s: &mut Session, k: ToolKey) {
|
||||
s.tool_key(k, Mods::default(), ViewInfo::default()).unwrap();
|
||||
}
|
||||
|
||||
/// The text objects in the document: (id, kind is point type, plain text).
|
||||
fn texts(s: &Session) -> Vec<(NodeId, bool, String)> {
|
||||
let mut v = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
v.push((n.id, matches!(t.kind, TextKind::Point), t.plain_text()));
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
fn undo_labels(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_without_typing_leaves_no_text_and_no_history() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
// Click, then Escape.
|
||||
click(&mut s, 100.0, 100.0);
|
||||
assert_eq!(texts(&s).len(), 1, "the click places point type to edit");
|
||||
assert!(s.tool_wants_text());
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert!(texts(&s).is_empty());
|
||||
// Click, then switch tools.
|
||||
click(&mut s, 150.0, 100.0);
|
||||
s.select_tool("rectangle", v).unwrap();
|
||||
assert!(texts(&s).is_empty());
|
||||
// Click in two places: the first is gone when the second is placed.
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 300.0);
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).len(), 1);
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert!(texts(&s).is_empty());
|
||||
assert!(undo_labels(&s).is_empty(), "{:?}", undo_labels(&s));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(!st.is_dirty() && st.selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_text_stays_and_typing_it_away_leaves_nothing() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("Hi", v).unwrap();
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hi"]);
|
||||
let kept = undo_labels(&s);
|
||||
assert_eq!(kept, ["Type", "Typing"]);
|
||||
// Typed, the caret moved (a step of its own), then deleted: all of it goes.
|
||||
click(&mut s, 300.0, 300.0);
|
||||
s.tool_text("a", v).unwrap();
|
||||
key(&mut s, ToolKey::Left);
|
||||
key(&mut s, ToolKey::Delete);
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert_eq!(texts(&s).len(), 1);
|
||||
assert_eq!(undo_labels(&s), kept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_stay_when_left_empty() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
// A dragged area-type frame.
|
||||
s.select_tool("type", v).unwrap();
|
||||
drag(&mut s, (100.0, 100.0), (300.0, 200.0));
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert!(matches!(texts(&s).as_slice(), [(_, false, t)] if t.is_empty()));
|
||||
// Area type made from a path.
|
||||
s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 200, "height": 100})).unwrap();
|
||||
s.select_tool("areaType", v).unwrap();
|
||||
click(&mut s, 500.0, 300.0);
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert_eq!(texts(&s).len(), 2);
|
||||
assert_eq!(undo_labels(&s), ["Type", "Rectangle", "Area Type"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_that_deselects_the_text_ends_the_edit() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
// Commands on the edited text (the Character panel) keep editing it.
|
||||
let id = texts(&s)[0].0;
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 30})).unwrap();
|
||||
s.execute("document.inspect", &json!({})).unwrap();
|
||||
assert!(s.tool_wants_text());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(!s.tool_wants_text());
|
||||
assert!(texts(&s).is_empty());
|
||||
assert!(undo_labels(&s).is_empty(), "{:?}", undo_labels(&s));
|
||||
// A command that edits something else as well: the text goes in a step of its own.
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 20, "height": 10})).unwrap();
|
||||
assert!(!s.tool_wants_text());
|
||||
assert!(texts(&s).is_empty());
|
||||
assert_eq!(undo_labels(&s), ["Type", "Rectangle", "Discard Empty Type"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_documents_discards_it_in_the_document_left() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s.set_active(0);
|
||||
assert!(texts(&s).is_empty() && undo_labels(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_empty_command() {
|
||||
let mut s = session();
|
||||
let r = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap();
|
||||
assert_eq!(s.execute("text.discardEmpty", &json!({"id": r["id"]})).unwrap(), json!({"removed": false}));
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(s.execute("text.discardEmpty", &json!({"id": rect["id"]})).is_err(), "not text");
|
||||
assert!(s.execute("text.discardEmpty", &json!({})).is_err(), "id required");
|
||||
// Created by the newest step: that step goes.
|
||||
let e = s.execute("text.create", &json!({"x": 10, "y": 80, "text": ""})).unwrap();
|
||||
assert_eq!(s.execute("text.discardEmpty", &json!({"id": e["id"]})).unwrap(), json!({"removed": true}));
|
||||
assert_eq!(undo_labels(&s), ["Type", "Rectangle"]);
|
||||
assert_eq!(texts(&s).len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! The fonts available (`text.fontList`), the installed ones listed without asking for them (#36),
|
||||
//! and Refresh Font List (`text.rescanFonts`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_text::{FontDb, system_font_dirs};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn names(v: &Value) -> Vec<String> {
|
||||
serde_json::from_value(v.clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_list_names_every_available_family_with_its_styles() {
|
||||
let mut s = Session::new();
|
||||
let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]);
|
||||
assert_eq!(fams, FontDb::global().families());
|
||||
for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
|
||||
assert!(fams.iter().any(|x| x == f), "{f}");
|
||||
}
|
||||
// Every installed family, though nothing asked for system fonts.
|
||||
let installed = FontDb::with_font_dirs(system_font_dirs());
|
||||
installed.load_system_fonts();
|
||||
let missing: Vec<String> = installed.families().into_iter().filter(|f| !fams.contains(f)).collect();
|
||||
assert!(missing.is_empty(), "{missing:?}");
|
||||
let r = s.execute("text.fontList", &json!({"family": "source sans 3"})).unwrap();
|
||||
assert_eq!(r["family"], "Source Sans 3");
|
||||
let styles = names(&r["styles"]);
|
||||
assert!(["Regular", "Semibold", "Bold", "Italic"].iter().all(|x| styles.iter().any(|s| s == x)), "{styles:?}");
|
||||
assert!(s.execute("text.fontList", &json!({"family": "No Such Font"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshing_the_font_list_redraws_type_without_editing_the_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Type", "font": "No Such Font"})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
assert_eq!(r["families"], json!(FontDb::global().families().len()));
|
||||
assert!(r["faces"].is_u64());
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.revision > revision, "the canvas redraws");
|
||||
assert!(!d.is_dirty(), "nothing to save");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Object → Vector Halftone.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::tests_adjust::{fill_hex, node, red_rect, session};
|
||||
|
||||
#[test]
|
||||
fn halftone_turns_art_into_dots_by_tone() {
|
||||
let mut s = session();
|
||||
let id = red_rect(&mut s);
|
||||
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("object.vectorHalftone", &json!({"frequency": 18})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert!(s.doc().unwrap().doc.node(id).is_none(), "the original is replaced");
|
||||
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let dots = r["dots"].as_u64().unwrap();
|
||||
// 60 pt at 18 lpi (4 pt cells) turned 45°: about 225 cells, all black.
|
||||
assert!((180..=320).contains(&dots), "{dots}");
|
||||
let ch = g.children().unwrap();
|
||||
assert_eq!(ch.len(), 1);
|
||||
let (pd, b) = (ch[0].path_data().unwrap(), ch[0].geometric_bounds().unwrap());
|
||||
assert!(pd.subpaths.len() as u64 >= dots / 2);
|
||||
// Clipped to the art.
|
||||
assert!(b.x0 >= 19.9 && b.y0 >= 19.9 && b.x1 <= 80.1 && b.y1 <= 80.1, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_options() {
|
||||
let mut s = session();
|
||||
let id = red_rect(&mut s);
|
||||
// Red art in CMYK: magenta and yellow screens, no cyan or black.
|
||||
let r = s.execute("object.vectorHalftone", &json!({"mode": "cmyk", "shape": "square", "keepOriginal": true})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(id).is_some(), "the original is kept");
|
||||
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let inks: Vec<String> = g.children().unwrap().iter().map(|c| fill_hex(c)).collect();
|
||||
assert_eq!(inks.len(), 2, "{inks:?}");
|
||||
assert!(g.children().unwrap().iter().all(|c| c.blend == vectorcraft_color::BlendMode::Multiply));
|
||||
// Light art gets no dots; inverted, it does.
|
||||
let mut s = session();
|
||||
let id = red_rect(&mut s);
|
||||
s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap();
|
||||
assert!(s.execute("object.vectorHalftone", &json!({})).is_err());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
assert!(s.execute("object.vectorHalftone", &json!({"invert": true, "shape": "line"})).is_ok());
|
||||
// Bad input is refused.
|
||||
for bad in [json!({"frequency": 0}), json!({"shape": "star"}), json!({"mode": "rgb"}), json!({"color": "nope"})] {
|
||||
let mut s = session();
|
||||
red_rect(&mut s);
|
||||
assert!(s.execute("object.vectorHalftone", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ fn built_in_presets_are_listed_and_read_only() {
|
||||
for n in ["High Quality Print", "Press Quality", "Smallest File Size", "PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] {
|
||||
assert_eq!(row(n)["builtIn"], true);
|
||||
}
|
||||
assert_eq!(row("PDF/X-4:2010")["supported"], false, "PDF/X is not written yet");
|
||||
assert!(rows.iter().all(|r| r["supported"] == true), "every built-in standard is written");
|
||||
assert_eq!(row(DEFAULT_PRESET)["settings"]["preserveEditing"], true);
|
||||
// Read-only: neither changed nor deleted, in any case.
|
||||
assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "press quality", "compatibility": "1.4"}))));
|
||||
@@ -47,7 +47,8 @@ fn built_in_presets_are_listed_and_read_only() {
|
||||
// Built-in presets apply as named.
|
||||
let v = s.execute("document.pdfSettings", &json!({"preset": "Smallest File Size"})).unwrap();
|
||||
assert_eq!((&v["settings"]["fastWebView"], &v["changed"]), (&json!(true), &json!([])), "{v}");
|
||||
assert!(is_bad_params(s.execute("document.exportPdf", &json!({"preset": "PDF/X-4:2010"}))), "PDF/X is refused on export");
|
||||
let pdf = s.execute("document.exportPdf", &json!({"preset": "PDF/X-4:2010"})).unwrap();
|
||||
assert!(pdf["dataBase64"].is_string(), "PDF/X is written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -61,7 +62,7 @@ fn saved_presets_apply_wherever_a_preset_is_named() {
|
||||
// document.exportPdf, document.export, serialize and pdfSettings take it.
|
||||
let v = s.execute("document.exportPdf", &json!({"preset": "web"})).unwrap();
|
||||
assert!(pdf(&v).starts_with(b"%PDF-1.5"), "the preset's compatibility");
|
||||
assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fast web view")), "{v}");
|
||||
assert!(String::from_utf8_lossy(&pdf(&v)).contains("/Linearized 1"), "the preset's fast web view: {v}");
|
||||
let v = s.execute("document.export", &json!({"format": "pdf", "preset": "Web"})).unwrap();
|
||||
assert!(pdf(&v).starts_with(b"%PDF-1.5"));
|
||||
let v = s.execute("document.serialize", &json!({"format": "pdf", "preset": "Web", "compatibility": "1.6"})).unwrap();
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! WebAssembly plug-ins (#121): object filters through `plugin.run` (one undo step,
|
||||
//! transactional), live-effect plug-ins in appearance stacks, and the `plugin.*` management
|
||||
//! commands.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{PathData, Point, SubPath};
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The example Desaturate plug-in (built from `crates/plugins/example`).
|
||||
const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm");
|
||||
const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate";
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn install(s: &mut Session, wasm: &[u8]) -> Value {
|
||||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)}))
|
||||
}
|
||||
|
||||
fn install_wat(s: &mut Session, src: &str) -> Value {
|
||||
install(s, &wat::parse_str(src).unwrap())
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A 10 × 10 rectangle at `x` filled with `fill` and no stroke.
|
||||
fn rect(s: &mut Session, x: f64, fill: &str) -> NodeId {
|
||||
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 10, "height": 10}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"ids": [id.0], "color": fill}));
|
||||
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
|
||||
id
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn triangle() -> PathData {
|
||||
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 0.0), Point::new(15.0, 20.0)], true))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_filter_recolours_the_selection_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let info = install(&mut s, DESATURATE);
|
||||
assert_eq!((info["id"].as_str(), info["kind"].as_str()), (Some(DESATURATE_ID), Some("filter")));
|
||||
let a = rect(&mut s, 10.0, "#ff0000");
|
||||
let b = rect(&mut s, 40.0, "#0000ff");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "params": {"amount": 100}}));
|
||||
assert_eq!(r["ids"], json!([a.0, b.0]));
|
||||
for id in [a, b] {
|
||||
let [r, g, b] = node(&s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated();
|
||||
assert!((r - g).abs() < 1e-6 && (g - b).abs() < 1e-6, "grey: {r} {g} {b}");
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Desaturate");
|
||||
assert_eq!(st.selection.objects, [a, b]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
// Plug-in parameters may also be top-level keys; wrong types are refused before it runs.
|
||||
run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "amount": 0}));
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_filters_add_replace_and_generate_objects() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, "#ff0000");
|
||||
let b = rect(&mut s, 40.0, "#00ff00");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
// Keeps A, drops B and adds a triangle above A.
|
||||
let out =
|
||||
json!({"objects": [{"id": a.0}, {"path": triangle(), "fills": [{"type": "solid", "color": {"model": "gray", "k": 1}}], "name": "Tri"}]});
|
||||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.replace", "filter", "{}"), &out.to_string()));
|
||||
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.replace"}));
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(ids[0], a);
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.node(b).is_none());
|
||||
let layer: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
||||
assert_eq!(layer, [a, ids[1]]);
|
||||
assert_eq!(st.doc.node(ids[1]).unwrap().name.as_deref(), Some("Tri"));
|
||||
assert_eq!(st.selection.objects, ids);
|
||||
// A generator with nothing selected adds to the current layer.
|
||||
let generated = json!({"objects": [{"path": triangle()}]});
|
||||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.generate", "filter", "{}"), &generated.to_string()));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.generate"}));
|
||||
let made = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().id, made);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failing_plugins_leave_the_document_and_history_alone() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0, "#ff0000");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let man = |id: &str| tpl::manifest(id, "filter", "{}");
|
||||
install_wat(&mut s, &tpl::module(&man("test.engine.trap"), "unreachable", "", 1));
|
||||
install_wat(&mut s, &tpl::returning(&man("test.engine.far"), r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#));
|
||||
install_wat(&mut s, &tpl::returning(&man("test.engine.declines"), r#"{"error":"Select two paths."}"#));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for id in ["test.engine.trap", "test.engine.far", "test.engine.declines", "test.engine.missing"] {
|
||||
let e = s.execute("plugin.run", &json!({"id": id})).unwrap_err();
|
||||
if id == "test.engine.declines" {
|
||||
assert!(e.to_string().contains("Select two paths."), "{e}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{id}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_effect_plugins_reshape_editable_art_and_expand() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, "#ff0000");
|
||||
// An effect that draws a triangle, and one that hands the geometry back.
|
||||
let tri = json!({"objects": [{"path": triangle()}]});
|
||||
install_wat(
|
||||
&mut s,
|
||||
&tpl::returning(&tpl::manifest("test.engine.tri", "effect", r#"{"size":{"type":"number","min":0,"max":10,"default":3}}"#), &tri.to_string()),
|
||||
);
|
||||
install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.echo", "effect", "{}"), tpl::ECHO, "", 1));
|
||||
let catalog = run(&mut s, "effect.list", json!({}))["catalog"].clone();
|
||||
let entry = catalog.as_array().unwrap().iter().find(|e| e["id"] == "plugin.test.engine.tri").expect("listed");
|
||||
assert_eq!((entry["label"].as_str(), entry["menu"].clone()), (Some("Test test.engine.tri…"), json!(["Effect", "Plug-ins"])));
|
||||
assert_eq!(entry["defaults"], json!({"size": 3.0}));
|
||||
// A filter can't be applied as an effect, nor an effect run as a filter.
|
||||
install(&mut s, DESATURATE);
|
||||
assert!(s.execute("effect.apply", &json!({"ids": [a.0], "effect": format!("plugin.{DESATURATE_ID}")})).is_err());
|
||||
assert!(s.execute("plugin.run", &json!({"id": "test.engine.tri"})).is_err());
|
||||
|
||||
let effected = |s: &Session| {
|
||||
let n = node(s, a);
|
||||
let path = n.path_data().unwrap().clone();
|
||||
let b = path.bounds().unwrap();
|
||||
vectorcraft_render::effects::apply_geometry(&n.appearance.effects, &path, b)
|
||||
};
|
||||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.echo"}));
|
||||
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap(), "identity");
|
||||
// Re-evaluated when the object changes (non-destructive).
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "object.move", json!({"dx": 50, "dy": 0}));
|
||||
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
|
||||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}}));
|
||||
assert_eq!(effected(&s), triangle());
|
||||
assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4}));
|
||||
// Uninstalled: the geometry is drawn as it is.
|
||||
run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"}));
|
||||
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
|
||||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.tri", "effect", "{}"), &tri.to_string()));
|
||||
// Expand Appearance bakes it.
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let mut baked = None;
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let Some(p) = n.path_data() {
|
||||
baked = Some(p.clone());
|
||||
}
|
||||
});
|
||||
assert_eq!(baked.unwrap().bounds(), triangle().bounds());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_management_commands() {
|
||||
let mut s = session();
|
||||
install(&mut s, DESATURATE);
|
||||
let list = run(&mut s, "plugin.list", json!({}));
|
||||
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == DESATURATE_ID));
|
||||
let info = run(&mut s, "plugin.info", json!({"id": DESATURATE_ID}));
|
||||
assert_eq!(info["defaults"], json!({"amount": 100.0}));
|
||||
assert_eq!(info["params"], "{amount: 0..100 (100)}");
|
||||
assert_eq!(info["manifest"]["kind"], "filter");
|
||||
// Refused installs.
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"dataBase64": "!!"}),
|
||||
json!({"dataBase64": "AGFzbQ=="}),
|
||||
json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE), "replace": false}),
|
||||
] {
|
||||
assert!(s.execute("plugin.install", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("plugin.info", &json!({"id": "test.engine.nothing"})).is_err());
|
||||
assert!(s.execute("plugin.remove", &json!({"id": "test.engine.nothing"})).is_err());
|
||||
assert!(s.execute("plugin.reload", &json!({})).is_err(), "no folder set");
|
||||
|
||||
// The Additional Plug-ins Folder preference installs what's in it.
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-engine-plugins-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let src = tpl::returning(&tpl::manifest("test.engine.folder", "filter", "{}"), r#"{"objects":[]}"#);
|
||||
std::fs::write(dir.join("folder.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||||
std::fs::write(dir.join("broken.wasm"), b"\0asm?").unwrap();
|
||||
run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": dir.display().to_string()}));
|
||||
assert!(vectorcraft_plugins::registry::get("test.engine.folder").is_some());
|
||||
run(&mut s, "plugin.remove", json!({"id": "test.engine.folder"}));
|
||||
let r = run(&mut s, "plugin.reload", json!({}));
|
||||
assert_eq!(r["loaded"], json!(["test.engine.folder"]));
|
||||
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(run(&mut s, "plugin.list", json!({}))["folder"]["loaded"], json!(["test.engine.folder"]));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! File → Print › Advanced: simulated overprints print what Overprint Preview shows, Print as
|
||||
//! Bitmap prints images of the art, and a flattener preset flattens transparency first.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_pdf::PrintSettings;
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A 200 × 100 CMYK document: a cyan square under a yellow one whose fill overprints, and a
|
||||
/// half-transparent magenta one; their ids.
|
||||
fn session() -> (Session, [u64; 3]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "colorMode": "cmyk"})).unwrap();
|
||||
let mut ids = [0; 3];
|
||||
for (i, (x, color)) in [(10, "#00ffff"), (40, "#ffff00"), (120, "#ff00ff")].into_iter().enumerate() {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
ids[i] = r["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [ids[i]], "color": color})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [ids[i]], "none": true})).unwrap();
|
||||
}
|
||||
s.execute("object.setOverprint", &json!({"ids": [ids[1]], "fill": true})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [ids[2]], "opacity": 50, "item": null})).unwrap();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn settings(v: Value) -> PrintSettings {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.doc().unwrap().doc
|
||||
}
|
||||
|
||||
/// `doc` rendered over its artboard at 2 px/pt on white.
|
||||
fn pixels(doc: &Document, opts: RenderOptions) -> Vec<u8> {
|
||||
let opts = RenderOptions { background: Some([255; 4]), skip_templates: true, ..opts };
|
||||
Renderer::new().render_region_with(doc, Rect::new(0.0, 0.0, 200.0, 100.0), 2.0, &opts).pixels
|
||||
}
|
||||
|
||||
/// The pixel of the page `pdf` prints at document point `(x, y)` of the first sheet.
|
||||
fn printed_pixel(s: &mut Session, settings: Value, (x, y): (f64, f64)) -> [u8; 4] {
|
||||
let v = s.execute("file.print", &json!({ "settings": settings })).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let pv = s.execute("print.preview", &json!({ "settings": settings })).unwrap();
|
||||
let t: Vec<f64> = pv["sheets"][0]["transform"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let back = vectorcraft_pdf::import(&bytes).unwrap();
|
||||
let page = back.artboards[0].rect;
|
||||
let (px, py) = (page.x0 + t[0] * x + t[2] * y + t[4], page.y0 + t[1] * x + t[3] * y + t[5]);
|
||||
let img = Renderer::new().render_region(&back, Rect::new(px - 0.5, py - 0.5, px + 0.5, py + 0.5), 1.0, true);
|
||||
img.pixels[..4].try_into().unwrap()
|
||||
}
|
||||
|
||||
fn near(a: [u8; 4], b: [u8; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 4)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulated_overprints_print_what_overprint_preview_shows() {
|
||||
let (mut s, _) = session();
|
||||
let preview = pixels(doc(&s), RenderOptions { overprint_preview: true, ..Default::default() });
|
||||
let simulated = vectorcraft_pdf::printed_document(doc(&s), &settings(json!({"advanced": {"overprints": "simulate"}})));
|
||||
assert_eq!(pixels(&simulated, RenderOptions::default()), preview, "the same pixels as Overprint Preview");
|
||||
assert_ne!(pixels(doc(&s), RenderOptions::default()), preview, "overprinting shows");
|
||||
// On the printed page too: where yellow overprints cyan it prints green; discarded, yellow.
|
||||
let at = (55.0, 30.0);
|
||||
let i = ((30.0 * 2.0) as usize * 400 + (55.0 * 2.0) as usize) * 4;
|
||||
let shown: [u8; 4] = preview[i..i + 4].try_into().unwrap();
|
||||
let sim = printed_pixel(&mut s, json!({"advanced": {"overprints": "simulate"}}), at);
|
||||
assert!(near(sim, shown), "{sim:?} vs {shown:?}");
|
||||
let discarded = printed_pixel(&mut s, json!({"advanced": {"overprints": "discard"}}), at);
|
||||
assert!(!near(discarded, shown) && discarded[2] < 64 && discarded[0] > 192, "yellow knocks out: {discarded:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flattener_preset_flattens_transparency_before_printing() {
|
||||
let (mut s, _) = session();
|
||||
let saved = s.prefs.flattener_presets.clone();
|
||||
let transparent = |d: &Document| {
|
||||
let mut any = false;
|
||||
d.walk(|n| any |= n.opacity < 1.0);
|
||||
any
|
||||
};
|
||||
assert!(transparent(doc(&s)));
|
||||
let none = super::cmd::printadvanced::prepare("file.print", doc(&s), &settings(json!({})), &saved).unwrap();
|
||||
assert!(none.is_none(), "transparency prints live without a preset");
|
||||
for name in ["High Resolution", "low"] {
|
||||
let set = settings(json!({"advanced": {"flattenerPreset": name}}));
|
||||
let flat = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().unwrap();
|
||||
assert!(!transparent(&flat), "{name}: flattened");
|
||||
}
|
||||
// A saved preset by name; an unknown one is refused by preview and print alike.
|
||||
s.execute("flattener.presets.save", &json!({"name": "Proofs", "preset": "low"})).unwrap();
|
||||
let ok = json!({"settings": {"advanced": {"flattenerPreset": "Proofs"}}});
|
||||
assert!(s.execute("print.preview", &ok).is_ok() && s.execute("file.print", &ok).is_ok());
|
||||
let bad = json!({"settings": {"advanced": {"flattenerPreset": "Nope"}}});
|
||||
assert!(s.execute("print.preview", &bad).is_err() && s.execute("file.print", &bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_as_bitmap_prints_an_image_of_each_artboard() {
|
||||
let (mut s, _) = session();
|
||||
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 100})).unwrap();
|
||||
let saved = s.prefs.flattener_presets.clone();
|
||||
let set = settings(json!({"advanced": {"printAsBitmap": true}}));
|
||||
let bitmap = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().unwrap();
|
||||
let mut kinds = vec![];
|
||||
bitmap.walk(|n| kinds.push(matches!(n.kind, NodeKind::Image(_))));
|
||||
assert_eq!(kinds.iter().filter(|k| **k).count(), 1, "one image: the empty artboard gets none");
|
||||
// The image looks like the art, at the raster effects resolution.
|
||||
assert_eq!(pixels(&bitmap, RenderOptions::default()).len(), pixels(doc(&s), RenderOptions::default()).len());
|
||||
let v = s.execute("file.print", &json!({"settings": {"advanced": {"printAsBitmap": true}, "skipBlank": true}})).unwrap();
|
||||
assert_eq!(v["pages"], 1, "the blank artboard is skipped");
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let back = vectorcraft_pdf::import(&bytes).unwrap();
|
||||
let (mut images, mut paths) = (0, 0);
|
||||
back.walk(|n| match n.kind {
|
||||
NodeKind::Image(_) => images += 1,
|
||||
NodeKind::Path { .. } => paths += 1,
|
||||
_ => {}
|
||||
});
|
||||
assert_eq!((images, paths), (1, 0), "no vector art is left");
|
||||
// Separations print the art as it is.
|
||||
let seps = json!({"settings": {"advanced": {"printAsBitmap": true}, "output": {"mode": "separations"}}});
|
||||
let pv = s.execute("print.preview", &seps).unwrap();
|
||||
assert!(pv["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("Print as Bitmap")));
|
||||
let set = settings(seps["settings"].clone());
|
||||
assert!(super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().is_none());
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! View → Show Print Tiling (`view.printTiling`, `print.tiling`) and the Print Tiling tool
|
||||
//! (`print.tiling.set`): the pages drawn are print.preview's, and where the tool puts them is
|
||||
//! saved with the print settings.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
use crate::tooling::ViewInfo;
|
||||
|
||||
/// A 300 × 200 document with a rectangle.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(v: &Value) -> [f64; 4] {
|
||||
let a: Vec<f64> = v.as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
[a[0], a[1], a[2], a[3]]
|
||||
}
|
||||
|
||||
fn close(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// `t` (a print.preview transform) applied to `(x, y)`.
|
||||
fn apply(t: &Value, (x, y): (f64, f64)) -> (f64, f64) {
|
||||
let c: Vec<f64> = t.as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
(c[0] * x + c[2] * y + c[4], c[1] * x + c[3] * y + c[5])
|
||||
}
|
||||
|
||||
fn tiling(s: &mut Session, settings: Value) -> Value {
|
||||
s.execute("print.tiling", &json!({ "settings": settings })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_print_tiling_is_a_per_document_view_toggle() {
|
||||
let mut s = session();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("view.printTiling", &json!({})).unwrap()["on"], true);
|
||||
assert!(s.doc().unwrap().print_tiling);
|
||||
assert_eq!(s.execute("view.printTiling", &json!({})).unwrap()["on"], false);
|
||||
assert_eq!(s.execute("view.printTiling", &json!({"on": true})).unwrap()["on"], true);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "a view toggle is no undo step");
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
assert!(!s.doc().unwrap().print_tiling, "each document has its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiling_pages_are_print_preview_tiles() {
|
||||
let mut s = session();
|
||||
let set = json!({"scaling": "tileImageable", "scale": {"width": 600, "height": 600}, "margin": 18, "overlap": 9, "tileRange": "1-2, 5"});
|
||||
let pv = s.execute("print.preview", &json!({ "settings": set })).unwrap();
|
||||
let grid = &pv["tiles"][0];
|
||||
let tiles = grid["tiles"].as_array().unwrap();
|
||||
let pages = tiling(&mut s, set.clone())["pages"].as_array().unwrap().clone();
|
||||
assert!(tiles.len() > 4);
|
||||
assert_eq!(pages.len(), tiles.len(), "one page per tile");
|
||||
for (i, (p, t)) in pages.iter().zip(tiles).enumerate() {
|
||||
assert_eq!(p["number"], i + 1);
|
||||
assert!(close(rect(&p["imageable"]), rect(t)), "tiles are imageable areas");
|
||||
let printed = grid["printed"].as_array().unwrap().contains(&json!(i + 1));
|
||||
assert_eq!(p["printed"], printed);
|
||||
// The paper reaches the margin (18 pt on paper, 3 pt of the document at 600%) further.
|
||||
let (pg, im) = (rect(&p["page"]), rect(&p["imageable"]));
|
||||
assert!((im[0] - pg[0] - 3.0).abs() < 1e-6 && (pg[3] - im[3] - 3.0).abs() < 1e-6);
|
||||
}
|
||||
// Full pages: the tiles are the paper, the imageable area inside it.
|
||||
let full = json!({"scaling": "tileFull", "scale": {"width": 400, "height": 400}, "margin": 18});
|
||||
let pv = s.execute("print.preview", &json!({ "settings": full })).unwrap();
|
||||
let pages = tiling(&mut s, full)["pages"].as_array().unwrap().clone();
|
||||
assert_eq!(pages.len(), pv["tiles"][0]["tiles"].as_array().unwrap().len());
|
||||
assert!(close(rect(&pages[0]["page"]), rect(&pv["tiles"][0]["tiles"][0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untiled_pages_come_from_the_sheets_once_per_page() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 400, "y": 0, "width": 300, "height": 200})).unwrap();
|
||||
// Letter turned landscape for the wide artboards, centred on each.
|
||||
let set = json!({"output": {"mode": "separations"}, "margin": 36});
|
||||
let pv = s.execute("print.preview", &json!({ "settings": set })).unwrap();
|
||||
assert!(pv["sheets"].as_array().unwrap().len() >= 8, "a page per ink");
|
||||
let pages = tiling(&mut s, set)["pages"].as_array().unwrap().clone();
|
||||
assert_eq!(pages.len(), 2, "each page once, not once per ink");
|
||||
for (p, cx) in pages.iter().zip([150.0, 550.0]) {
|
||||
let pg = rect(&p["page"]);
|
||||
assert!(close(pg, [cx - 396.0, 100.0 - 306.0, cx + 396.0, 100.0 + 306.0]), "{pg:?}");
|
||||
assert!(close(rect(&p["imageable"]), [pg[0] + 36.0, pg[1] + 36.0, pg[2] - 36.0, pg[3] - 36.0]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tool_puts_the_first_page_where_it_is_dragged_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("printTiling", v).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 45.0, 40.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 40.0, 30.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 40.0, 30.0), v).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Print Tiling");
|
||||
let set = s.execute("print.setup", &json!({})).unwrap()["settings"].clone();
|
||||
assert_eq!(set["tileOrigin"], json!({"placed": true, "x": 40.0, "y": 30.0}));
|
||||
// The page's imageable corner is the point dragged to, on the paper and on the canvas.
|
||||
let pv = s.execute("print.preview", &json!({"settings": {"margin": 12}})).unwrap();
|
||||
let (x, y) = apply(&pv["sheets"][0]["transform"], (40.0, 30.0));
|
||||
assert!((x - 12.0).abs() < 1e-6 && (y - 12.0).abs() < 1e-6, "({x}, {y})");
|
||||
let page = &tiling(&mut s, json!({"margin": 12}))["pages"][0];
|
||||
assert!((rect(&page["imageable"])[0] - 40.0).abs() < 1e-6 && (rect(&page["imageable"])[1] - 30.0).abs() < 1e-6);
|
||||
// Saved in the native file.
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap().print_setup, doc.print_setup);
|
||||
// A double click puts the pages back where the placement puts them.
|
||||
s.pointer(&PointerEvent::new(PointerKind::DoubleClick, 10.0, 10.0), v).unwrap();
|
||||
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["tileOrigin"]["placed"], false);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["tileOrigin"]["placed"], true);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.print_setup.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_start_at_the_tile_origin_and_cover_the_art() {
|
||||
let mut s = session();
|
||||
let tiles = json!({"scaling": "tileImageable", "scale": {"width": 400, "height": 400}});
|
||||
s.execute("print.setup", &json!({ "settings": tiles })).unwrap();
|
||||
let before = tiling(&mut s, json!({}))["pages"].as_array().unwrap().len();
|
||||
s.execute("print.tiling.set", &json!({"origin": [60, 35]})).unwrap();
|
||||
let pages = tiling(&mut s, json!({}))["pages"].as_array().unwrap().clone();
|
||||
let pv = s.execute("print.preview", &json!({})).unwrap();
|
||||
assert_eq!(pages.len(), pv["tiles"][0]["tiles"].as_array().unwrap().len(), "tile count equals print.preview");
|
||||
assert_eq!(pages.len(), pv["sheets"].as_array().unwrap().len());
|
||||
assert!(pages.len() > before, "the grid moved off the corner takes more tiles");
|
||||
let rects: Vec<[f64; 4]> = pages.iter().map(|p| rect(&p["imageable"])).collect();
|
||||
assert!(rects.iter().any(|r| (r[0] - 60.0).abs() < 1e-6 && (r[1] - 35.0).abs() < 1e-6), "a tile starts at the origin");
|
||||
let all = rects.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, r| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])]);
|
||||
assert!(all[0] <= 0.0 && all[1] <= 0.0 && all[2] >= 300.0 && all[3] >= 200.0, "the tiles cover the artboard: {all:?}");
|
||||
// Bad input is refused.
|
||||
assert!(s.execute("print.tiling.set", &json!({})).is_err());
|
||||
assert!(s.execute("print.tiling.set", &json!({"origin": [1, 2], "artboard": 7})).is_err());
|
||||
assert!(s.execute("print.tiling.set", &json!({"origin": [1e9, 2]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_saved_before_the_tile_origin_still_load() {
|
||||
let mut s = session();
|
||||
let mut old = serde_json::to_value(vectorcraft_pdf::PrintSettings::default()).unwrap();
|
||||
old.as_object_mut().unwrap().remove("tileOrigin");
|
||||
old["copies"] = json!(2);
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
doc.print_setup = Some(old);
|
||||
s.doc_mut().unwrap().doc = std::sync::Arc::new(doc);
|
||||
let set = s.execute("print.setup", &json!({})).unwrap()["settings"].clone();
|
||||
assert_eq!((set["copies"].clone(), set["tileOrigin"]["placed"].clone()), (json!(2), json!(false)));
|
||||
assert_eq!(tiling(&mut s, json!({}))["pages"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
@@ -119,6 +119,44 @@ fn type_tool_typing_is_one_undo_step() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_text("abc", v).unwrap();
|
||||
key(&mut s, ToolKey::Left, Mods::default());
|
||||
key(&mut s, ToolKey::Right, Mods::default());
|
||||
s.tool_text("def", v).unwrap();
|
||||
assert!(s.in_interaction(), "still typing");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "abc", "only the typing in progress is undone");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "abcdef", "and Redo brings it back");
|
||||
// Typing on after an undo starts a new session from the restored text.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.tool_text("!", v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "abc!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_during_a_drag_takes_back_only_the_drag() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 125.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 225.0, 125.0), v).unwrap();
|
||||
assert!(s.in_interaction(), "dragging");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let b = doc.bounds_of(&[id], false).expect("the rectangle is still there");
|
||||
assert_eq!(b.x0, 100.0, "back where it was: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_selection_replace_and_delete() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -164,8 +164,28 @@ impl Session {
|
||||
self.tool.set_option(key, v);
|
||||
}
|
||||
|
||||
/// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]):
|
||||
/// the Type tool stops editing text that is no longer selected. Only while it edits text.
|
||||
pub(crate) fn after_command(&mut self) {
|
||||
if self.in_tool_actions || !self.tool.wants_text() {
|
||||
return;
|
||||
}
|
||||
let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx));
|
||||
// The command itself succeeded: a failure here only leaves the edit as it was.
|
||||
if let Err(e) = self.apply_actions(acts) {
|
||||
log::warn!("ending the text edit: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply tool actions. Errors from previews are reported but keep the interaction alive.
|
||||
pub fn apply_actions(&mut self, acts: Vec<Action>) -> Result<Vec<UiRequest>> {
|
||||
let outer = std::mem::replace(&mut self.in_tool_actions, true);
|
||||
let r = self.apply_tool_actions(acts);
|
||||
self.in_tool_actions = outer;
|
||||
r
|
||||
}
|
||||
|
||||
fn apply_tool_actions(&mut self, acts: Vec<Action>) -> Result<Vec<UiRequest>> {
|
||||
let mut ui = vec![];
|
||||
for a in acts {
|
||||
match a {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Untrusted plug-ins never crash the app: mutated and garbage modules handed to `plugin.install`,
|
||||
//! and plug-ins returning hostile output (random JSON in the object model's shape: huge or
|
||||
//! malformed coordinates, unknown ids, broken paints and gradients, junk of every type) as object
|
||||
//! filters (`plugin.run`) or live effects (`effect.apply`). Each run must end as an error that
|
||||
//! leaves the document as it was, or as a document that then renders and exports, without a
|
||||
//! panic.
|
||||
//!
|
||||
//! `PROPTEST_CASES=2000 cargo test -p vectorcraft-engine --test plugin_fuzz` runs a deeper search.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
use vectorcraft_testkit::catch_quiet;
|
||||
use vectorcraft_testkit::fixtures::rich_session;
|
||||
|
||||
const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm");
|
||||
|
||||
/// 64 cases each, unless `PROPTEST_CASES` asks for more.
|
||||
fn config() -> ProptestConfig {
|
||||
let mut c = ProptestConfig { failure_persistence: None, ..ProptestConfig::default() };
|
||||
if std::env::var_os("PROPTEST_CASES").is_none() {
|
||||
c.cases = 64;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn install(s: &mut Session, wasm: &[u8]) -> Result<Value, String> {
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The document renders and exports without panicking.
|
||||
fn renders(s: &Session, what: &str) -> Result<(), TestCaseError> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let r = catch_quiet(|| {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
if let Some(ab) = d.artboards.first() {
|
||||
let _ = r.render_region(d, ab.rect, 0.25, true).to_png();
|
||||
}
|
||||
let _ = vectorcraft_svg::export(d, &vectorcraft_svg::ExportOptions::default());
|
||||
let _ = vectorcraft_engine::export_pdf(d, &vectorcraft_pdf::PdfOptions::default());
|
||||
let _ = vectorcraft_format::save_file(d);
|
||||
});
|
||||
r.map_err(|msg| TestCaseError::fail(format!("{what}: panicked: {msg}")))
|
||||
}
|
||||
|
||||
/// Junk of every JSON type.
|
||||
fn arb_junk() -> impl Strategy<Value = Value> {
|
||||
let leaf = prop_oneof![
|
||||
Just(Value::Null),
|
||||
any::<bool>().prop_map(Value::from),
|
||||
any::<i64>().prop_map(Value::from),
|
||||
(-1e3f64..1e3).prop_map(Value::from),
|
||||
"[a-zA-Z#]{0,6}".prop_map(Value::from),
|
||||
];
|
||||
leaf.prop_recursive(2, 12, 3, |inner| {
|
||||
prop_oneof![
|
||||
prop::collection::vec(inner.clone(), 0..3).prop_map(Value::Array),
|
||||
prop::collection::btree_map("[a-z]{1,5}", inner, 0..3).prop_map(|m| Value::Object(m.into_iter().collect())),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// Numbers that tend to break arithmetic.
|
||||
fn arb_num() -> impl Strategy<Value = Value> {
|
||||
prop_oneof![
|
||||
6 => (-600.0f64..600.0).prop_map(Value::from),
|
||||
1 => Just(json!(0)),
|
||||
1 => Just(json!(-0.0)),
|
||||
1 => Just(json!(1e308)),
|
||||
1 => Just(json!(-1e308)),
|
||||
1 => Just(json!(4.0e6)),
|
||||
1 => Just(json!(4.000001e6)),
|
||||
1 => Just(json!(1e-308)),
|
||||
1 => Just(json!(u64::MAX)),
|
||||
1 => Just(json!(i64::MIN)),
|
||||
1 => arb_junk(),
|
||||
]
|
||||
}
|
||||
|
||||
fn arb_point() -> impl Strategy<Value = Value> {
|
||||
prop_oneof![
|
||||
8 => (arb_num(), arb_num()).prop_map(|(x, y)| json!([x, y])),
|
||||
1 => (arb_num(), arb_num()).prop_map(|(x, y)| json!({"x": x, "y": y})),
|
||||
1 => arb_junk(),
|
||||
]
|
||||
}
|
||||
|
||||
fn arb_path() -> impl Strategy<Value = Value> {
|
||||
let anchor = (
|
||||
arb_point(),
|
||||
prop::option::of(arb_point()),
|
||||
prop::option::of(arb_point()),
|
||||
prop::option::of(prop_oneof![Just("Smooth"), Just("Corner"), Just("Weird")]),
|
||||
)
|
||||
.prop_map(|(p, i, o, k)| {
|
||||
let mut a = json!({"p": p});
|
||||
if let Some(i) = i {
|
||||
a["in"] = i;
|
||||
}
|
||||
if let Some(o) = o {
|
||||
a["out"] = o;
|
||||
}
|
||||
if let Some(k) = k {
|
||||
a["kind"] = json!(k);
|
||||
}
|
||||
a
|
||||
});
|
||||
let sub = (prop::collection::vec(anchor, 0..6), any::<bool>()).prop_map(|(anchors, closed)| json!({"anchors": anchors, "closed": closed}));
|
||||
prop_oneof![9 => prop::collection::vec(sub, 0..3).prop_map(|s| json!({"subpaths": s})), 1 => arb_junk()]
|
||||
}
|
||||
|
||||
fn arb_color() -> impl Strategy<Value = Value> {
|
||||
prop_oneof![
|
||||
(arb_num(), arb_num(), arb_num()).prop_map(|(r, g, b)| json!({"model": "rgb", "r": r, "g": g, "b": b})),
|
||||
(arb_num(), arb_num(), arb_num(), arb_num()).prop_map(|(c, m, y, k)| json!({"model": "cmyk", "c": c, "m": m, "y": y, "k": k})),
|
||||
arb_num().prop_map(|k| json!({"model": "gray", "k": k})),
|
||||
(arb_num(), arb_num(), arb_num()).prop_map(|(l, a, b)| json!({"model": "lab", "l": l, "a": a, "b": b})),
|
||||
arb_junk(),
|
||||
]
|
||||
}
|
||||
|
||||
fn arb_paint() -> impl Strategy<Value = Value> {
|
||||
let stop = (arb_num(), arb_color(), arb_num(), arb_num()).prop_map(|(o, c, a, m)| json!({"offset": o, "color": c, "opacity": a, "midpoint": m}));
|
||||
let freeform = (
|
||||
prop::collection::vec((arb_point(), arb_color(), arb_num()).prop_map(|(at, c, s)| json!({"at": at, "color": c, "spread": s})), 0..3),
|
||||
arb_junk(),
|
||||
)
|
||||
.prop_map(|(points, lines)| json!({"points": points, "lines": if lines.is_array() { lines } else { json!([[0, 1, 9]]) }}));
|
||||
let gradient = (
|
||||
prop_oneof![Just("Linear"), Just("Radial"), Just("Freeform"), Just("Conic")],
|
||||
prop::collection::vec(stop, 0..4),
|
||||
prop::option::of((arb_point(), arb_point(), arb_num(), prop::option::of(arb_point()))),
|
||||
arb_num(),
|
||||
prop::option::of(freeform),
|
||||
)
|
||||
.prop_map(|(kind, stops, geom, angle, ff)| {
|
||||
let mut g = json!({"type": "gradient", "gradient": {"kind": kind, "stops": stops}, "angle": angle});
|
||||
if let Some((start, end, aspect, focal)) = geom {
|
||||
g["geom"] = json!({"start": start, "end": end, "aspect": aspect, "focal": focal});
|
||||
}
|
||||
if let Some(ff) = ff {
|
||||
g["freeform"] = ff;
|
||||
}
|
||||
g
|
||||
});
|
||||
prop_oneof![
|
||||
2 => Just(Value::Null),
|
||||
3 => (arb_color(), prop::option::of(prop_oneof![Just("White"), Just("Nope")]), arb_num())
|
||||
.prop_map(|(c, sw, t)| json!({"type": "solid", "color": c, "swatch": sw, "tint": t})),
|
||||
3 => gradient,
|
||||
1 => (prop_oneof![Just("Nope"), Just("")], prop::collection::vec(arb_num(), 6)).prop_map(|(p, xf)| json!({"type": "pattern", "pattern": p, "xf": xf})),
|
||||
1 => Just(json!({"type": "none"})),
|
||||
1 => arb_junk(),
|
||||
]
|
||||
}
|
||||
|
||||
/// An output object: an input object's id (by index into the inputs) or a random one, and any of
|
||||
/// the object keys.
|
||||
fn arb_object() -> impl Strategy<Value = (Option<usize>, Value)> {
|
||||
let stroke = (arb_paint(), prop::option::of(arb_num())).prop_map(|(p, w)| json!({"paint": p, "width": w}));
|
||||
(
|
||||
prop::option::of(0usize..8),
|
||||
prop::option::of(prop_oneof![Just(json!("path")), Just(json!("compound")), arb_junk()]),
|
||||
prop::option::of(arb_path()),
|
||||
prop::option::of(prop_oneof![Just(json!("evenodd")), Just(json!("nonzero")), arb_junk()]),
|
||||
prop::option::of(prop::collection::vec(arb_paint(), 0..3)),
|
||||
prop::option::of(prop::collection::vec(stroke, 0..3)),
|
||||
prop::option::of(arb_num()),
|
||||
prop::option::of(prop_oneof![Just(json!("name")), arb_junk()]),
|
||||
)
|
||||
.prop_map(|(id, t, path, rule, fills, strokes, opacity, name)| {
|
||||
let mut o = Map::new();
|
||||
for (k, v) in [
|
||||
("type", t),
|
||||
("path", path),
|
||||
("fillRule", rule),
|
||||
("fills", fills.map(Value::from)),
|
||||
("strokes", strokes.map(Value::from)),
|
||||
("opacity", opacity),
|
||||
("name", name),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
o.insert(k.into(), v);
|
||||
}
|
||||
}
|
||||
(id, Value::Object(o))
|
||||
})
|
||||
}
|
||||
|
||||
/// The output document: objects (ids resolved against `inputs`; out-of-range indices become
|
||||
/// unknown ids), or junk.
|
||||
fn output(objects: &[(Option<usize>, Value)], inputs: &[u64], junk: Option<Value>) -> String {
|
||||
if let Some(j) = junk {
|
||||
return j.to_string();
|
||||
}
|
||||
let list: Vec<Value> = objects
|
||||
.iter()
|
||||
.map(|(id, o)| {
|
||||
let mut o = o.clone();
|
||||
if let Some(i) = id {
|
||||
o["id"] = json!(inputs.get(*i).copied().unwrap_or(9_999_999 + *i as u64));
|
||||
}
|
||||
o
|
||||
})
|
||||
.collect();
|
||||
json!({ "objects": list }).to_string()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn hostile_filter_and_effect_output_never_crashes(
|
||||
objects in prop::collection::vec(arb_object(), 0..5),
|
||||
junk in prop::option::weighted(0.1, arb_junk()),
|
||||
effect in any::<bool>(),
|
||||
) {
|
||||
let mut s = rich_session();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let inputs: Vec<u64> = vectorcraft_plugins::objects::filter_targets(&st.doc, &st.selection.objects).iter().map(|i| i.0).collect();
|
||||
let out = output(&objects, &inputs, junk);
|
||||
let id = if effect { "fuzz.effect" } else { "fuzz.filter" };
|
||||
let src = tpl::returning(&tpl::manifest(id, if effect { "effect" } else { "filter" }, "{}"), &out);
|
||||
install(&mut s, &wat::parse_str(src).unwrap()).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let r = catch_quiet(|| {
|
||||
if effect {
|
||||
s.execute("effect.apply", &json!({"effect": format!("plugin.{id}")})).map(|_| ())
|
||||
} else {
|
||||
s.execute("plugin.run", &json!({"id": id})).map(|_| ())
|
||||
}
|
||||
});
|
||||
match r {
|
||||
Err(msg) => return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))),
|
||||
Ok(Err(_)) => prop_assert!(s.doc().unwrap().doc == before, "a failed run changed the document: {}", out),
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
renders(&s, &out)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_and_garbage_modules_never_crash(
|
||||
flips in prop::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 0..8),
|
||||
cut in prop::option::of(any::<prop::sample::Index>()),
|
||||
garbage in prop::collection::vec(any::<u8>(), 0..64),
|
||||
) {
|
||||
let mut bytes = DESATURATE.to_vec();
|
||||
for (i, b) in &flips {
|
||||
let i = i.index(bytes.len());
|
||||
bytes[i] = *b;
|
||||
}
|
||||
if let Some(c) = cut {
|
||||
bytes.truncate(c.index(bytes.len()));
|
||||
}
|
||||
let mut s = rich_session();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
for module in [bytes, [b"\0asm\x01\0\0\0".as_slice(), &garbage].concat(), garbage.clone()] {
|
||||
let r = catch_quiet(|| {
|
||||
if let Ok(info) = install(&mut s, &module) {
|
||||
let _ = s.execute("plugin.run", &json!({"id": info["id"]}));
|
||||
}
|
||||
});
|
||||
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
|
||||
}
|
||||
renders(&s, "after mutated modules")?;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ mod text;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_fontnames;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Type names the available family as the font spells it, not as the PostScript name splits into
|
||||
//! words (#130).
|
||||
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_text::FontDb;
|
||||
|
||||
use crate::import::import;
|
||||
|
||||
/// The bundled Source Sans 3 renamed `family` (as long as the original name).
|
||||
fn renamed(family: &str) -> Vec<u8> {
|
||||
let mut data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").to_vec();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
|
||||
assert_eq!(from.len(), to.len());
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
i += at + to.len();
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postscript_names_resolve_to_the_available_family() {
|
||||
// Split into words, its PostScript name would read "My Face Ya Hei3".
|
||||
assert!(FontDb::global().add_font(renamed("MyFaceYaHei 3")) > 0);
|
||||
let body = "/MyFaceYaHei3-Bold findfont 12 scalefont setfont 10 20 moveto (Hi) show";
|
||||
let eps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n");
|
||||
let r = import(eps.as_bytes()).unwrap();
|
||||
let mut styles = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
styles.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone())));
|
||||
}
|
||||
});
|
||||
assert_eq!(styles, [("MyFaceYaHei 3".to_string(), "Bold".to_string())]);
|
||||
}
|
||||
@@ -223,7 +223,9 @@ impl Interp<'_> {
|
||||
if !(size.is_finite() && size > 1e-3 && size < 1e5) {
|
||||
return ps_err("undefinedresult", "a font size");
|
||||
}
|
||||
let (font_family, font_style) = family_style(&name);
|
||||
let (family, font_style) = family_style(&name);
|
||||
// The available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei".
|
||||
let font_family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
|
||||
let text: String = s.iter().map(|b| char::from(*b)).filter(|c| !c.is_control()).collect();
|
||||
let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() };
|
||||
let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() };
|
||||
|
||||
+102
-54
@@ -3,10 +3,12 @@
|
||||
//!
|
||||
//! The export writes a plain file; [`protect`] then encrypts every string and stream in it (the
|
||||
//! XMP metadata stays readable with Plaintext Metadata) and adds the encryption dictionary. The
|
||||
//! algorithm follows the compatibility ([`Encryption`]): 128-bit RC4 for PDF 1.4 (revision 3) and
|
||||
//! 1.5 (revision 4), 128-bit AES for 1.6 (revision 4) and 256-bit AES for 1.7 and 2.0
|
||||
//! (revision 6). The random file key, salts and initialisation vectors come from a SHA-256 stream
|
||||
//! seeded with the process's random hash keys, the time and the file itself.
|
||||
//! algorithm follows the compatibility ([`Encryption`]): 40-bit RC4 for PDF 1.3 (revision 2),
|
||||
//! 128-bit RC4 for PDF 1.4 (revision 3) and 1.5 (revision 4), 128-bit AES for 1.6 (revision 4) and
|
||||
//! 256-bit AES for 1.7 and 2.0 (revision 6). The random file key, salts and initialisation vectors
|
||||
//! come from a SHA-256 stream seeded with the process's random hash keys, the time and the file
|
||||
//! itself. A linearised file is encrypted between its two steps ([`protect_keeping`]): what
|
||||
//! encrypted it then encrypts the hint stream written last ([`Cipher`]).
|
||||
//!
|
||||
//! Reading: [`user_password`] turns the permissions (owner) password of a file encrypted with RC4
|
||||
//! or 128-bit AES into its open (user) password, so either password opens it.
|
||||
@@ -22,6 +24,7 @@ use crate::{Changes, Compatibility, PdfError, PdfSettings, Printing, SecuritySet
|
||||
choice! {
|
||||
/// The encryption algorithm, which follows the PDF version (Compatibility).
|
||||
Encryption {
|
||||
Rc440 = "rc440", "40-bit RC4";
|
||||
Rc4 = "rc4", "128-bit RC4";
|
||||
Aes128 = "aes128", "128-bit AES";
|
||||
Aes256 = "aes256", "256-bit AES";
|
||||
@@ -32,6 +35,7 @@ impl Encryption {
|
||||
/// The algorithm a file of PDF version `c` is encrypted with.
|
||||
pub fn for_compatibility(c: Compatibility) -> Self {
|
||||
match c {
|
||||
Compatibility::Pdf13 => Self::Rc440,
|
||||
Compatibility::Pdf14 | Compatibility::Pdf15 => Self::Rc4,
|
||||
Compatibility::Pdf16 => Self::Aes128,
|
||||
Compatibility::Pdf17 | Compatibility::Pdf20 => Self::Aes256,
|
||||
@@ -43,6 +47,7 @@ impl Encryption {
|
||||
/// encrypted), 4 (crypt filters: RC4 or AES-128) or 6 (AES-256).
|
||||
fn revision(c: Compatibility) -> u8 {
|
||||
match c {
|
||||
Compatibility::Pdf13 => 2,
|
||||
Compatibility::Pdf14 => 3,
|
||||
Compatibility::Pdf15 | Compatibility::Pdf16 => 4,
|
||||
Compatibility::Pdf17 | Compatibility::Pdf20 => 6,
|
||||
@@ -138,7 +143,7 @@ impl PdfSettings {
|
||||
pub(crate) fn warnings(set: &PdfSettings) -> Vec<String> {
|
||||
let s = &set.security;
|
||||
let mut out = vec![];
|
||||
if s.protected() && s.plaintext_metadata && revision(set.compatibility) == 3 {
|
||||
if s.protected() && s.plaintext_metadata && revision(set.compatibility) <= 3 {
|
||||
out.push(format!("{} encryption covers the metadata too: plaintext metadata needs PDF 1.5 or later", set.compatibility.label()));
|
||||
}
|
||||
out
|
||||
@@ -335,7 +340,7 @@ impl Handler {
|
||||
// Without a permissions password the open password grants every permission anyway.
|
||||
let owner = if sec.permissions_password.is_empty() { user } else { sec.permissions_password.as_bytes() };
|
||||
let p = sec.permission_bits();
|
||||
let encrypt_metadata = r == 3 || !sec.plaintext_metadata;
|
||||
let encrypt_metadata = r <= 3 || !sec.plaintext_metadata;
|
||||
let meta = if encrypt_metadata { "" } else { "/EncryptMetadata false" };
|
||||
if r == 6 {
|
||||
let key: [u8; 32] = rng.bytes();
|
||||
@@ -361,47 +366,59 @@ impl Handler {
|
||||
);
|
||||
return Ok(Self { key: key.to_vec(), cipher, encrypt_metadata, dict });
|
||||
}
|
||||
// O (algorithm 3), the file key (algorithm 2) and U (algorithm 5), for a 16-byte key.
|
||||
let owner_key = owner_key(owner, r, 16);
|
||||
// O (algorithm 3), the file key (algorithm 2) and U (algorithms 4 and 5): a 5-byte key and
|
||||
// single RC4 passes for revision 2, a 16-byte key for revisions 3 and 4.
|
||||
let n = if r == 2 { 5 } else { 16 };
|
||||
let owner_key = owner_key(owner, r, n);
|
||||
let mut o = rc4(&owner_key, &padded(user));
|
||||
for i in 1..=19 {
|
||||
for i in (1..=19).filter(|_| r >= 3) {
|
||||
o = rc4(&xored(&owner_key, i), &o);
|
||||
}
|
||||
let no_meta: &[u8] = if encrypt_metadata { &[] } else { &[0xFF; 4] };
|
||||
let mut key = md5(&[&padded(user), &o, &p.to_le_bytes(), id, no_meta]);
|
||||
for _ in 0..50 {
|
||||
for _ in (0..50).filter(|_| r >= 3) {
|
||||
key = md5(&[&key]);
|
||||
}
|
||||
let mut u = rc4(&key, &md5(&[&PAD, id]));
|
||||
for i in 1..=19 {
|
||||
u = rc4(&xored(&key, i), &u);
|
||||
}
|
||||
u.extend_from_slice(&rng.bytes::<16>());
|
||||
let dict = if r == 3 {
|
||||
format!("<</Filter/Standard/V 2/R 3/Length 128/P {p}/O{}/U{}>>", hex(&o), hex(&u))
|
||||
let key = key.get(..n).unwrap_or(&key).to_vec();
|
||||
let u = if r == 2 {
|
||||
rc4(&key, &PAD)
|
||||
} else {
|
||||
let cfm = if cipher == Encryption::Aes128 { "AESV2" } else { "V2" };
|
||||
format!(
|
||||
"<</Filter/Standard/V 4/R 4/Length 128/CF<</StdCF<</AuthEvent/DocOpen/CFM/{cfm}/Length 16>>>>/StmF/StdCF/StrF/StdCF/P {p}/O{}/U{}{meta}>>",
|
||||
hex(&o),
|
||||
hex(&u)
|
||||
)
|
||||
let mut u = rc4(&key, &md5(&[&PAD, id]));
|
||||
for i in 1..=19 {
|
||||
u = rc4(&xored(&key, i), &u);
|
||||
}
|
||||
u.extend_from_slice(&rng.bytes::<16>());
|
||||
u
|
||||
};
|
||||
Ok(Self { key: key.to_vec(), cipher, encrypt_metadata, dict })
|
||||
let dict = match r {
|
||||
2 => format!("<</Filter/Standard/V 1/R 2/Length 40/P {p}/O{}/U{}>>", hex(&o), hex(&u)),
|
||||
3 => format!("<</Filter/Standard/V 2/R 3/Length 128/P {p}/O{}/U{}>>", hex(&o), hex(&u)),
|
||||
_ => {
|
||||
let cfm = if cipher == Encryption::Aes128 { "AESV2" } else { "V2" };
|
||||
format!(
|
||||
"<</Filter/Standard/V 4/R 4/Length 128/CF<</StdCF<</AuthEvent/DocOpen/CFM/{cfm}/Length 16>>>>/StmF/StdCF/StrF/StdCF/P {p}/O{}/U{}{meta}>>",
|
||||
hex(&o),
|
||||
hex(&u)
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Self { key, cipher, encrypt_metadata, dict })
|
||||
}
|
||||
|
||||
/// `data` of object `num generation` encrypted.
|
||||
fn encrypt(&self, num: u32, generation: u16, data: &[u8], rng: &mut Entropy) -> Result<Vec<u8>, PdfError> {
|
||||
// The object's key: the file key's length plus 5 bytes, at most 16 (algorithm 1).
|
||||
let object_key = || {
|
||||
let salt: &[u8] = if self.cipher == Encryption::Aes128 { b"sAlT" } else { &[] };
|
||||
md5(&[&self.key, &num.to_le_bytes()[..3], &generation.to_le_bytes(), salt])
|
||||
let h = md5(&[&self.key, &num.to_le_bytes()[..3], &generation.to_le_bytes(), salt]);
|
||||
h.get(..(self.key.len() + 5).min(16)).unwrap_or(&h).to_vec()
|
||||
};
|
||||
let aes = |key: &[u8], rng: &mut Entropy| -> Result<Vec<u8>, PdfError> {
|
||||
let iv: [u8; 16] = rng.bytes();
|
||||
Ok([&iv[..], &Aes::new(key)?.cbc(iv, data, true)].concat())
|
||||
};
|
||||
match self.cipher {
|
||||
Encryption::Rc4 => Ok(rc4(&object_key(), data)),
|
||||
Encryption::Rc440 | Encryption::Rc4 => Ok(rc4(&object_key(), data)),
|
||||
Encryption::Aes128 => aes(&object_key(), rng),
|
||||
Encryption::Aes256 => aes(&self.key, rng),
|
||||
}
|
||||
@@ -421,7 +438,7 @@ fn owner_key(owner: &[u8], r: u8, n: usize) -> Vec<u8> {
|
||||
|
||||
// ---------- PDF syntax ----------
|
||||
|
||||
fn is_white(b: u8) -> bool {
|
||||
pub(crate) fn is_white(b: u8) -> bool {
|
||||
matches!(b, 0 | 9 | 10 | 12 | 13 | 32)
|
||||
}
|
||||
|
||||
@@ -430,7 +447,7 @@ fn is_regular(b: u8) -> bool {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Tok<'a> {
|
||||
pub(crate) enum Tok<'a> {
|
||||
DictOpen,
|
||||
DictClose,
|
||||
ArrayOpen,
|
||||
@@ -442,14 +459,14 @@ enum Tok<'a> {
|
||||
}
|
||||
|
||||
/// A token with its byte span.
|
||||
type Spanned<'a> = (Tok<'a>, usize, usize);
|
||||
pub(crate) type Spanned<'a> = (Tok<'a>, usize, usize);
|
||||
|
||||
/// Deepest nesting of arrays and dictionaries read.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// A parsed object, with what encryption needs: the strings and dictionaries with their spans.
|
||||
#[derive(Debug)]
|
||||
enum Obj<'a> {
|
||||
pub(crate) enum Obj<'a> {
|
||||
/// `start` is where `<<` is, `end` just after `>>`.
|
||||
Dict {
|
||||
entries: Vec<(&'a [u8], Obj<'a>)>,
|
||||
@@ -474,21 +491,21 @@ enum Obj<'a> {
|
||||
}
|
||||
|
||||
impl<'a> Obj<'a> {
|
||||
fn get(&self, key: &[u8]) -> Option<&Obj<'a>> {
|
||||
pub(crate) fn get(&self, key: &[u8]) -> Option<&Obj<'a>> {
|
||||
match self {
|
||||
Self::Dict { entries, .. } => entries.iter().find(|(k, _)| *k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self, key: &[u8]) -> Option<&'a [u8]> {
|
||||
pub(crate) fn name(&self, key: &[u8]) -> Option<&'a [u8]> {
|
||||
match self.get(key)? {
|
||||
Self::Name(n) => Some(n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn int(&self, key: &[u8]) -> Option<i64> {
|
||||
pub(crate) fn int(&self, key: &[u8]) -> Option<i64> {
|
||||
match self.get(key)? {
|
||||
Self::Int { value, .. } => Some(*value),
|
||||
_ => None,
|
||||
@@ -524,18 +541,18 @@ impl<'a> Obj<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn int(w: &[u8]) -> Option<i64> {
|
||||
pub(crate) fn int(w: &[u8]) -> Option<i64> {
|
||||
std::str::from_utf8(w).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Lexer<'a> {
|
||||
pub(crate) struct Lexer<'a> {
|
||||
buf: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
fn at(buf: &'a [u8], pos: usize) -> Self {
|
||||
pub(crate) fn at(buf: &'a [u8], pos: usize) -> Self {
|
||||
Self { buf, pos }
|
||||
}
|
||||
|
||||
@@ -557,7 +574,7 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Option<Spanned<'a>> {
|
||||
pub(crate) fn next(&mut self) -> Option<Spanned<'a>> {
|
||||
self.skip_space();
|
||||
let start = self.pos;
|
||||
let b = self.peek_byte(0)?;
|
||||
@@ -689,7 +706,7 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
|
||||
/// One object (a reference `n g R` reads as one).
|
||||
fn object(&mut self, depth: usize) -> Option<Obj<'a>> {
|
||||
pub(crate) fn object(&mut self, depth: usize) -> Option<Obj<'a>> {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
@@ -748,7 +765,7 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
|
||||
/// The header `num generation obj` of an indirect object.
|
||||
fn header(&mut self) -> Option<(u32, u16)> {
|
||||
pub(crate) fn header(&mut self) -> Option<(u32, u16)> {
|
||||
let (Some((Tok::Word(n), ..)), Some((Tok::Word(g), ..)), Some((Tok::Word(b"obj"), ..))) = (self.next(), self.next(), self.next()) else {
|
||||
return None;
|
||||
};
|
||||
@@ -820,14 +837,29 @@ fn object_edits(pdf: &[u8], off: usize, h: &Handler, catalog: bool, rng: &mut En
|
||||
{
|
||||
edits.push((start + 2, start + 2, AES256_EXTENSION.to_vec()));
|
||||
}
|
||||
let Some((Tok::Word(b"stream"), _, keyword_end)) = lx.next() else { return Ok(()) };
|
||||
let Some(((len_start, len_end), (data, data_end))) = stream_spans(pdf, &mut lx, &obj).map_err(failed)? else { return Ok(()) };
|
||||
let ty = obj.name(b"Type");
|
||||
if matches!(ty, Some(b"XRef" | b"ObjStm")) {
|
||||
return Err(failed("cross-reference and object streams aren't supported"));
|
||||
}
|
||||
let Some(Obj::Int { value, start: len_start, end: len_end }) = obj.get(b"Length") else {
|
||||
return Err(failed("a stream's length isn't a number"));
|
||||
};
|
||||
if ty == Some(&b"Metadata"[..]) && !h.encrypt_metadata {
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = pdf.get(data..data_end).ok_or_else(|| failed("a stream runs past the end"))?;
|
||||
let enc = h.encrypt(num, generation, bytes, rng)?;
|
||||
edits.push((len_start, len_end, enc.len().to_string().into_bytes()));
|
||||
edits.push((data, data_end, enc));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The spans of a stream's length value and of its data.
|
||||
pub(crate) type StreamSpans = ((usize, usize), (usize, usize));
|
||||
|
||||
/// Where the object whose dictionary `dict` the lexer `lx` has just read keeps its stream data:
|
||||
/// its [`StreamSpans`]; `None` when it isn't a stream.
|
||||
pub(crate) fn stream_spans(pdf: &[u8], lx: &mut Lexer<'_>, dict: &Obj<'_>) -> Result<Option<StreamSpans>, &'static str> {
|
||||
let Some((Tok::Word(b"stream"), _, keyword_end)) = lx.next() else { return Ok(None) };
|
||||
let Some(Obj::Int { value, start, end }) = dict.get(b"Length") else { return Err("a stream's length isn't a number") };
|
||||
// The data starts after the end of line that follows `stream`.
|
||||
let mut data = keyword_end;
|
||||
if pdf.get(data) == Some(&b'\r') {
|
||||
@@ -836,26 +868,42 @@ fn object_edits(pdf: &[u8], off: usize, h: &Handler, catalog: bool, rng: &mut En
|
||||
if pdf.get(data) == Some(&b'\n') {
|
||||
data += 1;
|
||||
}
|
||||
let data_end = usize::try_from(*value).ok().and_then(|n| data.checked_add(n)).ok_or_else(|| failed("bad stream length"))?;
|
||||
let bytes = pdf.get(data..data_end).ok_or_else(|| failed("a stream runs past the end"))?;
|
||||
let data_end = usize::try_from(*value).ok().and_then(|n| data.checked_add(n)).ok_or("bad stream length")?;
|
||||
if data_end > pdf.len() {
|
||||
return Err("a stream runs past the end");
|
||||
}
|
||||
if !matches!(Lexer::at(pdf, data_end).next(), Some((Tok::Word(b"endstream"), ..))) {
|
||||
return Err(failed("a stream's length is wrong"));
|
||||
return Err("a stream's length is wrong");
|
||||
}
|
||||
if ty == Some(&b"Metadata"[..]) && !h.encrypt_metadata {
|
||||
return Ok(());
|
||||
Ok(Some(((*start, *end), (data, data_end))))
|
||||
}
|
||||
|
||||
/// What encrypts the streams of a file [`protect_keeping`] encrypted, for those written after it
|
||||
/// (the hint stream of a linearised file).
|
||||
pub(crate) struct Cipher {
|
||||
h: Handler,
|
||||
rng: Entropy,
|
||||
}
|
||||
|
||||
impl Cipher {
|
||||
/// `data`, the stream of object `num` (generation 0), encrypted.
|
||||
pub(crate) fn stream(&mut self, num: u32, data: &[u8]) -> Result<Vec<u8>, PdfError> {
|
||||
self.h.encrypt(num, 0, data, &mut self.rng)
|
||||
}
|
||||
let enc = h.encrypt(num, generation, bytes, rng)?;
|
||||
edits.push((*len_start, *len_end, enc.len().to_string().into_bytes()));
|
||||
edits.push((data, data_end, enc));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encrypt `pdf` (as the export writes it: one cross-reference table) as `set`'s Security
|
||||
/// section says; without a password it comes back as it is.
|
||||
pub(crate) fn protect(pdf: Vec<u8>, set: &PdfSettings) -> Result<Vec<u8>, PdfError> {
|
||||
protect_keeping(pdf, set).map(|(pdf, _)| pdf)
|
||||
}
|
||||
|
||||
/// [`protect`], keeping what encrypted the file (`None` when it isn't encrypted). The objects keep
|
||||
/// their order and numbers; the encryption dictionary comes after them, numbered last.
|
||||
pub(crate) fn protect_keeping(pdf: Vec<u8>, set: &PdfSettings) -> Result<(Vec<u8>, Option<Cipher>), PdfError> {
|
||||
let sec = &set.security;
|
||||
if !sec.protected() {
|
||||
return Ok(pdf);
|
||||
return Ok((pdf, None));
|
||||
}
|
||||
let xref = xref_offset(&pdf).ok_or_else(|| failed("no cross-reference table"))?;
|
||||
let (mut objects, trailer) = xref_table(&pdf, xref)?;
|
||||
@@ -933,7 +981,7 @@ pub(crate) fn protect(pdf: Vec<u8>, set: &PdfSettings) -> Result<Vec<u8>, PdfErr
|
||||
out.extend_from_slice(format!("/ID[{}{}]", hex(&id), hex(&id)).as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("/Encrypt {size} 0 R>>\nstartxref\n{table}\n%%EOF").as_bytes());
|
||||
Ok(out)
|
||||
Ok((out, Some(Cipher { h, rng })))
|
||||
}
|
||||
|
||||
// ---------- reading ----------
|
||||
|
||||
+135
-65
@@ -23,6 +23,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
|
||||
use vectorcraft_text::TextLayout;
|
||||
use vectorcraft_text::embed::Embedding;
|
||||
|
||||
use crate::forms::Mark;
|
||||
use crate::lab_spot::{find, rfind};
|
||||
use crate::marks::PageBoxes;
|
||||
use crate::output::ColorOut;
|
||||
@@ -41,22 +42,11 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||
// Live geometry effects export as their result; raster effects are reported below.
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
if doc.artboards.is_empty() {
|
||||
return Err(PdfError::NoArtboards);
|
||||
}
|
||||
let indices: Vec<usize> = match &opts.artboards {
|
||||
Some(v) => v.clone(),
|
||||
None => (0..doc.artboards.len()).collect(),
|
||||
};
|
||||
if indices.is_empty() {
|
||||
return Err(PdfError::NoArtboards);
|
||||
}
|
||||
if let Some(bad) = indices.iter().find(|i| **i >= doc.artboards.len()) {
|
||||
return Err(PdfError::BadArtboard(*bad));
|
||||
}
|
||||
let pages = pages(doc, opts)?;
|
||||
|
||||
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
|
||||
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
|
||||
let created_at = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
|
||||
crate::pdfx::require_date(set.standard, created_at)?;
|
||||
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
|
||||
// Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it).
|
||||
let mut warnings = set.warnings();
|
||||
@@ -70,12 +60,15 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||
let mut ex = Exporter::new(doc, set);
|
||||
ex.non_printing = set.include_non_printing || set.create_layers;
|
||||
ex.out = w.out.clone();
|
||||
// Marks and Bleeds: each page is its artboard (the trim box) grown by the bleed, and by the
|
||||
// printer's marks around that.
|
||||
// A knockout page group draws the layers' objects one by one, not layer by layer.
|
||||
ex.layers = set.writes_layers() && !doc.page_knockout;
|
||||
if set.writes_layers() && doc.page_knockout {
|
||||
warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into());
|
||||
}
|
||||
ex.overprint = set.advanced.overprint == crate::Overprint::Preserve;
|
||||
let bleed = set.bleed_of(doc);
|
||||
let marks = set.marks.printer_marks();
|
||||
for i in indices {
|
||||
let boxes = PageBoxes::new(doc.artboards[i].rect, bleed, &marks);
|
||||
for (i, boxes) in pages {
|
||||
let (m, r) = (boxes.media, boxes.bleed);
|
||||
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, created_at) } else { String::new() };
|
||||
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
|
||||
@@ -96,15 +89,37 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||
};
|
||||
w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?;
|
||||
}
|
||||
let (layers, overprinted) = (ex.layers, ex.overprinted);
|
||||
w.absorb(ex);
|
||||
let (bytes, more) = w.finish()?;
|
||||
let bytes = if native.is_some() { crate::editing::seal(bytes)? } else { bytes };
|
||||
let bytes = crate::encrypt::protect(bytes, set)?;
|
||||
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted)?;
|
||||
warnings.extend(more);
|
||||
let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?;
|
||||
warnings.dedup();
|
||||
Ok(ExportReport { bytes, warnings })
|
||||
}
|
||||
|
||||
/// The artboards `opts` exports, in page order, with the boxes of their pages: Marks and Bleeds
|
||||
/// make each page its artboard (the trim box) grown by the bleed, and by the printer's marks
|
||||
/// around that.
|
||||
fn pages(doc: &Document, opts: &PdfOptions) -> Result<Vec<(usize, PageBoxes)>, PdfError> {
|
||||
let indices: Vec<usize> = match &opts.artboards {
|
||||
Some(v) => v.clone(),
|
||||
None => (0..doc.artboards.len()).collect(),
|
||||
};
|
||||
if doc.artboards.is_empty() || indices.is_empty() {
|
||||
return Err(PdfError::NoArtboards);
|
||||
}
|
||||
let (bleed, marks) = (opts.settings.bleed_of(doc), opts.settings.marks.printer_marks());
|
||||
indices.into_iter().map(|i| Ok((i, PageBoxes::new(doc.artboards.get(i).ok_or(PdfError::BadArtboard(i))?.rect, bleed, &marks)))).collect()
|
||||
}
|
||||
|
||||
/// What each page of `doc` exported with `opts` shows, in document space (its media box: the
|
||||
/// artboard with the bleed and the printer's marks), in page order.
|
||||
pub fn page_areas(doc: &Document, opts: &PdfOptions) -> Result<Vec<Rect>, PdfError> {
|
||||
Ok(pages(doc, opts)?.into_iter().map(|(_, b)| b.media).collect())
|
||||
}
|
||||
|
||||
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
|
||||
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
|
||||
pub(crate) struct Writer {
|
||||
@@ -117,6 +132,8 @@ pub(crate) struct Writer {
|
||||
/// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]).
|
||||
lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>,
|
||||
warnings: Vec<String>,
|
||||
/// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups.
|
||||
pdf13: bool,
|
||||
}
|
||||
|
||||
/// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]),
|
||||
@@ -156,7 +173,8 @@ impl Writer {
|
||||
warnings.push(format!("the CMYK profile {} can't be embedded: CMYK colours are written untagged", out.cmyk_profile()));
|
||||
}
|
||||
let version = match set.compatibility {
|
||||
Compatibility::Pdf14 => PdfVersion::Pdf14,
|
||||
// PDF 1.3 files are written with the PDF 1.4 settings: flat, they use nothing newer.
|
||||
Compatibility::Pdf13 | Compatibility::Pdf14 => PdfVersion::Pdf14,
|
||||
Compatibility::Pdf15 => PdfVersion::Pdf15,
|
||||
Compatibility::Pdf16 => PdfVersion::Pdf16,
|
||||
Compatibility::Pdf17 => PdfVersion::Pdf17,
|
||||
@@ -197,7 +215,7 @@ impl Writer {
|
||||
meta = meta.creation_date(t);
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings })
|
||||
Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings, pdf13: set.pdf13() })
|
||||
}
|
||||
|
||||
/// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks.
|
||||
@@ -226,10 +244,11 @@ impl Writer {
|
||||
s.push_clip_path(clip, &krilla::paint::FillRule::NonZero);
|
||||
}
|
||||
// Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF
|
||||
// writer has no page group attributes). In CMYK, transparency at the top of a page is put
|
||||
// in a non-isolated group of its own, whose blending space `cmyk_blending` rewrites.
|
||||
// writer has no page group attributes; a flat PDF 1.3 file has none). In CMYK, transparency
|
||||
// at the top of a page is put in a non-isolated group of its own, whose blending space
|
||||
// `cmyk_blending` rewrites.
|
||||
let doc = ex.doc;
|
||||
let page_group = doc.page_isolate || doc.page_knockout;
|
||||
let page_group = !self.pdf13 && (doc.page_isolate || doc.page_knockout);
|
||||
let cmyk_page_group = self.cmyk && !page_group && ex.shows_transparency();
|
||||
if page_group {
|
||||
s.push_isolated();
|
||||
@@ -284,17 +303,19 @@ impl Writer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The finished file (with the output intent and Trapped entries) and the warnings of its
|
||||
/// drawing.
|
||||
/// The finished file (with the output intent and Trapped entries, and checked against its
|
||||
/// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK
|
||||
/// alternates.
|
||||
pub(crate) fn finish(mut self) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||||
let mut bytes = self.pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
if self.cmyk {
|
||||
cmyk_blending(&mut bytes, self.out.tagged);
|
||||
}
|
||||
let bytes = if self.lab_spots.is_empty() { bytes } else { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) };
|
||||
let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only();
|
||||
let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes };
|
||||
let (bytes, more) = self.out.write_catalog(bytes)?;
|
||||
self.warnings.extend(more);
|
||||
Ok((bytes, self.warnings))
|
||||
Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +453,15 @@ pub(crate) struct Exporter<'a> {
|
||||
/// The fonts real text is written in, by face id, with their units per em; `None` for faces
|
||||
/// that can't be embedded.
|
||||
fonts: HashMap<u32, Option<(krilla::text::Font, f64)>>,
|
||||
/// Top-level layers are drawn as forms marked for their optional content groups
|
||||
/// ([`crate::forms`]).
|
||||
pub layers: bool,
|
||||
/// A top-level layer's form is being drawn.
|
||||
in_layer: bool,
|
||||
/// Overprinting fills and strokes are drawn as forms marked to overprint.
|
||||
pub overprint: bool,
|
||||
/// An overprinting fill or stroke was drawn so.
|
||||
pub overprinted: bool,
|
||||
}
|
||||
|
||||
impl<'a> Exporter<'a> {
|
||||
@@ -454,6 +484,10 @@ impl<'a> Exporter<'a> {
|
||||
convert: true,
|
||||
outline_text: set.advanced.outline_text,
|
||||
fonts: HashMap::new(),
|
||||
layers: false,
|
||||
in_layer: false,
|
||||
overprint: false,
|
||||
overprinted: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,10 +817,12 @@ impl Exporter<'_> {
|
||||
if !m.clip || m.invert {
|
||||
cover(&mut ms, page, if m.clip { 0 } else { 255 }, 1.0);
|
||||
}
|
||||
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
|
||||
// Mask art is a picture of its own: it takes no part in a knockout group around the object,
|
||||
// and has no inks to overprint.
|
||||
let knockout = std::mem::take(&mut self.knockout);
|
||||
let overprint = std::mem::take(&mut self.overprint);
|
||||
self.node(&mut ms, &m.art, page, true);
|
||||
self.knockout = knockout;
|
||||
(self.knockout, self.overprint) = (knockout, overprint);
|
||||
if m.invert {
|
||||
ms.push_blend_mode(krilla::blend::BlendMode::Difference);
|
||||
cover(&mut ms, page, 255, 1.0);
|
||||
@@ -834,7 +870,9 @@ impl Exporter<'_> {
|
||||
let alpha = {
|
||||
let mut ab = ms.stream_builder();
|
||||
let mut als = ab.surface();
|
||||
let overprint = std::mem::take(&mut self.overprint);
|
||||
self.node(&mut als, &shape, page, false);
|
||||
self.overprint = overprint;
|
||||
als.finish();
|
||||
krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha)
|
||||
};
|
||||
@@ -846,6 +884,18 @@ impl Exporter<'_> {
|
||||
}
|
||||
|
||||
fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) {
|
||||
if self.layers
|
||||
&& !self.in_layer
|
||||
&& let NodeKind::Layer { template: false, printable, .. } = n.kind
|
||||
&& (printable || self.non_printing)
|
||||
&& let Some(i) = self.doc.layers.iter().position(|l| std::ptr::eq(&**l, n))
|
||||
{
|
||||
// A PDF layer: hidden layers are written too (their group is off).
|
||||
self.in_layer = true;
|
||||
crate::forms::form(s, Mark::Layer(i), |s| self.node(s, n, page, true));
|
||||
self.in_layer = false;
|
||||
return;
|
||||
}
|
||||
if !force && !n.visible {
|
||||
return;
|
||||
}
|
||||
@@ -955,27 +1005,29 @@ impl Exporter<'_> {
|
||||
continue;
|
||||
}
|
||||
self.warn_raster(&fl.effects, || "fills".into());
|
||||
if self.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) {
|
||||
continue;
|
||||
}
|
||||
let Some(paint) = self.paint(&fl.paint, bounds) else { continue };
|
||||
let bl = fl.blend != BlendMode::Normal;
|
||||
if bl {
|
||||
s.push_blend_mode(blend(fl.blend));
|
||||
}
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) }));
|
||||
s.draw_path(&path);
|
||||
if bl {
|
||||
s.pop();
|
||||
}
|
||||
self.overprinting(s, fl.overprint, &fl.paint, |ex, s| {
|
||||
if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) {
|
||||
return;
|
||||
}
|
||||
let Some(paint) = ex.paint(&fl.paint, bounds) else { return };
|
||||
let bl = fl.blend != BlendMode::Normal;
|
||||
if bl {
|
||||
s.push_blend_mode(blend(fl.blend));
|
||||
}
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) }));
|
||||
s.draw_path(&path);
|
||||
if bl {
|
||||
s.pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
AppearanceItem::Stroke(st) => {
|
||||
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
self.warn_raster(&st.effects, || "strokes".into());
|
||||
self.stroke(s, bp, &path, r, st, page, bounds);
|
||||
self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, &path, r, st, page, bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -983,6 +1035,19 @@ impl Exporter<'_> {
|
||||
s.set_stroke(None);
|
||||
}
|
||||
|
||||
/// Draw a fill or stroke of `paint` with `draw`: as a form marked to overprint when it
|
||||
/// `overprints`, paints and overprints are kept (but not a white one with Discard White
|
||||
/// Overprint).
|
||||
fn overprinting(&mut self, s: &mut Surface, overprints: bool, paint: &Paint, draw: impl FnOnce(&mut Self, &mut Surface)) {
|
||||
let white = || self.doc.setup.discard_white_overprint && vectorcraft_doc::overprint::is_white(paint);
|
||||
if overprints && self.overprint && !paint.is_none() && !white() {
|
||||
self.overprinted = true;
|
||||
crate::forms::form(s, Mark::Overprint, |s| draw(self, s));
|
||||
} else {
|
||||
draw(self, s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` a paint PDF has no paint for, which [`Self::area`] draws as art: a pattern swatch or
|
||||
/// a freeform gradient?
|
||||
fn area_paint(&self, p: &Paint) -> bool {
|
||||
@@ -1231,25 +1296,28 @@ impl Exporter<'_> {
|
||||
bp.extend(g.outline.iter());
|
||||
}
|
||||
let fill = &run.style.fill;
|
||||
// The outlines left to fill as paths: all of them, or those real text leaves.
|
||||
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !self.area_paint(fill)) {
|
||||
Some(text) => Cow::Owned(self.glyph_text(s, &layout, i, text, fill)),
|
||||
None => Cow::Borrowed(&bp),
|
||||
};
|
||||
let opaque = (1.0, BlendMode::Normal);
|
||||
if let Some(path) = to_path(&outlines)
|
||||
&& !self.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box())
|
||||
&& let Some(paint) = self.paint(fill, layout.bounds)
|
||||
{
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
|
||||
s.draw_path(&path);
|
||||
}
|
||||
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
|
||||
// The outlines left to fill as paths: all of them, or those real text leaves.
|
||||
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) {
|
||||
Some(text) => Cow::Owned(ex.glyph_text(s, &layout, i, text, fill)),
|
||||
None => Cow::Borrowed(&bp),
|
||||
};
|
||||
let opaque = (1.0, BlendMode::Normal);
|
||||
if let Some(path) = to_path(&outlines)
|
||||
&& !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box())
|
||||
&& let Some(paint) = ex.paint(fill, layout.bounds)
|
||||
{
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
|
||||
s.draw_path(&path);
|
||||
}
|
||||
});
|
||||
if run.style.has_stroke()
|
||||
&& let Some(path) = to_path(&bp)
|
||||
{
|
||||
// Character strokes are drawn in text space, with their cap, join and dashes.
|
||||
self.stroke(s, &bp, &path, FillRule::NonZero, &run.style.stroke_layer(), page, layout.bounds);
|
||||
let st = run.style.stroke_layer();
|
||||
self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds));
|
||||
}
|
||||
}
|
||||
s.set_fill(None);
|
||||
@@ -1266,16 +1334,18 @@ impl Exporter<'_> {
|
||||
fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], bp: &BezPath, path: &Path, page: Rect, tb: Rect) {
|
||||
for item in items {
|
||||
match item {
|
||||
AppearanceItem::Fill(fl) if fl.visible => {
|
||||
if !self.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box())
|
||||
&& let Some(paint) = self.paint(&fl.paint, tb)
|
||||
AppearanceItem::Fill(fl) if fl.visible => self.overprinting(s, fl.overprint, &fl.paint, |ex, s| {
|
||||
if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box())
|
||||
&& let Some(paint) = ex.paint(&fl.paint, tb)
|
||||
{
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
|
||||
s.draw_path(path);
|
||||
}
|
||||
}),
|
||||
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => {
|
||||
self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, path, FillRule::NonZero, st, page, tb))
|
||||
}
|
||||
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => self.stroke(s, bp, path, FillRule::NonZero, st, page, tb),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! What the PDF writer has no operators for — optional content (PDF layers) and overprint — drawn
|
||||
//! as form XObjects of their own (without a transparency group, so they draw as if in place),
|
||||
//! each starting with a mark: an empty clip whose first point says what the form is. Marked
|
||||
//! forms also stay apart (the writer shares identical forms). [`finish`] completes them in the
|
||||
//! written file: a layer's form is drawn as the layer's optional content (`/OC … BDC`), and an
|
||||
//! overprinting form sets a graphics state that overprints first.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
|
||||
use krilla::geom::PathBuilder;
|
||||
use krilla::graphic::Graphic;
|
||||
use krilla::surface::Surface;
|
||||
use vectorcraft_doc::{Document, NodeKind};
|
||||
|
||||
use crate::encrypt::{Lexer, Obj, StreamSpans, Tok, int, stream_spans};
|
||||
use crate::output::{deflate, text_string};
|
||||
use crate::patch::{Patch, Xref};
|
||||
use crate::{PdfError, PdfSettings, Standard};
|
||||
|
||||
/// What a marked form is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Mark {
|
||||
/// The art of top-level layer `i` (`Document::layers`).
|
||||
Layer(usize),
|
||||
/// A fill or stroke that overprints.
|
||||
Overprint,
|
||||
}
|
||||
|
||||
/// The x coordinate of a layer mark's first point (its y is the layer index).
|
||||
const LAYER_X: i32 = -32000;
|
||||
/// The x coordinate of an overprint mark's first point.
|
||||
const OVERPRINT_X: i32 = -32001;
|
||||
|
||||
impl Mark {
|
||||
fn at(self) -> (f32, f32) {
|
||||
match self {
|
||||
// Exact in `f32` up to 2^24 layers.
|
||||
Self::Layer(i) => (LAYER_X as f32, i as f32),
|
||||
Self::Overprint => (OVERPRINT_X as f32, 0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw `draw` as a form of its own, marked `mark`.
|
||||
pub(crate) fn form(s: &mut Surface, mark: Mark, draw: impl FnOnce(&mut Surface)) {
|
||||
let mut sb = s.stream_builder();
|
||||
let mut fs = sb.surface();
|
||||
let (x, y) = mark.at();
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.move_to(x, y);
|
||||
pb.line_to(x + 1.0, y);
|
||||
pb.line_to(x + 1.0, y + 1.0);
|
||||
pb.close();
|
||||
// A form's drawing space is its own: the mark's points are written as they are.
|
||||
if let Some(p) = pb.finish() {
|
||||
fs.push_clip_path(&p, &krilla::paint::FillRule::NonZero);
|
||||
fs.pop();
|
||||
}
|
||||
draw(&mut fs);
|
||||
fs.finish();
|
||||
let stream = sb.finish();
|
||||
s.draw_graphic(Graphic::new(stream, false));
|
||||
}
|
||||
|
||||
// ---------- finishing the written file ----------
|
||||
|
||||
/// The resource name of the overprinting graphics state.
|
||||
const OVERPRINT_GS: &str = "VCop";
|
||||
/// The prefix of the resource names of the layers' optional content groups (then the index).
|
||||
const LAYER_OC: &str = "VCoc";
|
||||
/// The most bytes of a form's content read to find its mark.
|
||||
const HEAD: u64 = 64;
|
||||
|
||||
fn bad(why: &str) -> PdfError {
|
||||
PdfError::Write(format!("can't write PDF layers or overprint: {why}"))
|
||||
}
|
||||
|
||||
/// An object of the written file with a dictionary, and its stream's [`StreamSpans`].
|
||||
struct Object<'a> {
|
||||
dict: Obj<'a>,
|
||||
stream: Option<StreamSpans>,
|
||||
}
|
||||
|
||||
impl Object<'_> {
|
||||
/// The stream's data, decoded (Flate or none), at most `limit` bytes.
|
||||
fn data(&self, pdf: &[u8], limit: u64) -> Option<Vec<u8>> {
|
||||
let (_, (start, end)) = self.stream?;
|
||||
let data = pdf.get(start..end)?;
|
||||
let mut out = vec![];
|
||||
match self.dict.get(b"Filter") {
|
||||
None => out.extend_from_slice(data.get(..usize::try_from(limit).unwrap_or(usize::MAX).min(data.len()))?),
|
||||
Some(Obj::Name(b"FlateDecode")) => {
|
||||
flate2::read::ZlibDecoder::new(data).take(limit).read_to_end(&mut out).ok()?;
|
||||
}
|
||||
Some(_) => return None,
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Replace the stream's data with `content` (compressed as before).
|
||||
fn rewrite(&self, patch: &mut Patch, content: &[u8]) -> Result<(), PdfError> {
|
||||
let ((len_start, len_end), (start, end)) = self.stream.ok_or_else(|| bad("a content stream isn't a stream"))?;
|
||||
let data = if self.dict.get(b"Filter").is_some() { deflate(content).map_err(|e| bad(&e.to_string()))? } else { content.to_vec() };
|
||||
patch.replace(len_start, len_end, data.len().to_string().into_bytes());
|
||||
patch.replace(start, end, data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The mark of a marked form.
|
||||
fn mark(&self, pdf: &[u8]) -> Option<Mark> {
|
||||
if self.dict.name(b"Subtype") != Some(b"Form") {
|
||||
return None;
|
||||
}
|
||||
let head = self.data(pdf, HEAD)?;
|
||||
let mut lx = Lexer::at(&head, 0);
|
||||
let mut word = || match lx.next() {
|
||||
Some((Tok::Word(w), ..)) => Some(w),
|
||||
_ => None,
|
||||
};
|
||||
let (Some(b"q"), Some(x), Some(y), Some(b"m")) = (word(), word().and_then(int), word().and_then(int), word()) else { return None };
|
||||
match x {
|
||||
_ if x == i64::from(LAYER_X) => usize::try_from(y).ok().map(Mark::Layer),
|
||||
_ if x == i64::from(OVERPRINT_X) && y == 0 => Some(Mark::Overprint),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects of the written file that have a dictionary, by number.
|
||||
fn objects<'a>(pdf: &'a [u8], xref: &Xref) -> HashMap<u32, Object<'a>> {
|
||||
(1..xref.count())
|
||||
.filter_map(|n| {
|
||||
let n = u32::try_from(n).ok()?;
|
||||
let mut lx = Lexer::at(pdf, xref.offset(pdf, n)?);
|
||||
(lx.header()?.0 == n).then_some(())?;
|
||||
let dict = lx.object(0)?;
|
||||
let Obj::Dict { .. } = dict else { return None };
|
||||
let stream = stream_spans(pdf, &mut lx, &dict).ok().flatten();
|
||||
Some((n, Object { dict, stream }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The resource dictionary of the content of `o` (a page or a form).
|
||||
fn resources<'o>(objects: &'o HashMap<u32, Object<'_>>, o: &'o Object<'_>) -> Option<&'o Obj<'o>> {
|
||||
match o.dict.get(b"Resources")? {
|
||||
Obj::Ref(n, _) => objects.get(n).map(|r| &r.dict),
|
||||
d => Some(d),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `entries` (`/VCoc0 12 0 R`) to the `category` (`Properties`, `ExtGState`) of resource
|
||||
/// dictionary `res`.
|
||||
fn add_resources(patch: &mut Patch, res: &Obj<'_>, category: &str, entries: &str) -> Result<(), PdfError> {
|
||||
match (res.get(category.as_bytes()), res) {
|
||||
(Some(Obj::Dict { start, .. }), _) => patch.replace(start + 2, start + 2, entries.as_bytes().to_vec()),
|
||||
(None, Obj::Dict { start, .. }) => patch.replace(start + 2, start + 2, format!("/{category}<<{entries}>>").into_bytes()),
|
||||
_ => return Err(bad("unexpected resources")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `content` with each `Do` of a layer's form (`forms`: its resource name → the layer) marked as
|
||||
/// that layer's optional content, and the layers marked; `None` when there is none.
|
||||
fn mark_layers(content: &[u8], forms: &HashMap<&[u8], usize>) -> Option<(Vec<u8>, Vec<usize>)> {
|
||||
let mut lx = Lexer::at(content, 0);
|
||||
let mut name = None;
|
||||
let mut spans = vec![];
|
||||
while let Some((tok, start, end)) = lx.next() {
|
||||
name = match tok {
|
||||
Tok::Name(n) => forms.get(n).map(|layer| (start, *layer)),
|
||||
Tok::Word(b"Do") => {
|
||||
spans.extend(name.map(|(name, layer)| (name, end, layer)));
|
||||
None
|
||||
}
|
||||
// Inline image data isn't PDF syntax (the writer writes none).
|
||||
Tok::Word(b"ID") => break,
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if spans.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(content.len() + spans.len() * 24);
|
||||
let mut at = 0;
|
||||
for (start, end, layer) in &spans {
|
||||
out.extend_from_slice(content.get(at..*start)?);
|
||||
out.extend_from_slice(format!("/OC /{LAYER_OC}{layer} BDC ").as_bytes());
|
||||
out.extend_from_slice(content.get(*start..*end)?);
|
||||
out.extend_from_slice(b" EMC");
|
||||
at = *end;
|
||||
}
|
||||
out.extend_from_slice(content.get(at..)?);
|
||||
let mut layers: Vec<usize> = spans.iter().map(|s| s.2).collect();
|
||||
layers.sort_unstable();
|
||||
layers.dedup();
|
||||
Some((out, layers))
|
||||
}
|
||||
|
||||
/// Finish the marked forms of `pdf` (as [`crate::export`] writes `doc` with `set`): with
|
||||
/// `layers`, each top-level layer (but template layers) becomes an optional content group —
|
||||
/// named as the layer, off when it is hidden, not printed (`/PrintState /OFF`) when its Print
|
||||
/// option is off, locked when it is locked — and the uses of its forms its optional content;
|
||||
/// with `overprint`, the overprinting forms get a graphics state that overprints (`/OP`, `/op`,
|
||||
/// `/OPM 1`; `/OPM 0` in PDF/A, which allows no other with ICC-based CMYK colours).
|
||||
pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bool, overprint: bool) -> Result<Vec<u8>, PdfError> {
|
||||
if !layers && !overprint {
|
||||
return Ok(pdf);
|
||||
}
|
||||
let xref = Xref::read(&pdf).ok_or_else(|| bad("the written PDF has no cross-reference table"))?;
|
||||
let objects = objects(&pdf, &xref);
|
||||
let marks: HashMap<u32, Mark> = objects.iter().filter_map(|(n, o)| Some((*n, o.mark(&pdf)?))).collect();
|
||||
let mut patch = Patch::new(&xref);
|
||||
if overprint {
|
||||
let opm = u8::from(set.standard != Standard::PdfA2b);
|
||||
let gs = patch.add_object(format!("<</Type/ExtGState/OP true/op true/OPM {opm}>>").into_bytes());
|
||||
for (n, _) in marks.iter().filter(|(_, m)| **m == Mark::Overprint) {
|
||||
let Some(o) = objects.get(n) else { continue };
|
||||
let (Some(content), Some(res)) = (o.data(&pdf, u64::MAX), resources(&objects, o)) else {
|
||||
return Err(bad("an overprinting form can't be read"));
|
||||
};
|
||||
let mut new = format!("/{OVERPRINT_GS} gs\n").into_bytes();
|
||||
new.extend_from_slice(&content);
|
||||
o.rewrite(&mut patch, &new)?;
|
||||
add_resources(&mut patch, res, "ExtGState", &format!("/{OVERPRINT_GS} {gs} 0 R"))?;
|
||||
}
|
||||
}
|
||||
if layers {
|
||||
write_layers(&pdf, &xref, &objects, &marks, &mut patch, doc, set)?;
|
||||
}
|
||||
let pdf = patch.apply(&pdf, &xref)?;
|
||||
// The file still is what its standard says.
|
||||
crate::pdfx::verify(&pdf, set.standard)?;
|
||||
Ok(pdf)
|
||||
}
|
||||
|
||||
/// The optional content groups of `doc`'s top-level layers, the uses of their forms marked as
|
||||
/// their content, and the catalog's `/OCProperties`.
|
||||
fn write_layers(
|
||||
pdf: &[u8],
|
||||
xref: &Xref,
|
||||
objects: &HashMap<u32, Object<'_>>,
|
||||
marks: &HashMap<u32, Mark>,
|
||||
patch: &mut Patch,
|
||||
doc: &Document,
|
||||
set: &PdfSettings,
|
||||
) -> Result<(), PdfError> {
|
||||
// One group per layer, in paint order (bottom first).
|
||||
let mut groups: HashMap<usize, u32> = HashMap::new();
|
||||
let (mut all, mut off, mut locked) = (vec![], vec![], vec![]);
|
||||
for (i, l) in doc.layers.iter().enumerate() {
|
||||
let NodeKind::Layer { template: false, printable, .. } = l.kind else { continue };
|
||||
let name = l.name.clone().unwrap_or_else(|| format!("Layer {}", i + 1));
|
||||
let on = |b: bool| if b { "ON" } else { "OFF" };
|
||||
let g = patch.add_object(
|
||||
format!("<</Type/OCG/Name{}/Usage<</View<</ViewState/{}>>/Print<</PrintState/{}>>>>>>", text_string(&name), on(l.visible), on(printable))
|
||||
.into_bytes(),
|
||||
);
|
||||
groups.insert(i, g);
|
||||
all.push(g);
|
||||
if !l.visible {
|
||||
off.push(g);
|
||||
}
|
||||
if l.locked {
|
||||
locked.push(g);
|
||||
}
|
||||
}
|
||||
// Every content stream drawing layers' forms: the pages' and the forms'.
|
||||
for o in objects.values() {
|
||||
let content = match o.dict.get(b"Contents") {
|
||||
Some(Obj::Ref(c, _)) if o.dict.name(b"Type") == Some(b"Page") => objects.get(c),
|
||||
_ if o.dict.name(b"Subtype") == Some(b"Form") => Some(o),
|
||||
_ => None,
|
||||
};
|
||||
let (Some(content), Some(res)) = (content, resources(objects, o)) else { continue };
|
||||
let Some(Obj::Dict { entries, .. }) = res.get(b"XObject") else { continue };
|
||||
let forms: HashMap<&[u8], usize> = entries
|
||||
.iter()
|
||||
.filter_map(|(name, x)| {
|
||||
let Obj::Ref(n, _) = x else { return None };
|
||||
match marks.get(n) {
|
||||
Some(Mark::Layer(i)) if groups.contains_key(i) => Some((*name, *i)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if forms.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let data = content.data(pdf, u64::MAX).ok_or_else(|| bad("a content stream can't be read"))?;
|
||||
let Some((new, marked)) = mark_layers(&data, &forms) else { continue };
|
||||
content.rewrite(patch, &new)?;
|
||||
let entries: String = marked.iter().filter_map(|i| Some(format!("/{LAYER_OC}{i} {} 0 R", groups.get(i)?))).collect();
|
||||
add_resources(patch, res, "Properties", &entries)?;
|
||||
}
|
||||
let refs = |v: &[u32]| v.iter().map(|g| format!("{g} 0 R")).collect::<Vec<_>>().join(" ");
|
||||
// Layers panels list the top layer first.
|
||||
let order: Vec<u32> = all.iter().rev().copied().collect();
|
||||
let mut config = format!("/Name(Layers)/Order[{}]", refs(&order));
|
||||
if !off.is_empty() {
|
||||
config.push_str(&format!("/OFF[{}]", refs(&off)));
|
||||
}
|
||||
if !locked.is_empty() {
|
||||
config.push_str(&format!("/Locked[{}]", refs(&locked)));
|
||||
}
|
||||
// Viewers apply the print states when printing (the standards allow no automatic states).
|
||||
if set.standard == Standard::None {
|
||||
config.push_str(&format!("/AS[<</Event/Print/OCGs[{}]/Category[/Print]>>]", refs(&all)));
|
||||
}
|
||||
let root = xref.trailer_ref(pdf, b"/Root").and_then(|n| objects.get(&n));
|
||||
let Some(Object { dict: Obj::Dict { start, .. }, .. }) = root else { return Err(bad("the written PDF has no catalog")) };
|
||||
patch.replace(start + 2, start + 2, format!("/OCProperties<</OCGs[{}]/D<<{config}>>>>", refs(&all)).into_bytes());
|
||||
Ok(())
|
||||
}
|
||||
+26
-2
@@ -10,11 +10,16 @@
|
||||
//! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and
|
||||
//! given an output intent as the Output settings say. Hidden objects,
|
||||
//! guides and template layers are skipped, and so are non-printing layers unless asked for.
|
||||
//! Top-level layers can be PDF layers (optional content groups with their visibility, print
|
||||
//! state and lock), and overprinting fills and strokes overprint (Advanced).
|
||||
//! Each page is its artboard (the trim box) grown by the bleed (the bleed box) and by the
|
||||
//! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF
|
||||
//! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output,
|
||||
//! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve
|
||||
//! Editing the native document rides along as an embedded file ([`editing()`]).
|
||||
//! Editing the native document rides along as an embedded file ([`editing()`]). Each page can
|
||||
//! carry a thumbnail ([`Thumbnail`], drawn by the caller), and Fast Web View writes a linearised
|
||||
//! file (still linearised when encrypted). A PDF 1.3 file ([`PdfSettings::pdf13`]) must be flat:
|
||||
//! the app flattens the document first, and the writer refuses transparency that is left.
|
||||
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
|
||||
//! [`Document`]: one artboard per page, and a layer per page or per optional content group
|
||||
//! (with its visibility, print state and lock; art that is off comes in as a hidden layer),
|
||||
@@ -38,6 +43,7 @@
|
||||
mod editing;
|
||||
mod encrypt;
|
||||
mod export;
|
||||
mod forms;
|
||||
mod images;
|
||||
mod import;
|
||||
mod import_color;
|
||||
@@ -46,19 +52,24 @@ mod import_scan;
|
||||
mod import_shading;
|
||||
mod import_text;
|
||||
mod lab_spot;
|
||||
mod linearize;
|
||||
mod marks;
|
||||
mod output;
|
||||
mod pages;
|
||||
mod patch;
|
||||
mod pdfx;
|
||||
mod post;
|
||||
mod presets;
|
||||
mod print;
|
||||
mod settings;
|
||||
mod syntax;
|
||||
|
||||
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
|
||||
pub use encrypt::Encryption;
|
||||
pub use export::{export, export_with_report};
|
||||
pub use export::{export, export_with_report, page_areas};
|
||||
pub use import::{import, import_with_report};
|
||||
pub use pages::{PageInfo, PdfInfo, info, is_postscript};
|
||||
pub use post::{THUMBNAIL_SIZE, Thumbnail};
|
||||
pub use presets::*;
|
||||
pub use print::*;
|
||||
pub use settings::*;
|
||||
@@ -77,6 +88,9 @@ pub struct PdfOptions {
|
||||
pub created: Option<i64>,
|
||||
/// The native document (`.vectorcraft` bytes) Preserve Editing embeds.
|
||||
pub native: Option<Vec<u8>>,
|
||||
/// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer
|
||||
/// doesn't draw; [`page_areas`] says what each page shows).
|
||||
pub thumbnails: Vec<Thumbnail>,
|
||||
}
|
||||
|
||||
impl PdfOptions {
|
||||
@@ -216,14 +230,22 @@ mod tests_import_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_import_options;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_marks;
|
||||
#[cfg(test)]
|
||||
mod tests_output;
|
||||
#[cfg(test)]
|
||||
mod tests_pdf13;
|
||||
#[cfg(test)]
|
||||
mod tests_pdfx;
|
||||
#[cfg(test)]
|
||||
mod tests_presets;
|
||||
#[cfg(test)]
|
||||
mod tests_print;
|
||||
#[cfg(test)]
|
||||
mod tests_printadvanced;
|
||||
#[cfg(test)]
|
||||
mod tests_realtext;
|
||||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
@@ -233,3 +255,5 @@ mod tests_stroke;
|
||||
mod tests_strokeout;
|
||||
#[cfg(test)]
|
||||
mod tests_textstroke;
|
||||
#[cfg(test)]
|
||||
mod tests_webview;
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Save PDF › Optimize for fast web view: a linearised file (ISO 32000-1 Annex F), whose first page
|
||||
//! shows while the rest of it downloads.
|
||||
//!
|
||||
//! [`prepare`] writes the export's file (one cross-reference table) again with its objects in
|
||||
//! linearised order and numbering. First, with the highest numbers: the linearization dictionary,
|
||||
//! the catalog with what opening the document needs, the hint stream and the first page (its page
|
||||
//! object, then every object it uses, numbered on from it). Then, numbered from 1: each other page
|
||||
//! with the objects only it uses, the objects several pages share, and the rest (the page tree,
|
||||
//! document information, metadata, editing data, thumbnails). Encryption runs on that file: it
|
||||
//! keeps the order and the numbers and adds its dictionary as the last object. [`write`] then lays
|
||||
//! the file out: the linearization dictionary, the first page's cross-reference table and trailer,
|
||||
//! the catalog part (with the encryption dictionary), the hint stream (the page offset and shared
|
||||
//! object hint tables, encrypted like any stream), the first page, the other pages, the shared
|
||||
//! objects, the rest and the main cross-reference table.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use crate::PdfError;
|
||||
use crate::encrypt::Cipher;
|
||||
use crate::encrypt::{Lexer, Obj};
|
||||
use crate::patch::Xref;
|
||||
use crate::syntax::{Indirect, indirect, ref_spans};
|
||||
|
||||
fn failed(why: &str) -> PdfError {
|
||||
PdfError::Write(format!("can't linearise the PDF: {why}"))
|
||||
}
|
||||
|
||||
/// Catalog entries whose objects opening the document needs: they go with the catalog.
|
||||
const OPEN_KEYS: [&[u8]; 5] = [b"ViewerPreferences", b"Threads", b"OpenAction", b"AcroForm", b"OCProperties"];
|
||||
/// Keys whose objects aren't part of what a page uses: its parent (the page tree) and its
|
||||
/// thumbnail (shown without the page).
|
||||
const NOT_USED: [&[u8]; 2] = [b"Parent", b"Thumb"];
|
||||
/// Deepest page tree read.
|
||||
const MAX_TREE_DEPTH: usize = 64;
|
||||
/// Width of the numbers [`write`] fills in once the layout is known (offsets below 10 GB).
|
||||
const WIDTH: usize = 10;
|
||||
|
||||
/// The linearised order of a [`prepare`]d file's objects, by part (object numbers).
|
||||
pub(crate) struct Plan {
|
||||
/// The linearization dictionary: the first object of the first page's cross-reference table.
|
||||
first: u32,
|
||||
hint: u32,
|
||||
/// The catalog and the objects opening the document needs.
|
||||
open: Vec<u32>,
|
||||
/// The first page: its page object, then every object it uses.
|
||||
first_page: Vec<u32>,
|
||||
/// Each other page: its page object, then the objects only it uses.
|
||||
pages: Vec<Vec<u32>>,
|
||||
/// The objects several pages (not the first) use.
|
||||
shared: Vec<u32>,
|
||||
/// The rest.
|
||||
other: Vec<u32>,
|
||||
/// For each page but the first, the shared object hint table entries of the objects it uses
|
||||
/// from the first page and from [`Self::shared`].
|
||||
shared_ids: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
/// A written file (one cross-reference table, as the export writes it) read for rewriting.
|
||||
pub(crate) struct Parsed<'a> {
|
||||
pub xref: Xref,
|
||||
pub objects: HashMap<u32, Indirect<'a>>,
|
||||
/// The objects' numbers and offsets, in file order.
|
||||
pub order: Vec<(u32, usize)>,
|
||||
pub trailer: Obj<'a>,
|
||||
/// The catalog.
|
||||
pub root: u32,
|
||||
/// The page objects, in order.
|
||||
pub pages: Vec<u32>,
|
||||
/// The page tree's nodes.
|
||||
pub nodes: HashSet<u32>,
|
||||
}
|
||||
|
||||
/// Read `pdf` → its objects, catalog and pages, or why it can't be.
|
||||
pub(crate) fn parse(pdf: &[u8]) -> Result<Parsed<'_>, &'static str> {
|
||||
let xref = Xref::read(pdf).ok_or("no cross-reference table")?;
|
||||
let mut order = xref.objects(pdf);
|
||||
order.sort_by_key(|e| e.1);
|
||||
let mut objects = HashMap::with_capacity(order.len());
|
||||
for &(n, off) in &order {
|
||||
let o = indirect(pdf, off).filter(|o| o.num == n && o.generation == 0).ok_or("an object can't be read")?;
|
||||
objects.insert(n, o);
|
||||
}
|
||||
let trailer = Lexer::at(pdf, xref.trailer_at()).object(0).ok_or("bad trailer")?;
|
||||
let Some(&Obj::Ref(root, _)) = trailer.get(b"Root") else { return Err("no catalog") };
|
||||
let catalog = &objects.get(&root).ok_or("no catalog")?.value;
|
||||
let Some(&Obj::Ref(tree, _)) = catalog.get(b"Pages") else { return Err("no pages") };
|
||||
let (mut pages, mut nodes) = (vec![], HashSet::new());
|
||||
page_tree(&objects, tree, 0, &mut pages, &mut nodes)?;
|
||||
if pages.is_empty() {
|
||||
return Err("no pages");
|
||||
}
|
||||
Ok(Parsed { xref, objects, order, trailer, root, pages, nodes })
|
||||
}
|
||||
|
||||
/// The pages under page tree node `n`, in order, and the tree's nodes.
|
||||
pub(crate) fn page_tree(
|
||||
objects: &HashMap<u32, Indirect<'_>>,
|
||||
n: u32,
|
||||
depth: usize,
|
||||
pages: &mut Vec<u32>,
|
||||
nodes: &mut HashSet<u32>,
|
||||
) -> Result<(), &'static str> {
|
||||
let node = objects.get(&n).ok_or("a page is missing")?;
|
||||
match node.value.name(b"Type") {
|
||||
Some(b"Page") if !pages.contains(&n) => pages.push(n),
|
||||
Some(b"Pages") if depth < MAX_TREE_DEPTH && nodes.insert(n) => {
|
||||
let Some(Obj::Array(kids)) = node.value.get(b"Kids") else { return Err("a page tree node has no kids") };
|
||||
for kid in kids {
|
||||
let Obj::Ref(k, _) = kid else { return Err("a page tree kid isn't a reference") };
|
||||
page_tree(objects, *k, depth + 1, pages, nodes)?;
|
||||
}
|
||||
}
|
||||
_ => return Err("bad page tree"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The objects reached from `starts` (themselves included), breadth first, not entering those
|
||||
/// `stop` names.
|
||||
fn reach(objects: &HashMap<u32, Indirect<'_>>, starts: &[u32], stop: impl Fn(u32) -> bool) -> Vec<u32> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut queue: VecDeque<u32> = starts.iter().copied().filter(|n| objects.contains_key(n) && seen.insert(*n)).collect();
|
||||
let mut out = vec![];
|
||||
let mut links = vec![];
|
||||
while let Some(n) = queue.pop_front() {
|
||||
out.push(n);
|
||||
links.clear();
|
||||
if let Some(o) = objects.get(&n) {
|
||||
o.value.refs(&NOT_USED, &mut links);
|
||||
}
|
||||
for &m in &links {
|
||||
if objects.contains_key(&m) && !stop(m) && seen.insert(m) {
|
||||
queue.push_back(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `pdf` (as the export writes it: one cross-reference table, no encryption) with its objects in
|
||||
/// linearised order and numbering, a placeholder linearization dictionary and hint stream, and the
|
||||
/// plan [`write`] lays it out with.
|
||||
pub(crate) fn prepare(pdf: &[u8]) -> Result<(Vec<u8>, Plan), PdfError> {
|
||||
let Parsed { objects, order: entries, trailer, root, pages, nodes, .. } = parse(pdf).map_err(failed)?;
|
||||
if trailer.get(b"Encrypt").is_some() || trailer.get(b"Prev").is_some() {
|
||||
return Err(failed("the file was updated or encrypted already"));
|
||||
}
|
||||
let catalog = &objects.get(&root).ok_or_else(|| failed("no catalog"))?.value;
|
||||
let Some(&first_page_object) = pages.first() else { return Err(failed("no pages")) };
|
||||
let page_set: HashSet<u32> = pages.iter().copied().collect();
|
||||
let stop = |n: u32| n == root || page_set.contains(&n) || nodes.contains(&n);
|
||||
|
||||
// The catalog and what opening the document needs.
|
||||
let mut starts = vec![];
|
||||
for key in OPEN_KEYS {
|
||||
if let Some(v) = catalog.get(key) {
|
||||
v.refs(&NOT_USED, &mut starts);
|
||||
}
|
||||
}
|
||||
starts.retain(|n| !stop(*n));
|
||||
let open: Vec<u32> = std::iter::once(root).chain(reach(&objects, &starts, stop)).collect();
|
||||
let mut taken: HashSet<u32> = open.iter().copied().collect();
|
||||
// What each page uses (pages don't enter other pages).
|
||||
let uses: Vec<Vec<u32>> = pages.iter().map(|&p| reach(&objects, &[p], |n| n != p && stop(n))).collect();
|
||||
let first_page: Vec<u32> = uses.first().map_or(&[][..], Vec::as_slice).iter().copied().filter(|n| !taken.contains(n)).collect();
|
||||
taken.extend(&first_page);
|
||||
let rest = uses.get(1..).unwrap_or_default();
|
||||
let mut users: HashMap<u32, usize> = HashMap::new();
|
||||
for n in rest.iter().flatten().filter(|n| !taken.contains(n)) {
|
||||
*users.entry(*n).or_default() += 1;
|
||||
}
|
||||
let own: Vec<Vec<u32>> = rest.iter().map(|u| u.iter().copied().filter(|n| users.get(n) == Some(&1)).collect()).collect();
|
||||
let mut shared = vec![];
|
||||
for &n in rest.iter().flatten() {
|
||||
if users.get(&n).is_some_and(|c| *c > 1) && !shared.contains(&n) {
|
||||
shared.push(n);
|
||||
}
|
||||
}
|
||||
taken.extend(own.iter().flatten().chain(&shared));
|
||||
let other: Vec<u32> = entries.iter().map(|e| e.0).filter(|n| !taken.contains(n)).collect();
|
||||
if own.iter().any(|p| p.first().is_none_or(|n| !page_set.contains(n))) || first_page.first() != Some(&first_page_object) {
|
||||
return Err(failed("a page object is used by something else"));
|
||||
}
|
||||
|
||||
// Their shared object hint table entries: the first page's objects, then the shared ones.
|
||||
let index: HashMap<u32, u32> = first_page.iter().chain(&shared).enumerate().map(|(i, n)| (*n, i as u32)).collect();
|
||||
let shared_ids: Vec<Vec<u32>> =
|
||||
rest.iter().map(|u| u.iter().filter(|n| users.get(n) != Some(&1)).filter_map(|n| index.get(n).copied()).collect()).collect();
|
||||
|
||||
// Numbers: the second part from 1, then the first part.
|
||||
let mut numbers = Numbers { next: 1, of: HashMap::with_capacity(objects.len()) };
|
||||
let own_new: Vec<Vec<u32>> = own.iter().map(|p| numbers.assign(p)).collect();
|
||||
let shared_new = numbers.assign(&shared);
|
||||
let other_new = numbers.assign(&other);
|
||||
let first = numbers.fresh();
|
||||
let open_new = numbers.assign(&open);
|
||||
let hint = numbers.fresh();
|
||||
let first_page_new = numbers.assign(&first_page);
|
||||
let (size, number) = (numbers.next, numbers.of);
|
||||
if number.len() != objects.len() {
|
||||
return Err(failed("objects were left out"));
|
||||
}
|
||||
|
||||
// The file in that order.
|
||||
let head = entries.first().and_then(|e| pdf.get(..e.1)).ok_or_else(|| failed("no objects"))?;
|
||||
let mut out = Vec::with_capacity(pdf.len() + 256);
|
||||
out.extend_from_slice(head);
|
||||
let mut offsets = vec![0usize; size as usize];
|
||||
let mut put = |num: u32, body: &[u8], out: &mut Vec<u8>| {
|
||||
if let Some(slot) = offsets.get_mut(num as usize) {
|
||||
*slot = out.len();
|
||||
}
|
||||
out.extend_from_slice(format!("{num} 0 obj\n").as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
let body = |n: u32| -> Result<Vec<u8>, PdfError> {
|
||||
let o = objects.get(&n).ok_or_else(|| failed("an object is missing"))?;
|
||||
// Up to `endobj`, without the white space before it.
|
||||
let end = o.endobj - pdf.get(..o.endobj).map_or(0, |b| b.iter().rev().take_while(|c| c.is_ascii_whitespace()).count());
|
||||
renumber(pdf, o.value_span.0, end.max(o.value_span.1), o.value_span.1, &number)
|
||||
};
|
||||
put(first, b"<</Linearized 1>>", &mut out);
|
||||
for (&n, &new) in open.iter().zip(&open_new) {
|
||||
put(new, &body(n)?, &mut out);
|
||||
}
|
||||
put(hint, b"<</Length 0>>\nstream\n\nendstream", &mut out);
|
||||
let order = first_page.iter().zip(&first_page_new).chain(own.iter().flatten().zip(own_new.iter().flatten()));
|
||||
for (&n, &new) in order.chain(shared.iter().zip(&shared_new)).chain(other.iter().zip(&other_new)) {
|
||||
put(new, &body(n)?, &mut out);
|
||||
}
|
||||
let table = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f\r\n").as_bytes());
|
||||
for off in offsets.iter().skip(1) {
|
||||
out.extend_from_slice(format!("{off:010} 00000 n\r\n").as_bytes());
|
||||
}
|
||||
let Obj::Dict { start, end, .. } = &trailer else { return Err(failed("bad trailer")) };
|
||||
let Some(Obj::Int { start: size_start, end: size_end, .. }) = trailer.get(b"Size") else { return Err(failed("the trailer has no size")) };
|
||||
let mut dict = renumber(pdf, *start, *size_start, *size_start, &number)?;
|
||||
dict.extend_from_slice(size.to_string().as_bytes());
|
||||
dict.extend(renumber(pdf, *size_end, *end, *end, &number)?);
|
||||
out.extend_from_slice(b"trailer\n");
|
||||
out.extend(dict);
|
||||
out.extend_from_slice(format!("\nstartxref\n{table}\n%%EOF\n").as_bytes());
|
||||
let plan = Plan { first, hint, open: open_new, first_page: first_page_new, pages: own_new, shared: shared_new, other: other_new, shared_ids };
|
||||
Ok((out, plan))
|
||||
}
|
||||
|
||||
/// New object numbers, given in turn.
|
||||
struct Numbers {
|
||||
next: u32,
|
||||
/// Old number → new.
|
||||
of: HashMap<u32, u32>,
|
||||
}
|
||||
|
||||
impl Numbers {
|
||||
/// A number for a new object.
|
||||
fn fresh(&mut self) -> u32 {
|
||||
self.next += 1;
|
||||
self.next - 1
|
||||
}
|
||||
|
||||
/// Numbers for the objects `list` names, in its order.
|
||||
fn assign(&mut self, list: &[u32]) -> Vec<u32> {
|
||||
list.iter()
|
||||
.map(|n| {
|
||||
let new = self.fresh();
|
||||
self.of.insert(*n, new);
|
||||
new
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `pdf[start..end]` with the references in `start..refs_end` renumbered as `number`
|
||||
/// says (references to objects that aren't there become `null`).
|
||||
fn renumber(pdf: &[u8], start: usize, end: usize, refs_end: usize, number: &HashMap<u32, u32>) -> Result<Vec<u8>, PdfError> {
|
||||
let mut out = Vec::with_capacity(end.saturating_sub(start) + 16);
|
||||
let mut at = start;
|
||||
for (s, e, n) in ref_spans(pdf, start, refs_end) {
|
||||
out.extend_from_slice(pdf.get(at..s).ok_or_else(|| failed("bad reference"))?);
|
||||
match number.get(&n) {
|
||||
Some(new) => out.extend_from_slice(format!("{new} 0 R").as_bytes()),
|
||||
None => out.extend_from_slice(b"null"),
|
||||
}
|
||||
at = e;
|
||||
}
|
||||
out.extend_from_slice(pdf.get(at..end).ok_or_else(|| failed("bad object"))?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Bits written most significant first; [`Self::align`] pads to a byte.
|
||||
#[derive(Default)]
|
||||
struct Bits {
|
||||
out: Vec<u8>,
|
||||
byte: u8,
|
||||
used: u32,
|
||||
}
|
||||
|
||||
impl Bits {
|
||||
fn put(&mut self, value: u64, bits: u32) {
|
||||
for i in (0..bits).rev() {
|
||||
self.byte = self.byte << 1 | u8::from(value >> i & 1 == 1);
|
||||
self.used += 1;
|
||||
if self.used == 8 {
|
||||
self.out.push(self.byte);
|
||||
(self.byte, self.used) = (0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn align(&mut self) {
|
||||
if self.used > 0 {
|
||||
self.put(0, 8 - self.used);
|
||||
}
|
||||
}
|
||||
|
||||
/// One item of every entry, then the padding to a byte (each item of a hint table starts on
|
||||
/// one).
|
||||
fn column(&mut self, values: impl IntoIterator<Item = u64>, bits: u32) {
|
||||
for v in values {
|
||||
self.put(v, bits);
|
||||
}
|
||||
self.align();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bits needed to write `v`.
|
||||
fn bits(v: u64) -> u32 {
|
||||
64 - v.leading_zeros()
|
||||
}
|
||||
|
||||
/// A 32-bit hint table value.
|
||||
fn u32_of(v: usize) -> Result<u64, PdfError> {
|
||||
u32::try_from(v).map(u64::from).map_err(|_| failed("the file is too large"))
|
||||
}
|
||||
|
||||
/// The page offset and shared object hint tables → (the hint stream's data, where the shared
|
||||
/// object table starts), from where each object starts and how long it is, the hint stream left
|
||||
/// out.
|
||||
fn hint_tables(plan: &Plan, at: &HashMap<u32, (usize, usize)>) -> Result<(Vec<u8>, usize), PdfError> {
|
||||
let len = |n: &u32| at.get(n).map_or(0, |a| a.1);
|
||||
let pages: Vec<&[u32]> = std::iter::once(plan.first_page.as_slice()).chain(plan.pages.iter().map(Vec::as_slice)).collect();
|
||||
let counts: Vec<u64> = pages.iter().map(|p| p.len() as u64).collect();
|
||||
let lengths = pages.iter().map(|p| u32_of(p.iter().map(len).sum())).collect::<Result<Vec<u64>, _>>()?;
|
||||
let nshared: Vec<u64> = std::iter::once(0).chain(plan.shared_ids.iter().map(|s| s.len() as u64)).collect();
|
||||
let span = |v: &[u64]| {
|
||||
let (min, max) = (v.iter().copied().min().unwrap_or(0), v.iter().copied().max().unwrap_or(0));
|
||||
(min, bits(max - min))
|
||||
};
|
||||
let ((min_count, count_bits), (min_len, len_bits)) = (span(&counts), span(&lengths));
|
||||
let entries = plan.first_page.len() + plan.shared.len();
|
||||
let id_bits = bits(entries.saturating_sub(1) as u64);
|
||||
let first_at = plan.first_page.first().and_then(|n| at.get(n)).map_or(0, |a| a.0);
|
||||
let mut b = Bits::default();
|
||||
// The page offset hint table: its header, then each item for every page.
|
||||
b.put(min_count, 32);
|
||||
b.put(u32_of(first_at)?, 32);
|
||||
b.put(u64::from(count_bits), 16);
|
||||
b.put(min_len, 32);
|
||||
b.put(u64::from(len_bits), 16);
|
||||
// Content streams' offsets and lengths: the page's.
|
||||
b.put(0, 32);
|
||||
b.put(0, 16);
|
||||
b.put(min_len, 32);
|
||||
b.put(u64::from(len_bits), 16);
|
||||
b.put(u64::from(bits(nshared.iter().copied().max().unwrap_or(0))), 16);
|
||||
b.put(u64::from(id_bits), 16);
|
||||
// No fractional positions of shared objects.
|
||||
b.put(0, 16);
|
||||
b.put(1, 16);
|
||||
b.column(counts.iter().map(|c| c - min_count), count_bits);
|
||||
b.column(lengths.iter().map(|l| l - min_len), len_bits);
|
||||
b.column(nshared.iter().copied(), bits(nshared.iter().copied().max().unwrap_or(0)));
|
||||
b.column(plan.shared_ids.iter().flatten().map(|i| u64::from(*i)), id_bits);
|
||||
b.column(lengths.iter().map(|l| l - min_len), len_bits);
|
||||
// The shared object hint table: one object a group, the first page's objects first.
|
||||
let shared_at = b.out.len();
|
||||
let groups = plan.first_page.iter().chain(&plan.shared).map(|n| u32_of(len(n))).collect::<Result<Vec<u64>, _>>()?;
|
||||
let (min_group, group_bits) = span(&groups);
|
||||
let first_shared = plan.shared.first().and_then(|n| Some((*n, at.get(n)?.0))).unwrap_or((0, 0));
|
||||
b.put(u64::from(first_shared.0), 32);
|
||||
b.put(u32_of(first_shared.1)?, 32);
|
||||
b.put(plan.first_page.len() as u64, 32);
|
||||
b.put(entries as u64, 32);
|
||||
b.put(0, 16);
|
||||
b.put(min_group, 32);
|
||||
b.put(u64::from(group_bits), 16);
|
||||
b.column(groups.iter().map(|g| g - min_group), group_bits);
|
||||
// No MD5 signatures.
|
||||
b.column(groups.iter().map(|_| 0), 1);
|
||||
Ok((b.out, shared_at))
|
||||
}
|
||||
|
||||
/// A number padded with spaces to [`WIDTH`].
|
||||
fn padded(v: usize) -> String {
|
||||
format!("{v:<WIDTH$}")
|
||||
}
|
||||
|
||||
/// Lay out a [`prepare`]d file (encrypted after it, or not) as `plan` says, the hint stream
|
||||
/// encrypted with `cipher` when the file is.
|
||||
pub(crate) fn write(pdf: &[u8], plan: &Plan, cipher: Option<&mut Cipher>) -> Result<Vec<u8>, PdfError> {
|
||||
let xref = Xref::read(pdf).ok_or_else(|| failed("no cross-reference table"))?;
|
||||
let mut entries = xref.objects(pdf);
|
||||
entries.sort_by_key(|e| e.1);
|
||||
// Each object's bytes run to where the next one starts.
|
||||
let ends = entries.iter().skip(1).map(|e| e.1).chain(std::iter::once(xref.at()));
|
||||
let bytes: HashMap<u32, &[u8]> = entries.iter().zip(ends).filter_map(|(&(n, off), end)| Some((n, pdf.get(off..end)?))).collect();
|
||||
let head = entries.first().and_then(|e| pdf.get(..e.1)).ok_or_else(|| failed("no objects"))?;
|
||||
let trailer = Lexer::at(pdf, xref.trailer_at()).object(0).ok_or_else(|| failed("bad trailer"))?;
|
||||
let (Obj::Dict { start, end, .. }, Some(Obj::Int { value: size, .. })) = (&trailer, trailer.get(b"Size")) else {
|
||||
return Err(failed("bad trailer"));
|
||||
};
|
||||
let size = u32::try_from(*size).map_err(|_| failed("bad trailer"))?;
|
||||
let encrypt = match trailer.get(b"Encrypt") {
|
||||
Some(Obj::Ref(n, _)) => Some(*n),
|
||||
_ => None,
|
||||
};
|
||||
// The first part: the linearization dictionary, the catalog part with the encryption
|
||||
// dictionary, the hint stream and the first page, which the first page's table covers.
|
||||
let open: Vec<u32> = plan.open.iter().copied().chain(encrypt).collect();
|
||||
let mut first_part: Vec<u32> = [plan.first, plan.hint].into_iter().chain(open.iter().copied()).chain(plan.first_page.iter().copied()).collect();
|
||||
first_part.sort_unstable();
|
||||
let rest: Vec<u32> = plan.pages.iter().flatten().chain(&plan.shared).chain(&plan.other).copied().collect();
|
||||
let complete = (1..size).all(|n| bytes.contains_key(&n));
|
||||
if !complete || !first_part.iter().copied().eq(plan.first..size) || rest.len() + 1 != plan.first as usize {
|
||||
return Err(failed("the objects don't match the plan"));
|
||||
}
|
||||
let len = |n: &u32| bytes.get(n).map_or(0, |b| b.len());
|
||||
|
||||
// The layout without the hint stream (where its tables place the objects).
|
||||
let lin_len = linearization(plan, [0; 5]).len();
|
||||
let dict = pdf.get(*start..end.saturating_sub(2)).ok_or_else(|| failed("bad trailer"))?;
|
||||
let first_table = |prev: usize, at: &dyn Fn(u32) -> usize| {
|
||||
let mut t = format!("xref\n{} {}\n", plan.first, size - plan.first).into_bytes();
|
||||
for n in plan.first..size {
|
||||
t.extend_from_slice(format!("{:010} 00000 n\r\n", at(n)).as_bytes());
|
||||
}
|
||||
t.extend_from_slice(b"trailer\n");
|
||||
t.extend_from_slice(dict);
|
||||
t.extend_from_slice(format!("/Prev {}>>\nstartxref\n0\n%%EOF\n", padded(prev)).as_bytes());
|
||||
t
|
||||
};
|
||||
let table_len = first_table(0, &|_| 0).len();
|
||||
let mut at: HashMap<u32, (usize, usize)> = HashMap::with_capacity(bytes.len() + 2);
|
||||
let mut pos = head.len() + lin_len + table_len;
|
||||
let mut place = |list: &mut dyn Iterator<Item = &u32>, pos: &mut usize| {
|
||||
for n in list {
|
||||
at.insert(*n, (*pos, len(n)));
|
||||
*pos += len(n);
|
||||
}
|
||||
};
|
||||
place(&mut open.iter(), &mut pos);
|
||||
let hint_at = pos;
|
||||
place(&mut plan.first_page.iter(), &mut pos);
|
||||
let first_end = pos;
|
||||
place(&mut rest.iter(), &mut pos);
|
||||
let main_at = pos;
|
||||
|
||||
// The hint stream, and every offset after it moved by its length.
|
||||
let (tables, shared_at) = hint_tables(plan, &at)?;
|
||||
let data = match cipher {
|
||||
Some(c) => c.stream(plan.hint, &tables)?,
|
||||
None => tables,
|
||||
};
|
||||
let mut hint = format!("{} 0 obj\n<</Length {}/S {shared_at}>>\nstream\n", plan.hint, data.len()).into_bytes();
|
||||
hint.extend_from_slice(&data);
|
||||
hint.extend_from_slice(b"\nendstream\nendobj\n");
|
||||
let shift = |off: usize| if off >= hint_at { off + hint.len() } else { off };
|
||||
let offset = |n: u32| match n {
|
||||
n if n == plan.first => head.len(),
|
||||
n if n == plan.hint => hint_at,
|
||||
n => at.get(&n).map_or(0, |a| shift(a.0)),
|
||||
};
|
||||
let main = {
|
||||
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", plan.first).into_bytes();
|
||||
for n in 1..plan.first {
|
||||
t.extend_from_slice(format!("{:010} 00000 n\r\n", offset(n)).as_bytes());
|
||||
}
|
||||
t
|
||||
};
|
||||
let main_at = shift(main_at);
|
||||
let entries_at = main_at + format!("xref\n0 {}\n", plan.first).len();
|
||||
let tail = format!("trailer\n<</Size {}>>\nstartxref\n{}\n%%EOF\n", plan.first, head.len() + lin_len);
|
||||
let total = main_at + main.len() + tail.len();
|
||||
if total >= 10usize.pow(WIDTH as u32) {
|
||||
return Err(failed("the file is too large"));
|
||||
}
|
||||
let lin = linearization(plan, [total, hint_at, hint.len(), shift(first_end), entries_at - 1]);
|
||||
let table = first_table(main_at, &offset);
|
||||
if lin.len() != lin_len || table.len() != table_len {
|
||||
return Err(failed("the layout moved"));
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(total);
|
||||
out.extend_from_slice(head);
|
||||
out.extend_from_slice(&lin);
|
||||
out.extend_from_slice(&table);
|
||||
let copy = |list: &mut dyn Iterator<Item = &u32>, out: &mut Vec<u8>| {
|
||||
for n in list {
|
||||
out.extend_from_slice(bytes.get(n).copied().unwrap_or_default());
|
||||
}
|
||||
};
|
||||
copy(&mut open.iter(), &mut out);
|
||||
out.extend_from_slice(&hint);
|
||||
copy(&mut plan.first_page.iter(), &mut out);
|
||||
copy(&mut rest.iter(), &mut out);
|
||||
out.extend_from_slice(&main);
|
||||
out.extend_from_slice(tail.as_bytes());
|
||||
if out.len() != total {
|
||||
return Err(failed("the layout moved"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The linearization dictionary object, its numbers ([file length, hint stream offset and length,
|
||||
/// end of the first page, offset of the white space before the main table's first entry]) padded
|
||||
/// to a fixed width.
|
||||
fn linearization(plan: &Plan, [l, h, h_len, e, t]: [usize; 5]) -> Vec<u8> {
|
||||
let o = plan.first_page.first().copied().unwrap_or_default();
|
||||
let n = 1 + plan.pages.len();
|
||||
format!(
|
||||
"{} 0 obj\n<</Linearized 1/L {}/H[{} {}]/O {o}/E {}/N {n}/T {}>>\nendobj\n",
|
||||
plan.first,
|
||||
padded(l),
|
||||
padded(h),
|
||||
padded(h_len),
|
||||
padded(e),
|
||||
padded(t)
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
+70
-35
@@ -12,11 +12,14 @@
|
||||
//! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK
|
||||
//! with the CMYK profile in effect (the destination when converting to CMYK, else the
|
||||
//! document's), RGB with sRGB (colours of another RGB space as their sRGB equivalents) and grey
|
||||
//! with the sRGB tone curve. Otherwise they are device colours. PDF/A always tags them.
|
||||
//! with the sRGB tone curve. Otherwise they are device colours. PDF/A, PDF/X-3 and PDF/X-4
|
||||
//! always tag them; PDF/X-1a never does, and always converts to a CMYK destination (see
|
||||
//! [`crate::pdfx`]).
|
||||
//! - Output intent: the profile `outputIntent` names, embedded as the `/GTS_PDFX` output intent
|
||||
//! of the catalog with the condition, identifier and registry; Trapped goes in the document
|
||||
//! information (`/False` when an output intent is written untrapped). PDF/A files carry their
|
||||
//! own output intent: neither is written there.
|
||||
//! own output intent: neither is written there. PDF/X files always have one: blank, the CMYK
|
||||
//! profile in effect.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
@@ -58,6 +61,8 @@ pub(crate) struct ColorOut {
|
||||
intent: Intent,
|
||||
/// The output intent and Trapped entries to write.
|
||||
catalog: Option<Catalog>,
|
||||
/// The standard the file conforms to.
|
||||
pub standard: Standard,
|
||||
}
|
||||
|
||||
/// An output intent dictionary's entries.
|
||||
@@ -85,20 +90,34 @@ impl Default for ColorOut {
|
||||
let source = cms::active();
|
||||
let cmyk_profile = source.settings().cmyk.clone();
|
||||
let intent = source.settings().intent;
|
||||
Self { source, dest: None, tagged: false, cmyk_profile, srgb_note: None, pixels: OnceLock::new(), intent, catalog: None }
|
||||
Self {
|
||||
source,
|
||||
dest: None,
|
||||
tagged: false,
|
||||
cmyk_profile,
|
||||
srgb_note: None,
|
||||
pixels: OnceLock::new(),
|
||||
intent,
|
||||
catalog: None,
|
||||
standard: Standard::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile of `set`'s destination and its colour model; `None` without conversion. A blank
|
||||
/// destination is the profile of a document in colour mode `mode`.
|
||||
/// destination is the profile of a document in colour mode `mode`. PDF/X-1a files always convert,
|
||||
/// to a CMYK destination: blank, their output intent's profile ([`crate::pdfx::cmyk_destination`]).
|
||||
fn destination(set: &PdfSettings, mode: ColorMode, source: &Cms) -> Result<Option<(String, Model)>, PdfError> {
|
||||
let o = &set.output;
|
||||
if o.conversion == ColorConversion::None {
|
||||
let cmyk_only = set.standard.cmyk_only();
|
||||
if o.conversion == ColorConversion::None && !cmyk_only {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = o.destination.trim();
|
||||
let fallback = if cmyk_only { crate::pdfx::cmyk_destination(set, source) } else { String::new() };
|
||||
let name = match (name.is_empty(), mode) {
|
||||
(false, _) => name,
|
||||
(true, _) if cmyk_only => fallback.as_str(),
|
||||
(true, ColorMode::Cmyk) => source.settings().cmyk.as_str(),
|
||||
(true, ColorMode::Rgb) => source.settings().rgb.as_str(),
|
||||
};
|
||||
@@ -109,6 +128,9 @@ fn destination(set: &PdfSettings, mode: ColorMode, source: &Cms) -> Result<Optio
|
||||
ProfileKind::Cmyk => Model::Cmyk,
|
||||
ProfileKind::Gray => return Err(unknown()),
|
||||
};
|
||||
if cmyk_only && model != Model::Cmyk {
|
||||
return Err(PdfError::BadSetting(format!("output.destination: {} files are CMYK, and “{name}” is an RGB profile", set.standard.label())));
|
||||
}
|
||||
Ok(Some((info.name, model)))
|
||||
}
|
||||
|
||||
@@ -136,7 +158,8 @@ pub(crate) fn warnings(set: &PdfSettings) -> Vec<String> {
|
||||
if !name.is_empty() && cms::profile(name).is_none() {
|
||||
out.push(format!("no profile is called “{name}”: the output intent names it without embedding it"));
|
||||
}
|
||||
if !asks && (!o.output_condition.trim().is_empty() || !o.registry.trim().is_empty()) {
|
||||
// PDF/X files always have an output intent.
|
||||
if !asks && !set.standard.is_pdfx() && (!o.output_condition.trim().is_empty() || !o.registry.trim().is_empty()) {
|
||||
out.push("an output intent needs a profile or a condition identifier: the output condition and registry are left out".into());
|
||||
}
|
||||
out
|
||||
@@ -155,18 +178,20 @@ impl ColorOut {
|
||||
_ => std::mem::replace(&mut settings.rgb, name.clone()),
|
||||
};
|
||||
let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?;
|
||||
let keep = set.output.conversion == ColorConversion::PreserveNumbers || cms::canonical_name(&own) == name;
|
||||
// PDF/X-1a converts without being asked: as Preserve Numbers does.
|
||||
let keep = set.output.conversion != ColorConversion::Destination || cms::canonical_name(&own) == name;
|
||||
if model == Model::Cmyk {
|
||||
out.cmyk_profile = name.clone();
|
||||
}
|
||||
out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep });
|
||||
}
|
||||
out.tagged = set.standard == Standard::PdfA2b
|
||||
|| match set.output.profiles {
|
||||
ProfileInclusion::None => false,
|
||||
ProfileInclusion::All | ProfileInclusion::Destination => true,
|
||||
ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(),
|
||||
};
|
||||
out.tagged = matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4)
|
||||
|| (!set.standard.cmyk_only()
|
||||
&& match set.output.profiles {
|
||||
ProfileInclusion::None => false,
|
||||
ProfileInclusion::All | ProfileInclusion::Destination => true,
|
||||
ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(),
|
||||
});
|
||||
let rgb = match &out.dest {
|
||||
Some(d) if d.model == Model::Rgb => (d.srgb, d.cms.settings().rgb.clone()),
|
||||
_ => (source.rgb_is_srgb(), st.rgb.clone()),
|
||||
@@ -174,7 +199,8 @@ impl ColorOut {
|
||||
if out.tagged && !rgb.0 {
|
||||
out.srgb_note = Some(format!("RGB colours are tagged with the sRGB profile: those of {} are written as their sRGB equivalents", rgb.1));
|
||||
}
|
||||
out.catalog = catalog(set);
|
||||
out.catalog = catalog(set, &out.cmyk_profile);
|
||||
out.standard = set.standard;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -250,16 +276,17 @@ impl ColorOut {
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// `pdf` with the output intent and Trapped entries → (the file, warnings).
|
||||
/// `pdf` with the output intent and Trapped entries, and a PDF/X file's identification
|
||||
/// ([`crate::pdfx`]) → (the file, warnings).
|
||||
pub(crate) fn write_catalog(&self, pdf: Vec<u8>) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||||
let Some(cat) = &self.catalog else { return Ok((pdf, vec![])) };
|
||||
let xref = Xref::read(&pdf).ok_or_else(|| PdfError::Write("the written PDF has no cross-reference table".into()))?;
|
||||
let mut patch = Patch::new(&xref);
|
||||
let mut warnings = vec![];
|
||||
let root = xref.trailer_ref(&pdf, b"/Root").and_then(|n| xref.dict(&pdf, n));
|
||||
let Some(root) = root else { return Err(PdfError::Write("the written PDF has no catalog".into())) };
|
||||
if let Some(OutputIntent { id, condition, registry, info, profile }) = &cat.intent {
|
||||
let root = xref.trailer_ref(&pdf, b"/Root").and_then(|n| xref.dict(&pdf, n));
|
||||
let Some((root, root_end)) = root else { return Err(PdfError::Write("the written PDF has no catalog".into())) };
|
||||
if crate::lab_spot::find(pdf.get(root..root_end).unwrap_or_default(), b"/OutputIntents", 0).is_some() {
|
||||
if crate::lab_spot::find(pdf.get(root.0..root.1).unwrap_or_default(), b"/OutputIntents", 0).is_some() {
|
||||
warnings.push("the file has an output intent already: the one asked for is left out".into());
|
||||
} else {
|
||||
let mut dict = format!("<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier{}", text_string(id));
|
||||
@@ -278,22 +305,26 @@ impl ColorOut {
|
||||
}
|
||||
dict.push_str(">>");
|
||||
let oi = patch.add_object(dict.into_bytes());
|
||||
patch.replace(root, root, format!("/OutputIntents[{oi} 0 R]").into_bytes());
|
||||
patch.replace(root.0, root.0, format!("/OutputIntents[{oi} 0 R]").into_bytes());
|
||||
}
|
||||
}
|
||||
if let Some(trapped) = cat.trapped {
|
||||
let entry = if trapped { "/Trapped/True" } else { "/Trapped/False" };
|
||||
match xref.trailer_ref(&pdf, b"/Info").and_then(|n| xref.dict(&pdf, n)) {
|
||||
Some((info, end)) if crate::lab_spot::find(pdf.get(info..end).unwrap_or_default(), b"/Trapped", 0).is_none() => {
|
||||
patch.replace(info, info, entry.as_bytes().to_vec());
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let n = patch.add_object(format!("<<{entry}>>").into_bytes());
|
||||
patch.trailer_entry(format!("/Info {n} 0 R").as_bytes());
|
||||
}
|
||||
let info = xref.trailer_ref(&pdf, b"/Info").and_then(|n| xref.dict(&pdf, n));
|
||||
let mut entries = String::new();
|
||||
if let Some(trapped) = cat.trapped
|
||||
&& info.is_none_or(|(s, e)| crate::lab_spot::find(pdf.get(s..e).unwrap_or_default(), b"/Trapped", 0).is_none())
|
||||
{
|
||||
entries.push_str(if trapped { "/Trapped/True" } else { "/Trapped/False" });
|
||||
}
|
||||
entries.push_str(&crate::pdfx::info_entries(self.standard));
|
||||
match info {
|
||||
_ if entries.is_empty() => {}
|
||||
Some((at, _)) => patch.replace(at, at, entries.into_bytes()),
|
||||
None => {
|
||||
let n = patch.add_object(format!("<<{entries}>>").into_bytes());
|
||||
patch.trailer_entry(format!("/Info {n} 0 R").as_bytes());
|
||||
}
|
||||
}
|
||||
crate::pdfx::xmp(&pdf, &xref, root, &mut patch, self.standard, cat.trapped == Some(true))?;
|
||||
Ok((patch.apply(&pdf, &xref)?, warnings))
|
||||
}
|
||||
}
|
||||
@@ -323,13 +354,17 @@ impl Dest {
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog entries `set` asks for (none in PDF/A files, see [`warnings`]).
|
||||
fn catalog(set: &PdfSettings) -> Option<Catalog> {
|
||||
/// The catalog entries `set` asks for (none in PDF/A files, see [`warnings`]); a PDF/X file's
|
||||
/// output intent profile is `cmyk_profile` (the CMYK profile in effect) unless `set` names one.
|
||||
fn catalog(set: &PdfSettings, cmyk_profile: &str) -> Option<Catalog> {
|
||||
let o = &set.output;
|
||||
if set.standard == Standard::PdfA2b {
|
||||
return None;
|
||||
}
|
||||
let (name, id) = (o.output_intent.trim(), o.output_condition_id.trim());
|
||||
let (mut name, id) = (o.output_intent.trim(), o.output_condition_id.trim());
|
||||
if name.is_empty() && set.standard.is_pdfx() {
|
||||
name = cmyk_profile;
|
||||
}
|
||||
let intent = (!name.is_empty() || !id.is_empty()).then(|| {
|
||||
let profile = cms::profile(name).and_then(|p| match p.kind {
|
||||
ProfileKind::Rgb => Some((p.name, 3)),
|
||||
@@ -351,7 +386,7 @@ fn catalog(set: &PdfSettings) -> Option<Catalog> {
|
||||
|
||||
/// `s` as a PDF text string: a literal string when it is printable ASCII, else UTF-16BE (with its
|
||||
/// byte order mark) in hex.
|
||||
fn text_string(s: &str) -> String {
|
||||
pub(crate) fn text_string(s: &str) -> String {
|
||||
if s.bytes().all(|b| (b' '..=b'~').contains(&b)) {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('(');
|
||||
@@ -373,7 +408,7 @@ fn text_string(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn deflate(data: &[u8]) -> std::io::Result<Vec<u8>> {
|
||||
pub(crate) fn deflate(data: &[u8]) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Write;
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
e.write_all(data)?;
|
||||
|
||||
+22
-2
@@ -21,7 +21,7 @@ pub(crate) struct Xref {
|
||||
const MAX_DIGITS: usize = 10;
|
||||
|
||||
/// The unsigned number `pdf` holds at `at` (after white space), and where it ends.
|
||||
fn number(pdf: &[u8], mut at: usize) -> Option<(usize, usize)> {
|
||||
pub(crate) fn number(pdf: &[u8], mut at: usize) -> Option<(usize, usize)> {
|
||||
while pdf.get(at).is_some_and(u8::is_ascii_whitespace) {
|
||||
at += 1;
|
||||
}
|
||||
@@ -48,14 +48,34 @@ impl Xref {
|
||||
Some(Self { at, entries, count, trailer: (start, end) })
|
||||
}
|
||||
|
||||
/// Number of entries (objects `0..count`).
|
||||
pub fn count(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// Where in-use object `n` starts.
|
||||
fn offset(&self, pdf: &[u8], n: u32) -> Option<usize> {
|
||||
pub fn offset(&self, pdf: &[u8], n: u32) -> Option<usize> {
|
||||
let e = self.entries + 20 * usize::try_from(n).ok().filter(|n| *n < self.count)?;
|
||||
let entry = pdf.get(e..e + 20)?;
|
||||
(entry.get(17) == Some(&b'n')).then_some(())?;
|
||||
number(entry, 0).map(|(off, _)| off)
|
||||
}
|
||||
|
||||
/// The in-use objects: number and offset, in number order.
|
||||
pub fn objects(&self, pdf: &[u8]) -> Vec<(u32, usize)> {
|
||||
(1..u32::try_from(self.count).unwrap_or(u32::MAX)).filter_map(|n| Some((n, self.offset(pdf, n)?))).collect()
|
||||
}
|
||||
|
||||
/// Where the table (`xref`) starts.
|
||||
pub fn at(&self) -> usize {
|
||||
self.at
|
||||
}
|
||||
|
||||
/// Where the trailer dictionary starts (its `<<`).
|
||||
pub fn trailer_at(&self) -> usize {
|
||||
self.trailer.0
|
||||
}
|
||||
|
||||
/// The object the trailer's `key` (`b"/Root"`) refers to.
|
||||
pub fn trailer_ref(&self, pdf: &[u8], key: &[u8]) -> Option<u32> {
|
||||
let dict = pdf.get(self.trailer.0..self.trailer.1)?;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! PDF/X files (print exchange): what each standard asks of a file, and the entries that say which
|
||||
//! one it is.
|
||||
//!
|
||||
//! - PDF/X-1a:2001: every colour is CMYK, grey or a spot ink with a CMYK alternate (colours and
|
||||
//! images are converted to a CMYK destination and written untagged, see [`crate::output`]), no
|
||||
//! transparency (the app flattens it before writing), no layers, PDF 1.3.
|
||||
//! - PDF/X-3:2002: colours are ICC-based (always tagged), no transparency, no layers, PDF 1.3.
|
||||
//! - PDF/X-4:2010: transparency and layers are kept, colours are tagged, PDF 1.6 at most, and the
|
||||
//! XMP metadata names the standard too.
|
||||
//! - All of them: a `/GTS_PDFX` output intent (blank, the CMYK profile in effect, embedded), a
|
||||
//! TrimBox on every page (the artboard), `/Trapped` True or False, `/GTS_PDFXVersion` in the
|
||||
//! document information, a title and dates, embedded fonts (or text as outlines), no editing
|
||||
//! data and no encryption (both refused by the settings checks).
|
||||
//!
|
||||
//! PDF/X-1a and PDF/X-3 files are PDF 1.3 files ([`crate::PdfSettings::pdf13`]): flat, written with
|
||||
//! the PDF 1.4 settings, their header and metadata saying 1.3. The written file is checked ([`verify`]): what the
|
||||
//! standard forbids that is still there refuses the export, rather than a file claiming a
|
||||
//! standard it breaks.
|
||||
|
||||
use vectorcraft_color::cms::{self, Cms, ProfileKind};
|
||||
|
||||
use crate::lab_spot::find;
|
||||
use crate::patch::{Patch, Xref};
|
||||
use crate::{PdfError, PdfSettings, Standard};
|
||||
|
||||
impl Standard {
|
||||
/// A PDF/X standard.
|
||||
pub fn is_pdfx(self) -> bool {
|
||||
matches!(self, Self::PdfX1a | Self::PdfX3 | Self::PdfX4)
|
||||
}
|
||||
|
||||
/// Files of this standard have no transparency (PDF/X-1a and PDF/X-3): it is flattened.
|
||||
pub fn flattens(self) -> bool {
|
||||
matches!(self, Self::PdfX1a | Self::PdfX3)
|
||||
}
|
||||
|
||||
/// Every colour is CMYK, grey or a spot ink (PDF/X-1a).
|
||||
pub fn cmyk_only(self) -> bool {
|
||||
self == Self::PdfX1a
|
||||
}
|
||||
|
||||
/// Files of this standard may have PDF layers (PDF/X-1a and PDF/X-3 are PDF 1.3, which has
|
||||
/// none).
|
||||
pub fn allows_layers(self) -> bool {
|
||||
!self.flattens()
|
||||
}
|
||||
|
||||
/// `/GTS_PDFXVersion` and `/GTS_PDFXConformance` (when the standard has one).
|
||||
fn pdfx_ids(self) -> Option<(&'static str, Option<&'static str>)> {
|
||||
match self {
|
||||
Self::PdfX1a => Some(("PDF/X-1:2001", Some("PDF/X-1a:2001"))),
|
||||
Self::PdfX3 => Some(("PDF/X-3:2002", None)),
|
||||
Self::PdfX4 => Some(("PDF/X-4", None)),
|
||||
Self::None | Self::PdfA2b => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a PDF/X output intent the file can't have: a grey profile, an RGB one in PDF/X-1a, or a
|
||||
/// name that isn't a profile, unless PDF/X-1a and PDF/X-3 name a registered printing condition
|
||||
/// (with a registry) without embedding a profile; PDF/X-4 always embeds one.
|
||||
pub(crate) fn check(set: &PdfSettings) -> Result<(), PdfError> {
|
||||
let o = &set.output;
|
||||
let name = o.output_intent.trim();
|
||||
if !set.standard.is_pdfx() || name.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let std = set.standard.label();
|
||||
match cms::profile(name).map(|p| p.kind) {
|
||||
Some(ProfileKind::Cmyk) => Ok(()),
|
||||
Some(ProfileKind::Rgb) if !set.standard.cmyk_only() => Ok(()),
|
||||
Some(ProfileKind::Rgb) => {
|
||||
Err(PdfError::BadSetting(format!("output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile")))
|
||||
}
|
||||
Some(ProfileKind::Gray) => {
|
||||
Err(PdfError::BadSetting(format!("output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent")))
|
||||
}
|
||||
None if !o.registry.trim().is_empty() && set.standard != Standard::PdfX4 => Ok(()),
|
||||
None if set.standard == Standard::PdfX4 => {
|
||||
Err(PdfError::BadSetting(format!("output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile")))
|
||||
}
|
||||
None => Err(PdfError::BadSetting(format!(
|
||||
"output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry)"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The CMYK profile PDF/X-1a colours are converted to when no destination is named: the output
|
||||
/// intent's profile when it is a CMYK one, else the colour settings' CMYK profile.
|
||||
pub(crate) fn cmyk_destination(set: &PdfSettings, source: &Cms) -> String {
|
||||
match cms::profile(set.output.output_intent.trim()) {
|
||||
Some(p) if p.kind == ProfileKind::Cmyk => p.name,
|
||||
_ => source.settings().cmyk.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The title a file of `standard` gets: PDF/X files need one.
|
||||
pub(crate) fn title(standard: Standard, title: String) -> String {
|
||||
if standard.is_pdfx() && title.trim().is_empty() { "Untitled".into() } else { title }
|
||||
}
|
||||
|
||||
/// Refuse a PDF/X file without a creation date (its dates are required).
|
||||
pub(crate) fn require_date(standard: Standard, created: Option<i64>) -> Result<(), PdfError> {
|
||||
match created {
|
||||
None if standard.is_pdfx() => {
|
||||
Err(PdfError::Unsupported(format!("{} files need a creation date, and this platform has no clock to give one", standard.label())))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The document information entries naming `standard` (empty for other files).
|
||||
pub(crate) fn info_entries(standard: Standard) -> String {
|
||||
let Some((version, conformance)) = standard.pdfx_ids() else { return String::new() };
|
||||
let mut out = format!("/GTS_PDFXVersion({version})");
|
||||
if let Some(c) = conformance {
|
||||
out.push_str(&format!("/GTS_PDFXConformance({c})"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Edit the XMP metadata of a PDF/X file (the catalog's `/Metadata` stream; `root` is the
|
||||
/// catalog dictionary's contents) into `patch`: PDF/X-4 adds the standard, Trapped and a version
|
||||
/// id (PDF 1.3 files say so in [`finish`]). Files without metadata are left as they are.
|
||||
pub(crate) fn xmp(pdf: &[u8], xref: &Xref, root: (usize, usize), patch: &mut Patch, standard: Standard, trapped: bool) -> Result<(), PdfError> {
|
||||
if !standard.is_pdfx() {
|
||||
return Ok(());
|
||||
}
|
||||
let bad = || PdfError::Write("the written PDF's metadata can't be edited".into());
|
||||
let Some(at) = pdf.get(root.0..root.1).and_then(|d| find(d, b"/Metadata", 0)) else { return Ok(()) };
|
||||
let n = crate::patch::number(pdf, root.0 + at + b"/Metadata".len()).and_then(|(n, _)| u32::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let (start, end) = xref.dict(pdf, n).ok_or_else(bad)?;
|
||||
let obj = pdf.get(start..end).ok_or_else(bad)?;
|
||||
let length_at = find(obj, b"/Length", 0).ok_or_else(bad)? + b"/Length".len();
|
||||
let (length, length_end) = crate::patch::number(obj, length_at).ok_or_else(bad)?;
|
||||
let data = find(obj, b"stream", length_end).map(|i| i + b"stream".len()).ok_or_else(bad)?;
|
||||
let data = data + obj.get(data..).map_or(0, |r| r.iter().take(2).take_while(|b| matches!(b, b'\r' | b'\n')).count());
|
||||
let xml = data.checked_add(length).and_then(|e| obj.get(data..e)).ok_or_else(bad)?;
|
||||
let mut edits: Vec<(usize, usize, Vec<u8>)> = vec![];
|
||||
if let Some((version, _)) = standard.pdfx_ids().filter(|_| standard == Standard::PdfX4) {
|
||||
// Into the writer's description, whose namespaces declare the `pdf` and `xmpMM` prefixes.
|
||||
let close = crate::lab_spot::rfind(xml, b"</rdf:Description>").ok_or_else(bad)?;
|
||||
let trapped = if trapped { "True" } else { "False" };
|
||||
let props = format!(
|
||||
"<pdfxid:GTS_PDFXVersion xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\">{version}</pdfxid:GTS_PDFXVersion><pdf:Trapped>{trapped}</pdf:Trapped><xmpMM:VersionID>1</xmpMM:VersionID>"
|
||||
);
|
||||
edits.push((close, close, props.into_bytes()));
|
||||
}
|
||||
// Every edit grows the stream (or keeps its length).
|
||||
let grown: usize = edits.iter().map(|(s, e, new)| new.len().saturating_sub(e - s)).sum();
|
||||
if grown > 0 {
|
||||
patch.replace(start + length_at, start + length_end, format!(" {}", length + grown).into_bytes());
|
||||
}
|
||||
for (s, e, new) in edits {
|
||||
patch.replace(start + data + s, start + data + e, new);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The finished file of `standard`: a PDF 1.3 file (`pdf13`: PDF/X-1a, PDF/X-3, or asked for) says
|
||||
/// so ([`crate::post::declare_pdf13`]) and must be flat, then the file is checked against its
|
||||
/// standard ([`verify`]).
|
||||
pub(crate) fn finish(mut pdf: Vec<u8>, standard: Standard, pdf13: bool) -> Result<Vec<u8>, PdfError> {
|
||||
if pdf13 {
|
||||
crate::post::declare_pdf13(&mut pdf);
|
||||
if !standard.flattens() {
|
||||
flat(&without_streams(&pdf), "PDF 1.3")?;
|
||||
}
|
||||
}
|
||||
verify(&pdf, standard)?;
|
||||
Ok(pdf)
|
||||
}
|
||||
|
||||
/// Refuse a file (`objects`, without its stream data) with transparency: `kind` files have none.
|
||||
fn flat(objects: &[u8], kind: &str) -> Result<(), PdfError> {
|
||||
match transparency(objects) {
|
||||
Some(what) => Err(PdfError::Unsupported(format!(
|
||||
"{kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency)"
|
||||
))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a written file that breaks `standard`: transparency in PDF/X-1a and PDF/X-3 (soft
|
||||
/// masks, transparency groups, constant opacity, blend modes), colour spaces other than CMYK,
|
||||
/// grey and spot inks in PDF/X-1a, and fonts that aren't embedded.
|
||||
pub(crate) fn verify(pdf: &[u8], standard: Standard) -> Result<(), PdfError> {
|
||||
if !standard.is_pdfx() {
|
||||
return Ok(());
|
||||
}
|
||||
let objects = without_streams(pdf);
|
||||
let std = standard.label();
|
||||
if standard.flattens() {
|
||||
flat(&objects, std)?;
|
||||
}
|
||||
if standard.cmyk_only()
|
||||
&& let Some(space) =
|
||||
["/DeviceRGB", "/CalRGB", "/CalGray", "/Lab", "/ICCBased"].into_iter().find(|n| !names(&objects, n.as_bytes()).is_empty())
|
||||
{
|
||||
return Err(PdfError::Unsupported(format!("{std} files are CMYK, and the file would have colours in {}", &space[1..])));
|
||||
}
|
||||
let mut from = 0;
|
||||
while let Some(at) = find(&objects, b"/Type/FontDescriptor", from) {
|
||||
let end = find(&objects, b"endobj", at).unwrap_or(objects.len());
|
||||
let dict = objects.get(at..end).unwrap_or_default();
|
||||
if find(dict, b"/FontFile", 0).is_none() {
|
||||
return Err(PdfError::Unsupported(format!("{std} files embed their fonts, and the file would have one that isn't")));
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pdf` without its streams' data (dictionaries and the rest of the syntax), so that what is
|
||||
/// looked for there isn't found in image or font bytes.
|
||||
fn without_streams(pdf: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(pdf.len().min(1 << 20));
|
||||
let mut at = 0;
|
||||
while let Some(s) = find(pdf, b"stream", at) {
|
||||
let data = s + b"stream".len();
|
||||
// `endstream` itself, or a stream keyword that isn't one (inside a name or string).
|
||||
if (s >= 3 && pdf.get(s - 3..s) == Some(b"end")) || !pdf.get(data).is_some_and(|b| matches!(b, b'\r' | b'\n')) {
|
||||
out.extend_from_slice(pdf.get(at..data).unwrap_or_default());
|
||||
at = data;
|
||||
continue;
|
||||
}
|
||||
out.extend_from_slice(pdf.get(at..data).unwrap_or_default());
|
||||
at = find(pdf, b"endstream", data).unwrap_or(pdf.len());
|
||||
}
|
||||
out.extend_from_slice(pdf.get(at..).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
|
||||
/// Where name `name` (`/SMask`) occurs in `objects` as a whole name: the index just after it.
|
||||
fn names(objects: &[u8], name: &[u8]) -> Vec<usize> {
|
||||
let mut out = vec![];
|
||||
let mut from = 0;
|
||||
while let Some(at) = find(objects, name, from) {
|
||||
let end = at + name.len();
|
||||
if !objects.get(end).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'#')) {
|
||||
out.push(end);
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The token after `at` in `objects` (after white space): a name with its slash, or a number.
|
||||
fn token(objects: &[u8], at: usize) -> &[u8] {
|
||||
let rest = objects.get(at..).unwrap_or_default();
|
||||
let rest = rest.get(rest.iter().take_while(|b| b.is_ascii_whitespace()).count()..).unwrap_or_default();
|
||||
let len = rest.iter().enumerate().take_while(|(i, b)| *i == 0 || !(b.is_ascii_whitespace() || b"/[]<>()".contains(b))).count();
|
||||
rest.get(..len).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The first kind of transparency `objects` has, if any.
|
||||
fn transparency(objects: &[u8]) -> Option<&'static str> {
|
||||
if names(objects, b"/SMask").into_iter().any(|at| token(objects, at) != b"/None") {
|
||||
return Some("soft masks");
|
||||
}
|
||||
if !names(objects, b"/Transparency").is_empty() {
|
||||
return Some("transparency groups");
|
||||
}
|
||||
let below_one = |t: &[u8]| std::str::from_utf8(t).ok().and_then(|s| s.parse::<f64>().ok()).is_some_and(|v| v < 1.0);
|
||||
if names(objects, b"/CA").into_iter().chain(names(objects, b"/ca")).any(|at| below_one(token(objects, at))) {
|
||||
return Some("opacity");
|
||||
}
|
||||
if names(objects, b"/BM").into_iter().any(|at| !matches!(token(objects, at), b"/Normal" | b"/Compatible")) {
|
||||
return Some("blend modes");
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! What the export does to the file once it is written, in this order: the pages' thumbnails
|
||||
//! (General › Embed page thumbnails), the seal of the editing data, fast web view (a linearised
|
||||
//! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes
|
||||
//! through between its two steps so that it stays linearised. Also here: what makes a file say it
|
||||
//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::encrypt::Obj;
|
||||
use crate::linearize::{self, Parsed};
|
||||
use crate::patch::Patch;
|
||||
use crate::{PdfError, PdfOptions, PdfSettings};
|
||||
|
||||
/// Longest side of a page thumbnail, in pixels.
|
||||
pub const THUMBNAIL_SIZE: u32 = 106;
|
||||
|
||||
/// A page thumbnail: 8-bit RGB pixels, row by row, at most [`THUMBNAIL_SIZE`] pixels a side.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Thumbnail {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgb: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Thumbnail {
|
||||
/// The image XObject of a page's `/Thumb` entry; `gray`: in DeviceGray (for PDF/X files, whose
|
||||
/// device colours must suit the printing condition, an RGB thumbnail wouldn't).
|
||||
fn object(&self, gray: bool) -> Result<Vec<u8>, PdfError> {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let sized = (1..=THUMBNAIL_SIZE).contains(&w) && (1..=THUMBNAIL_SIZE).contains(&h);
|
||||
if !sized || self.rgb.len() as u64 != u64::from(w) * u64::from(h) * 3 {
|
||||
return Err(PdfError::BadSetting(format!("a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h})")));
|
||||
}
|
||||
let (space, pixels) = if gray {
|
||||
// Rec. 601 luma.
|
||||
let luma = |p: &[u8; 3]| ((299 * u32::from(p[0]) + 587 * u32::from(p[1]) + 114 * u32::from(p[2]) + 500) / 1000) as u8;
|
||||
("DeviceGray", Cow::Owned(self.rgb.as_chunks::<3>().0.iter().map(luma).collect()))
|
||||
} else {
|
||||
("DeviceRGB", Cow::Borrowed(self.rgb.as_slice()))
|
||||
};
|
||||
let data = crate::output::deflate(&pixels).map_err(|e| PdfError::Write(format!("page thumbnail: {e}")))?;
|
||||
let mut out = format!("<</Width {w}/Height {h}/ColorSpace/{space}/BitsPerComponent 8/Filter/FlateDecode/Length {}>>\nstream\n", data.len())
|
||||
.into_bytes();
|
||||
out.extend_from_slice(&data);
|
||||
out.extend_from_slice(b"\nendstream");
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// `pdf` with `thumbs` as the thumbnails of its pages, in page order (in grey when `gray`).
|
||||
fn thumbnails(pdf: Vec<u8>, thumbs: &[Thumbnail], gray: bool) -> Result<Vec<u8>, PdfError> {
|
||||
let failed = |why: &str| PdfError::Write(format!("can't add page thumbnails: {why}"));
|
||||
let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?;
|
||||
let mut patch = Patch::new(&xref);
|
||||
for (page, thumb) in pages.iter().zip(thumbs) {
|
||||
let (dict, _) = xref.dict(&pdf, *page).ok_or_else(|| failed("a page can't be read"))?;
|
||||
let n = patch.add_object(thumb.object(gray)?);
|
||||
patch.replace(dict, dict, format!("/Thumb {n} 0 R").into_bytes());
|
||||
}
|
||||
patch.apply(&pdf, &xref)
|
||||
}
|
||||
|
||||
/// Linearise `pdf`, encrypting it on the way as `set` says.
|
||||
fn linearised(pdf: &[u8], set: &PdfSettings) -> Result<Vec<u8>, PdfError> {
|
||||
let (prepared, plan) = linearize::prepare(pdf)?;
|
||||
let (encrypted, mut cipher) = crate::encrypt::protect_keeping(prepared, set)?;
|
||||
linearize::write(&encrypted, &plan, cipher.as_mut())
|
||||
}
|
||||
|
||||
/// The file the export wrote (`pdf`), finished as `opts` say; `editing`: it carries the editing
|
||||
/// data, to seal once the pages are final. What can't be done comes back in `warnings`.
|
||||
pub(crate) fn finish(pdf: Vec<u8>, opts: &PdfOptions, editing: bool, warnings: &mut Vec<String>) -> Result<Vec<u8>, PdfError> {
|
||||
let set = &opts.settings;
|
||||
let pdf = match (set.thumbnails, opts.thumbnails.is_empty()) {
|
||||
(true, false) => thumbnails(pdf, &opts.thumbnails, set.standard.is_pdfx())?,
|
||||
(true, true) => {
|
||||
warnings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into());
|
||||
pdf
|
||||
}
|
||||
(false, _) => pdf,
|
||||
};
|
||||
let pdf = if editing { crate::editing::seal(pdf)? } else { pdf };
|
||||
if !set.fast_web_view {
|
||||
return crate::encrypt::protect(pdf, set);
|
||||
}
|
||||
match linearised(&pdf, set) {
|
||||
Ok(out) => Ok(out),
|
||||
Err(e) => {
|
||||
warnings.push(format!("the file isn't optimized for fast web view: {e}"));
|
||||
crate::encrypt::protect(pdf, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a file written with the PDF 1.4 settings say it is PDF 1.3: its header and the version
|
||||
/// its XMP metadata gives, rewritten in place.
|
||||
pub(crate) fn declare_pdf13(pdf: &mut [u8]) {
|
||||
if let Some(v) = pdf.get_mut(..8).filter(|h| h.starts_with(b"%PDF-1.4")) {
|
||||
v.copy_from_slice(b"%PDF-1.3");
|
||||
}
|
||||
if let Some(digit) = metadata_version(pdf).and_then(|at| pdf.get_mut(at)) {
|
||||
*digit = b'3';
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the minor version digit is in the `1.4` of the XMP metadata stream of `pdf`'s catalog.
|
||||
fn metadata_version(pdf: &[u8]) -> Option<usize> {
|
||||
const VERSION: &[u8] = b"<pdf:PDFVersion>1.4<";
|
||||
let p = linearize::parse(pdf).ok()?;
|
||||
let Some(&Obj::Ref(n, _)) = p.objects.get(&p.root)?.value.get(b"Metadata") else { return None };
|
||||
let o = p.objects.get(&n)?;
|
||||
let at = crate::lab_spot::find(pdf.get(..o.endobj)?, VERSION, o.value_span.1)?;
|
||||
Some(at + VERSION.len() - 2)
|
||||
}
|
||||
@@ -79,17 +79,17 @@ pub fn builtin_presets() -> Vec<PdfPreset> {
|
||||
),
|
||||
preset(
|
||||
"PDF/X-1a:2001",
|
||||
"Print exchange with CMYK and spot colours only (PDF/X output is not supported yet).",
|
||||
pdf_x(Standard::PdfX1a, Compatibility::Pdf14),
|
||||
"Print exchange with CMYK and spot colours only: transparency flattened, fonts embedded.",
|
||||
pdf_x(Standard::PdfX1a, Compatibility::Pdf13),
|
||||
),
|
||||
preset(
|
||||
"PDF/X-3:2002",
|
||||
"Print exchange allowing colour-managed colours (PDF/X output is not supported yet).",
|
||||
pdf_x(Standard::PdfX3, Compatibility::Pdf14),
|
||||
"Print exchange allowing colour-managed colours: transparency flattened, fonts embedded.",
|
||||
pdf_x(Standard::PdfX3, Compatibility::Pdf13),
|
||||
),
|
||||
preset(
|
||||
"PDF/X-4:2010",
|
||||
"Print exchange keeping live transparency and layers (PDF/X output is not supported yet).",
|
||||
"Print exchange keeping live transparency and layers, colour-managed colours and embedded fonts.",
|
||||
pdf_x(Standard::PdfX4, Compatibility::Pdf16),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -102,7 +102,7 @@ fn art_bounds(doc: &Document) -> Option<Rect> {
|
||||
}
|
||||
|
||||
/// The regions that print: `(artboard, rect)`.
|
||||
fn regions(doc: &Document, set: &PrintSettings) -> Result<Vec<(Option<usize>, Rect)>, PdfError> {
|
||||
pub(crate) fn regions(doc: &Document, set: &PrintSettings) -> Result<Vec<(Option<usize>, Rect)>, PdfError> {
|
||||
let count = doc.artboards.len();
|
||||
let picked: Vec<usize> = match set.artboards {
|
||||
PrintArtboards::Ignore => {
|
||||
@@ -128,6 +128,22 @@ fn scaled(sides: [f64; 4], (sx, sy): (f64, f64)) -> [f64; 4] {
|
||||
[sides[0] * sy, sides[1] * sy, sides[2] * sx, sides[3] * sx]
|
||||
}
|
||||
|
||||
/// The tiles along one axis of a printed area `extent` long, `tile` long each and `step` apart:
|
||||
/// the first (tile `k` starts at `start + k × step`) and how many. Tiles showing no more of the area
|
||||
/// than the `overlap` are left out. Without a `start`, the first tile starts at the area's edge.
|
||||
fn span(start: Option<f64>, extent: f64, tile: f64, step: f64, overlap: f64) -> (f64, f64) {
|
||||
// Tiles that end (or start) exactly on an edge show none of the area beyond it.
|
||||
const EPS: f64 = 1e-6;
|
||||
match start {
|
||||
None => (0.0, ((extent - overlap) / step).ceil().max(1.0)),
|
||||
Some(g) => {
|
||||
let first = ((overlap - tile - g) / step + EPS).floor() + 1.0;
|
||||
let last = ((extent - overlap - g) / step - EPS).ceil() - 1.0;
|
||||
(first, (last - first + 1.0).max(1.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The page turns of a `w` × `h` paper (flipped, emulsion down, transverse) as one transform from
|
||||
/// the paper onto the page, and the page's size.
|
||||
fn turns(set: &PrintSettings, (w, h): (f64, f64), flipped: bool) -> (Affine, (f64, f64)) {
|
||||
@@ -151,6 +167,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
|
||||
let marks = set.marks.printer_marks();
|
||||
let bleed = set.bleed.of(doc);
|
||||
let (pw, ph) = set.paper();
|
||||
let origin = set.tile_origin.placed.then_some((set.tile_origin.x, set.tile_origin.y));
|
||||
let (mut pages, mut grids) = (vec![], vec![]);
|
||||
for (artboard, r) in regions(doc, set)? {
|
||||
let what = artboard.map_or_else(|| "The art".to_string(), |i| format!("Artboard {}", i + 1));
|
||||
@@ -225,9 +242,17 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
|
||||
page_bleed,
|
||||
};
|
||||
if !set.scaling.tiles() {
|
||||
let (fx, fy) = set.placement.origin.factors();
|
||||
let x = imageable.x0 + (imageable.width() - extent.0) * fx + set.placement.x;
|
||||
let y = imageable.y0 + (imageable.height() - extent.1) * fy + set.placement.y;
|
||||
// The printed area's top-left corner: where the tile origin puts it, else the placement.
|
||||
let (x, y) = match origin {
|
||||
Some((ox, oy)) => (imageable.x0 - ox * scale.0 - most[2], imageable.y0 - oy * scale.1 - most[0]),
|
||||
None => {
|
||||
let (fx, fy) = set.placement.origin.factors();
|
||||
(
|
||||
imageable.x0 + (imageable.width() - extent.0) * fx + set.placement.x,
|
||||
imageable.y0 + (imageable.height() - extent.1) * fy + set.placement.y,
|
||||
)
|
||||
}
|
||||
};
|
||||
let trim = Rect::new(x + most[2], y + most[0], x + most[2] + size.0, y + most[0] + size.1);
|
||||
if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + extent.0, y + extent.1)) {
|
||||
warnings.push(format!("{what} with its bleed and marks is larger than the imageable area: part of it doesn't print"));
|
||||
@@ -242,7 +267,13 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
|
||||
return Err(PdfError::BadSetting(format!("overlap: {o} pt is half a tile or more")));
|
||||
}
|
||||
let step = (tile.0 - o, tile.1 - o);
|
||||
let (cols, rows) = (((extent.0 - o) / step.0).ceil().max(1.0), ((extent.1 - o) / step.1).ceil().max(1.0));
|
||||
// The tile origin's page starts the grid: its imageable area's corner is the origin's point
|
||||
// (a full page's imageable area is inside the margin).
|
||||
let inside = if set.scaling == PrintScaling::TileFull { m } else { 0.0 };
|
||||
let start = origin.map(|(ox, oy)| (most[2] + ox * scale.0 - inside, most[0] + oy * scale.1 - inside));
|
||||
let (first_x, cols) = span(start.map(|s| s.0), extent.0, tile.0, step.0, o);
|
||||
let (first_y, rows) = span(start.map(|s| s.1), extent.1, tile.1, step.1, o);
|
||||
let grid = (start.map_or(0.0, |s| s.0) + first_x * step.0, start.map_or(0.0, |s| s.1) + first_y * step.1);
|
||||
if cols * rows > MAX_TILES as f64 {
|
||||
return Err(PdfError::BadSetting(format!("{what} would print on more than {MAX_TILES} tiles: scale it down")));
|
||||
}
|
||||
@@ -259,7 +290,8 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
|
||||
let at = if set.scaling == PrintScaling::TileFull { (0.0, 0.0) } else { (imageable.x0, imageable.y0) };
|
||||
let window = |t: usize| {
|
||||
let (c, rw) = ((t % cols) as f64, (t / cols) as f64);
|
||||
Rect::new(c * step.0, rw * step.1, c * step.0 + tile.0, rw * step.1 + tile.1)
|
||||
let (x, y) = (grid.0 + c * step.0, grid.1 + rw * step.1);
|
||||
Rect::new(x, y, x + tile.0, y + tile.1)
|
||||
};
|
||||
for &t in &pick {
|
||||
let w = window(t);
|
||||
|
||||
+83
-12
@@ -4,39 +4,49 @@
|
||||
//! - General: copies (collated or not), reverse order, the artboards (all, a range or ignored:
|
||||
//! all the art as one page), blank artboards skipped, the paper and its orientation (or turned
|
||||
//! to each artboard's), transverse, which layers print (template layers never do), the
|
||||
//! placement on the imageable area, and the scale: none, fit, custom, or tiles of the paper or
|
||||
//! of its imageable area, overlapping, a range of them.
|
||||
//! placement on the imageable area (or where the Print Tiling tool put the pages,
|
||||
//! [`TileOrigin`]), and the scale: none, fit, custom, or tiles of the paper or of its imageable
|
||||
//! area, overlapping, a range of them.
|
||||
//! - Marks and bleed as PDF export draws them ([`crate::MarkSettings`], [`crate::BleedSettings`]),
|
||||
//! around the artboard on the paper, at the paper's scale.
|
||||
//! - Output: composite, or separations, one page per ink that prints ([`print_inks`]) in the
|
||||
//! grey of its coverage (overprints honoured, spot colours as process if asked), emulsion down
|
||||
//! (mirrored), negative (inverted).
|
||||
//! - Colour management: the rendering intent colours are separated with, and whether CMYK
|
||||
//! colours keep their numbers.
|
||||
//! - Colour management: the printer profile composite colours are converted to (and
|
||||
//! separations separate with), the rendering intent, and whether CMYK colours keep their
|
||||
//! numbers.
|
||||
//! - Advanced: overprints in composite output preserved, discarded or simulated (as Overprint
|
||||
//! Preview shows them). Print as Bitmap and the flattener preset need the renderer and the
|
||||
//! flattener: the app applies them to the document before printing it.
|
||||
//!
|
||||
//! Halftone screens and flatness are left to the output device (warnings say so). [`preview`]
|
||||
//! lists the pages, tiles and inks without writing the file; [`plan`] lays the job out for other
|
||||
//! writers (PostScript), which can write screens and flatness themselves.
|
||||
//! writers (PostScript), which can write screens and flatness themselves; [`tiling`] turns the
|
||||
//! layout into the pages in document space (View → Show Print Tiling).
|
||||
|
||||
mod layout;
|
||||
mod plates;
|
||||
mod settings;
|
||||
mod tiling;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Affine, Rect};
|
||||
use serde::Serialize;
|
||||
use vectorcraft_color::cms::{self, ProfileKind};
|
||||
use vectorcraft_doc::marks::PrinterMarks;
|
||||
use vectorcraft_doc::overprint::{clear_overprints, multiply_overprints};
|
||||
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
|
||||
|
||||
pub use layout::{MAX_TILES, TileGrid};
|
||||
pub use plates::print_inks;
|
||||
pub use settings::*;
|
||||
pub use tiling::{TilingPage, tiling};
|
||||
|
||||
use crate::export::{Exporter, Sheet, Writer};
|
||||
use crate::marks::PageBoxes;
|
||||
use crate::{CompressionSettings, PdfError, PdfSettings};
|
||||
use crate::{AdvancedSettings, ColorConversion, CompressionSettings, OutputSettings, Overprint, PdfError, PdfSettings};
|
||||
use layout::Layout;
|
||||
use plates::Separator;
|
||||
|
||||
@@ -145,12 +155,61 @@ pub fn keep_layers(nodes: &mut Vec<Arc<Node>>, which: PrintLayers) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `doc` as `set` prints it: live effects applied (raster effects are rendered by the app first),
|
||||
/// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`]
|
||||
/// says.
|
||||
pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> {
|
||||
let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned);
|
||||
let d = doc.to_mut();
|
||||
keep_layers(&mut d.layers, set.print_layers);
|
||||
composite_overprints(d, set);
|
||||
doc
|
||||
}
|
||||
|
||||
/// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated
|
||||
/// (drawn with Multiply, as Overprint Preview shows them), after which nothing overprints any
|
||||
/// more; or preserved. Separations always honour them.
|
||||
fn composite_overprints(doc: &mut Document, set: &PrintSettings) {
|
||||
let how = set.advanced.overprints;
|
||||
if set.output.mode == OutputMode::Separations || how == PrintOverprints::Preserve {
|
||||
return;
|
||||
}
|
||||
let discard_white = doc.setup.discard_white_overprint;
|
||||
let symbols = doc.symbols.iter_mut().map(|s| &mut s.art);
|
||||
let nodes = doc.layers.iter_mut().chain(symbols).chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut()));
|
||||
for n in nodes {
|
||||
if how == PrintOverprints::Simulate {
|
||||
multiply_overprints(n, discard_white);
|
||||
}
|
||||
clear_overprints(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// How composite output writes colours: converted to the printer profile (CMYK colours keep
|
||||
/// their numbers with Preserve CMYK Numbers), overprints kept unless discarded or simulated.
|
||||
fn composite_pdf(set: &PrintSettings) -> (OutputSettings, AdvancedSettings) {
|
||||
let profile = set.color.profile.trim();
|
||||
let output = if profile.is_empty() {
|
||||
OutputSettings::default()
|
||||
} else {
|
||||
let conversion = if set.color.preserve_numbers { ColorConversion::PreserveNumbers } else { ColorConversion::Destination };
|
||||
OutputSettings { conversion, destination: profile.to_string(), ..Default::default() }
|
||||
};
|
||||
let overprint = if set.advanced.overprints == PrintOverprints::Preserve { Overprint::Preserve } else { Overprint::Discard };
|
||||
(output, AdvancedSettings { overprint, ..Default::default() })
|
||||
}
|
||||
|
||||
/// What of `doc` prints on pages of its own: each artboard printed (its rect), or all the art
|
||||
/// (`None`) with artboards ignored.
|
||||
pub fn print_regions(doc: &Document, set: &PrintSettings) -> Result<Vec<(Option<usize>, kurbo::Rect)>, PdfError> {
|
||||
set.check()?;
|
||||
layout::regions(&printed_document(doc, set), set)
|
||||
}
|
||||
|
||||
impl<'a> Job<'a> {
|
||||
fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result<Self, PdfError> {
|
||||
set.check()?;
|
||||
// Live effects print as their result (raster effects are rendered by the app first).
|
||||
let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned);
|
||||
keep_layers(&mut doc.to_mut().layers, set.print_layers);
|
||||
let doc = printed_document(doc, set);
|
||||
let mut warnings = vec![];
|
||||
let (layouts, tiles) = layout::layout(&doc, set, &mut warnings)?;
|
||||
let separations = set.output.mode == OutputMode::Separations;
|
||||
@@ -171,8 +230,11 @@ impl<'a> Job<'a> {
|
||||
if !postscript && separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) {
|
||||
warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into());
|
||||
}
|
||||
if !separations && doc.layers.iter().any(|l| l.has_overprint()) {
|
||||
warnings.push("overprinting fills and strokes print as knockouts in composite output (separations honour them)".into());
|
||||
if separations && set.advanced.print_as_bitmap {
|
||||
warnings.push("Print as Bitmap is for composite output: separations print the art as it is".into());
|
||||
}
|
||||
if separations && cms::profile(set.color.profile.trim()).is_some_and(|p| p.kind != ProfileKind::Cmyk) {
|
||||
warnings.push("separations separate with a CMYK profile: the RGB printer profile is left out".into());
|
||||
}
|
||||
if !postscript && !set.graphics.auto_flatness {
|
||||
warnings.push("a fixed flatness is not written yet: the output device flattens curves".into());
|
||||
@@ -330,8 +392,11 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
|
||||
pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
|
||||
let plan = plan(doc, opts)?;
|
||||
let doc = &*plan.doc;
|
||||
let pdf = PdfSettings { compression: CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() }, ..Default::default() };
|
||||
let separations = opts.settings.output.mode == OutputMode::Separations;
|
||||
// Plates are greys already: only composite colours go to the printer profile.
|
||||
let (output, advanced) = if separations { Default::default() } else { composite_pdf(&opts.settings) };
|
||||
let compression = CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() };
|
||||
let pdf = PdfSettings { compression, output, advanced, ..Default::default() };
|
||||
let mut w = Writer::new(doc, &pdf, &plan.title, plan.created, !separations && doc.color_mode == ColorMode::Cmyk)?;
|
||||
// One exporter per ink (one for composite), each drawing its own document.
|
||||
let mut exporters: Vec<Exporter> =
|
||||
@@ -339,16 +404,22 @@ pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfErro
|
||||
for ex in &mut exporters {
|
||||
ex.non_printing = true;
|
||||
ex.intent = opts.settings.color.intent;
|
||||
// The writer's colour output: the printer profile's conversion.
|
||||
ex.out = w.out.clone();
|
||||
// Composite output keeps overprints as the Advanced option says (plates multiply them).
|
||||
ex.overprint = !separations && pdf.advanced.overprint == Overprint::Preserve;
|
||||
}
|
||||
for &i in &plan.order {
|
||||
let Some(s) = plan.sheets.get(i) else { continue };
|
||||
let Some(ex) = exporters.get_mut(s.plate.unwrap_or(0)) else { continue };
|
||||
w.page(ex, &s.sheet())?;
|
||||
}
|
||||
let overprinted = exporters.iter().any(|ex| ex.overprinted);
|
||||
for ex in exporters {
|
||||
w.absorb(ex);
|
||||
}
|
||||
let (bytes, more) = w.finish()?;
|
||||
let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted)?;
|
||||
let mut warnings = plan.warnings;
|
||||
for m in more {
|
||||
if !warnings.contains(&m) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::cms::{Cms, Model};
|
||||
use vectorcraft_color::cms::{self, Cms, Model, ProfileKind};
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors};
|
||||
use vectorcraft_doc::overprint::multiply_overprints;
|
||||
@@ -42,6 +42,19 @@ pub fn print_inks(doc: &Document, set: &PrintSettings) -> (Vec<PrintInk>, Vec<St
|
||||
(list, warnings)
|
||||
}
|
||||
|
||||
/// The colour settings separations convert with: the active ones, with the printer profile as
|
||||
/// their CMYK profile when it is a CMYK one.
|
||||
fn separation_cms(set: &PrintSettings) -> Arc<Cms> {
|
||||
let active = cms::active();
|
||||
match cms::profile(set.color.profile.trim()) {
|
||||
Some(p) if p.kind == ProfileKind::Cmyk && cms::canonical_name(&active.settings().cmyk) != p.name => {
|
||||
let settings = cms::ColorSettings { cmyk: p.name, ..active.settings().clone() };
|
||||
Cms::new(&settings).map_or(active, Arc::new)
|
||||
}
|
||||
_ => active,
|
||||
}
|
||||
}
|
||||
|
||||
/// Separates colours into inks as the job's colour options say.
|
||||
pub(crate) struct Separator<'a> {
|
||||
pub doc: &'a Document,
|
||||
@@ -51,7 +64,7 @@ pub(crate) struct Separator<'a> {
|
||||
|
||||
impl<'a> Separator<'a> {
|
||||
pub fn new(doc: &'a Document, set: &'a PrintSettings) -> Self {
|
||||
Self { doc, cms: vectorcraft_color::cms::active(), set }
|
||||
Self { doc, cms: separation_cms(set), set }
|
||||
}
|
||||
|
||||
/// How much of ink `plate` colour `c` (with swatch link `link`) prints, 0..1.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! separation ([`PrintInk`]).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::cms::Intent;
|
||||
use vectorcraft_color::cms::{self, Intent, ProfileKind};
|
||||
|
||||
use crate::settings::{choice, within};
|
||||
use crate::{BleedSettings, MarkSettings, PdfError};
|
||||
@@ -214,6 +214,10 @@ pub struct PrintSettings {
|
||||
pub output: PrintOutput,
|
||||
pub graphics: PrintGraphics,
|
||||
pub color: PrintColor,
|
||||
/// Where the Print Tiling tool put the pages.
|
||||
pub tile_origin: TileOrigin,
|
||||
// Advanced.
|
||||
pub advanced: PrintAdvanced,
|
||||
}
|
||||
|
||||
impl Default for PrintSettings {
|
||||
@@ -244,6 +248,8 @@ impl Default for PrintSettings {
|
||||
output: PrintOutput::default(),
|
||||
graphics: PrintGraphics::default(),
|
||||
color: PrintColor::default(),
|
||||
tile_origin: TileOrigin::default(),
|
||||
advanced: PrintAdvanced::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,14 +365,57 @@ pub struct PrintColor {
|
||||
pub intent: Intent,
|
||||
/// CMYK colours print with their own values; off, they go through the colour settings too.
|
||||
pub preserve_numbers: bool,
|
||||
/// The printer profile (an RGB or CMYK profile by name): composite output converts colours to
|
||||
/// it with the intent (CMYK colours keep their numbers with `preserve_numbers`), and
|
||||
/// separations separate with it when it is a CMYK one. Empty: colours print as they are.
|
||||
pub profile: String,
|
||||
}
|
||||
|
||||
impl Default for PrintColor {
|
||||
fn default() -> Self {
|
||||
Self { intent: Intent::RelativeColorimetric, preserve_numbers: true }
|
||||
Self { intent: Intent::RelativeColorimetric, preserve_numbers: true, profile: String::new() }
|
||||
}
|
||||
}
|
||||
|
||||
choice! {
|
||||
/// What composite output does with overprinting fills and strokes (separations always
|
||||
/// honour them).
|
||||
PrintOverprints {
|
||||
Preserve = "preserve", "Preserve";
|
||||
/// They knock out.
|
||||
Discard = "discard", "Discard";
|
||||
/// Composited as Overprint Preview shows them.
|
||||
Simulate = "simulate", "Simulate";
|
||||
} default Preserve
|
||||
}
|
||||
|
||||
/// The Advanced section (with [`PrintSettings::margin`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PrintAdvanced {
|
||||
/// Composite pages print as one image each of the art, at the document's raster effects
|
||||
/// resolution (the app renders them before printing).
|
||||
pub print_as_bitmap: bool,
|
||||
pub overprints: PrintOverprints,
|
||||
/// The transparency flattener preset transparency is flattened with (a built-in or saved
|
||||
/// one, by name; the app flattens before printing). Empty: transparency prints live.
|
||||
pub flattener_preset: String,
|
||||
}
|
||||
|
||||
/// Where the Print Tiling tool put the pages (View → Show Print Tiling).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TileOrigin {
|
||||
/// The tool placed the pages: the top-left corner of the first page's imageable area is at
|
||||
/// (`x`, `y`) from the top-left corner of the art that prints (the artboard, or all the art
|
||||
/// with artboards ignored), in document points. It replaces the placement, and tiles start
|
||||
/// there (and go on to the left and up as far as the art does). Off: the placement places the
|
||||
/// art and tiles start at the top-left corner of the art with its bleed and marks.
|
||||
pub placed: bool,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
/// Most copies of a job.
|
||||
pub const MAX_COPIES: u32 = 999;
|
||||
|
||||
@@ -385,9 +434,15 @@ impl PrintSettings {
|
||||
within("scale.height", self.scale.height, 1.0, 1000.0, "%")?;
|
||||
within("overlap", self.overlap, 0.0, 720.0, " pt")?;
|
||||
within("margin", self.margin, 0.0, 144.0, " pt")?;
|
||||
within("tileOrigin.x", self.tile_origin.x, -14_400.0, 14_400.0, " pt")?;
|
||||
within("tileOrigin.y", self.tile_origin.y, -14_400.0, 14_400.0, " pt")?;
|
||||
self.marks.check()?;
|
||||
self.bleed.check()?;
|
||||
within("graphics.flatness", self.graphics.flatness, 0.2, 100.0, "")?;
|
||||
let profile = self.color.profile.trim();
|
||||
if !profile.is_empty() && cms::profile(profile).is_none_or(|p| p.kind == ProfileKind::Gray) {
|
||||
return Err(PdfError::BadSetting(format!("color.profile: no RGB or CMYK profile is called “{profile}”")));
|
||||
}
|
||||
for ink in &self.output.inks {
|
||||
if let Some(f) = ink.frequency {
|
||||
within(&format!("output.inks[{}].frequency", ink.name), f, 1.0, 1000.0, " lpi")?;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! View → Show Print Tiling: the pages of a job in document space, as [`super::preview`] lays
|
||||
//! them out: the layout [`super::plan`] gives the PDF and PostScript writers, so the canvas shows
|
||||
//! what prints (without separating the plates, which the overlay doesn't need).
|
||||
|
||||
use kurbo::{Affine, Rect};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{PrintPreview, PrintScaling, PrintSettings};
|
||||
|
||||
/// One page of the print tiling.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TilingPage {
|
||||
/// 0-based artboard (`None`: artboards ignored).
|
||||
pub artboard: Option<usize>,
|
||||
/// 1-based: the tile (across then down), or the page of the job.
|
||||
pub number: usize,
|
||||
/// It prints (tiles outside the tile range don't).
|
||||
pub printed: bool,
|
||||
/// The paper's edge, `[x0, y0, x1, y1]` in document space.
|
||||
pub page: [f64; 4],
|
||||
/// Its imageable area (inside the unprintable margin).
|
||||
pub imageable: [f64; 4],
|
||||
}
|
||||
|
||||
fn arr(r: Rect) -> [f64; 4] {
|
||||
[r.x0, r.y0, r.x1, r.y1]
|
||||
}
|
||||
|
||||
/// The pages of the job `pv` previews (printed with `set`), in document space: every tile of each
|
||||
/// artboard when tiling, else each page once (not once per ink).
|
||||
pub fn tiling(pv: &PrintPreview, set: &PrintSettings) -> Vec<TilingPage> {
|
||||
let m = set.margin;
|
||||
if set.scaling.tiles() {
|
||||
// Tiles are the paper (full pages) or its imageable area, at the print scale.
|
||||
let (sx, sy) = (set.scale.width / 100.0, set.scale.height / 100.0);
|
||||
let inset = (m / sx, m / sy);
|
||||
return pv
|
||||
.tiles
|
||||
.iter()
|
||||
.flat_map(|g| {
|
||||
g.tiles.iter().enumerate().map(move |(i, t)| {
|
||||
let tile = Rect::new(t[0], t[1], t[2], t[3]);
|
||||
let (page, imageable) = if set.scaling == PrintScaling::TileFull {
|
||||
(tile, tile.inflate(-inset.0, -inset.1))
|
||||
} else {
|
||||
(tile.inflate(inset.0, inset.1), tile)
|
||||
};
|
||||
TilingPage {
|
||||
artboard: g.artboard,
|
||||
number: i + 1,
|
||||
printed: g.printed.contains(&(i + 1)),
|
||||
page: arr(page),
|
||||
imageable: arr(imageable),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let mut out: Vec<TilingPage> = vec![];
|
||||
let mut last = None;
|
||||
for s in &pv.sheets {
|
||||
// Separations repeat each page once per ink, one after the other.
|
||||
if last == Some((s.artboard, s.tile)) {
|
||||
continue;
|
||||
}
|
||||
last = Some((s.artboard, s.tile));
|
||||
let to_page = Affine::new(s.transform);
|
||||
let det = to_page.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let back = to_page.inverse();
|
||||
let paper = Rect::new(0.0, 0.0, s.width, s.height);
|
||||
let page = back.transform_rect_bbox(paper);
|
||||
let imageable = back.transform_rect_bbox(paper.inflate(-m, -m));
|
||||
out.push(TilingPage { artboard: s.artboard, number: out.len() + 1, printed: true, page: arr(page), imageable: arr(imageable) });
|
||||
}
|
||||
out
|
||||
}
|
||||
+54
-20
@@ -67,23 +67,35 @@ choice! {
|
||||
PdfX4 = "pdfX4", "PDF/X-4:2010";
|
||||
} default None
|
||||
}
|
||||
|
||||
impl Standard {
|
||||
/// The writer can produce this standard (PDF/X is not supported yet).
|
||||
pub fn supported(self) -> bool {
|
||||
matches!(self, Self::None | Self::PdfA2b)
|
||||
/// A file of this standard can declare PDF version `c`: PDF/A-2 is based on PDF 1.7 (so it
|
||||
/// can't be PDF 2.0, nor PDF 1.3, which has no transparency), PDF/X-4 on PDF 1.6, and PDF/X-1a
|
||||
/// and PDF/X-3 on PDF 1.3 (PDF 1.4 is accepted too, and written as 1.3).
|
||||
pub fn allows(self, c: Compatibility) -> bool {
|
||||
use Compatibility::*;
|
||||
match self {
|
||||
Self::None => true,
|
||||
Self::PdfA2b => !matches!(c, Pdf13 | Pdf20),
|
||||
Self::PdfX1a | Self::PdfX3 => matches!(c, Pdf13 | Pdf14),
|
||||
Self::PdfX4 => matches!(c, Pdf14 | Pdf15 | Pdf16),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file of this standard can declare PDF version `c` (PDF/A-2 is based on PDF 1.7, so it
|
||||
/// can't be PDF 2.0).
|
||||
pub fn allows(self, c: Compatibility) -> bool {
|
||||
!(self == Self::PdfA2b && c == Compatibility::Pdf20)
|
||||
/// The PDF version a file of this standard is (what choosing it sets).
|
||||
pub fn version(self) -> Compatibility {
|
||||
match self {
|
||||
Self::None | Self::PdfA2b => Compatibility::Pdf17,
|
||||
Self::PdfX1a | Self::PdfX3 => Compatibility::Pdf13,
|
||||
Self::PdfX4 => Compatibility::Pdf16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
choice! {
|
||||
/// The PDF version written in the file header.
|
||||
Compatibility {
|
||||
/// No transparency: it is flattened (see [`PdfSettings::pdf13`]).
|
||||
Pdf13 = "1.3", "PDF 1.3";
|
||||
Pdf14 = "1.4", "PDF 1.4";
|
||||
Pdf15 = "1.5", "PDF 1.5";
|
||||
Pdf16 = "1.6", "PDF 1.6";
|
||||
@@ -92,6 +104,13 @@ choice! {
|
||||
} default Pdf17
|
||||
}
|
||||
|
||||
impl Compatibility {
|
||||
/// The version has PDF layers (optional content, PDF 1.5).
|
||||
pub fn has_layers(self) -> bool {
|
||||
matches!(self, Self::Pdf15 | Self::Pdf16 | Self::Pdf17 | Self::Pdf20)
|
||||
}
|
||||
}
|
||||
|
||||
choice! {
|
||||
/// How images above the threshold resolution are resampled.
|
||||
Downsample {
|
||||
@@ -218,6 +237,10 @@ pub struct PdfSettings {
|
||||
pub output: OutputSettings,
|
||||
pub advanced: AdvancedSettings,
|
||||
pub security: SecuritySettings,
|
||||
/// The transparency flattener preset PDF 1.3 files (PDF/X-1a and PDF/X-3 ones too) are
|
||||
/// flattened with: a built-in or saved preset's name; empty, High Resolution. The app flattens
|
||||
/// the document before writing; the writer doesn't read it.
|
||||
pub flattener_preset: String,
|
||||
}
|
||||
|
||||
/// Resampling and compression of one kind of image.
|
||||
@@ -392,27 +415,28 @@ pub(crate) fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result
|
||||
}
|
||||
|
||||
impl PdfSettings {
|
||||
/// Refuse settings the writer can't honour: an unsupported standard, or what
|
||||
/// [`Self::check_values`] refuses.
|
||||
/// Refuse settings the writer can't honour: what [`Self::check_values`] refuses, a destination
|
||||
/// profile that isn't there, or a PDF/X output intent it can't write ([`crate::pdfx`]).
|
||||
pub fn check(&self) -> Result<(), PdfError> {
|
||||
if !self.standard.supported() {
|
||||
return Err(PdfError::Unsupported(format!("{} output is not supported yet", self.standard.label())));
|
||||
}
|
||||
crate::output::check(self)?;
|
||||
crate::pdfx::check(self)?;
|
||||
self.check_values()
|
||||
}
|
||||
|
||||
/// The part of [`Self::check`] a preset must pass (it may name a standard the writer can't
|
||||
/// produce yet): a standard with a PDF version it doesn't allow, PDF/A with editing data, or
|
||||
/// an out-of-range value.
|
||||
/// The part of [`Self::check`] a preset must pass (without the colour settings' profiles): a
|
||||
/// standard with a PDF version it doesn't allow, a standard with editing data, PDF/X-1a or
|
||||
/// PDF/X-3 with PDF layers, or an out-of-range value.
|
||||
pub fn check_values(&self) -> Result<(), PdfError> {
|
||||
self.security.check(self.standard, self.compatibility)?;
|
||||
if !self.standard.allows(self.compatibility) {
|
||||
return Err(PdfError::BadSetting(format!("{} files can't be {}", self.standard.label(), self.compatibility.label())));
|
||||
}
|
||||
if self.preserve_editing && self.standard == Standard::PdfA2b {
|
||||
if self.preserve_editing && self.standard != Standard::None {
|
||||
return Err(PdfError::BadSetting(format!("{} files can't carry editing data: turn off preserveEditing", self.standard.label())));
|
||||
}
|
||||
if self.create_layers && !self.standard.allows_layers() {
|
||||
return Err(PdfError::BadSetting(format!("{} files can't have PDF layers: turn off createLayers", self.standard.label())));
|
||||
}
|
||||
let c = &self.compression;
|
||||
for (name, img) in
|
||||
[("color", (c.color.ppi, c.color.above_ppi)), ("gray", (c.gray.ppi, c.gray.above_ppi)), ("mono", (c.mono.ppi, c.mono.above_ppi))]
|
||||
@@ -425,6 +449,18 @@ impl PdfSettings {
|
||||
within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%")
|
||||
}
|
||||
|
||||
/// Top-level layers are written as PDF layers: asked for, at a version that has them.
|
||||
pub fn writes_layers(&self) -> bool {
|
||||
self.create_layers && self.compatibility.has_layers()
|
||||
}
|
||||
|
||||
/// The file is PDF 1.3 (asked for, or a PDF/X-1a or PDF/X-3 file): it has no transparency (the
|
||||
/// app flattens it, see [`Self::flattener_preset`]), is written with the PDF 1.4 settings, and
|
||||
/// its header and metadata say 1.3.
|
||||
pub fn pdf13(&self) -> bool {
|
||||
self.compatibility == Compatibility::Pdf13 || self.standard.flattens()
|
||||
}
|
||||
|
||||
/// Forget the passwords (presets never store them).
|
||||
pub fn clear_passwords(&mut self) {
|
||||
self.security.open_password.clear();
|
||||
@@ -436,9 +472,7 @@ impl PdfSettings {
|
||||
let d = Self::default();
|
||||
let s = &self.security;
|
||||
[
|
||||
(self.thumbnails, "page thumbnails are not embedded yet"),
|
||||
(self.fast_web_view, "fast web view (a linearised file) is not written yet"),
|
||||
(self.create_layers, "PDF layers are not written yet: every layer is plain page content"),
|
||||
(self.create_layers && !self.compatibility.has_layers(), "PDF layers need PDF 1.5 or later: every layer is plain page content"),
|
||||
(
|
||||
!self.advanced.outline_text && self.advanced.font_subset_percent < 100.0,
|
||||
"fonts are embedded as subsets of the characters used: a subset threshold below 100% is not applied",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! A written file's indirect objects read for rewriting it (the linearisation and the page
|
||||
//! thumbnails), on the lexer of [`crate::encrypt`]: where each object's parts lie, what it refers
|
||||
//! to, and the references to renumber.
|
||||
|
||||
use crate::encrypt::{Lexer, Obj, Tok, int, stream_spans};
|
||||
|
||||
impl Obj<'_> {
|
||||
/// Every object this one refers to, in file order, leaving out the values of the dictionary
|
||||
/// keys in `skip` (`Parent`).
|
||||
pub(crate) fn refs(&self, skip: &[&[u8]], out: &mut Vec<u32>) {
|
||||
match self {
|
||||
Self::Ref(n, _) => out.push(*n),
|
||||
Self::Dict { entries, .. } => entries.iter().filter(|(k, _)| !skip.contains(k)).for_each(|(_, v)| v.refs(skip, out)),
|
||||
Self::Array(items) => items.iter().for_each(|v| v.refs(skip, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An indirect object of a file, with where its parts lie.
|
||||
pub(crate) struct Indirect<'a> {
|
||||
pub num: u32,
|
||||
pub generation: u16,
|
||||
pub value: Obj<'a>,
|
||||
/// Where the value (a stream's dictionary) starts and ends.
|
||||
pub value_span: (usize, usize),
|
||||
/// Where `endobj` starts.
|
||||
pub endobj: usize,
|
||||
}
|
||||
|
||||
/// The indirect object at `off` of `pdf` (a stream's length must be direct); `None` when it can't
|
||||
/// be read.
|
||||
pub(crate) fn indirect(pdf: &[u8], off: usize) -> Option<Indirect<'_>> {
|
||||
let mut lx = Lexer::at(pdf, off);
|
||||
let (num, generation) = lx.header()?;
|
||||
let start = lx.clone().next()?.1;
|
||||
let value = lx.object(0)?;
|
||||
// The value ends before the white space ahead of the next token (`stream` or `endobj`).
|
||||
let (next, next_at, _) = lx.clone().next()?;
|
||||
let end = next_at - pdf.get(..next_at)?.iter().rev().take_while(|b| b.is_ascii_whitespace()).count();
|
||||
let endobj = match next {
|
||||
Tok::Word(b"endobj") => next_at,
|
||||
_ => {
|
||||
let (_, (_, data_end)) = stream_spans(pdf, &mut lx, &value).ok()??;
|
||||
let mut lx = Lexer::at(pdf, data_end);
|
||||
let (Tok::Word(b"endstream"), ..) = lx.next()? else { return None };
|
||||
let (Tok::Word(b"endobj"), at, _) = lx.next()? else { return None };
|
||||
at
|
||||
}
|
||||
};
|
||||
Some(Indirect { num, generation, value, value_span: (start, end.max(start)), endobj })
|
||||
}
|
||||
|
||||
/// The references (`n g R`) in `pdf[start..end]` (an object's value: no stream data), outside
|
||||
/// strings: span and object number.
|
||||
pub(crate) fn ref_spans(pdf: &[u8], start: usize, end: usize) -> Vec<(usize, usize, u32)> {
|
||||
let mut out = vec![];
|
||||
let mut lx = Lexer::at(pdf, start);
|
||||
// The last two tokens when they are numbers: (start, value).
|
||||
let mut last: [Option<(usize, i64)>; 2] = [None, None];
|
||||
while let Some((tok, s, e)) = lx.next().filter(|t| t.1 < end) {
|
||||
match tok {
|
||||
Tok::Word(b"R") => {
|
||||
if let [Some((at, n)), Some(_)] = last
|
||||
&& let Ok(n) = u32::try_from(n)
|
||||
{
|
||||
out.push((at, e, n));
|
||||
}
|
||||
last = [None, None];
|
||||
}
|
||||
Tok::Word(w) => last = [last[1], int(w).map(|v| (s, v))],
|
||||
_ => last = [None, None],
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -522,8 +522,11 @@ fn compatibility_levels() {
|
||||
}
|
||||
let a = export(&d, &with(|s| s.standard = Standard::PdfA2b)).unwrap();
|
||||
assert!(String::from_utf8_lossy(&a).contains("pdfaid"), "PDF/A identification in XMP");
|
||||
for x in [Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] {
|
||||
assert!(matches!(export(&d, &with(|s| s.standard = x)), Err(PdfError::Unsupported(_))), "{x:?}");
|
||||
// PDF/X files are PDF 1.3 (PDF/X-1a, PDF/X-3) or at most 1.6 (PDF/X-4): not the default 1.7.
|
||||
for (x, header) in [(Standard::PdfX1a, "%PDF-1.3"), (Standard::PdfX3, "%PDF-1.3"), (Standard::PdfX4, "%PDF-1.6")] {
|
||||
assert!(matches!(export(&d, &with(|s| s.standard = x)), Err(PdfError::BadSetting(_))), "{x:?}");
|
||||
let b = export(&d, &with(|s| (s.standard, s.compatibility) = (x, x.version()))).unwrap();
|
||||
assert!(b.starts_with(header.as_bytes()), "{x:?}");
|
||||
}
|
||||
// PDF/A-2b is a PDF 1.7 standard.
|
||||
let e = export(&d, &with(|s| (s.standard, s.compatibility) = (Standard::PdfA2b, Compatibility::Pdf20)));
|
||||
|
||||
@@ -148,11 +148,11 @@ fn permission_bits_follow_the_options() {
|
||||
fn standards_and_bad_passwords_are_refused() {
|
||||
let set = |standard, compatibility, sec| PdfSettings { standard, compatibility, security: sec, ..Default::default() };
|
||||
for standard in [Standard::PdfA2b, Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] {
|
||||
let e = set(standard, Compatibility::Pdf17, security("pw", "")).check_values().unwrap_err();
|
||||
let e = set(standard, standard.version(), security("pw", "")).check_values().unwrap_err();
|
||||
assert!(matches!(&e, PdfError::BadSetting(m) if m.contains("password-protected")), "{standard:?}: {e}");
|
||||
let e = set(standard, Compatibility::Pdf17, security("", "pw")).check_values().unwrap_err();
|
||||
let e = set(standard, standard.version(), security("", "pw")).check_values().unwrap_err();
|
||||
assert!(e.to_string().contains("password-protected"), "{standard:?}: {e}");
|
||||
assert!(set(standard, Compatibility::Pdf17, security("", "")).check_values().is_ok(), "{standard:?} without passwords");
|
||||
assert!(set(standard, standard.version(), security("", "")).check_values().is_ok(), "{standard:?} without passwords");
|
||||
}
|
||||
let bad = |c, sec| set(Standard::None, c, sec).check_values().unwrap_err().to_string();
|
||||
assert!(bad(Compatibility::Pdf17, security("same", "same")).contains("must differ"));
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Save PDF › Create PDF layers (an optional content group per top-level layer, with its
|
||||
//! visibility, print state and lock) and Advanced › Overprint (a graphics state that overprints
|
||||
//! for the overprinting fills and strokes).
|
||||
|
||||
use hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Document, LayerColor, Node, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
const RED: Color = Color::Rgb { r: 1.0, g: 0.0, b: 0.0 };
|
||||
const GREEN: Color = Color::Rgb { r: 0.0, g: 1.0, b: 0.0 };
|
||||
const BLUE: Color = Color::Rgb { r: 0.0, g: 0.0, b: 1.0 };
|
||||
|
||||
fn rect(d: &mut Document, x: f64, c: Color) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 30.0, 40.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0))
|
||||
}
|
||||
|
||||
/// Layers, bottom first: "Back" (red), "Hidden" (green; hidden and locked), "Notes" (blue; not
|
||||
/// printed) and a template layer (grey).
|
||||
fn layered() -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.layers.clear();
|
||||
for (i, (name, c)) in [("Back", RED), ("Hidden", GREEN), ("Notes", BLUE), ("Template", Color::gray(0.5))].into_iter().enumerate() {
|
||||
let art = rect(&mut d, 10.0 + 40.0 * i as f64, c);
|
||||
let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(i as u8));
|
||||
(l.visible, l.locked) = (name != "Hidden", name == "Hidden");
|
||||
if let NodeKind::Layer { children, printable, template, .. } = &mut l.kind {
|
||||
children.push(art.into());
|
||||
*printable = name != "Notes";
|
||||
*template = name == "Template";
|
||||
}
|
||||
d.layers.push(l.into());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn export(d: &Document, v: Value) -> ExportReport {
|
||||
let settings: PdfSettings = serde_json::from_value(v).unwrap();
|
||||
export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
/// The groups of `refs` (an array of the catalog's optional content properties) by name.
|
||||
fn names(xref: &hayro_syntax::xref::XRef, refs: Option<Array<'_>>) -> Vec<String> {
|
||||
let ids = refs.map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::<Vec<_>>()).unwrap_or_default();
|
||||
ids.into_iter()
|
||||
.map(|id| {
|
||||
let g = xref.get::<Dict<'_>>(id).unwrap();
|
||||
String::from_utf8(g.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap().as_bytes().to_vec()).unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The catalog's `/OCProperties` of `bytes` (opened with `password`), if any.
|
||||
fn oc_properties(bytes: &[u8], password: Option<&str>, f: impl FnOnce(Option<Dict<'_>>, &hayro_syntax::xref::XRef)) {
|
||||
let file = crate::pages::open(bytes, password).unwrap();
|
||||
let xref = file.xref();
|
||||
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
|
||||
f(catalog.get::<Dict<'_>>(b"OCProperties"), xref);
|
||||
}
|
||||
|
||||
/// The layers of an import: name, visible, printable, locked and fill colours of their art.
|
||||
fn layers(d: &Document) -> Vec<(String, bool, bool, bool, Vec<Color>)> {
|
||||
d.layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let printable = matches!(l.kind, NodeKind::Layer { printable: true, .. });
|
||||
let colors = l.children().unwrap().iter().filter_map(|n| n.appearance.fill().and_then(|f| f.paint.color())).collect();
|
||||
(l.name.clone().unwrap_or_default(), l.visible, printable, l.locked, colors)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_layers_writes_a_group_per_layer_with_its_states() {
|
||||
let d = layered();
|
||||
let r = export(&d, json!({"createLayers": true, "compression": {"compressText": false}}));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
oc_properties(&r.bytes, None, |props, xref| {
|
||||
let props = props.expect("/OCProperties");
|
||||
// Template layers aren't exported; the groups are in paint order, listed top first.
|
||||
assert_eq!(names(xref, props.get(b"OCGs")), ["Back", "Hidden", "Notes"]);
|
||||
let config = props.get::<Dict<'_>>(b"D").unwrap();
|
||||
assert_eq!(names(xref, config.get(b"Order")), ["Notes", "Hidden", "Back"]);
|
||||
assert_eq!(names(xref, config.get(b"OFF")), ["Hidden"], "hidden layers are off");
|
||||
assert_eq!(names(xref, config.get(b"Locked")), ["Hidden"]);
|
||||
assert!(config.get::<hayro_syntax::object::String<'_>>(b"Name").is_some());
|
||||
let auto = config.get::<Array<'_>>(b"AS").unwrap().iter::<Dict<'_>>().next().unwrap();
|
||||
assert_eq!(auto.get::<Name<'_>>(b"Event").unwrap().as_ref(), b"Print");
|
||||
let print_state = |name: &str| {
|
||||
let ids =
|
||||
props.get::<Array<'_>>(b"OCGs").unwrap().raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::<Vec<_>>();
|
||||
let g = ids
|
||||
.into_iter()
|
||||
.map(|id| xref.get::<Dict<'_>>(id).unwrap())
|
||||
.find(|g| g.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap().as_bytes() == name.as_bytes());
|
||||
let usage = g.unwrap().get::<Dict<'_>>(b"Usage").unwrap();
|
||||
usage.get::<Dict<'_>>(b"Print").unwrap().get::<Name<'_>>(b"PrintState").unwrap().as_ref().to_vec()
|
||||
};
|
||||
assert_eq!(print_state("Notes"), b"OFF", "non-printing layers aren't printed");
|
||||
assert_eq!(print_state("Back"), b"ON");
|
||||
});
|
||||
// Each layer's art is its group's optional content.
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
assert_eq!(text.matches(" BDC ").count(), 3, "{text}");
|
||||
assert!(text.contains("/OC /VCoc0 BDC /x") && text.contains("/Properties<<"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layers_import_back_as_layers() {
|
||||
let d = layered();
|
||||
for password in [None, Some("secret")] {
|
||||
let mut set = json!({"createLayers": true});
|
||||
if let Some(p) = password {
|
||||
set["security"] = json!({"openPassword": p});
|
||||
}
|
||||
let bytes = export(&d, set).bytes;
|
||||
let opts = ImportOptions { password: password.map(str::to_string), ..Default::default() };
|
||||
let back = import_with_report(&bytes, &opts).unwrap().document;
|
||||
assert_eq!(
|
||||
layers(&back),
|
||||
[
|
||||
("Back".to_string(), true, true, false, vec![RED]),
|
||||
("Hidden".to_string(), false, true, true, vec![GREEN]),
|
||||
("Notes".to_string(), true, false, false, vec![BLUE]),
|
||||
],
|
||||
"password {password:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_layers_stay_apart() {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.layers.clear();
|
||||
for name in ["One", "Two"] {
|
||||
let art = rect(&mut d, 10.0, RED);
|
||||
let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(0));
|
||||
if let NodeKind::Layer { children, .. } = &mut l.kind {
|
||||
children.push(art.into());
|
||||
}
|
||||
d.layers.push(l.into());
|
||||
}
|
||||
let back = import(&export(&d, json!({"createLayers": true})).bytes).unwrap();
|
||||
let got: Vec<(String, usize)> = back.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.children().unwrap().len())).collect();
|
||||
assert_eq!(got, [("One".to_string(), 1), ("Two".to_string(), 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_pdf_layers_the_layers_are_plain_page_content() {
|
||||
let mut d = layered();
|
||||
for (v, warning) in [
|
||||
(json!({}), None),
|
||||
(json!({"createLayers": true, "compatibility": "1.4"}), Some("1.5")),
|
||||
(json!({"createLayers": true, "compatibility": "1.5"}), None),
|
||||
] {
|
||||
let r = export(&d, v.clone());
|
||||
let mut has = false;
|
||||
oc_properties(&r.bytes, None, |props, _| has = props.is_some());
|
||||
assert_eq!(has, v["createLayers"] == json!(true) && warning.is_none(), "{v}");
|
||||
match warning {
|
||||
Some(w) => assert!(r.warnings.iter().any(|x| x.contains(w)), "{v}: {:?}", r.warnings),
|
||||
None => assert!(r.warnings.is_empty(), "{v}: {:?}", r.warnings),
|
||||
}
|
||||
}
|
||||
// A knockout page group draws objects, not layers.
|
||||
d.page_knockout = true;
|
||||
let r = export(&d, json!({"createLayers": true}));
|
||||
oc_properties(&r.bytes, None, |props, _| assert!(props.is_none()));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("knockout page group")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_a_layers_have_no_automatic_states() {
|
||||
let r = export(&layered(), json!({"standard": "pdfA2b", "createLayers": true}));
|
||||
oc_properties(&r.bytes, None, |props, _| {
|
||||
let config = props.unwrap().get::<Dict<'_>>(b"D").unwrap();
|
||||
assert!(config.get::<Array<'_>>(b"AS").is_none() && config.get::<hayro_syntax::object::String<'_>>(b"Name").is_some());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_x4_keeps_pdf_layers_and_x1a_refuses_them() {
|
||||
let d = layered();
|
||||
let r = export(&d, json!({"standard": "pdfX4", "compatibility": "1.6", "createLayers": true}));
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.6"));
|
||||
oc_properties(&r.bytes, None, |props, xref| {
|
||||
let props = props.expect("/OCProperties");
|
||||
assert_eq!(names(xref, props.get(b"OCGs")), ["Back", "Hidden", "Notes"]);
|
||||
assert!(props.get::<Dict<'_>>(b"D").unwrap().get::<Array<'_>>(b"AS").is_none());
|
||||
});
|
||||
let set: PdfSettings = serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.4", "createLayers": true})).unwrap();
|
||||
assert!(matches!(export_with_report(&d, &PdfOptions { settings: set, ..Default::default() }), Err(PdfError::BadSetting(_))));
|
||||
}
|
||||
|
||||
/// The painted paths of `d`: their bounds' corner and colour, in order.
|
||||
fn leaves(d: &Document) -> Vec<(i32, i32, Option<[u8; 4]>)> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if let Some(b) = n.path_data().and_then(|p| p.bounds()) {
|
||||
let paint = n.appearance.fill().map(|f| f.paint.clone()).or_else(|| n.appearance.stroke().map(|s| s.paint.clone()));
|
||||
out.push((b.x0.round() as i32, b.y0.round() as i32, paint.and_then(|p| p.color()).map(|c| c.to_rgba8(1.0))));
|
||||
}
|
||||
});
|
||||
out.sort_by_key(|l| (l.0, l.1));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_art_imports_as_it_was_drawn() {
|
||||
let mut d = layered();
|
||||
d.layers = d.layers.iter().map(|l| Node { visible: true, ..(**l).clone() }.into()).collect();
|
||||
let plain = leaves(&import(&export(&d, json!({"includeNonPrinting": true})).bytes).unwrap());
|
||||
assert_eq!(plain.len(), 3);
|
||||
assert_eq!(leaves(&import(&export(&d, json!({"createLayers": true})).bytes).unwrap()), plain, "the marks draw nothing");
|
||||
}
|
||||
|
||||
/// A rectangle whose fill overprints, one whose stroke does, a plain one and type whose
|
||||
/// characters' fill overprints, filled with `fill` (the overprinting rectangle).
|
||||
fn overprinting(fill: Color) -> Document {
|
||||
let mut d = Document::new(300.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut a = rect(&mut d, 10.0, fill);
|
||||
if let Some(f) = a.appearance.fill_mut() {
|
||||
f.overprint = true;
|
||||
}
|
||||
let mut b =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), Appearance::basic(Paint::None, Paint::solid(BLUE), 2.0));
|
||||
if let Some(s) = b.appearance.stroke_mut() {
|
||||
s.overprint = true;
|
||||
}
|
||||
let c = rect(&mut d, 90.0, GREEN);
|
||||
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), overprint_fill: true, ..Default::default() };
|
||||
let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(130.0, 40.0), "Ink", style))));
|
||||
for (i, n) in [a, b, c, t].into_iter().enumerate() {
|
||||
d.insert(Some(l), i, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The uncompressed export of `d` with `v`: the overprinting graphics states it defines, and the
|
||||
/// forms that set one.
|
||||
fn overprints(d: &Document, v: Value) -> (usize, usize) {
|
||||
let mut v = v;
|
||||
v["compression"] = json!({"compressText": false});
|
||||
let r = export(d, v);
|
||||
assert!(r.warnings.iter().all(|w| !w.contains("overprint")), "{:?}", r.warnings);
|
||||
let text = String::from_utf8_lossy(&r.bytes).into_owned();
|
||||
(text.matches("/Type/ExtGState/OP true/op true/OPM 1").count(), text.matches("stream\n/VCop gs\n").count())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprint_preserve_writes_an_overprinting_graphics_state() {
|
||||
let d = overprinting(RED);
|
||||
assert_eq!(overprints(&d, json!({})), (1, 3), "the fill, the stroke and the characters' fill");
|
||||
assert_eq!(overprints(&d, json!({"advanced": {"overprint": "discard"}})), (0, 0));
|
||||
assert_eq!(overprints(&overprinting(RED), json!({"createLayers": true})), (1, 3), "with PDF layers too");
|
||||
// Nothing overprints: no graphics state.
|
||||
assert_eq!(overprints(&layered(), json!({})), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_overprint_is_discarded_as_the_document_setup_says() {
|
||||
let mut d = overprinting(Color::WHITE);
|
||||
assert!(d.setup.discard_white_overprint);
|
||||
assert_eq!(overprints(&d, json!({})).1, 2, "the white fill knocks out");
|
||||
d.setup.discard_white_overprint = false;
|
||||
assert_eq!(overprints(&d, json!({})).1, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_a_overprint_mode_is_0() {
|
||||
let r = export(&overprinting(RED), json!({"standard": "pdfA2b", "compression": {"compressText": false}}));
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
assert!(text.contains("/OP true/op true/OPM 0") && !text.contains("/OPM 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprinting_art_imports_as_it_was_drawn() {
|
||||
let d = overprinting(RED);
|
||||
let plain = leaves(&import(&export(&d, json!({"advanced": {"overprint": "discard"}})).bytes).unwrap());
|
||||
for v in [json!({}), json!({"createLayers": true})] {
|
||||
assert_eq!(leaves(&import(&export(&d, v.clone()).bytes).unwrap()), plain, "{v}: the marks draw nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_x1a_keeps_overprint() {
|
||||
let r = export(&overprinting(RED), json!({"standard": "pdfX1a", "compatibility": "1.4", "compression": {"compressText": false}}));
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.3") && String::from_utf8_lossy(&r.bytes).contains("/OP true/op true/OPM 1"));
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Compatibility PDF 1.3: which standards allow it, the header and metadata that say so, the
|
||||
//! writer refusing transparency (the app flattens it first) and 40-bit RC4 encryption.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A document with a black rectangle; `opacity` below 1 makes it transparent.
|
||||
fn doc(opacity: f32) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.default_layer().unwrap();
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
n.opacity = opacity;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn options(v: serde_json::Value) -> PdfOptions {
|
||||
PdfOptions { settings: serde_json::from_value(v).unwrap(), created: Some(0), ..PdfOptions::uncompressed() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn which_standards_allow_pdf_1_3() {
|
||||
use Compatibility::*;
|
||||
assert!(Standard::None.allows(Pdf13) && Standard::PdfX1a.allows(Pdf13) && Standard::PdfX3.allows(Pdf13));
|
||||
assert!(!Standard::PdfA2b.allows(Pdf13) && !Standard::PdfX4.allows(Pdf13));
|
||||
// PDF/X-1a and PDF/X-3 files are PDF 1.3; PDF 1.4 is still accepted, and written as 1.3.
|
||||
assert_eq!((Standard::PdfX1a.version(), Standard::PdfX3.version()), (Pdf13, Pdf13));
|
||||
assert!(Standard::PdfX1a.allows(Pdf14));
|
||||
let s = |v| serde_json::from_value::<PdfSettings>(v).unwrap();
|
||||
assert!(s(json!({"compatibility": "1.3"})).pdf13() && s(json!({"standard": "pdfX3", "compatibility": "1.4"})).pdf13());
|
||||
assert!(!s(json!({"compatibility": "1.4"})).pdf13() && !s(json!({"standard": "pdfX4"})).pdf13());
|
||||
assert!(matches!(s(json!({"standard": "pdfA2b", "compatibility": "1.3"})).check(), Err(PdfError::BadSetting(m)) if m.contains("PDF 1.3")));
|
||||
assert_eq!(Compatibility::IDS.first(), Some(&"1.3"), "first in the dropdown");
|
||||
assert_eq!(Encryption::for_compatibility(Pdf13), Encryption::Rc440);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_1_3_files_say_so_and_have_no_transparency() {
|
||||
let mut d = doc(1.0);
|
||||
// Isolated page blending is a transparency group: a flat file has none.
|
||||
d.page_isolate = true;
|
||||
let r = export_with_report(&d, &options(json!({"compatibility": "1.3"}))).unwrap();
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.3") && text.contains("<pdf:PDFVersion>1.3<"), "the header and the metadata");
|
||||
assert!(!text.contains("/Transparency") && !text.contains("/SMask"));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
// The same at 1.4 has its page group.
|
||||
assert!(String::from_utf8_lossy(&export(&d, &options(json!({"compatibility": "1.4"}))).unwrap()).contains("/S/Transparency"));
|
||||
// Transparency the app didn't flatten is refused.
|
||||
let e = export(&doc(0.5), &options(json!({"compatibility": "1.3"}))).unwrap_err();
|
||||
assert!(matches!(&e, PdfError::Unsupported(m) if m.contains("PDF 1.3 files have no transparency")), "{e}");
|
||||
let mut d = doc(1.0);
|
||||
if let Some(n) = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id) {
|
||||
d.node_mut(n).unwrap().blend = BlendMode::Multiply;
|
||||
}
|
||||
assert!(matches!(export(&d, &options(json!({"compatibility": "1.3"}))), Err(PdfError::Unsupported(m)) if m.contains("PDF 1.3")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_1_3_files_are_encrypted_with_40_bit_rc4() {
|
||||
let o = options(json!({"compatibility": "1.3", "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": true}}));
|
||||
let r = export_with_report(&doc(1.0), &o).unwrap();
|
||||
assert!(String::from_utf8_lossy(&r.bytes).contains("/Filter/Standard/V 1/R 2/Length 40/P "));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.warnings);
|
||||
assert!(matches!(import(&r.bytes), Err(PdfError::NeedsPassword)));
|
||||
for password in ["open", "owner"] {
|
||||
let opened = import_with_report(&r.bytes, &ImportOptions { password: Some(password.into()), ..Default::default() }).unwrap();
|
||||
assert_eq!(opened.document.layers[0].children().map_or(0, |c| c.len()), 1, "{password}");
|
||||
}
|
||||
assert_eq!(crate::encrypt::user_password(&r.bytes, "owner").as_deref(), Some("open"));
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! PDF/X-1a, PDF/X-3 and PDF/X-4: each standard's header, output intent, boxes and identification,
|
||||
//! and what each refuses (RGB in PDF/X-1a, transparency in PDF/X-1a and PDF/X-3, passwords).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::cms::{self, GENERIC_CMYK, SRGB};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
const X: [Standard; 3] = [Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4];
|
||||
|
||||
/// A document with a rectangle filled with each of `fills`.
|
||||
fn doc(fills: &[Color]) -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
for (i, c) in fills.iter().enumerate() {
|
||||
let x = 10.0 + 30.0 * i as f64;
|
||||
let n =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), Appearance::basic(Paint::solid(*c), Paint::None, 0.0));
|
||||
d.insert(Some(l), i, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Options for `standard` at its version, content streams readable, a fixed date, changed by `f`.
|
||||
fn opts(standard: Standard, f: impl FnOnce(&mut PdfSettings)) -> PdfOptions {
|
||||
let mut o = PdfOptions::uncompressed();
|
||||
(o.settings.standard, o.settings.compatibility) = (standard, standard.version());
|
||||
o.created = Some(1_700_000_000);
|
||||
f(&mut o.settings);
|
||||
o
|
||||
}
|
||||
|
||||
fn settings(v: serde_json::Value) -> PdfSettings {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
/// The file's text with its stream data left out (what dictionaries say).
|
||||
fn text(pdf: &[u8]) -> String {
|
||||
String::from_utf8_lossy(pdf).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_standard_writes_its_header_output_intent_boxes_and_identification() {
|
||||
let mut d = doc(&[Color::cmyk(0.0, 1.0, 1.0, 0.0), Color::rgb(0.2, 0.4, 0.9)]);
|
||||
// PDF/X files have a title: an untitled document gets one.
|
||||
d.title.clear();
|
||||
// As a PDF string (parentheses escaped).
|
||||
let profile = cms::active().settings().cmyk.replace('(', r"\(").replace(')', r"\)");
|
||||
for (standard, header) in [(Standard::PdfX1a, "%PDF-1.3"), (Standard::PdfX3, "%PDF-1.3"), (Standard::PdfX4, "%PDF-1.6")] {
|
||||
let r = export_with_report(&d, &opts(standard, |_| {})).unwrap();
|
||||
let t = text(&r.bytes);
|
||||
assert!(r.bytes.starts_with(header.as_bytes()), "{standard:?}: {}", &t[..8]);
|
||||
// The output intent: the CMYK profile in effect, embedded.
|
||||
for want in ["/OutputIntents[", "/S/GTS_PDFX", "/DestOutputProfile ", &format!("/OutputConditionIdentifier({profile})")] {
|
||||
assert!(t.contains(want), "{standard:?}: {want}");
|
||||
}
|
||||
assert!(t.contains("/N 4/Length"), "{standard:?}: a CMYK profile");
|
||||
// Every page is trimmed to its artboard; the file is untrapped, titled and dated.
|
||||
for want in ["/TrimBox[0 0 200 100]", "/Trapped/False", "/Title(Untitled)", "/CreationDate", "/ModDate"] {
|
||||
assert!(t.contains(want), "{standard:?}: {want}");
|
||||
}
|
||||
let (version, conformance) = match standard {
|
||||
Standard::PdfX1a => ("PDF/X-1:2001", Some("PDF/X-1a:2001")),
|
||||
Standard::PdfX3 => ("PDF/X-3:2002", None),
|
||||
_ => ("PDF/X-4", None),
|
||||
};
|
||||
assert!(t.contains(&format!("/GTS_PDFXVersion({version})")), "{standard:?}");
|
||||
assert_eq!(conformance.is_some_and(|c| t.contains(&format!("/GTS_PDFXConformance({c})"))), conformance.is_some(), "{standard:?}");
|
||||
// The metadata agrees with the header; PDF/X-4 names its standard there too.
|
||||
let xmp_version = if standard == Standard::PdfX4 { "1.6" } else { "1.3" };
|
||||
assert!(t.contains(&format!("<pdf:PDFVersion>{xmp_version}</pdf:PDFVersion>")), "{standard:?}");
|
||||
assert_eq!(t.contains("<pdfxid:GTS_PDFXVersion xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\">PDF/X-4<"), standard == Standard::PdfX4);
|
||||
assert_eq!(t.contains("<pdf:Trapped>False</pdf:Trapped>"), standard == Standard::PdfX4);
|
||||
assert!(!t.contains("/Encrypt"), "{standard:?}");
|
||||
// The file reads back.
|
||||
let back = import(&r.bytes).unwrap();
|
||||
assert_eq!(back.artboards.len(), 1, "{standard:?}");
|
||||
assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings);
|
||||
}
|
||||
// Trapped, a named output intent with its condition and registry.
|
||||
let o = opts(Standard::PdfX4, |s| {
|
||||
s.output.trapped = true;
|
||||
s.output.output_intent = GENERIC_CMYK.into();
|
||||
s.output.output_condition_id = "CGATS TR 001".into();
|
||||
s.output.registry = "http://www.color.org".into();
|
||||
});
|
||||
let t = text(&export(&d, &o).unwrap());
|
||||
for want in ["/Trapped/True", "<pdf:Trapped>True<", "/OutputConditionIdentifier(CGATS TR 001)", "/RegistryName(http://www.color.org)"] {
|
||||
assert!(t.contains(want), "{want}");
|
||||
}
|
||||
// A registered condition that isn't a profile is named without one.
|
||||
let o = opts(Standard::PdfX1a, |s| {
|
||||
s.output.output_intent = "FOGRA39".into();
|
||||
s.output.registry = "http://www.color.org".into();
|
||||
});
|
||||
let t = text(&export(&d, &o).unwrap());
|
||||
assert!(t.contains("/OutputConditionIdentifier(FOGRA39)") && !t.contains("/DestOutputProfile"), "{t}");
|
||||
// PDF/X-4 always embeds its output intent's profile.
|
||||
let e = export(&d, &opts(Standard::PdfX4, |s| (s.output.output_intent, s.output.registry) = ("FOGRA39".into(), "http://www.color.org".into())));
|
||||
assert!(matches!(e, Err(PdfError::BadSetting(m)) if m.contains("embeds")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfx_1a_files_have_no_rgb() {
|
||||
let mut d = doc(&[Color::rgb(0.9, 0.1, 0.1), Color::Lab { l: 50.0, a: 40.0, b: -20.0 }, Color::gray(0.5), Color::cmyk(0.1, 0.2, 0.3, 0.4)]);
|
||||
// An RGB image too.
|
||||
let png = {
|
||||
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([10, 200, 30, 255]));
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
d.images.insert("img".into(), ImageBlob::new("image/png", png));
|
||||
let l = d.layers[0].id;
|
||||
let im = ImageObject {
|
||||
key: "img".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: Affine::translate((150.0, 50.0)) * Affine::scale(5.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(im));
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let t = text(&export(&d, &opts(Standard::PdfX1a, |_| {})).unwrap());
|
||||
for no in ["/DeviceRGB", "/ICCBased", "/Lab", "/CalRGB"] {
|
||||
assert!(!t.contains(no), "PDF/X-1a has no {no}");
|
||||
}
|
||||
let ops: Vec<&str> = t.split_whitespace().collect();
|
||||
assert!(!ops.contains(&"rg") && !ops.contains(&"RG"), "no RGB fills or strokes");
|
||||
assert!(ops.iter().filter(|o| **o == "k").count() >= 3, "the RGB, Lab and CMYK fills are CMYK");
|
||||
assert!(ops.contains(&"g"), "grey stays grey");
|
||||
assert!(t.contains("/ColorSpace/DeviceCMYK"), "the image is CMYK");
|
||||
// An RGB destination or output intent is refused; PDF/X-3 tags colours instead.
|
||||
let refused = |f: fn(&mut PdfSettings)| export(&d, &opts(Standard::PdfX1a, f)).unwrap_err();
|
||||
assert!(matches!(refused(|s| s.output.destination = SRGB.into()), PdfError::BadSetting(m) if m.contains("output.destination")));
|
||||
assert!(matches!(refused(|s| s.output.output_intent = SRGB.into()), PdfError::BadSetting(m) if m.contains("output.outputIntent")));
|
||||
let x3 = text(&export(&d, &opts(Standard::PdfX3, |_| {})).unwrap());
|
||||
assert!(x3.contains("/ICCBased") && !x3.contains("/DeviceRGB"), "PDF/X-3 colours are ICC-based");
|
||||
assert!(export(&d, &opts(Standard::PdfX3, |s| s.output.output_intent = SRGB.into())).is_ok(), "PDF/X-3 may print in RGB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfx_1a_and_x3_files_have_no_transparency() {
|
||||
let mut d = doc(&[Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)]);
|
||||
let half = d.layers[0].children().unwrap()[1].id;
|
||||
d.node_mut(half).unwrap().opacity = 0.5;
|
||||
for standard in [Standard::PdfX1a, Standard::PdfX3] {
|
||||
// The writer can't flatten (the app does, before writing): it refuses.
|
||||
let e = export(&d, &opts(standard, |_| {})).unwrap_err();
|
||||
assert!(matches!(&e, PdfError::Unsupported(m) if m.contains("transparency") && m.contains("flatten")), "{standard:?}: {e}");
|
||||
}
|
||||
// Opaque art passes; PDF/X-4 keeps the transparency.
|
||||
d.node_mut(half).unwrap().opacity = 1.0;
|
||||
let t = text(&export(&d, &opts(Standard::PdfX3, |_| {})).unwrap());
|
||||
assert!(!t.contains("/S/Transparency") && !t.contains("/SMask"));
|
||||
d.node_mut(half).unwrap().opacity = 0.5;
|
||||
let t = text(&export(&d, &opts(Standard::PdfX4, |_| {})).unwrap());
|
||||
assert!(t.contains("/ca 0.5") || t.contains("/S/Transparency"), "PDF/X-4 keeps transparency");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfx_settings_rules() {
|
||||
for standard in X {
|
||||
// A password is refused, and so are editing data.
|
||||
let e = opts(standard, |s| s.security.open_password = "x".into()).settings.check().unwrap_err();
|
||||
assert!(matches!(e, PdfError::BadSetting(_)), "{standard:?}: {e}");
|
||||
assert!(export(&doc(&[]), &opts(standard, |s| s.security.permissions_password = "x".into())).is_err());
|
||||
assert!(
|
||||
matches!(opts(standard, |s| s.preserve_editing = true).settings.check(), Err(PdfError::BadSetting(m)) if m.contains("preserveEditing"))
|
||||
);
|
||||
// Its own version, never PDF 1.7 or 2.0.
|
||||
assert!(standard.allows(standard.version()), "{standard:?}");
|
||||
assert!(!standard.allows(Compatibility::Pdf17) && !standard.allows(Compatibility::Pdf20), "{standard:?}");
|
||||
let e = opts(standard, |s| s.compatibility = Compatibility::Pdf17).settings.check().unwrap_err();
|
||||
assert!(matches!(e, PdfError::BadSetting(ref m) if m.contains(standard.label())), "{standard:?}: {e}");
|
||||
// A name that is no profile, without a registry.
|
||||
let e = opts(standard, |s| s.output.output_intent = "No Such Press".into()).settings.check().unwrap_err();
|
||||
assert!(matches!(e, PdfError::BadSetting(ref m) if m.contains("No Such Press")), "{standard:?}: {e}");
|
||||
}
|
||||
assert_eq!(Standard::PdfX4.version(), Compatibility::Pdf16);
|
||||
assert!(Standard::PdfX4.allows(Compatibility::Pdf15) && !Standard::PdfX1a.allows(Compatibility::Pdf15));
|
||||
// Layers: PDF/X-4 only.
|
||||
assert!(
|
||||
matches!(settings(json!({"standard": "pdfX1a", "compatibility": "1.4", "createLayers": true})).check(), Err(PdfError::BadSetting(m)) if m.contains("createLayers"))
|
||||
);
|
||||
assert!(settings(json!({"standard": "pdfX4", "compatibility": "1.6", "createLayers": true})).check_values().is_ok());
|
||||
// A date is required.
|
||||
assert!(matches!(crate::pdfx::require_date(Standard::PdfX4, None), Err(PdfError::Unsupported(_))));
|
||||
assert!(crate::pdfx::require_date(Standard::None, None).is_ok());
|
||||
// PDF/X settings don't warn about the output condition: the output intent is always written.
|
||||
assert!(settings(json!({"standard": "pdfX4", "compatibility": "1.6", "output": {"outputCondition": "Coated"}})).warnings().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_finds_what_each_standard_forbids() {
|
||||
use crate::pdfx::verify;
|
||||
let file = |body: &str| format!("%PDF-1.4\n1 0 obj\n{body}\nendobj\n").into_bytes();
|
||||
for (body, what) in [
|
||||
("<</Type/XObject/SMask 5 0 R>>", "soft masks"),
|
||||
("<</Group<</S/Transparency/CS/DeviceCMYK>>>>", "transparency groups"),
|
||||
("<</Type/ExtGState/ca 0.5>>", "opacity"),
|
||||
("<</Type/ExtGState/BM/Multiply>>", "blend modes"),
|
||||
] {
|
||||
for standard in [Standard::PdfX1a, Standard::PdfX3] {
|
||||
assert!(matches!(verify(&file(body), standard), Err(PdfError::Unsupported(m)) if m.contains(what)), "{standard:?}: {body}");
|
||||
}
|
||||
assert!(verify(&file(body), Standard::PdfX4).is_ok(), "PDF/X-4 keeps {what}");
|
||||
}
|
||||
for ok in ["<</Type/ExtGState/SMask/None/ca 1/CA 1.0/BM/Normal>>", "<</Length 9>>\nstream\n/SMask 1 /DeviceRGB\nendstream"] {
|
||||
assert!(verify(&file(ok), Standard::PdfX1a).is_ok(), "{ok}");
|
||||
}
|
||||
assert!(matches!(verify(&file("[/ICCBased 3 0 R]"), Standard::PdfX1a), Err(PdfError::Unsupported(m)) if m.contains("ICCBased")));
|
||||
assert!(verify(&file("[/ICCBased 3 0 R]"), Standard::PdfX3).is_ok());
|
||||
assert!(verify(&file("<</PageLabels 3 0 R>>"), Standard::PdfX1a).is_ok(), "whole names only");
|
||||
let font = "<</Type/FontDescriptor/FontName/A>>";
|
||||
assert!(matches!(verify(&file(font), Standard::PdfX4), Err(PdfError::Unsupported(m)) if m.contains("fonts")));
|
||||
assert!(verify(&file("<</Type/FontDescriptor/FontFile2 4 0 R>>"), Standard::PdfX4).is_ok());
|
||||
}
|
||||
@@ -14,9 +14,11 @@ fn built_in_presets() {
|
||||
let back: PdfPreset = serde_json::from_value(serde_json::to_value(p).unwrap()).unwrap();
|
||||
assert_eq!(&back, p, "round-trips");
|
||||
}
|
||||
// PDF/X presets name standards the writer doesn't produce yet.
|
||||
let x4 = builtin_preset("pdf/x-4:2010").unwrap();
|
||||
assert!(matches!(x4.settings.check(), Err(PdfError::Unsupported(_))));
|
||||
// The PDF/X presets are written as their standards say.
|
||||
for name in ["pdf/x-1a:2001", "PDF/X-3:2002", "pdf/x-4:2010"] {
|
||||
let x = builtin_preset(name).unwrap();
|
||||
assert!(x.settings.standard.is_pdfx() && x.settings.check().is_ok(), "{name}");
|
||||
}
|
||||
assert_eq!(builtin_preset("default").unwrap().name, DEFAULT_PRESET);
|
||||
assert!(builtin_preset("Nope").is_none());
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! File → Print › Advanced and Color Management: overprints preserved, discarded or simulated in
|
||||
//! composite output, and colours converted to the printer profile with the rendering intent.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use hayro_syntax::Pdf;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, Intent};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A 200 × 100 document with a rectangle filled with each `(colour, overprints)`, overlapping
|
||||
/// one another left to right, in paint order.
|
||||
fn doc(fills: &[(Color, bool)]) -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
for (i, (c, over)) in fills.iter().enumerate() {
|
||||
let x = 10.0 + 30.0 * i as f64;
|
||||
let mut a = Appearance::basic(Paint::solid(*c), Paint::None, 0.0);
|
||||
for it in &mut a.items {
|
||||
*it.overprint_mut() = *over;
|
||||
}
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 50.0, 60.0)), a);
|
||||
d.insert(Some(l), i, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Cyan under a yellow that overprints.
|
||||
fn overprinted() -> Document {
|
||||
doc(&[(Color::cmyk(1.0, 0.0, 0.0, 0.0), false), (Color::cmyk(0.0, 0.0, 1.0, 0.0), true)])
|
||||
}
|
||||
|
||||
fn settings(v: Value) -> PrintSettings {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
/// The job's bytes, content streams left readable.
|
||||
fn job(d: &Document, v: Value) -> PrintReport {
|
||||
let opts = PrintOptions { settings: settings(v), created: Some(1_791_200_000), uncompressed: true, ..Default::default() };
|
||||
print(d, &opts).unwrap()
|
||||
}
|
||||
|
||||
/// The fill colours page `page` sets: (operator, operands).
|
||||
fn colours(pdf: &[u8], page: usize) -> Vec<(String, Vec<f32>)> {
|
||||
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||||
let pages = file.pages();
|
||||
let content = String::from_utf8_lossy(pages.get(page).unwrap().page_stream().unwrap()).into_owned();
|
||||
let (mut nums, mut out) = (vec![], vec![]);
|
||||
for t in content.split_whitespace() {
|
||||
if let Ok(v) = t.parse::<f32>() {
|
||||
nums.push(v);
|
||||
continue;
|
||||
}
|
||||
if ["rg", "k", "g", "scn"].contains(&t) {
|
||||
out.push((t.to_string(), nums.clone()));
|
||||
}
|
||||
nums.clear();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `v` as the writer stores it (8 bits a component).
|
||||
fn q8(v: &[f32]) -> Vec<f32> {
|
||||
v.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect()
|
||||
}
|
||||
|
||||
fn close(a: &[f32], b: &[f32]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
|
||||
}
|
||||
|
||||
/// Does any fill or stroke of `d` overprint?
|
||||
fn overprints(d: &Document) -> bool {
|
||||
d.layers.iter().any(|l| l.has_overprint())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_saved_before_the_advanced_options_load_with_their_defaults() {
|
||||
let s = settings(json!({"copies": 2, "color": {"intent": "perceptual"}}));
|
||||
assert_eq!((s.copies, s.color.intent, s.color.preserve_numbers), (2, Intent::Perceptual, true));
|
||||
assert_eq!(s.advanced, PrintAdvanced::default());
|
||||
assert_eq!((s.advanced.print_as_bitmap, s.advanced.overprints, s.advanced.flattener_preset.as_str()), (false, PrintOverprints::Preserve, ""));
|
||||
assert_eq!(s.color.profile, "");
|
||||
let v = serde_json::to_value(&s).unwrap();
|
||||
assert_eq!(v["advanced"], json!({"printAsBitmap": false, "overprints": "preserve", "flattenerPreset": ""}));
|
||||
// A printer profile must be one there is.
|
||||
assert!(settings(json!({"color": {"profile": GENERIC_CMYK}})).check().is_ok());
|
||||
assert!(matches!(settings(json!({"color": {"profile": "No Such Profile"}})).check(), Err(PdfError::BadSetting(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_and_simulated_overprints_write_no_overprint() {
|
||||
let d = overprinted();
|
||||
let preserve = job(&d, json!({}));
|
||||
assert!(preserve.warnings.is_empty(), "{:?}", preserve.warnings);
|
||||
assert!(String::from_utf8_lossy(&preserve.bytes).contains("/OP true/op true/OPM 1"), "preserved overprints overprint in the file");
|
||||
assert!(overprints(&printed_document(&d, &settings(json!({})))), "preserved");
|
||||
for (how, multiplies) in [("discard", false), ("simulate", true)] {
|
||||
let set = json!({"advanced": {"overprints": how}});
|
||||
assert!(!overprints(&printed_document(&d, &settings(set.clone()))), "{how}: nothing overprints");
|
||||
let r = job(&d, set);
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
assert!(!text.contains("/OP"), "{how}: no overprint in the file");
|
||||
assert_eq!(text.contains("/Multiply"), multiplies, "{how}");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("knockouts")), "{how}: {:?}", r.warnings);
|
||||
}
|
||||
// Separations honour overprints whatever the option says.
|
||||
let seps = settings(json!({"output": {"mode": "separations"}, "advanced": {"overprints": "discard"}}));
|
||||
assert!(overprints(&printed_document(&d, &seps)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_printer_profile_writes_device_cmyk_with_the_intent() {
|
||||
let d = doc(&[(Color::rgb(1.0, 0.0, 0.0), false)]);
|
||||
assert_eq!(colours(&job(&d, json!({})).bytes, 0), [("rg".to_string(), vec![1.0, 0.0, 0.0])], "as it is without a profile");
|
||||
let to_cmyk = Cms::new(&ColorSettings { cmyk: GENERIC_CMYK.into(), ..cms::active_settings() }).unwrap();
|
||||
for intent in [Intent::Perceptual, Intent::Saturation] {
|
||||
let c = colours(&job(&d, json!({"color": {"profile": GENERIC_CMYK, "intent": intent}})).bytes, 0);
|
||||
let ink = q8(&to_cmyk.srgb_to_cmyk([1.0, 0.0, 0.0], intent));
|
||||
assert!(c.len() == 1 && c[0].0 == "k" && close(&c[0].1, &ink), "{intent:?}: {c:?} vs {ink:?}");
|
||||
}
|
||||
// CMYK colours keep their numbers with Preserve CMYK Numbers.
|
||||
let d = doc(&[(Color::cmyk(0.1, 0.2, 0.3, 0.4), false)]);
|
||||
let c = colours(&job(&d, json!({"color": {"profile": DEVICE_CMYK, "preserveNumbers": true}})).bytes, 0);
|
||||
assert!(c[0].0 == "k" && close(&c[0].1, &q8(&[0.1, 0.2, 0.3, 0.4])), "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separations_separate_with_a_cmyk_printer_profile() {
|
||||
// A mid grey: naive device CMYK puts it all on the black plate; a press profile doesn't.
|
||||
let d = doc(&[(Color::rgb(0.5, 0.5, 0.5), false)]);
|
||||
let cyan = |profile: &str| {
|
||||
let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": profile}}));
|
||||
colours(&r.bytes, 0)
|
||||
};
|
||||
let device = cyan(DEVICE_CMYK);
|
||||
let press = cyan(GENERIC_CMYK);
|
||||
assert_ne!(device, press, "the profile separates");
|
||||
// An RGB printer profile doesn't separate.
|
||||
let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": cms::SRGB}}));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.warnings);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ fn json_is_camel_case_and_partial_objects_keep_defaults() {
|
||||
assert_eq!(s.compression.gray.downsample, Downsample::Bicubic);
|
||||
assert_eq!(s.compression.gray.ppi, 300.0);
|
||||
assert!(s.compression.compress_text);
|
||||
for bad in [json!({"compatibility": "1.3"}), json!({"standard": "pdfX9"}), json!({"marks": {"trim": "yes"}})] {
|
||||
for bad in [json!({"compatibility": "1.2"}), json!({"standard": "pdfX9"}), json!({"marks": {"trim": "yes"}})] {
|
||||
assert!(serde_json::from_value::<PdfSettings>(bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,9 @@ fn passwords_are_never_serialized() {
|
||||
fn checks_refuse_what_the_writer_cant_honour() {
|
||||
assert_eq!(PdfSettings::default().check(), Ok(()));
|
||||
let refused = |v: serde_json::Value| settings(v).check().unwrap_err();
|
||||
assert!(matches!(refused(json!({"standard": "pdfX4"})), PdfError::Unsupported(_)));
|
||||
// PDF/X-4 is a PDF 1.6 standard: the default 1.7 is refused.
|
||||
assert!(matches!(refused(json!({"standard": "pdfX4"})), PdfError::BadSetting(m) if m.contains("PDF 1.7")));
|
||||
assert_eq!(settings(json!({"standard": "pdfX4", "compatibility": "1.6"})).check(), Ok(()));
|
||||
// Passwords are written (the file is encrypted); a password with a standard is not.
|
||||
assert_eq!(settings(json!({"security": {"openPassword": "x"}})).check(), Ok(()));
|
||||
assert_eq!(settings(json!({"security": {"permissionsPassword": "x"}})).check(), Ok(()));
|
||||
@@ -67,15 +69,14 @@ fn checks_refuse_what_the_writer_cant_honour() {
|
||||
assert_eq!(settings(json!({"standard": "pdfA2b", "compatibility": "1.4"})).check(), Ok(()));
|
||||
assert!(Compatibility::ALL.iter().all(|c| Standard::None.allows(*c)));
|
||||
assert!(export(&doc(), &PdfOptions { settings: settings(json!({"standard": "pdfX1a"})), ..Default::default() }).is_err());
|
||||
assert!(export(&doc(), &PdfOptions { settings: settings(json!({"standard": "pdfX1a", "compatibility": "1.4"})), ..Default::default() }).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_not_applied_yet_come_back_as_warnings() {
|
||||
assert!(PdfSettings::default().warnings().is_empty());
|
||||
for (v, word) in [
|
||||
(json!({"thumbnails": true}), "thumbnails"),
|
||||
(json!({"fastWebView": true}), "fast web view"),
|
||||
(json!({"createLayers": true}), "layers"),
|
||||
(json!({"createLayers": true, "compatibility": "1.4"}), "PDF 1.5"),
|
||||
(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 35}}), "subset"),
|
||||
(json!({"output": {"outputIntent": "No Such Press"}}), "without embedding"),
|
||||
(json!({"output": {"registry": "http://example.com"}}), "condition identifier"),
|
||||
@@ -98,7 +99,9 @@ fn options_not_applied_yet_come_back_as_warnings() {
|
||||
json!({"bleed": {"useDocument": true, "top": 9}}),
|
||||
json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}),
|
||||
json!({"includeNonPrinting": true}),
|
||||
json!({"createLayers": true}),
|
||||
json!({"viewAfterSaving": true}),
|
||||
json!({"thumbnails": true, "fastWebView": true}),
|
||||
json!({"compression": {"compressText": false}}),
|
||||
json!({"compression": {"color": {"compression": "jpeg"}, "mono": {"compression": "ccittG4"}}}),
|
||||
] {
|
||||
@@ -107,6 +110,7 @@ fn options_not_applied_yet_come_back_as_warnings() {
|
||||
let r =
|
||||
export_with_report(&doc(), &PdfOptions { settings: settings(json!({"thumbnails": true, "marks": {"trim": true}})), ..Default::default() })
|
||||
.unwrap();
|
||||
assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
|
||||
// The writer embeds the thumbnails it is given: without them, the one warning says so.
|
||||
assert!(r.warnings.len() == 1 && r.warnings[0].contains("thumbnails need the pages drawn"), "{:?}", r.warnings);
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.7"));
|
||||
}
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user