M037: reconcile current libraries and preserve native IME reset acknowledgements

This commit is contained in:
skylartaylor committed 2026-10-25 01:58:55 +02:00
commit 650b1c0b05
157 files changed
+35139 -1138

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
name: Shared JPEG
on:
push:
branches: [main]
paths: ["crates/jpeg/**", "Cargo.toml", "Cargo.lock", ".github/workflows/jpeg.yml"]
pull_request:
paths: ["crates/jpeg/**", "Cargo.toml", "Cargo.lock", ".github/workflows/jpeg.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: shared-jpeg-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
timeout-minutes: 10
name: JPEG lint, tests, WASM
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-jpeg
- run: cargo fmt -p craft-jpeg --check
- run: cargo clippy -p craft-jpeg --all-targets --locked -- -D warnings
- run: cargo test -p craft-jpeg --locked
- run: cargo check -p craft-jpeg --locked --target wasm32-unknown-unknown
+2 -2
View File
@@ -106,5 +106,5 @@ jobs:
python scripts/test-fetch-pdf-test-fonts.py
python scripts/fetch-pdf-test-fonts.py "$RUNNER_TEMP/craft-pdf-test-fonts"
echo "CRAFT_PDF_TEST_FONT_DIR=$RUNNER_TEMP/craft-pdf-test-fonts" >> "$GITHUB_ENV"
- run: cargo clippy -p craft-plate-test -p craft-prepress --all-targets --locked -- -D warnings
- run: cargo test -p craft-plate-test -p craft-prepress --locked
- run: cargo clippy -p craft-plate-test -p craft-prepress --all-targets --all-features --locked -- -D warnings
- run: cargo test -p craft-plate-test -p craft-prepress --all-features --locked
Generated
+175 -5
View File
@@ -108,6 +108,15 @@ name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
dependencies = [
"serde",
]
[[package]]
name = "as-raw-xcb-connection"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "ash"
@@ -516,6 +525,15 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "cgl"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff"
dependencies = [
"libc",
]
[[package]]
name = "chacha20"
version = "0.10.2"
@@ -784,6 +802,15 @@ dependencies = [
"writeable",
]
[[package]]
name = "craft-jpeg"
version = "0.1.0"
dependencies = [
"jpeg-encoder",
"thiserror 2.0.21",
"zune-jpeg",
]
[[package]]
name = "craft-keymap"
version = "0.1.0"
@@ -860,7 +887,11 @@ version = "0.1.0"
dependencies = [
"craft-cms",
"craft-pdf",
"craft-plate-test",
"kurbo 0.13.1",
"linesweeper",
"thiserror 2.0.21",
"ttf-parser",
]
[[package]]
@@ -901,10 +932,15 @@ dependencies = [
name = "craft-ui"
version = "0.1.0"
dependencies = [
"eframe",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"serde",
"serde_json",
]
@@ -1147,6 +1183,9 @@ dependencies = [
"egui",
"egui-wgpu",
"egui-winit",
"egui_glow",
"glutin",
"glutin-winit",
"image",
"js-sys",
"log",
@@ -1213,6 +1252,7 @@ version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"block2 0.6.2",
"bytemuck",
"egui",
"log",
"objc2 0.6.5",
@@ -1240,6 +1280,19 @@ dependencies = [
"resvg",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
"glow",
"log",
"profiling",
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.37.0"
@@ -1674,6 +1727,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.5",
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1767,6 +1830,62 @@ dependencies = [
"web-sys",
]
[[package]]
name = "glutin"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
dependencies = [
"bitflags 2.13.2",
"cfg_aliases",
"cgl",
"dispatch2",
"glutin_egl_sys",
"glutin_glx_sys",
"glutin_wgl_sys",
"libloading",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"raw-window-handle",
"windows-sys 0.52.0",
"x11-dl",
]
[[package]]
name = "glutin-winit"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f"
dependencies = [
"cfg_aliases",
"glutin",
"raw-window-handle",
"winit",
]
[[package]]
name = "glutin_egl_sys"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2"
dependencies = [
"gl_generator",
"windows-sys 0.52.0",
]
[[package]]
name = "glutin_glx_sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185"
dependencies = [
"gl_generator",
"x11-dl",
]
[[package]]
name = "glutin_wgl_sys"
version = "0.6.1"
@@ -2449,6 +2568,12 @@ dependencies = [
"libc",
]
[[package]]
name = "jpeg-encoder"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
[[package]]
name = "js-sys"
version = "0.3.106"
@@ -2544,6 +2669,7 @@ dependencies = [
"arrayvec",
"euclid",
"polycool",
"serde",
"smallvec",
]
@@ -2587,6 +2713,19 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linesweeper"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
dependencies = [
"arrayvec",
"kurbo 0.13.1",
"polycool",
"rustc-hash 2.1.3",
"smallvec",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -5968,6 +6107,7 @@ dependencies = [
"atomic-waker",
"bitflags 2.13.2",
"block2 0.5.1",
"bytemuck",
"calloop",
"cfg_aliases",
"concurrent-queue",
@@ -5983,6 +6123,7 @@ dependencies = [
"objc2-foundation 0.2.2",
"objc2-ui-kit 0.2.2",
"orbclient",
"percent-encoding",
"pin-project",
"raw-window-handle",
"redox_syscall 0.4.1",
@@ -5995,6 +6136,8 @@ dependencies = [
"web-sys",
"web-time",
"windows-sys 0.52.0",
"x11-dl",
"x11rb",
"xkbcommon-dl",
]
@@ -6073,6 +6216,38 @@ dependencies = [
"either",
]
[[package]]
name = "x11-dl"
version = "2.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
dependencies = [
"libc",
"once_cell",
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"as-raw-xcb-connection",
"gethostname",
"libc",
"libloading",
"once_cell",
"rustix 1.1.5",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x509-cert"
version = "0.2.5"
@@ -6415,11 +6590,6 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_glow"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+2
View File
@@ -21,12 +21,14 @@ hayro = { version = "=0.7.1", default-features = false }
hayro-interpret = { version = "=0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
bytemuck = "1"
kurbo = "0.13"
thiserror = "2"
craft-atomic = { path = "crates/atomic" }
craft-cms = { path = "crates/cms" }
craft-docpath = { path = "crates/docpath" }
craft-fonts-build = { path = "crates/fonts-build" }
craft-i18n = { path = "crates/i18n" }
craft-jpeg = { path = "crates/jpeg" }
craft-keymap = { path = "crates/keymap" }
craft-media = { path = "crates/media" }
craft-prepress = { path = "crates/prepress" }
+3
View File
@@ -44,6 +44,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
lookup with fallback (`pt-br` → `pt` → English), plurals, language names, the system locale and
a pseudo locale. The catalogs stay a fully optional build input; apps keep `tl!` and their
language setting.
- [`craft-jpeg`](crates/jpeg/README.md): clean-room decoder for 12-bit and arithmetic-coded
JPEG (T.81 SOF0/SOF1 at 12 bits, SOF9, SOF10), from PhotoCraft, and a frame probe for routing.
Metadata stays in each app.
- [`craft-keymap`](crates/keymap/README.md): keyboard bindings: chords and their text forms,
scopes, conflicts, user overrides stored through craft-settings and import of other
applications' shortcut sets. Command catalogs, file readers and the shortcut editor UI stay
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "craft-jpeg"
description = "Clean-room decoder for 12-bit and arithmetic-coded JPEG (ITU-T T.81)."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
thiserror = { workspace = true }
[dev-dependencies]
# Reference encoder and baseline decoder for the comparison tests.
jpeg-encoder = "0.6"
zune-jpeg = "0.5"
+145
View File
@@ -0,0 +1,145 @@
# craft-jpeg
A clean-room JPEG decoder for the Craft apps, written from ITU-T T.81 (ISO/IEC 10918-1). It
covers the processes that common Rust JPEG decoders skip: 12-bit samples and arithmetic coding.
No `unsafe`, no panics on malformed input, and it builds for `wasm32-unknown-unknown`. The only
dependency is `thiserror`.
```toml
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "<commit>" }
```
## What it decodes
| Frame | Process | Precision |
|---|---|---|
| SOF0, SOF1 | sequential DCT, Huffman coded | 8 or 12-bit |
| SOF9 | extended sequential DCT, arithmetic coded | 8 or 12-bit |
| SOF10 | progressive DCT, arithmetic coded | 8 or 12-bit |
Every other frame returns `Error::Unsupported`: progressive Huffman (SOF2), lossless (SOF3,
SOF11), hierarchical (SOF5 to SOF7, SOF13 to SOF15), other precisions, more than one frame, and
a height given only in a DNL marker. Images have 1, 3 or 4 components with any sampling factors
and restart intervals.
## Use
```rust
use craft_jpeg::{decode, Limits, Samples};
let img = decode(&bytes, &Limits::default(), adobe_transform)?;
match img.samples {
Samples::U8(px) => { /* 8-bit frames */ }
Samples::U16(px) => { /* 12-bit frames, scaled to 16 bits */ }
}
if img.damaged { /* warn: part of the image is missing */ }
```
- `decode(bytes, &Limits, adobe_transform: Option<u8>) -> Result<Decoded, Error>`.
`adobe_transform` is the transform byte of the APP14 `Adobe` segment, when the app found one.
- `frame(bytes) -> Option<FrameKind>` reads the headers up to the first SOFn marker and returns
that marker and the declared sample precision, without decoding.
- `Error` is `Malformed`, `Unsupported` or `LimitExceeded`, each with a message.
## Output
`Decoded` has `width`, `height`, `components`, `samples` and `damaged`.
- `samples` is interleaved by pixel, rows top to bottom: `Samples::U8` for 8-bit frames,
`Samples::U16` for 12-bit frames, scaled to the full 16-bit range
(`round(v × 65535 / 4095)`).
- `components` is 1 (gray), 3 (RGB) or 4 (CMYK).
- 3 components are converted from YCbCr to RGB (JFIF, T.871), unless the Adobe transform is
0 or the component IDs are `R`, `G`, `B`.
- 4 components are ink amounts (0 = no ink). The stored values are taken to follow Adobe's
inverted convention (maximum = no ink) and are inverted back. With Adobe transform 2 (YCCK),
C, M and Y are converted from YCbCr, which yields ink amounts directly, and K is inverted.
- `damaged` is set when entropy-coded data was cut short or corrupt after at least one MCU had
decoded, or its scans would decode more than 64 passes over the frame's blocks. The rest of
that scan, and every scan past the budget, is left out; those blocks keep what earlier scans
decoded. A stream with no decodable image data is an error. Arithmetic-coded data that stops
early at a marker reads as the zero bytes encoders drop at the end of a scan (libjpeg-turbo
treats it the same way), so for arithmetic scans only data that runs to the end of the file
and a missing restart marker count as damage.
## Limits
`Limits` (fields `max_pixels`, `max_alloc`) is checked against the frame header before anything
is allocated: `width × height` against `max_pixels`, and the output buffer plus the decoder's
own coefficient and sample buffers, together, against `max_alloc`. The defaults are 2³⁰ pixels
and 8 GiB on 64-bit targets, 1 GiB on 32-bit ones (wasm32). The struct is `#[non_exhaustive]`:
start from the defaults and set the fields you change:
```rust
let mut limits = craft_jpeg::Limits::default();
limits.max_alloc = 512 << 20;
```
A file of under a hundred bytes can declare a frame that fills the default limits, and
decoding it still renders every sample. Callers that decode for a UI or for previews should
pass a `max_alloc` well below the default.
## Routing
An app that decodes baseline JPEG with another decoder keeps its own rule for which files come
here, built on `frame`. PhotoCraft sends arithmetic-coded files (SOF9 to SOF11) and SOF0/SOF1
files whose precision is not 8 bits:
```rust
fn use_craft_jpeg(bytes: &[u8]) -> bool {
match craft_jpeg::frame(bytes) {
Some(f) if (0xC9..=0xCB).contains(&f.marker) => true,
Some(f) if matches!(f.marker, 0xC0 | 0xC1) => f.precision != 8,
_ => false,
}
}
```
SOF11 (lossless) falls in that range and returns `Unsupported`.
## Boundaries
- In scope: decoding the frames above into samples.
- Out of scope: metadata. The decoder skips every APPn and COM segment. ICC profiles, EXIF,
XMP, Photoshop resources, resolution and the APP14 `Adobe` segment are the app's to read.
Color management and encoding also stay in the apps.
## Testing
`cargo test -p craft-jpeg` runs:
- unit tests in `src/tests.rs`: arithmetic-coded streams from a test-only encoder must decode to
the coefficients that went in, and Huffman streams are compared against `zune-jpeg`.
- `tests/conformance.rs`, on libjpeg-turbo's test images in `tests/data` (provenance and how
the derived files were made: `tests/data/SOURCE.md`):
- `testimgari.jpg` (SOF9) decodes to exactly the samples of its jpegtran Huffman transcode.
- jpegtran's arithmetic and progressive arithmetic transcodes of `testorig.jpg` (one with
restarts) and `testorig12.jpg` decode to exactly the samples of their sources.
- `whitetail-ari.jpg` (SOF9 from cjpeg, a white lower part that decodes from the zeros
supplied at EOI) decodes undamaged and equal to its Huffman transcode.
- `testorig.jpg` matches `zune-jpeg`, and `testorig12.jpg` matches libjpeg-turbo's 12-bit
decode, within the float-against-integer IDCT difference (mean under 0.75, max 4).
- `tests/mutation.rs`: a seeded mutation run (truncations, bit flips, byte and frame-header
overwrites, deleted and repeated spans) over the test images and the fuzz seeds. Decoding must
not panic and must stay within small `Limits`.
`.github/workflows/jpeg.yml` runs fmt, clippy, the tests and a `wasm32-unknown-unknown` check on
Linux.
### Fuzzing
`fuzz/` is a cargo-fuzz crate outside the workspace, with one target, `decode`. Its seeds in
`fuzz/corpus/decode` are the test images plus small SOF9 (with restarts), 12-bit SOF9, SOF10
and 12-bit SOF1 streams. With nightly and `cargo install cargo-fuzz`:
```sh
cd crates/jpeg/fuzz
cargo +nightly fuzz run decode
```
Running it adds the inputs it finds to `fuzz/corpus/decode`; commit only seeds that cover
something new.
## Credits
The decoder was written by Michael Higgins (Higgs32584) for PhotoCraft.
+4
View File
@@ -0,0 +1,4 @@
target
artifacts
coverage
Cargo.lock
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "craft-jpeg-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
craft-jpeg = { path = ".." }
# Its own workspace: cargo-fuzz needs nightly and sanitizer flags, so this crate stays out of the
# craft-libs workspace and its lockfile.
[workspace]
[[bin]]
name = "decode"
path = "fuzz_targets/decode.rs"
test = false
doc = false
bench = false
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+29
View File
@@ -0,0 +1,29 @@
#![no_main]
use craft_jpeg::{Limits, Samples, decode, frame};
use libfuzzer_sys::fuzz_target;
fn fuzz_limits() -> Limits {
let mut limits = Limits::default();
limits.max_pixels = 1 << 20;
limits.max_alloc = 64 << 20;
limits
}
fuzz_target!(|data: &[u8]| {
let limits = fuzz_limits();
let _ = frame(data);
// The input length picks the Adobe transform, so every color path is reached without
// changing the stream.
let adobe = [None, Some(0), Some(1), Some(2)][data.len() % 4];
if let Ok(img) = decode(data, &limits, adobe) {
let pixels = u64::from(img.width) * u64::from(img.height);
let (len, width) = match &img.samples {
Samples::U8(v) => (v.len() as u64, 1),
Samples::U16(v) => (v.len() as u64, 2),
};
assert!(pixels <= limits.max_pixels);
assert!(len * width <= limits.max_alloc);
assert_eq!(len, pixels * u64::from(img.components));
}
});
File diff suppressed because it is too large. Load diff
+833
View File
@@ -0,0 +1,833 @@
use super::*;
/// The test image decoded by `zune-jpeg`, as interleaved 8-bit samples, and its size.
fn zune_decode(bytes: &[u8]) -> (Vec<u8>, (u32, u32)) {
let mut dec = zune_jpeg::JpegDecoder::new(zune_jpeg::zune_core::bytestream::ZCursor::new(bytes));
let px = dec.decode().unwrap();
let (w, h) = dec.dimensions().unwrap();
(px, (w as u32, h as u32))
}
fn decode_default(bytes: &[u8]) -> Result<Decoded, Error> {
decode(bytes, &Limits::default(), None)
}
fn u16_samples(img: &Decoded) -> &[u16] {
match &img.samples {
Samples::U16(v) => v,
Samples::U8(_) => panic!("8-bit samples"),
}
}
// --- A test-only arithmetic encoder (T.81 D.1, F.1.4 and G.1.3), mirroring the decoder ---
struct Enc {
out: Vec<u8>,
c: u32,
a: u32,
ct: u32,
stacked: u32,
b: Option<u8>,
}
impl Enc {
fn new() -> Self {
Enc { out: Vec::new(), c: 0, a: 0x10000, ct: 11, stacked: 0, b: None }
}
fn emit(&mut self, v: u8) {
self.out.push(v);
if v == 0xFF {
self.out.push(0);
}
}
fn byte_out(&mut self) {
let t = self.c >> 19;
if t > 0xFF {
let b = self.b.unwrap() + 1;
self.emit(b);
for _ in 0..self.stacked {
self.emit(0);
}
self.stacked = 0;
self.b = Some((t & 0xFF) as u8);
} else if t == 0xFF {
self.stacked += 1;
} else {
if let Some(b) = self.b {
self.emit(b);
}
for _ in 0..self.stacked {
self.emit(0xFF);
}
self.stacked = 0;
self.b = Some(t as u8);
}
self.c &= 0x7FFFF;
}
fn renorm(&mut self) {
while self.a < 0x8000 {
self.a <<= 1;
self.c <<= 1;
self.ct -= 1;
if self.ct == 0 {
self.byte_out();
self.ct = 8;
}
}
}
fn code(&mut self, st: &mut u8, d: u8) {
let mps = *st >> 7;
let (qe, nlps, nmps, switch) = QE[usize::from(*st & 0x7F)];
self.a -= qe;
if d == mps {
if self.a < 0x8000 {
if self.a < qe {
self.c += self.a;
self.a = qe;
}
*st = nmps | (mps << 7);
self.renorm();
}
} else {
if self.a >= qe {
self.c += self.a;
self.a = qe;
}
*st = nlps | ((mps ^ u8::from(switch)) << 7);
self.renorm();
}
}
fn code_fixed(&mut self, d: u8) {
let mut st = 0;
self.code(&mut st, d);
}
fn finish(mut self) -> Vec<u8> {
let mut t = (self.c + self.a - 1) & 0xFFFF_0000;
if t < self.c {
t += 0x8000;
}
self.c = t << self.ct;
self.byte_out();
self.c <<= 8;
self.byte_out();
if let Some(b) = self.b {
self.emit(b);
}
for _ in 0..self.stacked {
self.emit(0xFF);
}
// Trailing zero bytes are dropped, as libjpeg's encoder does; the decoder supplies
// zeros at the marker. A zero right after `FF` is stuffing and stays.
while self.out.last() == Some(&0) && self.out.len().checked_sub(2).map(|i| self.out[i]) != Some(0xFF) {
self.out.pop();
}
self.out
}
}
/// Arithmetic conditioning of one entropy table: DC (L, U) and AC Kx (F.1.4.4).
#[derive(Clone, Copy, PartialEq, Eq)]
struct Cond {
l: u8,
u: u8,
kx: u8,
}
/// The conditioning in force without a DAC segment (B.2.4.3).
const DEFAULT_COND: Cond = Cond { l: 0, u: 1, kx: 5 };
/// Statistics per entropy table, DC prediction and context per scan component.
struct EncScan {
dc: [[u8; 64]; 4],
ac: [[u8; 256]; 4],
ctx: [usize; 4],
pred: [i32; 4],
cond: [Cond; 4],
}
impl EncScan {
fn new(cond: [Cond; 4]) -> Self {
EncScan { dc: [[0; 64]; 4], ac: [[0; 256]; 4], ctx: [0; 4], pred: [0; 4], cond }
}
/// Encode_DC_DIFF of scan component `sc` with DC table `t`.
fn dc(&mut self, e: &mut Enc, sc: usize, t: usize, value: i32) {
let diff = value - self.pred[sc];
self.pred[sc] = value;
let st = &mut self.dc[t];
let s0 = self.ctx[sc];
if diff == 0 {
e.code(&mut st[s0], 0);
self.ctx[sc] = 0;
return;
}
e.code(&mut st[s0], 1);
let sign = u8::from(diff < 0);
e.code(&mut st[s0 + 1], sign);
let sp = s0 + 2 + usize::from(sign);
let sz = diff.unsigned_abs() - 1;
let mut m = 0u32;
if sz == 0 {
e.code(&mut st[sp], 0);
} else {
e.code(&mut st[sp], 1);
m = 1;
let mut s = 20;
while m << 1 <= sz {
e.code(&mut st[s], 1);
m <<= 1;
s += 1;
}
e.code(&mut st[s], 0);
let mut bit = m >> 1;
while bit != 0 {
e.code(&mut st[s + 14], u8::from(sz & bit != 0));
bit >>= 1;
}
}
// Conditioning category as libjpeg's jcarith.c computes it, from the magnitude
// category `m` rather than the difference.
let Cond { l, u, .. } = self.cond[t];
self.ctx[sc] = if m < (1 << l) >> 1 {
0
} else if m > (1 << u) >> 1 {
12 + 4 * usize::from(sign)
} else {
4 + 4 * usize::from(sign)
};
}
/// Encode_AC_coefficients with AC table `t` over `ss..=se` of the coefficients scaled down
/// by `al`.
fn ac(&mut self, e: &mut Enc, t: usize, blk: &[i32; 64], ss: usize, se: usize, al: u8) {
let kx = usize::from(self.cond[t].kx);
let st = &mut self.ac[t];
let v = |k: usize| if blk[k] < 0 { -((-blk[k]) >> al) } else { blk[k] >> al };
let last = (ss..=se).rev().find(|&k| v(k) != 0);
let mut k = ss;
while k <= se {
if last.is_none_or(|l| k > l) {
e.code(&mut st[3 * (k - 1)], 1);
return;
}
e.code(&mut st[3 * (k - 1)], 0);
while v(k) == 0 {
e.code(&mut st[3 * (k - 1) + 1], 0);
k += 1;
}
e.code(&mut st[3 * (k - 1) + 1], 1);
e.code_fixed(u8::from(v(k) < 0));
let sz = v(k).unsigned_abs() - 1;
let sp = 3 * (k - 1) + 2;
if sz == 0 {
e.code(&mut st[sp], 0);
} else {
e.code(&mut st[sp], 1);
if sz == 1 {
e.code(&mut st[sp], 0);
} else {
e.code(&mut st[sp], 1);
let mut m = 2u32;
let mut s = if k <= kx { 189 } else { 217 };
while m << 1 <= sz {
e.code(&mut st[s], 1);
m <<= 1;
s += 1;
}
e.code(&mut st[s], 0);
let mut bit = m >> 1;
while bit != 0 {
e.code(&mut st[s + 14], u8::from(sz & bit != 0));
bit >>= 1;
}
}
}
k += 1;
}
}
/// Successive approximation of AC coefficients with table `t`: bit `al` of each (G.1.3.3).
fn ac_refine(&mut self, e: &mut Enc, t: usize, blk: &[i32; 64], ss: usize, se: usize, al: u8) {
let st = &mut self.ac[t];
let prior = |k: usize| blk[k].unsigned_abs() >> (al + 1) != 0;
let now = |k: usize| blk[k].unsigned_abs() >> al != 0;
let eob_prev = (ss..=se).rev().find(|&k| prior(k)).map_or(ss, |k| k + 1);
let eob_now = (ss..=se).rev().find(|&k| now(k)).map_or(ss, |k| k + 1);
let mut k = ss;
while k <= se {
if k >= eob_prev {
if k >= eob_now {
e.code(&mut st[3 * (k - 1)], 1);
return;
}
e.code(&mut st[3 * (k - 1)], 0);
}
loop {
if prior(k) {
e.code(&mut st[3 * (k - 1) + 2], ((blk[k].unsigned_abs() >> al) & 1) as u8);
break;
}
if now(k) {
e.code(&mut st[3 * (k - 1) + 1], 1);
e.code_fixed(u8::from(blk[k] < 0));
break;
}
e.code(&mut st[3 * (k - 1) + 1], 0);
k += 1;
}
k += 1;
}
}
}
/// Deterministic pseudo-random coefficients: mostly small, zero runs, a few large.
fn coefficients(n: usize, seed: u32, big: i32) -> Vec<[i32; 64]> {
let mut s = seed;
let mut next = move || {
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
s >> 8
};
(0..n)
.map(|_| {
let mut b = [0i32; 64];
b[0] = (next() % (2 * big as u32 + 1)) as i32 - big;
for v in b.iter_mut().skip(1) {
let mag = match next() % 100 {
0..=59 => 0,
60..=89 => 1 + (next() % 4) as i32,
90..=97 => 1 + (next() % 60) as i32,
_ => 1 + (next() % big as u32) as i32,
};
*v = if next() % 2 == 0 { mag } else { -mag };
}
if next() % 3 == 0 {
for v in &mut b[20..] {
*v = 0;
}
}
b
})
.collect()
}
fn segment(out: &mut Vec<u8>, marker: u8, body: &[u8]) {
out.extend_from_slice(&[0xFF, marker]);
out.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(body);
}
/// A component of a test frame: id, sampling factors, quantization table slot, and the
/// coefficients of its block grid padded to whole MCUs, row-major.
struct TestComp {
id: u8,
h: usize,
v: usize,
tq: u8,
blocks: Vec<[i32; 64]>,
}
/// A scan of a test frame: frame component indexes, `(ss, se, ah, al)`, and segments
/// written just before its header.
struct TestScan {
comps: Vec<usize>,
band: (usize, usize, u8, u8),
before: Vec<u8>,
}
/// An arithmetic-coded frame (SOF9, or SOF10 when `progressive`). Component `ci` uses entropy
/// tables `min(ci, 1)`, as libjpeg's defaults do; a DAC segment is written for the tables
/// whose `cond` isn't the default, and quantization slot 0 holds all ones until a scan's
/// `before` replaces it.
struct TestFrame {
precision: u8,
progressive: bool,
width: usize,
height: usize,
comps: Vec<TestComp>,
cond: [Cond; 4],
ri: usize,
scans: Vec<TestScan>,
}
impl TestFrame {
fn encode(&self) -> Vec<u8> {
let mut s = vec![0xFF, 0xD8];
let mut dqt = vec![0u8];
dqt.extend([1u8; 64]);
segment(&mut s, 0xDB, &dqt);
let mut sof = vec![self.precision];
sof.extend((self.height as u16).to_be_bytes());
sof.extend((self.width as u16).to_be_bytes());
sof.push(self.comps.len() as u8);
for c in &self.comps {
sof.extend([c.id, (c.h << 4 | c.v) as u8, c.tq]);
}
segment(&mut s, if self.progressive { 0xCA } else { 0xC9 }, &sof);
let mut dac = Vec::new();
for (t, c) in self.cond.iter().enumerate().filter(|(_, c)| **c != DEFAULT_COND) {
dac.extend([t as u8, c.l | c.u << 4, 0x10 | t as u8, c.kx]);
}
if !dac.is_empty() {
segment(&mut s, 0xCC, &dac);
}
if self.ri > 0 {
segment(&mut s, 0xDD, &(self.ri as u16).to_be_bytes());
}
let hmax = self.comps.iter().map(|c| c.h).max().unwrap();
let vmax = self.comps.iter().map(|c| c.v).max().unwrap();
let mcux = self.width.div_ceil(8 * hmax);
let mcuy = self.height.div_ceil(8 * vmax);
for scan in &self.scans {
s.extend(&scan.before);
let (ss, se, ah, al) = scan.band;
let mut sos = vec![scan.comps.len() as u8];
for &ci in &scan.comps {
let t = ci.min(1) as u8;
sos.extend([self.comps[ci].id, t << 4 | t]);
}
sos.extend([ss as u8, se as u8, ah << 4 | al]);
segment(&mut s, 0xDA, &sos);
// Blocks in coding order (A.2): `(scan component, block)` for each MCU.
let mut mcus: Vec<Vec<(usize, &[i32; 64])>> = Vec::new();
if let [ci] = scan.comps[..] {
let c = &self.comps[ci];
let bw = mcux * c.h;
let (cw, ch) = ((self.width * c.h).div_ceil(hmax), (self.height * c.v).div_ceil(vmax));
for by in 0..ch.div_ceil(8) {
for bx in 0..cw.div_ceil(8) {
mcus.push(vec![(0, &c.blocks[by * bw + bx])]);
}
}
} else {
for my in 0..mcuy {
for mx in 0..mcux {
let mut mcu = Vec::new();
for (sc, &ci) in scan.comps.iter().enumerate() {
let c = &self.comps[ci];
for by in my * c.v..(my + 1) * c.v {
for bx in mx * c.h..(mx + 1) * c.h {
mcu.push((sc, &c.blocks[by * mcux * c.h + bx]));
}
}
}
mcus.push(mcu);
}
}
}
let mut e = Enc::new();
let mut st = EncScan::new(self.cond);
let mut rst = 0u8;
for (n, mcu) in mcus.iter().enumerate() {
if self.ri > 0 && n > 0 && n % self.ri == 0 {
s.extend(std::mem::replace(&mut e, Enc::new()).finish());
s.extend([0xFF, 0xD0 + rst]);
rst = (rst + 1) & 7;
st = EncScan::new(self.cond);
}
for &(sc, blk) in mcu {
let t = scan.comps[sc].min(1);
match (self.progressive, ss, ah) {
(false, _, _) => {
st.dc(&mut e, sc, t, blk[0]);
st.ac(&mut e, t, blk, 1, 63, 0);
}
(true, 0, 0) => st.dc(&mut e, sc, t, blk[0] >> al),
(true, 0, _) => e.code_fixed(((blk[0] >> al) & 1) as u8),
(true, _, 0) => st.ac(&mut e, t, blk, ss, se, al),
(true, _, _) => st.ac_refine(&mut e, t, blk, ss, se, al),
}
}
}
s.extend(e.finish());
}
s.extend([0xFF, 0xD9]);
s
}
}
/// A gray SOF9 stream (SOF10 when there are several scans) holding `blocks` (`bw` × `bh`),
/// with the scans `(ss, se, ah, al)` and restart interval `ri` (in MCUs).
fn arith_stream(precision: u8, bw: usize, bh: usize, blocks: &[[i32; 64]], scans: &[(usize, usize, u8, u8)], ri: usize) -> Vec<u8> {
TestFrame {
precision,
progressive: scans.len() > 1,
width: bw * 8,
height: bh * 8,
comps: vec![TestComp { id: 1, h: 1, v: 1, tq: 0, blocks: blocks.to_vec() }],
cond: [DEFAULT_COND; 4],
ri,
scans: scans.iter().map(|&band| TestScan { comps: vec![0], band, before: Vec::new() }).collect(),
}
.encode()
}
fn decoded_blocks(stream: &[u8]) -> Vec<[i32; 64]> {
let (mut frame, _, damaged) = decode_coefficients(stream, &Limits::default()).unwrap();
assert!(!damaged);
std::mem::take(&mut frame.comps[0].coefs)
}
#[test]
fn arithmetic_sequential_round_trips_coefficients() {
for (precision, big, ri) in [(8u8, 1000, 0usize), (12, 16000, 0), (8, 300, 1), (12, 3000, 5)] {
let blocks = coefficients(12, u32::from(precision) * 7 + ri as u32, big);
let stream = arith_stream(precision, 4, 3, &blocks, &[(0, 63, 0, 0)], ri);
assert_eq!(decoded_blocks(&stream), blocks, "P={precision} ri={ri}");
}
}
#[test]
fn arithmetic_progressive_round_trips_coefficients() {
let blocks = coefficients(6, 99, 2000);
let scans = [(0, 0, 0, 1), (1, 5, 0, 2), (6, 63, 0, 1), (1, 5, 2, 1), (0, 0, 1, 0), (1, 5, 1, 0), (6, 63, 1, 0)];
for ri in [0, 2] {
let stream = arith_stream(8, 3, 2, &blocks, &scans, ri);
assert_eq!(decoded_blocks(&stream), blocks, "ri={ri}");
}
}
/// Every component's coefficients, and whether the stream was damaged.
fn decoded_components(stream: &[u8]) -> (Vec<Vec<[i32; 64]>>, bool) {
let (frame, _, damaged) = decode_coefficients(stream, &Limits::default()).unwrap();
(frame.comps.into_iter().map(|c| c.coefs).collect(), damaged)
}
/// Non-default DC conditioning (L, U) and AC Kx from a DAC segment round-trip, and the decoder
/// applies them: the same data read with the default conditioning comes out different.
#[test]
fn arithmetic_conditioning_from_dac_round_trips() {
let blocks = coefficients(12, 21, 1000);
for (l, u, kx) in [(2, 4, 2), (1, 1, 20), (0, 0, 1), (3, 7, 63), (5, 15, 40)] {
let cond = Cond { l, u, kx };
for progressive in [false, true] {
let bands: &[(usize, usize, u8, u8)] = if progressive { &[(0, 0, 0, 0), (1, 63, 0, 0)] } else { &[(0, 63, 0, 0)] };
let stream = TestFrame {
precision: 8,
progressive,
width: 32,
height: 24,
comps: vec![TestComp { id: 1, h: 1, v: 1, tq: 0, blocks: blocks.clone() }],
cond: [cond, DEFAULT_COND, DEFAULT_COND, DEFAULT_COND],
ri: 0,
scans: bands.iter().map(|&band| TestScan { comps: vec![0], band, before: Vec::new() }).collect(),
}
.encode();
assert_eq!(decoded_blocks(&stream), blocks, "L={l} U={u} Kx={kx} progressive={progressive}");
let dac = stream.windows(2).position(|p| p == [0xFF, 0xCC]).unwrap();
let len = usize::from(u16::from_be_bytes([stream[dac + 2], stream[dac + 3]]));
let mut without = stream.clone();
without.drain(dac..dac + 2 + len);
let differs = decode_coefficients(&without, &Limits::default()).map_or(true, |(f, _, _)| f.comps[0].coefs != blocks);
assert!(differs, "L={l} U={u} Kx={kx} progressive={progressive}: DAC ignored");
}
}
}
/// Interleaved scans of a subsampled frame (2×2 luma, two 1×1 chroma components sharing
/// entropy table 1 with non-default conditioning), sequential with and without restarts, and
/// progressive.
#[test]
fn arithmetic_interleaved_subsampled_round_trips() {
let comps = |seed: u32| {
vec![
TestComp { id: 1, h: 2, v: 2, tq: 0, blocks: coefficients(8, seed, 800) },
TestComp { id: 2, h: 1, v: 1, tq: 0, blocks: coefficients(2, seed + 1, 300) },
TestComp { id: 3, h: 1, v: 1, tq: 0, blocks: coefficients(2, seed + 2, 300) },
]
};
let cond = [DEFAULT_COND, Cond { l: 1, u: 3, kx: 8 }, DEFAULT_COND, DEFAULT_COND];
let scan = |comps: &[usize], band| TestScan { comps: comps.to_vec(), band, before: Vec::new() };
let progressive = vec![
scan(&[0, 1, 2], (0, 0, 0, 1)),
scan(&[0], (1, 63, 0, 0)),
scan(&[2], (1, 63, 0, 0)),
scan(&[1], (1, 63, 0, 0)),
scan(&[0, 1, 2], (0, 0, 1, 0)),
];
for (seed, ri, scans) in [(1, 0, vec![scan(&[0, 1, 2], (0, 63, 0, 0))]), (2, 1, vec![scan(&[0, 1, 2], (0, 63, 0, 0))]), (3, 0, progressive)] {
let frame = TestFrame { precision: 8, progressive: scans.len() > 1, width: 32, height: 16, comps: comps(seed), cond, ri, scans };
let (got, damaged) = decoded_components(&frame.encode());
assert!(!damaged, "seed {seed}");
for (c, want) in got.iter().zip(&frame.comps) {
assert_eq!(c, &want.blocks, "seed {seed} component {}", want.id);
}
}
}
/// Each component dequantizes with the table in its slot at its first scan, not the one the
/// slot holds when the stream ends.
#[test]
fn quantization_tables_are_latched_at_the_first_scan() {
// DC-only blocks: sample = DC × q₀ / 8 + 128.
let mut blk = [0i32; 64];
blk[0] = 40;
let dqt = |q0: u8| {
let mut body = vec![0u8, q0];
body.extend([1u8; 63]);
let mut seg = Vec::new();
segment(&mut seg, 0xDB, &body);
seg
};
let stream = TestFrame {
precision: 8,
progressive: false,
width: 16,
height: 16,
comps: b"RGB".iter().map(|&id| TestComp { id, h: 1, v: 1, tq: 0, blocks: vec![blk; 4] }).collect(),
cond: [DEFAULT_COND; 4],
ri: 0,
scans: [2, 4, 8].iter().enumerate().map(|(ci, &q0)| TestScan { comps: vec![ci], band: (0, 63, 0, 0), before: dqt(q0) }).collect(),
}
.encode();
let img = decode_default(&stream).unwrap();
assert!(!img.damaged);
let Samples::U8(px) = &img.samples else { panic!("16-bit samples") };
assert!(px.chunks(3).all(|p| p == [138, 148, 168]), "{:?}", &px[..3]);
}
#[test]
fn arithmetic_twelve_bit_flat_block_is_sixteen_bit() {
// DC only: sample = DC / 8 + 2048 with a quantizer of 1; 1000 → 2173.
let mut blk = [0i32; 64];
blk[0] = 1000;
let stream = arith_stream(12, 1, 1, &[blk], &[(0, 63, 0, 0)], 0);
assert_eq!(frame(&stream), Some(FrameKind { marker: 0xC9, precision: 12 }));
let img = decode_default(&stream).unwrap();
assert_eq!((img.width, img.height, img.components), (8, 8, 1));
let want = (2173.0f32 * 65535.0 / 4095.0).round() as u16;
assert!(u16_samples(&img).iter().all(|&v| v.abs_diff(want) <= 1));
}
/// A 12-bit SOF1 stream built by hand: one 8×8 gray block with only a DC coefficient. The DC
/// table has one code (`0`, category 13), the AC table one (`0`, end of block).
fn twelve_bit_huffman_block() -> Vec<u8> {
let mut s = vec![0xFF, 0xD8];
let mut dqt = vec![0u8];
dqt.extend([1u8; 64]);
segment(&mut s, 0xDB, &dqt);
segment(&mut s, 0xC1, &[12, 0, 8, 0, 8, 1, 1, 0x11, 0]);
let mut dht = vec![0x00, 1];
dht.extend([0u8; 15]);
dht.push(13);
dht.extend([0x10, 1]);
dht.extend([0u8; 15]);
dht.push(0x00);
segment(&mut s, 0xC4, &dht);
segment(&mut s, 0xDA, &[1, 1, 0x00, 0, 63, 0]);
// DC = 7616 → sample 7616 / 8 + 2048 = 3000. Bits: `0`, 7616 in 13 bits, `0`, one pad `1`.
let word: u16 = (7616 << 2) | 1;
s.extend(word.to_be_bytes());
s.extend([0xFF, 0xD9]);
s
}
#[test]
fn twelve_bit_huffman_block_decodes_to_sixteen_bit() {
let stream = twelve_bit_huffman_block();
assert_eq!(frame(&stream), Some(FrameKind { marker: 0xC1, precision: 12 }));
let img = decode_default(&stream).unwrap();
assert_eq!((img.width, img.height), (8, 8));
let want = (3000.0f32 * 65535.0 / 4095.0).round() as u16;
let px = u16_samples(&img);
assert!(px.iter().all(|&v| v.abs_diff(want) <= 1), "{:?} vs {want}", &px[..4]);
}
/// APPn segments before the frame (here an APP13 `8BIM` block right after SOI) are skipped by
/// the probe and the decoder alike; reading them is the caller's job.
#[test]
fn twelve_bit_jpeg_with_photoshop_resources_decodes() {
let resources = b"8BIM\x04\x04\0\0\0\0\0\0".to_vec();
let mut app13 = b"Photoshop 3.0\0".to_vec();
app13.extend_from_slice(&resources);
let stream = twelve_bit_huffman_block();
let mut with = stream[..2].to_vec();
segment(&mut with, 0xED, &app13);
with.extend_from_slice(&stream[2..]);
assert_eq!(frame(&with), frame(&stream));
assert_eq!(decode_default(&with).unwrap(), decode_default(&stream).unwrap());
}
/// Where the entropy-coded data of the last scan starts.
fn last_scan_data(s: &[u8]) -> usize {
let sos = s.windows(2).rposition(|p| p == [0xFF, 0xDA]).unwrap();
sos + 2 + usize::from(u16::from_be_bytes([s[sos + 2], s[sos + 3]]))
}
/// A Huffman scan whose entropy data runs out is not a clean image, even with EOI present:
/// the zero padding supplied at the marker must not be decoded as real coefficients.
#[test]
fn huffman_scan_out_of_data_is_not_clean() {
let full = twelve_bit_huffman_block();
let data = last_scan_data(&full);
for keep in 0..2 {
let mut s = full[..data + keep].to_vec();
s.extend([0xFF, 0xD9]);
assert!(decode_default(&s).is_err(), "keep {keep}");
}
// A multi-MCU scan cut in half, EOI kept, decodes as damaged.
let (w, h) = (64usize, 64usize);
let rgb: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 4) as u8, (i / w * 4) as u8, (i * 7) as u8]).collect();
let mut out = Vec::new();
jpeg_encoder::Encoder::new(&mut out, 90).encode(&rgb, w as u16, h as u16, jpeg_encoder::ColorType::Rgb).unwrap();
let sof = out.windows(2).position(|p| p == [0xFF, 0xC0]).unwrap();
out[sof + 1] = 0xC1;
let data = last_scan_data(&out);
let end = out.len() - 2;
let mut cut = out[..data + (end - data) / 2].to_vec();
if cut.last() == Some(&0xFF) {
cut.pop();
}
cut.extend([0xFF, 0xD9]);
assert!(!decode(&out, &Limits::default(), None).unwrap().damaged);
assert!(decode(&cut, &Limits::default(), None).unwrap().damaged);
}
/// Arithmetic data that runs to the end of the file, or loses a restart marker, is reported
/// damaged, and the blocks before the cut are kept. Data cut short before EOI with no restart
/// interval reads as dropped trailing zeros and can't be detected.
#[test]
fn arithmetic_scan_out_of_data_is_damaged() {
let blocks = coefficients(64, 11, 500);
for ri in [0, 2] {
let full = arith_stream(8, 8, 8, &blocks, &[(0, 63, 0, 0)], ri);
assert_eq!(decoded_blocks(&full), blocks, "ri={ri}");
let data = last_scan_data(&full);
let end = full.len() - 2;
for cut in [data + (end - data) / 2, data + 1] {
let mut s = full[..cut].to_vec();
if s.last() == Some(&0xFF) {
s.pop();
}
for eoi in [false, true] {
if eoi && ri == 0 {
continue;
}
let mut s = s.clone();
if eoi {
s.extend([0xFF, 0xD9]);
}
// With one byte of data the first MCU fails, which is an error, not damage.
match decode_coefficients(&s, &Limits::default()) {
Ok((frame, _, damaged)) => {
assert!(damaged, "ri={ri} eoi={eoi} cut at {}", cut - data);
if cut > data + 1 {
assert_eq!(frame.comps[0].coefs[0], blocks[0], "ri={ri} eoi={eoi}");
}
}
Err(_) => assert_eq!(cut, data + 1, "ri={ri} eoi={eoi}"),
}
}
}
}
}
/// A baseline file from `jpeg-encoder`, relabeled SOF1, decodes like zune decodes the original.
#[test]
fn extended_huffman_matches_zune_on_baseline_data() {
let (w, h) = (37u16, 29u16);
let rgb: Vec<u8> = (0..usize::from(w) * usize::from(h))
.flat_map(|i| {
let (x, y) = (i % usize::from(w), i / usize::from(w));
[(x * 7) as u8, (y * 9) as u8, ((x + y) * 3) as u8]
})
.collect();
for (sampling, restart) in
[(jpeg_encoder::SamplingFactor::R_4_2_0, 0u16), (jpeg_encoder::SamplingFactor::R_4_4_4, 3), (jpeg_encoder::SamplingFactor::R_4_2_2, 1)]
{
let mut out = Vec::new();
let mut enc = jpeg_encoder::Encoder::new(&mut out, 90);
enc.set_sampling_factor(sampling);
if restart > 0 {
enc.set_restart_interval(restart);
}
enc.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb).unwrap();
let (zune, _) = zune_decode(&out);
let sof = out.windows(2).position(|p| p == [0xFF, 0xC0]).unwrap();
out[sof + 1] = 0xC1;
let ours = decode(&out, &Limits::default(), None).unwrap();
assert_eq!((ours.width, ours.height, ours.components), (u32::from(w), u32::from(h), 3));
assert!(!ours.damaged);
let Samples::U8(data) = &ours.samples else { panic!("16-bit samples") };
let mad = data.iter().zip(&zune).map(|(&a, &b)| f32::from(a.abs_diff(b))).sum::<f32>() / data.len() as f32;
assert!(mad < 1.5, "{sampling:?} restart {restart}: mean abs diff {mad}");
}
}
#[test]
fn malformed_streams_return_errors_not_panics() {
let blocks = coefficients(6, 5, 500);
let mut streams = vec![
arith_stream(12, 3, 2, &blocks, &[(0, 63, 0, 0)], 2),
arith_stream(8, 3, 2, &blocks, &[(0, 0, 0, 1), (1, 63, 0, 0), (0, 0, 1, 0)], 0),
twelve_bit_huffman_block(),
];
// Header damage: zero sizes, bad component counts, sampling factors and tables, bad DAC.
let base = arith_stream(8, 1, 1, &blocks[..1], &[(0, 63, 0, 0)], 0);
let sof = base.windows(2).position(|p| p == [0xFF, 0xC9]).unwrap();
for (offset, value) in [(4, 16u8), (6, 0), (8, 0), (9, 7), (10, 9), (11, 0x50), (12, 9)] {
let mut s = base.clone();
s[sof + offset] = value;
assert!(decode_default(&s).is_err(), "SOF byte {offset} = {value}");
streams.push(s);
}
let mut dac = base.clone();
dac.splice(2..2, [0xFF, 0xCC, 0, 4, 0x00, 0x13]);
assert!(decode_default(&dac).is_err());
streams.push(dac);
for s in &streams {
for cut in 0..s.len() {
let _ = decode_default(&s[..cut]);
}
for i in 2..s.len() {
for v in [0x00, 0xFF, 0x5A] {
let mut t = s.clone();
t[i] = v;
let _ = decode_default(&t);
}
}
}
assert!(decode_default(&streams[0][..40]).is_err());
}
/// Scans past the block budget (`SCAN_PASSES` passes over a one-block frame) aren't decoded:
/// the image keeps what the earlier scans decoded and is reported damaged.
#[test]
fn scans_past_the_block_budget_are_not_decoded() {
let blocks = coefficients(1, 3, 500);
for (count, damaged) in [(SCAN_PASSES, false), (SCAN_PASSES + 1, true)] {
let stream = arith_stream(8, 1, 1, &blocks, &vec![(0, 0, 0, 0); count], 0);
let (frame, _, d) = decode_coefficients(&stream, &Limits::default()).unwrap();
assert_eq!(d, damaged, "{count} scans");
assert_eq!(frame.comps[0].coefs[0][0], blocks[0][0], "{count} scans");
}
}
#[test]
fn limits_are_checked_before_allocating() {
let mut s = vec![0xFF, 0xD8];
segment(&mut s, 0xC9, &[8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x44, 0, 2, 0x11, 0, 3, 0x11, 0]);
let small = Limits { max_alloc: 1 << 20, ..Limits::default() };
assert!(matches!(decode(&s, &small, None), Err(Error::LimitExceeded(_))));
// 64×64 gray: 32 KiB of coefficients and plane plus 4 KiB of output, each under the limit
// alone but not together.
let stream = arith_stream(8, 8, 8, &coefficients(64, 1, 100), &[(0, 63, 0, 0)], 0);
for (max_alloc, ok) in [(36 << 10, true), ((36 << 10) - 1, false), (32 << 10, false)] {
let limits = Limits { max_alloc, ..Limits::default() };
assert_eq!(decode(&stream, &limits, None).is_ok(), ok, "max_alloc {max_alloc}");
}
}
#[test]
fn frame_reports_the_marker_and_precision() {
let kind = |marker, precision| Some(FrameKind { marker, precision });
assert_eq!(frame(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]), kind(0xC0, 8));
assert_eq!(frame(&[0xFF, 0xD8, 0xFF, 0xC9, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]), kind(0xC9, 8));
assert_eq!(frame(&[0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0]), kind(0xC1, 12));
assert_eq!(frame(&[0xFF, 0xD8]), None);
}
+128
View File
@@ -0,0 +1,128 @@
//! Decodes of the libjpeg-turbo test images (tests/data, see SOURCE.md there).
use craft_jpeg::{Decoded, Limits, Samples, decode};
fn fixture(name: &str) -> Vec<u8> {
let path = format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR"));
std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}"))
}
fn decode_clean(name: &str) -> Decoded {
let img = decode(&fixture(name), &Limits::default(), None).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(!img.damaged, "{name}");
img
}
fn decode_fixture(name: &str) -> Decoded {
let img = decode_clean(name);
assert_eq!((img.width, img.height, img.components), (227, 149, 3), "{name}");
img
}
/// Mean and maximum absolute difference.
fn diff(a: impl Iterator<Item = f64>, b: impl Iterator<Item = f64>) -> (f64, f64) {
let (mut sum, mut max, mut n) = (0.0, 0.0f64, 0usize);
for (a, b) in a.zip(b) {
let d = (a - b).abs();
sum += d;
max = max.max(d);
n += 1;
}
(sum / n.max(1) as f64, max)
}
/// testimgari-huffman.jpg is testimgari.jpg transcoded by jpegtran: the same coefficients, only
/// the entropy coding differs, so the samples must be identical.
#[test]
fn arithmetic_coded_image_matches_its_huffman_transcode() {
let huffman = decode_fixture("testimgari-huffman.jpg");
let arithmetic = decode_fixture("testimgari.jpg");
assert!(matches!(huffman.samples, Samples::U8(_)));
assert!(huffman.samples == arithmetic.samples, "testimgari.jpg decodes differently from testimgari-huffman.jpg");
}
/// The arithmetic and progressive arithmetic transcodes jpegtran made of testorig.jpg and
/// testorig12.jpg hold their coefficients, so they decode to exactly the same samples.
#[test]
fn arithmetic_transcodes_match_their_huffman_sources() {
for (source, transcodes) in [
("testorig.jpg", &["testorig-ari-progressive.jpg", "testorig-ari-progressive-restart.jpg"]),
("testorig12.jpg", &["testorig12-ari.jpg", "testorig12-ari-progressive.jpg"]),
] {
let reference = decode_fixture(source);
for name in transcodes {
assert!(decode_fixture(name).samples == reference.samples, "{name} decodes differently from {source}");
}
}
}
/// libjpeg's arithmetic encoder drops the trailing zero bytes of a scan, so the white lower
/// part of whitetail-ari.jpg decodes from zeros supplied at the EOI marker. It must decode
/// whole and match its Huffman transcode.
#[test]
fn arithmetic_white_tail_decodes_from_implied_zeros() {
let arithmetic = decode_clean("whitetail-ari.jpg");
assert_eq!((arithmetic.width, arithmetic.height, arithmetic.components), (512, 512, 1));
assert!(decode_clean("whitetail-huffman.jpg").samples == arithmetic.samples, "whitetail-ari.jpg decodes differently from whitetail-huffman.jpg");
}
/// The 8-bit path against zune-jpeg. Both use centered linear chroma upsampling; the residual is
/// float against integer IDCT and color conversion.
#[test]
fn baseline_image_matches_zune_jpeg() {
let bytes = fixture("testorig.jpg");
let mut zune = zune_jpeg::JpegDecoder::new(zune_jpeg::zune_core::bytestream::ZCursor::new(&bytes[..]));
let reference = zune.decode().unwrap_or_else(|e| panic!("zune-jpeg: {e:?}"));
let img = decode_fixture("testorig.jpg");
let Samples::U8(px) = &img.samples else { panic!("testorig.jpg: expected 8-bit samples") };
assert_eq!(px.len(), reference.len());
let (mean, max) = diff(px.iter().map(|&v| f64::from(v)), reference.iter().map(|&v| f64::from(v)));
eprintln!("testorig.jpg vs zune-jpeg: mean {mean:.3}, max {max}");
assert!(mean < MAX_MEAN_8 && max <= MAX_ABS_8, "mean {mean}, max {max}");
}
// Observed: mean 0.52, max 3.
const MAX_MEAN_8: f64 = 0.75;
const MAX_ABS_8: f64 = 4.0;
/// Parse a binary PPM with 16-bit big-endian samples.
fn ppm16(bytes: &[u8]) -> (u32, u32, u32, Vec<u16>) {
let mut fields = Vec::new();
let mut at = 0;
while fields.len() < 4 {
while bytes.get(at).is_some_and(u8::is_ascii_whitespace) {
at += 1;
}
let start = at;
while bytes.get(at).is_some_and(|b| !b.is_ascii_whitespace()) {
at += 1;
}
fields.push(std::str::from_utf8(&bytes[start..at]).unwrap_or_default().to_owned());
}
assert_eq!(fields[0], "P6");
let num = |s: &str| s.parse::<u32>().unwrap_or_else(|e| panic!("PPM header {s}: {e}"));
let (w, h, maxval) = (num(&fields[1]), num(&fields[2]), num(&fields[3]));
let data = &bytes[at + 1..];
let px: Vec<u16> = data.as_chunks::<2>().0.iter().map(|&c| u16::from_be_bytes(c)).collect();
assert_eq!(px.len(), (w * h * 3) as usize);
(w, h, maxval, px)
}
/// testorig12.jpg against libjpeg-turbo's decode (testorig12.ppm, 12-bit RGB; SOURCE.md says how
/// it was made). libjpeg-turbo uses an integer IDCT and integer color conversion, craft-jpeg
/// floats; the comparison is on the 12-bit scale.
#[test]
fn twelve_bit_image_matches_libjpeg_turbo() {
let (w, h, maxval, reference) = ppm16(&fixture("testorig12.ppm"));
assert_eq!((w, h, maxval), (227, 149, 4095));
let img = decode_fixture("testorig12.jpg");
let Samples::U16(px) = &img.samples else { panic!("testorig12.jpg: expected 16-bit samples") };
assert_eq!(px.len(), reference.len());
let (mean, max) = diff(px.iter().map(|&v| f64::from(v) * 4095.0 / 65535.0), reference.iter().map(|&v| f64::from(v)));
eprintln!("testorig12.jpg vs libjpeg-turbo (12-bit units): mean {mean:.3}, max {max:.2}");
assert!(mean < MAX_MEAN_12 && max <= MAX_ABS_12, "mean {mean}, max {max}");
}
// Observed: mean 0.50, max 2.46 (of 4095).
const MAX_MEAN_12: f64 = 0.75;
const MAX_ABS_12: f64 = 4.0;
+258
View File
@@ -0,0 +1,258 @@
libjpeg-turbo note: This file has been modified by The libjpeg-turbo Project
to include only information relevant to libjpeg-turbo, to wordsmith certain
sections, and to remove impolitic language that existed in the libjpeg v8
README. It is included only for reference. Please see README.md for
information specific to libjpeg-turbo.
The Independent JPEG Group's JPEG software
==========================================
This distribution contains a release of the Independent JPEG Group's free JPEG
software. You are welcome to redistribute this software and to use it for any
purpose, subject to the conditions under LEGAL ISSUES, below.
This software is the work of Tom Lane, Guido Vollbeding, Philip Gladstone,
Bill Allombert, Jim Boucher, Lee Crocker, Bob Friesenhahn, Ben Jackson,
Julian Minguillon, Luis Ortiz, George Phillips, Davide Rossi, Ge' Weijers,
and other members of the Independent JPEG Group.
IJG is not affiliated with the ISO/IEC JTC1/SC29/WG1 standards committee
(also known as JPEG, together with ITU-T SG16).
DOCUMENTATION ROADMAP
=====================
This file contains the following sections:
OVERVIEW General description of JPEG and the IJG software.
LEGAL ISSUES Copyright, lack of warranty, terms of distribution.
REFERENCES Where to learn more about JPEG.
ARCHIVE LOCATIONS Where to find newer versions of this software.
FILE FORMAT WARS Software *not* to get.
TO DO Plans for future IJG releases.
Other documentation files in the distribution are:
User documentation:
usage.txt Usage instructions for cjpeg, djpeg, jpegtran,
rdjpgcom, and wrjpgcom.
*.1 Unix-style man pages for programs (same info as usage.txt).
wizard.txt Advanced usage instructions for JPEG wizards only.
change.log Version-to-version change highlights.
Programmer and internal documentation:
libjpeg.txt How to use the JPEG library in your own programs.
example.c Sample code for calling the JPEG library.
structure.txt Overview of the JPEG library's internal structure.
coderules.txt Coding style rules --- please read if you contribute code.
Please read at least usage.txt. Some information can also be found in the JPEG
FAQ (Frequently Asked Questions) article. See ARCHIVE LOCATIONS below to find
out where to obtain the FAQ article.
If you want to understand how the JPEG code works, we suggest reading one or
more of the REFERENCES, then looking at the documentation files (in roughly
the order listed) before diving into the code.
OVERVIEW
========
This package contains C software to implement JPEG image encoding, decoding,
and transcoding. JPEG (pronounced "jay-peg") is a standardized compression
method for full-color and grayscale images. JPEG's strong suit is compressing
photographic images or other types of images that have smooth color and
brightness transitions between neighboring pixels. Images with sharp lines or
other abrupt features may not compress well with JPEG, and a higher JPEG
quality may have to be used to avoid visible compression artifacts with such
images.
JPEG is normally lossy, meaning that the output pixels are not necessarily
identical to the input pixels. However, on photographic content and other
"smooth" images, very good compression ratios can be obtained with no visible
compression artifacts, and extremely high compression ratios are possible if
you are willing to sacrifice image quality (by reducing the "quality" setting
in the compressor.)
This software implements JPEG baseline, extended-sequential, progressive, and
lossless compression processes. Provision is made for supporting all variants
of these processes, although some uncommon parameter settings aren't
implemented yet. We have made no provision for supporting the hierarchical
processes defined in the standard.
We provide a set of library routines for reading and writing JPEG image files,
plus two sample applications "cjpeg" and "djpeg", which use the library to
perform conversion between JPEG and some other popular image file formats.
The library is intended to be reused in other applications.
In order to support file conversion and viewing software, we have included
considerable functionality beyond the bare JPEG coding/decoding capability;
for example, the color quantization modules are not strictly part of JPEG
decoding, but they are essential for output to colormapped file formats or
colormapped displays. These extra functions can be compiled out of the
library if not required for a particular application.
We have also included "jpegtran", a utility for lossless transcoding between
different JPEG processes, and "rdjpgcom" and "wrjpgcom", two simple
applications for inserting and extracting textual comments in JFIF files.
The emphasis in designing this software has been on achieving portability and
flexibility, while also making it fast enough to be useful. In particular,
the software is not intended to be read as a tutorial on JPEG. (See the
REFERENCES section for introductory material.) Rather, it is intended to
be reliable, portable, industrial-strength code. We do not claim to have
achieved that goal in every aspect of the software, but we strive for it.
We welcome the use of this software as a component of commercial products.
No royalty is required, but we do ask for an acknowledgement in product
documentation, as described under LEGAL ISSUES.
LEGAL ISSUES
============
In plain English:
1. We don't promise that this software works. (But if you find any bugs,
please let us know!)
2. You can use this software for whatever you want. You don't have to pay us.
3. You may not pretend that you wrote this software. If you use it in a
program, you must acknowledge somewhere in your documentation that
you've used the IJG code.
In legalese:
The authors make NO WARRANTY or representation, either express or implied,
with respect to this software, its quality, accuracy, merchantability, or
fitness for a particular purpose. This software is provided "AS IS", and you,
its user, assume the entire risk as to its quality and accuracy.
This software is copyright (C) 1991-2020, Thomas G. Lane, Guido Vollbeding.
All Rights Reserved except as specified below.
Permission is hereby granted to use, copy, modify, and distribute this
software (or portions thereof) for any purpose, without fee, subject to these
conditions:
(1) If any part of the source code for this software is distributed, then this
README file must be included, with this copyright and no-warranty notice
unaltered; and any additions, deletions, or changes to the original files
must be clearly indicated in accompanying documentation.
(2) If only executable code is distributed, then the accompanying
documentation must state that "this software is based in part on the work of
the Independent JPEG Group".
(3) Permission for use of this software is granted only if the user accepts
full responsibility for any undesirable consequences; the authors accept
NO LIABILITY for damages of any kind.
These conditions apply to any software derived from or based on the IJG code,
not just to the unmodified library. If you use our work, you ought to
acknowledge us.
Permission is NOT granted for the use of any IJG author's name or company name
in advertising or publicity relating to this software or products derived from
it. This software may be referred to only as "the Independent JPEG Group's
software".
We specifically permit and encourage the use of this software as the basis of
commercial products, provided that all warranty or liability claims are
assumed by the product vendor.
REFERENCES
==========
We recommend reading one or more of these references before trying to
understand the innards of the JPEG software.
The best short technical introduction to the JPEG compression algorithm is
Wallace, Gregory K. "The JPEG Still Picture Compression Standard",
Communications of the ACM, April 1991 (vol. 34 no. 4), pp. 30-44.
(Adjacent articles in that issue discuss MPEG motion picture compression,
applications of JPEG, and related topics.) If you don't have the CACM issue
handy, a PDF file containing a revised version of Wallace's article is
available at http://www.ijg.org/files/Wallace.JPEG.pdf. The file (actually
a preprint for an article that appeared in IEEE Trans. Consumer Electronics)
omits the sample images that appeared in CACM, but it includes corrections
and some added material. Note: the Wallace article is copyright ACM and IEEE,
and it may not be used for commercial purposes.
A somewhat less technical, more leisurely introduction to JPEG can be found in
"The Data Compression Book" by Mark Nelson and Jean-loup Gailly, published by
M&T Books (New York), 2nd ed. 1996, ISBN 1-55851-434-1. This book provides
good explanations and example C code for a multitude of compression methods
including JPEG. It is an excellent source if you are comfortable reading C
code but don't know much about data compression in general. The book's JPEG
sample code is far from industrial-strength, but when you are ready to look
at a full implementation, you've got one here...
The best currently available description of JPEG is the textbook "JPEG Still
Image Data Compression Standard" by William B. Pennebaker and Joan L.
Mitchell, published by Van Nostrand Reinhold, 1993, ISBN 0-442-01272-1.
Price US$59.95, 638 pp. The book includes the complete text of the ISO JPEG
standards (DIS 10918-1 and draft DIS 10918-2).
The original JPEG standard is divided into two parts, Part 1 being the actual
specification, while Part 2 covers compliance testing methods. Part 1 is
titled "Digital Compression and Coding of Continuous-tone Still Images,
Part 1: Requirements and guidelines" and has document numbers ISO/IEC IS
10918-1, ITU-T T.81. Part 2 is titled "Digital Compression and Coding of
Continuous-tone Still Images, Part 2: Compliance testing" and has document
numbers ISO/IEC IS 10918-2, ITU-T T.83.
The JPEG standard does not specify all details of an interchangeable file
format. For the omitted details, we follow the "JFIF" conventions, revision
1.02. JFIF version 1 has been adopted as ISO/IEC 10918-5 (05/2013) and
Recommendation ITU-T T.871 (05/2011): Information technology - Digital
compression and coding of continuous-tone still images: JPEG File Interchange
Format (JFIF). It is available as a free download in PDF file format from
https://www.iso.org/standard/54989.html and http://www.itu.int/rec/T-REC-T.871.
A PDF file of the older JFIF 1.02 specification is available at
http://www.w3.org/Graphics/JPEG/jfif3.pdf.
The TIFF 6.0 file format specification can be obtained from
http://mirrors.ctan.org/graphics/tiff/TIFF6.ps.gz. The JPEG incorporation
scheme found in the TIFF 6.0 spec of 3-June-92 has a number of serious
problems. IJG does not recommend use of the TIFF 6.0 design (TIFF Compression
tag 6). Instead, we recommend the JPEG design proposed by TIFF Technical Note
#2 (Compression tag 7). Copies of this Note can be obtained from
http://www.ijg.org/files/. It is expected that the next revision
of the TIFF spec will replace the 6.0 JPEG design with the Note's design.
Although IJG's own code does not support TIFF/JPEG, the free libtiff library
uses our library to implement TIFF/JPEG per the Note.
ARCHIVE LOCATIONS
=================
The "official" archive site for this software is www.ijg.org.
The most recent released version can always be found there in
directory "files".
The JPEG FAQ (Frequently Asked Questions) article is a source of some
general information about JPEG. It is available at
http://www.faqs.org/faqs/jpeg-faq.
FILE FORMAT COMPATIBILITY
=========================
This software implements ITU T.81 | ISO/IEC 10918 with some extensions from
ITU T.871 | ISO/IEC 10918-5 (JPEG File Interchange Format-- see REFERENCES).
Informally, the term "JPEG image" or "JPEG file" most often refers to JFIF or
a subset thereof, but there are other formats containing the name "JPEG" that
are incompatible with the original JPEG standard or with JFIF (for instance,
JPEG 2000 and JPEG XR). This software therefore does not support these
formats. Indeed, one of the original reasons for developing this free software
was to help force convergence on a common, interoperable format standard for
JPEG files.
JFIF is a minimal or "low end" representation. TIFF/JPEG (TIFF revision 6.0 as
modified by TIFF Technical Note #2) can be used for "high end" applications
that need to record a lot of additional data about an image.
TO DO
=====
Please send bug reports, offers of help, etc. to jpeg-info@jpegclub.org.
+11
View File
@@ -0,0 +1,11 @@
4672c7f08864cd0a8c73a4fa4b66ca32b635d38464551c1ecf06564ae8c89b38 testimgari.jpg
491679b8057739b3c8e5bacd1e918efb1691d271cbbd69820ff8d480dcb90963 testimgari-huffman.jpg
acc6ec555d41d15b368320edaa3b20958ee6fa97cb6e4a18d1213d5ae8bec73b testorig.jpg
34790770f76db8e60a7765b52ca4edf5f16bc21bcb8c6045ca2efef39a8a013e testorig12.jpg
62d182ed042e243544d3d753c440ada1a6992464717311fe6efd546632f44dc4 testorig12.ppm
0031b82a06300b5b5d19ef5553cc39d3fafafa4fbc474199e6cfbc7818312a87 whitetail-ari.jpg
0f6e17a44be7a005d6610e66f91fa119388dc6b8438e95cb8816b6db58f8c365 whitetail-huffman.jpg
7e7edd9d9825c2eca1e6ff6dfafeaddec39e2464fc5d6d1ce4a378a59ec3f716 testorig-ari-progressive.jpg
cb0a2c60e6a89c68ef8cf48854dc09c77c26ffb27ac62e17d343d98f1424cc77 testorig-ari-progressive-restart.jpg
c4942c4b704d20e27c4cd3e613c6ef975dd7cfe53a756e2c209fa3ad033d8b51 testorig12-ari.jpg
18df0394115d9f8751939fb9f45a0d330df2ca849e9f8b07d0af76c09b90fcdb testorig12-ari-progressive.jpg
+81
View File
@@ -0,0 +1,81 @@
# Test images
The JPEG files come from libjpeg-turbo 3.0.0, `testimages/`
(https://github.com/libjpeg-turbo/libjpeg-turbo/tree/3.0.0/testimages), under the IJG License
(README.ijg in this directory). The other files are derived from them, or made with
libjpeg-turbo's tools, as described below.
`SHA256SUMS` lists every file.
| File | What it is |
|---|---|
| `testorig.jpg` | 227×149 YCbCr, 2×2 luma sampling, baseline Huffman (SOF0) |
| `testimgari.jpg` | the same image, arithmetic coded (SOF9), one interleaved scan |
| `testimgari-huffman.jpg` | `testimgari.jpg` losslessly transcoded to baseline Huffman (SOF0) |
| `testorig12.jpg` | 227×149 YCbCr, 12-bit extended sequential Huffman (SOF1) |
| `testorig12.ppm` | libjpeg-turbo's decode of `testorig12.jpg`: 12-bit RGB, binary PPM, maxval 4095 |
| `testorig-ari-progressive.jpg` | `testorig.jpg` transcoded to progressive arithmetic (SOF10) |
| `testorig-ari-progressive-restart.jpg` | the same with a restart interval of 3 MCU rows |
| `testorig12-ari.jpg` | `testorig12.jpg` transcoded to 12-bit arithmetic (SOF9) |
| `testorig12-ari-progressive.jpg` | `testorig12.jpg` transcoded to 12-bit progressive arithmetic (SOF10) |
| `whitetail-ari.jpg` | 512×512 gray, arithmetic coded (SOF9), noise in the top 16 rows, white below |
| `whitetail-huffman.jpg` | `whitetail-ari.jpg` losslessly transcoded to baseline Huffman (SOF0) |
`testimgari.jpg` and `testorig.jpg` share their quantization tables but not their coefficients:
libjpeg-turbo decodes them to different pixels. `testimgari-huffman.jpg` holds exactly the
coefficients of `testimgari.jpg`, so the two must decode to identical samples. The same holds
for each jpegtran transcode below and its source.
## How the derived files were made
`testimgari-huffman.jpg`, with jpegtran from libjpeg-turbo 3.1.0 (Alpine 3.22,
`libjpeg-turbo-utils-3.1.0-r0`):
```sh
jpegtran -copy none testimgari.jpg > testimgari-huffman.jpg
```
The arithmetic transcodes of `testorig.jpg` and `testorig12.jpg`, with jpegtran from
libjpeg-turbo 3.1.3 (Alpine 3.24, `libjpeg-turbo-utils`):
```sh
jpegtran -copy none -arithmetic -progressive testorig.jpg > testorig-ari-progressive.jpg
jpegtran -copy none -arithmetic -progressive -restart 3 testorig.jpg > testorig-ari-progressive-restart.jpg
jpegtran -copy none -arithmetic testorig12.jpg > testorig12-ari.jpg
jpegtran -copy none -arithmetic -progressive testorig12.jpg > testorig12-ari-progressive.jpg
```
`testorig12.ppm`, with libjpeg-turbo 3.2.0 through imagecodecs 2026.10.10 (default settings:
integer IDCT `JDCT_ISLOW`, fancy upsampling), run as
`uv run --with imagecodecs --with numpy python make.py testorig12.jpg testorig12.ppm`:
```python
import sys, imagecodecs, numpy as np
src, dst = sys.argv[1], sys.argv[2]
a = imagecodecs.jpeg8_decode(open(src, "rb").read())
h, w, c = a.shape
assert c == 3 and a.dtype == np.uint16 and a.max() <= 4095
with open(dst, "wb") as f:
f.write(b"P6\n%d %d\n4095\n" % (w, h))
f.write(a.astype(">u2").tobytes())
```
`djpeg -dct int testorig12.jpg` from libjpeg-turbo 3.1.0 writes a byte-identical file.
`whitetail-ari.jpg` and `whitetail-huffman.jpg`, with cjpeg and jpegtran from libjpeg-turbo 3.1.3
(Alpine 3.24, `libjpeg-turbo-utils`). libjpeg's arithmetic encoder drops the trailing zero bytes
of a scan, so the white rows decode almost entirely from zeros supplied at the EOI marker.
`python3 whitetail.py whitetail.pgm` writes the source image:
```python
import sys
w, h, top = 512, 512, 16
px = bytes(((x * 3 + y * 5) ^ (x * y)) & 0xFF if y < top else 255 for y in range(h) for x in range(w))
open(sys.argv[1], "wb").write(b"P5\n%d %d\n255\n" % (w, h) + px)
```
```sh
cjpeg -arithmetic -quality 75 whitetail.pgm > whitetail-ari.jpg
jpegtran -copy none whitetail-ari.jpg > whitetail-huffman.jpg
```
"This software is based in part on the work of the Independent JPEG Group."
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+131
View File
@@ -0,0 +1,131 @@
//! Deterministic mutation run over the test images and fuzz seeds: decoding corrupted input never panics and
//! stays within the caller's limits.
use craft_jpeg::{Limits, Samples, decode, frame};
/// Inputs and mutation counts. The 227×149 test images take tens of milliseconds each to decode
/// in a debug build; the small fuzz seeds (SOF9 with restarts, 12-bit SOF9, SOF10, 12-bit SOF1)
/// take microseconds and get most of the cases.
const INPUTS: [(&str, usize); 8] = [
("tests/data/testorig.jpg", 12),
("tests/data/testimgari.jpg", 12),
("tests/data/testimgari-huffman.jpg", 12),
("tests/data/testorig12.jpg", 12),
("fuzz/corpus/decode/sof9-restart.jpg", 75),
("fuzz/corpus/decode/sof9-12bit.jpg", 75),
("fuzz/corpus/decode/sof10-progressive.jpg", 75),
("fuzz/corpus/decode/sof1-12bit.jpg", 75),
];
/// Room for the 227×149 fixtures, too little for a mutated frame header that grows them much.
fn test_limits() -> Limits {
let mut limits = Limits::default();
limits.max_pixels = 1 << 16;
limits.max_alloc = 1 << 20;
limits
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
// xorshift64*
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
fn byte(&mut self) -> u8 {
self.next() as u8
}
}
/// Offset of the first SOFn segment's body, if any.
fn sof_body(b: &[u8]) -> Option<usize> {
(2..b.len().saturating_sub(1))
.find(|&i| b[i] == 0xFF && matches!(b[i + 1], 0xC0..=0xCF) && !matches!(b[i + 1], 0xC4 | 0xC8 | 0xCC))
.map(|i| i + 4)
}
fn mutate(rng: &mut Rng, original: &[u8]) -> Vec<u8> {
let mut b = original.to_vec();
match rng.below(6) {
0 => b.truncate(rng.below(original.len())),
1 => {
for _ in 0..1 + rng.below(8) {
let at = rng.below(b.len());
b[at] ^= 1 << rng.below(8);
}
}
2 => {
for _ in 0..1 + rng.below(4) {
let at = rng.below(b.len());
b[at] = rng.byte();
}
}
3 => {
// Frame header fields: precision, height, width, component count and the
// per-component sampling and table selectors.
if let Some(body) = sof_body(&b) {
for _ in 0..1 + rng.below(3) {
let at = body + rng.below(15);
if let Some(v) = b.get_mut(at) {
*v = rng.byte();
}
}
}
}
4 => {
let at = rng.below(b.len());
let len = rng.below(64).min(b.len() - at);
b.drain(at..at + len);
}
_ => {
// Repeat a span, e.g. a marker segment or part of the entropy-coded data.
let at = rng.below(b.len());
let len = rng.below(256).min(b.len() - at);
let span = b[at..at + len].to_vec();
let to = rng.below(b.len());
b.splice(to..to, span);
}
}
b
}
#[test]
fn mutated_streams_never_panic_and_stay_within_limits() {
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
let limits = test_limits();
let (mut ok, mut errors) = (0, 0);
for (name, cases) in INPUTS {
let path = format!("{}/{name}", env!("CARGO_MANIFEST_DIR"));
let original = std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
assert!(decode(&original, &limits, None).is_ok(), "{name} must decode within the test limits");
for case in 0..cases {
let bytes = mutate(&mut rng, &original);
let _ = frame(&bytes);
match decode(&bytes, &limits, None) {
Ok(img) => {
let pixels = u64::from(img.width) * u64::from(img.height);
let (len, width) = match &img.samples {
Samples::U8(v) => (v.len() as u64, 1),
Samples::U16(v) => (v.len() as u64, 2),
};
assert!(pixels <= limits.max_pixels, "{name} case {case}: {pixels} pixels");
assert!(len * width <= limits.max_alloc, "{name} case {case}: {} bytes", len * width);
assert_eq!(len, pixels * u64::from(img.components), "{name} case {case}");
ok += 1;
}
Err(_) => errors += 1,
}
}
}
eprintln!("{ok} decoded, {errors} rejected");
// A mutation run that only produces errors would no longer reach the entropy decoders.
assert!(ok > 50 && errors > 50, "{ok} decoded, {errors} rejected");
}
+2 -1
View File
@@ -146,7 +146,8 @@ let writer = Writer::new(SerializeSettings { configuration, ..settings }, metada
For another profile, fill `PrintColor` with its condition identifier (`Custom` when it is
not registered) and `info`. The settings claim no PDF/X version and are not checked against
PDF/X; apps that claim conformance set `pdfx_version` and keep their own checks. Writing these
PDF/X; apps that claim conformance set `pdfx_version` (and `pdfx_conformance` for PDF/X-1a
and X-3) and keep their own checks. Writing these
settings needs the patched Krilla (`CRAFT_PRINT_COLOR`, see
[`vendor/krilla/README.craft.md`](../../vendor/krilla/README.craft.md)).
+5
View File
@@ -84,6 +84,8 @@ pub struct Metadata {
pub description: Option<String>,
pub keywords: Vec<String>,
pub created: Option<backend::metadata::DateTime>,
/// Written as the modification date; `created` is written there when this is `None`.
pub modified: Option<backend::metadata::DateTime>,
}
impl Metadata {
@@ -99,6 +101,9 @@ impl Metadata {
if let Some(created) = self.created {
metadata = metadata.creation_date(created);
}
if let Some(modified) = self.modified {
metadata = metadata.modification_date(modified);
}
metadata
}
}
+38 -4
View File
@@ -630,6 +630,33 @@ fn device_n_mixing_spots_and_process_writes_nchannel_attributes() {
}
}
/// NChannel's `/Subtype` and `/Process` are PDF 1.6: a 1.4 file (PDF/X-1a) gets only
/// `/Colorants`, a 1.7 file the NChannel attributes.
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn device_n_attributes_follow_the_pdf_version() {
for version in [PdfVersion::Pdf14, PdfVersion::Pdf17] {
let settings = SerializeSettings {
configuration: craft_pdf::write::configuration(version, None).unwrap(),
compress_content_streams: false,
..cmyk_settings()
};
let color = device_n(vec![Colorant::Process(Process::Cyan), spot("Gold", gold())], vec![255, 128]);
let bytes = device_n_sample(settings, vec![color]).unwrap();
let nchannel = version >= PdfVersion::Pdf16;
assert_eq!(bytes.windows(8).any(|w| w == b"NChannel"), nchannel, "{version:?}");
let mut found = 0;
device_n_spaces(&bytes, |_, _, _, attrs| {
found += 1;
let attrs = attrs.expect("attributes");
assert!(attrs.get::<Dict<'_>>(b"Colorants".as_slice()).unwrap().get::<Array<'_>>(b"Gold".as_slice()).is_some());
assert_eq!(attrs.contains_key(b"Subtype".as_slice()), nchannel, "{version:?}");
assert_eq!(attrs.contains_key(b"Process".as_slice()), nchannel, "{version:?}");
});
assert_eq!(found, 1);
}
}
/// A spot name keeps one alternate across Separation and DeviceN; PDF/A-2 rejects a second.
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
@@ -1084,16 +1111,23 @@ fn print_settings_reject_a_profile_that_is_not_cmyk() {
}
/// `/Trapped` (`Unknown` included) and `GTS_PDFXVersion` go to the information dictionary and
/// to XMP; without settings neither appears.
/// to XMP, `GTS_PDFXConformance` to the dictionary only; without settings none appears.
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn trapped_and_pdfx_version_are_written_to_info_and_xmp() {
let settings = SerializeSettings { trapped: Some(Trapped::True), pdfx_version: Some("PDF/X-4".to_owned()), ..Default::default() };
let settings = SerializeSettings {
trapped: Some(Trapped::True),
pdfx_version: Some("PDF/X-1:2003".to_owned()),
pdfx_conformance: Some("PDF/X-1a:2003".to_owned()),
..Default::default()
};
let marked = String::from_utf8_lossy(&sample(settings, None).unwrap()).into_owned();
assert!(marked.contains("/Trapped/True"), "Info /Trapped");
assert!(marked.contains("/GTS_PDFXVersion(PDF/X-4)"), "Info /GTS_PDFXVersion");
assert!(marked.contains("/GTS_PDFXVersion(PDF/X-1:2003)"), "Info /GTS_PDFXVersion");
assert!(marked.contains("/GTS_PDFXConformance(PDF/X-1a:2003)"), "Info /GTS_PDFXConformance");
assert!(!marked.contains("pdfxid:GTS_PDFXConformance"), "no XMP conformance property");
assert!(marked.contains("<pdf:Trapped>True</pdf:Trapped>"), "XMP pdf:Trapped");
assert!(marked.contains("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>"), "XMP GTS_PDFXVersion");
assert!(marked.contains("<pdfxid:GTS_PDFXVersion>PDF/X-1:2003</pdfxid:GTS_PDFXVersion>"), "XMP GTS_PDFXVersion");
let unknown = SerializeSettings { trapped: Some(Trapped::Unknown), ..Default::default() };
let unknown = String::from_utf8_lossy(&sample(unknown, None).unwrap()).into_owned();
+17
View File
@@ -16,6 +16,7 @@ fn document(compress: bool) -> Vec<u8> {
description: Some("Original synthetic artwork".into()),
keywords: vec!["craft".into(), "pdf".into()],
created: Some(backend::metadata::DateTime::new(2026).month(10).day(9)),
modified: Some(backend::metadata::DateTime::new(2026).month(10).day(12)),
};
let settings = backend::SerializeSettings {
compress_content_streams: compress,
@@ -49,9 +50,25 @@ fn writes_pages_metadata_and_selected_version_without_a_renderer() {
let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.clone())).unwrap();
let sizes: Vec<_> = pdf.pages().iter().map(|page| page.render_dimensions()).collect();
assert_eq!(sizes, vec![(80.0, 40.0), (30.0, 60.0)]);
let day = |d: &Option<hayro_syntax::object::DateTime>| d.as_ref().map(|d| (d.year, d.month, d.day));
assert_eq!(day(&pdf.metadata().creation_date), Some((2026, 10, 9)));
assert_eq!(day(&pdf.metadata().modification_date), Some((2026, 10, 12)));
assert!(text.contains("<xmp:CreateDate>2026-10-09") && text.contains("<xmp:ModifyDate>2026-10-12"), "XMP dates");
}
}
/// Without a modification date, the creation date is written as both.
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn the_creation_date_is_the_modification_date_when_none_is_given() {
let created = Some(backend::metadata::DateTime::new(2026).month(10).day(9));
let mut writer = Writer::new(Default::default(), Metadata { created, ..Default::default() });
writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish();
let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(writer.finish().unwrap())).unwrap();
let day = |d: &Option<hayro_syntax::object::DateTime>| d.as_ref().map(|d| (d.year, d.month, d.day));
assert_eq!(day(&pdf.metadata().modification_date), Some((2026, 10, 9)));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn fixed_input_does_not_read_a_clock_or_change_pdf_bytes() {
+22 -1
View File
@@ -17,6 +17,18 @@ and the PDF structure a correct print export has.
`tests/scenes.rs` parses every scene, writes every scene without `pending`, and checks the
structure. It runs in CI in seconds, with the unit tests of the TIFF reader and the judge.
## Scenes
The file names say what each scene tests; `title` and `source` say more.
| IDs | Cases |
|---|---|
| `S01`–`S17` | Print-color research cases: overprint, spots, blending spaces, knockout and isolated groups, masks, placed PDFs, gradients |
| `D01`–`D10` | DesignCraft print cases (its DC list) and a GWG idea (D04) |
| `M1`, `M2`, `N1` | Luminosity mask backdrop and transfer; DeviceN overprint |
| `F01` | Flattener: a semi-transparent image over a flat fill; where the image's alpha is 0 the fill prints unchanged (vectorcraft#2160) |
| `F02` | Flattener: art hidden under opaque fills, with transparency over them, leaves no trace (vectorcraft#2161) |
## Scene format
Coordinates are points from the page's top-left corner; rectangles are `[x, y, width, height]`.
@@ -57,7 +69,9 @@ Paints:
Images are synthetic: `{"px": [w, h], "ramp": [paint, paint]}` (left to right),
`"split": [paint, paint]` (left and right halves) or `"feather": pt` (a gray soft-mask image,
white inside and fading to black at the edges). Shadows and feathers are luminosity masks over
such an image.
such an image. A ramp or split image can carry `"alpha": {"rect": [x, y, w, h], "opacity": 0.6}`,
written as its `/SMask` like a PNG with transparency: pixels whose centers lie in `rect` (page
pt) have that opacity, the rest 0.
Tolerances: ±2 for device, spot and overprint values; ±6 for blends and ICC; ±10 for shadows and
gradients.
@@ -131,6 +145,13 @@ is reported as such: its plates don't show the missing feature under Ghostscript
`structure` block is the check that does. The test fails when a scene without `pending` fails or
the harness errors.
`scripts/plate-check.sh --flatten` runs the same check on each scene's page flattened by
craft-prepress (`flatten_page` at 300 dpi; the test is `flatten_plates` in craft-prepress, since
craft-prepress dev-depends on this crate). The flattened page must also have no transparency
left (`structure::transparency`: transparency groups, soft masks in graphics states, blend modes
other than Normal, `ca`/`CA` below 1). Its report goes to `plate-test/flattened/` and shows both
pages side by side, with the flattener's time per scene.
The results go to `plate-test/report.md` and `results.json` in Cargo's target directory
(`CARGO_TARGET_DIR` is respected). The script sets `CRAFT_PDF_TEST_FONT_DIR` to
`~/.cache/craft-pdf-test-fonts` when the font is there; without the font, text points are skipped.
@@ -0,0 +1,48 @@
{
"id": "F01",
"title": "Semi-transparent image over a flat process fill: where the image's alpha is 0, the fill prints unchanged",
"source": "vectorcraft#2160 (X-1a flattening washed out a fill behind a transparent PNG)",
"page": [360, 216],
"settings": {"group_cs": "cmyk"},
"objects": [
{"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [100, 6, 41, 3]}},
{
"type": "image",
"rect": [60, 48, 290, 120],
"image": {
"px": [290, 120],
"split": [{"cmyk": [0, 100, 0, 0]}, {"cmyk": [0, 0, 0, 100]}],
"alpha": {"rect": [100, 78, 160, 60], "opacity": 0.6}
},
"note": "image alpha 0.6 inside x 100–260, y 78–138; 0 in the rest of the image, which reaches past the fill on the right"
}
],
"expect": {
"plates": ["Cyan", "Magenta", "Yellow", "Black"],
"points": [
{"at": [40, 108], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "fill clear of the image"},
{"at": [80, 108], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "image alpha 0 over the fill"},
{"at": [180, 60], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "image alpha 0 over the fill"},
{"at": [300, 150], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "image alpha 0 over the fill"},
{"at": [345, 108], "inks": {}, "tol": 0.5, "note": "image alpha 0 over paper"},
{
"at": [150, 108],
"inks": {"Cyan": 40, "Magenta": 62.4, "Yellow": 16.4, "Black": 1.2},
"tol": 6,
"note": "M100 at 60% over the fill, CMYK blending"
},
{
"at": [230, 108],
"inks": {"Cyan": 40, "Magenta": 2.4, "Yellow": 16.4, "Black": 61.2},
"tol": 6,
"note": "K100 at 60% over the fill, CMYK blending"
}
]
},
"structure": {
"page_group": "DeviceCMYK",
"color_spaces": ["DeviceCMYK"],
"device_rgb": false,
"output_intents": []
}
}
@@ -0,0 +1,63 @@
{
"id": "F02",
"title": "Art hidden under opaque fills, with transparency over them: nothing of it shows",
"source": "vectorcraft#2161 (old art under opaque layers showed through an X-1a export)",
"page": [360, 216],
"settings": {"group_cs": "cmyk"},
"objects": [
{
"type": "group",
"note": "an old copy of the page, still visible and fully covered",
"objects": [
{"type": "rect", "rect": [190, 30, 140, 156], "fill": {"cmyk": [0, 30, 0, 0]}},
{"type": "text", "at": [200, 80], "size": 40, "text": "HHH", "fill": {"cmyk": [0, 0, 0, 100]}},
{"type": "rect", "rect": [200, 100, 120, 6], "fill": {"cmyk": [0, 0, 0, 100]}},
{"type": "rect", "rect": [200, 115, 120, 0.5], "fill": {"cmyk": [0, 0, 0, 100]}},
{"type": "rect", "rect": [200, 130, 12, 12], "stroke": {"paint": {"cmyk": [0, 0, 0, 100]}, "width": 1}},
{
"type": "image",
"rect": [260, 140, 60, 40],
"image": {"px": [6, 4], "split": [{"cmyk": [100, 0, 0, 0]}, {"cmyk": [0, 0, 100, 0]}]}
},
{"type": "rect", "rect": [200, 150, 50, 30], "fill": {"cmyk": [0, 100, 0, 0]}, "opacity": 0.5},
{"type": "text", "at": [30, 80], "size": 40, "text": "HHH", "fill": {"cmyk": [0, 0, 0, 100]}},
{"type": "rect", "rect": [30, 100, 120, 6], "fill": {"cmyk": [0, 0, 0, 100]}}
]
},
{"type": "rect", "rect": [20, 20, 150, 176], "fill": {"cmyk": [0, 0, 0, 0]}, "note": "white card"},
{"type": "rect", "rect": [180, 20, 160, 176], "fill": {"cmyk": [100, 6, 41, 3]}, "note": "teal box"},
{"type": "rect", "rect": [100, 160, 200, 20], "fill": {"cmyk": [0, 0, 0, 100]}, "opacity": 0.3, "note": "a 30% black band over both"}
],
"expect": {
"plates": ["Cyan", "Magenta", "Yellow", "Black"],
"points": [
{"at": [230, 103], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over a hidden K bar"},
{"at": [215, 70], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over hidden text"},
{"at": [290, 150], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over a hidden image"},
{"at": [206, 136], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over a hidden checkbox"},
{"at": [335, 25], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over nothing"},
{"at": [60, 103], "inks": {}, "tol": 0.5, "note": "white card over a hidden K bar"},
{"at": [45, 70], "inks": {}, "tol": 0.5, "note": "white card over hidden text"},
{"at": [175, 108], "inks": {}, "tol": 0.5, "note": "paper between the card and the box"},
{
"at": [220, 170],
"inks": {"Cyan": 70, "Magenta": 4.2, "Yellow": 28.7, "Black": 32.1},
"tol": 2,
"note": "band over teal over a hidden 50% magenta fill"
},
{
"at": [290, 170],
"inks": {"Cyan": 70, "Magenta": 4.2, "Yellow": 28.7, "Black": 32.1},
"tol": 2,
"note": "band over teal over a hidden image"
},
{"at": [130, 170], "inks": {"Black": 30}, "tol": 2, "note": "band over the white card"}
]
},
"structure": {
"page_group": "DeviceCMYK",
"color_spaces": ["DeviceCMYK"],
"device_rgb": false,
"output_intents": []
}
}
+18 -4
View File
@@ -411,7 +411,7 @@ impl Resolver<'_, '_> {
DeviceNSpace::new(colorants).ok_or_else(|| self.bad("bad DeviceN ink list"))
}
fn image(&self, source: &ImageSource, [_, _, w, h]: [f32; 4]) -> Result<Image, Error> {
fn image(&self, source: &ImageSource, [x0, y0, w, h]: [f32; 4]) -> Result<Image, Error> {
let [px, py] = source.px;
let (width, height) = (px as usize, py as usize);
let image = if let Some(radius) = source.feather {
@@ -423,7 +423,7 @@ impl Resolver<'_, '_> {
data.push(channel(255.0 * (edge / radius.max(f32::EPSILON)).min(1.0)));
}
}
Synthetic { data, size: (px, py), space: ImageColorspace::Luma, device_n: None }
Synthetic { data, alpha: None, size: (px, py), space: ImageColorspace::Luma, device_n: None }
} else {
let (pair, ramp) = match (&source.ramp, &source.split) {
(Some(pair), _) => (pair, true),
@@ -456,7 +456,20 @@ impl Resolver<'_, '_> {
data.extend(a.iter().zip(&b).map(|(a, b)| channel(a + (b - a) * t)));
}
}
Synthetic { data, size: (px, py), space, device_n }
let alpha = source.alpha.as_ref().map(|a| {
let [ax, ay, aw, ah] = a.rect;
let value = channel(a.opacity * 255.0);
let mut alpha = Vec::with_capacity(width * height);
for y in 0..height {
for x in 0..width {
let (fx, fy) = (x0 + (x as f32 + 0.5) * w / px as f32, y0 + (y as f32 + 0.5) * h / py as f32);
let inside = (ax..ax + aw).contains(&fx) && (ay..ay + ah).contains(&fy);
alpha.push(if inside { value } else { 0 });
}
}
alpha
});
Synthetic { data, alpha, size: (px, py), space, device_n }
};
Image::from_custom(image, false).map_err(|e| self.bad(&e))
}
@@ -574,6 +587,7 @@ fn ellipse_path(x: f32, y: f32, w: f32, h: f32) -> Option<Path> {
#[derive(Clone, Hash)]
struct Synthetic {
data: Vec<u8>,
alpha: Option<Vec<u8>>,
size: (u32, u32),
space: ImageColorspace,
device_n: Option<DeviceNSpace>,
@@ -584,7 +598,7 @@ impl CustomImage for Synthetic {
&self.data
}
fn alpha_channel(&self) -> Option<&[u8]> {
None
self.alpha.as_deref()
}
fn bits_per_component(&self) -> BitsPerComponent {
BitsPerComponent::Eight
+166 -25
View File
@@ -2,16 +2,21 @@
//!
//! Pending scenes are written as far as the writer can ([`reference::write_partial`]) and are
//! expected to fail; their results show what the writer prints today.
//!
//! [`run_flattened`] also flattens each scene's PDF with a caller's flattener and checks the
//! flattened page the same way, plus [`structure::transparency`]. The flattener comes from the
//! caller so that this crate doesn't depend on it.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::time::Instant;
use serde::Serialize;
use crate::judge::{self, Judgement, Verdict};
use crate::reference::{self, Context};
use crate::scene::{Level, PROCESS, Scene};
use crate::{Error, gs};
use crate::{Error, gs, structure};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
@@ -57,8 +62,26 @@ pub struct SceneResult {
/// Why the scene was not written or judged.
pub error: Option<String>,
pub judgement: Option<Judgement>,
/// The flattened page's result, from [`run_flattened`].
pub flattened: Option<FlatResult>,
}
/// One scene's flattened page.
#[derive(Debug, Clone, Serialize)]
pub struct FlatResult {
/// As for the source page; a pass also needs `transparency` empty.
pub status: Status,
/// What [`structure::transparency`] found in the flattened page.
pub transparency: Vec<String>,
/// Time spent in the flattener.
pub millis: f64,
pub error: Option<String>,
pub judgement: Option<Judgement>,
}
/// Flattens a scene's PDF: the flattened PDF, or why it failed.
pub type Flatten<'a> = &'a dyn Fn(&[u8]) -> Result<Vec<u8>, String>;
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub ghostscript: String,
@@ -66,13 +89,40 @@ pub struct Report {
pub scenes: Vec<SceneResult>,
}
impl Report {
/// Scenes whose source or flattened result is not what their `pending` field says.
pub fn unexpected(&self) -> Vec<String> {
let mut out = Vec::new();
for r in &self.scenes {
if !r.status.as_expected() {
out.push(format!("{} {:?}{}", r.id, r.status, r.error.as_deref().map(|e| format!(": {e}")).unwrap_or_default()));
}
if let Some(f) = r.flattened.as_ref().filter(|f| !f.status.as_expected()) {
let why = f.error.clone().or_else(|| (!f.transparency.is_empty()).then(|| f.transparency.join(", ")));
out.push(format!("{} flattened {:?}{}", r.id, f.status, why.map(|e| format!(": {e}")).unwrap_or_default()));
}
}
out
}
}
/// Write, separate and judge every scene in `ctx.scenes`.
pub fn run(ctx: &Context<'_>, ghostscript: String) -> Report {
Report { ghostscript, resolution: gs::RESOLUTION, scenes: ctx.scenes.iter().map(|scene| check_scene(scene, ctx)).collect() }
}
/// [`run`], and each scene's page flattened by `flatten`, separated and judged as well.
pub fn run_flattened(ctx: &Context<'_>, ghostscript: String, flatten: Flatten<'_>) -> Report {
let scenes = ctx.scenes.iter().map(|scene| check_scene_with(scene, ctx, Some(flatten))).collect();
Report { ghostscript, resolution: gs::RESOLUTION, scenes }
}
/// Write, separate and judge one scene.
pub fn check_scene(scene: &Scene, ctx: &Context<'_>) -> SceneResult {
check_scene_with(scene, ctx, None)
}
fn check_scene_with(scene: &Scene, ctx: &Context<'_>, flatten: Option<Flatten<'_>>) -> SceneResult {
let mut result = SceneResult {
id: scene.id.clone(),
title: scene.title.clone(),
@@ -81,6 +131,7 @@ pub fn check_scene(scene: &Scene, ctx: &Context<'_>) -> SceneResult {
notes: Vec::new(),
error: None,
judgement: None,
flattened: None,
};
let written = if scene.pending.is_some() { reference::write_partial(scene, ctx) } else { reference::write(scene, ctx) };
let output = match written {
@@ -96,22 +147,58 @@ pub fn check_scene(scene: &Scene, ctx: &Context<'_>) -> SceneResult {
}
};
result.notes = output.notes;
let plates = match gs::separate(&output.pdf, &scene.id) {
Ok(plates) => plates,
result.flattened = flatten.map(|f| check_flattened(scene, ctx, &output.pdf, f));
match separate_and_judge(scene, ctx, &output.pdf, &scene.id) {
Ok(judgement) => {
result.status = status(scene, judgement.passed());
result.judgement = Some(judgement);
}
Err(e) => result.error = Some(e.to_string()),
}
result
}
fn check_flattened(scene: &Scene, ctx: &Context<'_>, pdf: &[u8], flatten: Flatten<'_>) -> FlatResult {
let mut result = FlatResult { status: Status::Error, transparency: Vec::new(), millis: 0.0, error: None, judgement: None };
let start = Instant::now();
let flat = flatten(pdf);
result.millis = start.elapsed().as_secs_f64() * 1000.0;
let flat = match flat {
Ok(flat) => flat,
Err(e) => {
result.error = Some(format!("flatten: {e}"));
return result;
}
};
match structure::summarize(&flat) {
Ok(summary) => result.transparency = structure::transparency(&summary),
Err(e) => {
result.error = Some(e.to_string());
return result;
}
};
let judgement = judge::judge(scene, &plates, gs::RESOLUTION, ctx.font.is_some());
result.status = match (scene.pending.is_some(), judgement.passed()) {
}
match separate_and_judge(scene, ctx, &flat, &format!("{}-flat", scene.id)) {
Ok(judgement) => {
result.status = status(scene, judgement.passed() && result.transparency.is_empty());
result.judgement = Some(judgement);
}
Err(e) => result.error = Some(e.to_string()),
}
result
}
fn separate_and_judge(scene: &Scene, ctx: &Context<'_>, pdf: &[u8], label: &str) -> Result<Judgement, Error> {
let plates = gs::separate(pdf, label)?;
Ok(judge::judge(scene, &plates, gs::RESOLUTION, ctx.font.is_some()))
}
fn status(scene: &Scene, passed: bool) -> Status {
match (scene.pending.is_some(), passed) {
(false, true) => Status::Pass,
(false, false) => Status::Fail,
(true, false) => Status::ExpectedFail,
(true, true) => Status::PendingPasses,
};
result.judgement = Some(judgement);
result
}
}
/// Cargo's target directory: `CARGO_TARGET_DIR` (relative to the workspace root) or `target`.
@@ -143,6 +230,7 @@ pub fn write(report: &Report, dir: &Path) -> Result<PathBuf, Error> {
/// The report as Markdown.
pub fn markdown(report: &Report) -> String {
let flat = report.scenes.iter().any(|s| s.flattened.is_some());
let mut md = String::new();
let _ = writeln!(
md,
@@ -151,20 +239,36 @@ pub fn markdown(report: &Report) -> String {
they wait for and are expected to fail. Generated by `scripts/plate-check.sh`.\n",
report.ghostscript, report.resolution
);
md.push_str("| Scene | Status | Pending | Failing points | Plates |\n|---|---|---|---|---|\n");
if flat {
md.push_str(
"Each scene's page is also flattened and separated the same way. \"Transparency left\" lists transparency \
groups, graphics-state soft masks, blend modes and `ca`/`CA` below 1 in the flattened page.\n\n",
);
md.push_str(
"| Scene | Status | Pending | Failing points | Plates | Flattened | Failing points | Plates | Transparency left | Flatten ms |\n\
|---|---|---|---|---|---|---|---|---|---|\n",
);
} else {
md.push_str("| Scene | Status | Pending | Failing points | Plates |\n|---|---|---|---|---|\n");
}
for scene in &report.scenes {
let (points, plates) = match &scene.judgement {
Some(j) => (format!("{}/{}", j.failed_points(), j.judged_points()), plate_problems(j).unwrap_or_else(|| "ok".into())),
None => ("–".into(), "–".into()),
};
let (points, plates) = summary_cells(scene.judgement.as_ref());
let pending = scene.pending.as_deref().unwrap_or("");
let _ = writeln!(
let _ = write!(
md,
"| [{id}](#{anchor}) | {} | {pending} | {points} | {plates} |",
scene.status.label(),
id = scene.id,
anchor = scene.id.to_lowercase()
);
if let Some(f) = &scene.flattened {
let (points, plates) = summary_cells(f.judgement.as_ref());
let left = if f.transparency.is_empty() { "none".to_owned() } else { f.transparency.join(", ") };
let _ = write!(md, " {} | {points} | {plates} | {left} | {:.0} |", f.status.label(), f.millis);
} else if flat {
md.push_str(" – | – | – | – | – |");
}
md.push('\n');
}
for scene in &report.scenes {
let _ = writeln!(md, "\n## {}\n\n{}. Status: {}.", scene.id, scene.title, scene.status.label());
@@ -177,34 +281,71 @@ pub fn markdown(report: &Report) -> String {
for note in &scene.notes {
let _ = writeln!(md, "- {note}");
}
let flat_judgement = scene.flattened.as_ref().and_then(|f| f.judgement.as_ref());
if let Some(f) = &scene.flattened {
let _ = writeln!(md, "\nFlattened: {}, {:.0} ms.", f.status.label(), f.millis);
if let Some(error) = &f.error {
let _ = writeln!(md, "\n{error}");
}
if !f.transparency.is_empty() {
let _ = writeln!(md, "Transparency left: {}.", f.transparency.join(", "));
}
if let Some(j) = flat_judgement {
let _ = writeln!(md, "Flattened plates: {}.{}", j.plates.join(", "), plate_problems(j).map(|p| format!(" {p}.")).unwrap_or_default());
}
}
let Some(judgement) = &scene.judgement else { continue };
let _ = writeln!(md, "\nPlates: {}.{}", judgement.plates.join(", "), plate_problems(judgement).map(|p| format!(" {p}.")).unwrap_or_default());
md.push_str("\n| # | Point | Expected | Measured | Verdict |\n|---|---|---|---|---|\n");
if scene.flattened.is_some() {
md.push_str("\n| # | Point | Expected | Measured | Verdict | Flattened | Verdict |\n|---|---|---|---|---|---|---|\n");
} else {
md.push_str("\n| # | Point | Expected | Measured | Verdict |\n|---|---|---|---|---|\n");
}
for (i, point) in judgement.points.iter().enumerate() {
let verdict = match point.verdict {
Verdict::Pass => "pass".to_owned(),
Verdict::Info => "info".to_owned(),
Verdict::Skipped => "skipped".to_owned(),
Verdict::Fail if point.off.is_empty() => "**fail**".to_owned(),
Verdict::Fail => format!("**fail** ({})", point.off.iter().map(|p| short(p)).collect::<Vec<_>>().join(" ")),
};
let how = point.how.as_deref().map(|h| format!("; {h}")).unwrap_or_default();
let note = point.note.as_deref().map(|n| format!("{n} ")).unwrap_or_default();
let _ = writeln!(
let _ = write!(
md,
"| {} | {note}({}, {}){how} | {} ±{} | {} | {verdict} |",
"| {} | {note}({}, {}){how} | {} ±{} | {} | {} |",
i + 1,
point.at[0],
point.at[1],
expected(&point.expected),
point.tol,
measured(&point.measured),
verdict(point),
);
if scene.flattened.is_some() {
match flat_judgement.and_then(|j| j.points.get(i)) {
Some(p) => {
let _ = write!(md, " {} | {} |", measured(&p.measured), verdict(p));
}
None => md.push_str(" – | – |"),
}
}
md.push('\n');
}
}
md
}
fn summary_cells(judgement: Option<&Judgement>) -> (String, String) {
match judgement {
Some(j) => (format!("{}/{}", j.failed_points(), j.judged_points()), plate_problems(j).unwrap_or_else(|| "ok".into())),
None => ("–".into(), "–".into()),
}
}
fn verdict(point: &judge::PointResult) -> String {
match point.verdict {
Verdict::Pass => "pass".to_owned(),
Verdict::Info => "info".to_owned(),
Verdict::Skipped => "skipped".to_owned(),
Verdict::Fail if point.off.is_empty() => "**fail**".to_owned(),
Verdict::Fail => format!("**fail** ({})", point.off.iter().map(|p| short(p)).collect::<Vec<_>>().join(" ")),
}
}
fn plate_problems(judgement: &Judgement) -> Option<String> {
let mut parts = Vec::new();
if !judgement.missing_plates.is_empty() {
+14
View File
@@ -232,6 +232,17 @@ pub struct ImageSource {
/// Gray image for soft masks: white inside, fading to black over this many pt at the edges.
#[serde(default)]
pub feather: Option<f32>,
/// An alpha channel, written as the image's `/SMask` (a PNG with transparency).
#[serde(default)]
pub alpha: Option<ImageAlpha>,
}
/// Image alpha: `opacity` (0–1) for pixels whose centers lie inside `rect` (page pt), 0 for the rest.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImageAlpha {
pub rect: [f32; 4],
pub opacity: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
@@ -463,6 +474,9 @@ impl Scene {
if image.px[0] == 0 || image.px[1] == 0 || image.px[0] > 4096 || image.px[1] > 4096 {
out.push("image px must be 1–4096".into());
}
if image.alpha.as_ref().is_some_and(|a| image.feather.is_some() || !(0.0..=1.0).contains(&a.opacity)) {
out.push("image alpha needs a color image and an opacity of 0–1".into());
}
}
if (kind == Kind::Pdf) != object.scene.is_some() {
out.push(format!("{kind:?}: scene belongs to (and is required by) pdf objects"));
+31
View File
@@ -52,6 +52,12 @@ pub struct ExtGState {
pub opm: Option<i32>,
#[serde(rename = "BM", skip_serializing_if = "Option::is_none")]
pub bm: Option<String>,
/// `/ca` (fill and text alpha).
#[serde(rename = "ca", skip_serializing_if = "Option::is_none")]
pub alpha_fill: Option<f32>,
/// `/CA` (stroke alpha).
#[serde(rename = "CA", skip_serializing_if = "Option::is_none")]
pub alpha_stroke: Option<f32>,
/// The soft mask's `/S`, when the state sets a mask.
#[serde(rename = "SMask", skip_serializing_if = "Option::is_none")]
pub smask: Option<String>,
@@ -208,6 +214,8 @@ impl Walk {
op_stroke: gs.get::<bool>(b"OP".as_slice()),
op_fill: gs.get::<bool>(b"op".as_slice()),
opm: gs.get::<i32>(b"OPM".as_slice()),
alpha_fill: gs.get::<f32>(b"ca".as_slice()),
alpha_stroke: gs.get::<f32>(b"CA".as_slice()),
bm: gs.get::<Object<'_>>(b"BM".as_slice()).and_then(|bm| match bm {
Object::Name(name) => Some(name.as_str().to_owned()),
Object::Array(names) => names.iter::<Name<'_>>().next().map(|n| n.as_str().to_owned()),
@@ -308,6 +316,29 @@ fn describe(cs: &Object<'_>, depth: usize) -> String {
}
}
/// Live transparency left in a page that should have none, as a flattener's output must: transparency
/// groups, soft masks in graphics states, blend modes other than Normal, and `ca` or `CA` below 1.
/// Soft masks of images are not graphics states and are allowed. Empty when there is none.
pub fn transparency(summary: &Summary) -> Vec<String> {
let mut out: Vec<String> = summary.groups.iter().filter(|g| g.s == "Transparency").map(|g| format!("transparency group ({})", g.at)).collect();
for gs in &summary.ext_g_states {
if let Some(mask) = gs.smask.as_deref().filter(|m| !m.is_empty() && *m != "None") {
out.push(format!("soft mask /{mask}"));
}
if let Some(bm) = gs.bm.as_deref().filter(|b| *b != "Normal" && *b != "Compatible") {
out.push(format!("blend mode /{bm}"));
}
for (key, alpha) in [("ca", gs.alpha_fill), ("CA", gs.alpha_stroke)] {
if let Some(a) = alpha.filter(|a| *a < 1.0) {
out.push(format!("/{key} {a}"));
}
}
}
out.sort();
out.dedup();
out
}
/// A scene's `structure` block. Every field is optional; only the fields present are checked.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
+1 -6
View File
@@ -26,12 +26,7 @@ fn every_scene_separates_as_expected() {
println!("{} {:?}{pending}", result.id, result.status);
}
println!("report: {}", path.display());
let wrong: Vec<String> = report
.scenes
.iter()
.filter(|r| !r.status.as_expected())
.map(|r| format!("{} {:?}{}", r.id, r.status, r.error.as_deref().map(|e| format!(": {e}")).unwrap_or_default()))
.collect();
let wrong = report.unexpected();
assert!(wrong.is_empty(), "see {}:\n{}", path.display(), wrong.join("\n"));
assert!(report.scenes.iter().any(|r| r.status == Status::Pass));
}
+11
View File
@@ -14,3 +14,14 @@ workspace = true
craft-pdf = { workspace = true, features = ["raster", "write"] }
craft-cms = { workspace = true }
thiserror = { workspace = true }
kurbo = { workspace = true, optional = true }
linesweeper = { version = "0.5", optional = true }
ttf-parser = { version = "=0.25.1", optional = true }
[dev-dependencies]
craft-plate-test = { workspace = true }
ttf-parser = "=0.25.1"
[features]
# The transparency flattener.
flatten = ["craft-pdf/write-pdf", "dep:kurbo", "dep:linesweeper", "dep:ttf-parser"]
+29 -2
View File
@@ -14,7 +14,24 @@ Prepress for the Craft apps: separations, ink limits and the ink manager.
- `composite`: an sRGB preview of chosen plates.
- `cms`: process color conversion into a CMYK printing condition through craft-cms.
A transparency flattener is planned as a module behind a `flatten` feature.
- `flatten` (feature `flatten`): the display list a transparency flattener reads and writes:
fills, text runs, images and transparency groups in page space, with ink colors and overprint
per item. Text runs keep their font (bytes, face index, a dedup key), glyph ids and positions,
and Unicode text, so text without transparency stays live text; outlines are kept as the
fallback for fonts that can't be embedded again. `flatten` turns the transparent parts into
opaque region fills, compositing one value per ink (process and spot) with blend modes, group
opacity, isolated and knockout groups, and overprint per plate. Items without transparency
come back unchanged (JPEG images in DeviceGray or DeviceCMYK keep their DCT data; other images
are written as 8-bit Flate samples); regions a gradient, image or soft mask reaches are listed
for rasterizing. `flatten::raster` rasterizes them: each region's items are written alone,
separated with `plates` and written back as a DeviceN image of the page's inks, clipped to the
region, snapped to the device grid and a pixel larger than the clip so no paper shows at its
edges. `flatten_document` flattens every page of a PDF, or a range of pages, into one PDF with
the source's output intent, page boxes, title, author, creation date and modification date; a
font used on several pages is embedded once. The caller sets the PDF version and the PDF/X
version and conformance keys (for PDF/X-1a:2003, 1.4, `PDF/X-1:2003` and `PDF/X-1a:2003`);
krilla has no PDF/X validator, so the app keeps its own PDF/X checks. `flatten_page` is its
one-page case.
## Plates from an exported PDF
@@ -193,5 +210,15 @@ if let Some(color) = to_pdf_color(&printed, &inks, &cms) {
(guillehr2), with craft-cms in place of moxcms.
- Knockout groups in the renderer come from
[storytold/pdfcraft#2974](https://github.com/storytold/pdfcraft/pull/2974) by mtlcl410.
- The flattener's planar regions (`flatten::geom`) are ported from VectorCraft at commit
`c2b15982` by the VectorCraft contributors: `crates/pathops/src/pathfinder.rs`,
`crates/pathops/src/boolean.rs` and `crates/engine/src/cmd/flatten.rs`.
- The flattener's compositor (`flatten::compose`) is ported from VectorCraft at commit
`c2b15982` by the VectorCraft contributors: `Composer`, `region_fill` and the raster/vector
balance in `crates/engine/src/cmd/flatten.rs`, and the blend formulas in
`crates/color/src/blend.rs`.
- The flattener's raster step (`flatten::raster`) follows VectorCraft's `render_raster` in
`crates/engine/src/cmd/flatten.rs` at commit `c2b15982`: the image reaching a pixel past its
area, and the coverage of the art drawn opaque as the image's alpha.
Both MIT OR Apache-2.0, as this crate.
All MIT OR Apache-2.0, as this crate.
File diff suppressed because it is too large. Load diff
+387
View File
@@ -0,0 +1,387 @@
//! [`flatten_document`]: every page of a PDF flattened into one PDF.
//!
//! - Output intent: written once, the first flattened page's as [`flatten_page`] picks it (the
//! page's own `/OutputIntents`, PDF 2.0, else the catalog's), else [`ReadOptions::fallback`]'s
//! profile, else FOGRA39. Each page is read and its complex regions separated with its own
//! intent; krilla writes no page-level `/OutputIntents`, so pages whose intent differs from the
//! one written are listed in [`FlattenDocumentReport::other_intents`].
//! - Page boxes: the page is the source's visible area (CropBox within MediaBox, rotated), written
//! as the MediaBox. TrimBox, BleedBox and ArtBox carry over, mapped onto it and clipped to it.
//! - Metadata: title, author, creation date and modification date carry over; the producer is
//! the caller's.
//! - Version: [`FlattenDocumentOptions::version`], [`FlattenDocumentOptions::pdfx_version`]
//! and [`FlattenDocumentOptions::pdfx_conformance`] set the header, `pdf:PDFVersion` and the
//! PDF/X version and conformance keys. krilla has no PDF/X validator, so a caller claiming
//! PDF/X runs its own conformance checks.
//! - Fonts: prepared per page ([`FontCache`](super::font::FontCache)); pages share a krilla font
//! only when the font bytes are equal ([`FontMap`]).
//! - The source is opened once, and each output intent and printing condition parsed once.
use std::cell::Cell;
use std::collections::HashMap;
use std::ops::Range;
use std::rc::Rc;
use std::sync::{Arc, OnceLock};
use craft_pdf::interpret::TransformExt as _;
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::dict::keys::{ART_BOX, BLEED_BOX, OUTPUT_INTENTS, TRIM_BOX};
use craft_pdf::syntax::object::{Array, DateTime as PdfDate, Dict, Rect as PdfRect};
use craft_pdf::syntax::page::Page;
use craft_pdf::write::backend::SerializeSettings;
use craft_pdf::write::backend::configure::PdfVersion;
use craft_pdf::write::backend::geom::{Rect as KRect, Size, Transform};
use craft_pdf::write::backend::metadata::DateTime;
use craft_pdf::write::backend::page::PageSettings;
use craft_pdf::write::backend::pdf::PdfDocument;
use craft_pdf::write::print::{self as print_settings, PrintColor};
use craft_pdf::write::{self as pdf_write, Metadata, Writer};
use craft_pdf::{OpenError, open_slice};
use kurbo::Rect;
use super::pdf::{ReadColor, ReadError, ReadOptions, read_color, read_page_in};
use super::raster::{FlattenPageReport, Rasterizer};
use super::write::{FontMap, WriteError, write_flattened_with_fonts};
use super::{DisplayList, FlattenError, FlattenOptions, flatten};
use crate::cms::CmsConversion;
use crate::plates::{self, Fallback, pdf_text};
/// How [`flatten_document`] reads, flattens and writes.
#[derive(Clone, Debug, Default)]
pub struct FlattenDocumentOptions {
pub read: ReadOptions,
pub flatten: FlattenOptions,
/// The pages to flatten, from 0; all when `None`.
pub pages: Option<Range<usize>>,
/// Written as `/Producer`.
pub producer: String,
pub unreadable: Unreadable,
/// The output's PDF version. Below 1.6, DeviceN spaces get no NChannel attributes; at 1.4,
/// krilla also enforces the 1.4 implementation limits (reals, array and dictionary sizes),
/// and a DeviceN space of more than 8 colorants is a [`WriteError::Colorants`].
pub version: PdfVersion,
/// The PDF/X version claimed: `/GTS_PDFXVersion` in the information dictionary and
/// `pdfxid:GTS_PDFXVersion` in XMP. For PDF/X-1a:2003 it is `PDF/X-1:2003`. Not checked.
pub pdfx_version: Option<String>,
/// The PDF/X conformance level claimed, `/GTS_PDFXConformance` in the information
/// dictionary: `PDF/X-1a:2003` for PDF/X-1a:2003. Not checked.
pub pdfx_conformance: Option<String>,
}
/// What [`flatten_document`] does with a page the PDF interpreter fails on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Unreadable {
/// Fail with [`FlattenDocumentError::Read`].
#[default]
Fail,
/// Copy the page unflattened, as a form XObject, and list it in
/// [`FlattenDocumentReport::unflattened`]. Its transparency stays.
Keep,
}
/// What [`flatten_document`] did.
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct FlattenDocumentReport {
/// Each flattened page (from 0) and its report.
pub pages: Vec<(usize, FlattenPageReport)>,
/// Pages copied unflattened ([`Unreadable::Keep`]) and why they didn't read.
pub unflattened: Vec<(usize, String)>,
/// Pages read and separated with an output intent other than the one written.
pub other_intents: Vec<usize>,
pub totals: FlattenTotals,
}
/// [`FlattenDocumentReport::pages`] summed.
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct FlattenTotals {
/// Top-level items cut into regions.
pub flattened: usize,
pub complex_regions: usize,
/// Groups rasterized whole.
pub complex_groups: usize,
pub raster_images: usize,
pub raster_pixels: u64,
/// The lowest resolution a complex region was rasterized at, when one needed less than
/// [`FlattenOptions::raster_dpi`].
pub lowest_dpi: Option<f32>,
/// Raster images read.
pub images: usize,
pub unreadable_images: usize,
pub dropped_tilings: usize,
pub dropped_inks: usize,
pub dropped_annotations: usize,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FlattenDocumentError {
#[error(transparent)]
Open(#[from] OpenError),
#[error("pages {start}..{end} are not pages of the document's {count}")]
PageRange { start: usize, end: usize, count: usize },
#[error("page {page} (from 0): {source}")]
Read { page: usize, source: ReadError },
#[error("page {page} (from 0): {source}")]
Flatten { page: usize, source: FlattenError },
#[error("page {page} (from 0): {source}")]
Write { page: usize, source: WriteError },
/// Serializing the document failed.
#[error(transparent)]
Serialize(#[from] pdf_write::WriteError),
}
/// `pdf` with the transparency of every page in [`FlattenDocumentOptions::pages`] flattened, as
/// one PDF. See the [module docs](self) for what carries over.
pub fn flatten_document(pdf: &[u8], opts: &FlattenDocumentOptions) -> Result<(Vec<u8>, FlattenDocumentReport), FlattenDocumentError> {
flatten_document_with_progress(pdf, opts, &mut |_, _| {})
}
/// [`flatten_document`], calling `progress(done, total)` after each page.
pub fn flatten_document_with_progress(
pdf: &[u8],
opts: &FlattenDocumentOptions,
progress: &mut dyn FnMut(usize, usize),
) -> Result<(Vec<u8>, FlattenDocumentReport), FlattenDocumentError> {
let doc = Arc::new(open_slice(pdf, None)?);
let count = doc.pages().len();
let range = opts.pages.clone().unwrap_or(0..count);
if range.is_empty() || range.end > count {
return Err(FlattenDocumentError::PageRange { start: range.start, end: range.end, count });
}
let mut colors = Colors { doc: &doc, fallback: &opts.read.fallback, cache: HashMap::new() };
let first = colors.get(range.start)?;
let mut report = FlattenDocumentReport::default();
// The page being written, to name it when krilla panics.
let current = Cell::new(None);
let written = pdf_write::guard(|| {
let settings = SerializeSettings {
configuration: pdf_write::configuration(opts.version, None)?,
pdfx_version: opts.pdfx_version.clone(),
pdfx_conformance: opts.pdfx_conformance.clone(),
..first.settings.clone()
};
let mut writer = Writer::new(settings, metadata(&doc, &opts.producer));
let mut fonts = FontMap::new();
let mut source = None;
let total = range.len();
for (done, page) in range.clone().enumerate() {
current.set(Some(page));
let color = colors.get(page)?;
if !Rc::ptr_eq(&color, &first) && !same_condition(&color.condition, &first.condition) {
report.other_intents.push(page);
}
let p = doc.pages().get(page).ok_or(FlattenDocumentError::Read { page, source: ReadError::NoPage(page) })?;
match read_page_in(&doc, page, &opts.read, color.read.clone()) {
Ok((list, read)) => {
let flat = flatten(&list, &opts.flatten).map_err(|source| FlattenDocumentError::Flatten { page, source })?;
let mut raster = Rasterizer::new(&list, color.settings.clone(), color.condition.clone(), &color.cms, opts.flatten.raster_dpi);
let at = |source| FlattenDocumentError::Write { page, source };
let settings = page_settings(p, list.page).ok_or(at(WriteError::Page))?;
write_page(&mut writer, settings, &list, |surface| {
write_flattened_with_fonts(surface, &list, &flat, &color.cms, opts.version, &mut fonts, &mut |r| raster.region(r))
})
.map_err(at)?;
report.pages.push((page, FlattenPageReport { read, flatten: flat.report, raster: raster.report().clone() }));
}
Err(e) if opts.unreadable == Unreadable::Keep => {
let (w, h) = p.render_dimensions();
let bbox = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
let at = |source| FlattenDocumentError::Write { page, source };
let settings = page_settings(p, bbox).ok_or(at(WriteError::Page))?;
let size = Size::from_wh(w, h).ok_or(at(WriteError::Page))?;
let source = source.get_or_insert_with(|| PdfDocument::new(doc.clone()));
let mut page_builder = writer.document_mut().start_page_with(settings);
let mut surface = page_builder.surface();
surface.draw_pdf_page(source, size, page);
surface.finish();
page_builder.finish();
report.unflattened.push((page, e.to_string()));
}
Err(source) => return Err(FlattenDocumentError::Read { page, source }),
}
progress(done + 1, total);
}
current.set(None);
Ok(writer.finish()?)
});
let bytes = written.map_err(|e| match (e, current.get()) {
(FlattenDocumentError::Serialize(source), Some(page)) => FlattenDocumentError::Write { page, source: WriteError::Pdf(source) },
(e, _) => e,
})?;
report.totals = totals(&report.pages);
Ok((bytes, report))
}
/// Draws one page of `list.page`'s size with `draw`, in page space.
fn write_page(
writer: &mut Writer,
settings: PageSettings,
list: &DisplayList,
draw: impl FnOnce(&mut craft_pdf::write::backend::surface::Surface<'_>) -> Result<(), WriteError>,
) -> Result<(), WriteError> {
let box_ = list.page;
let mut page_builder = writer.document_mut().start_page_with(settings);
let mut surface = page_builder.surface();
let shifted = box_.x0 != 0.0 || box_.y0 != 0.0;
if shifted {
surface.push_transform(&Transform::from_translate(-box_.x0 as f32, -box_.y0 as f32));
}
let drawn = draw(&mut surface);
if shifted {
surface.pop();
}
surface.finish();
page_builder.finish();
drawn
}
/// A page of `bbox`'s size (page space) with the source page's TrimBox, BleedBox and ArtBox,
/// mapped into page space and clipped to `bbox`.
fn page_settings(p: &Page<'_>, bbox: Rect) -> Option<PageSettings> {
let size = PageSettings::from_wh(bbox.width() as f32, bbox.height() as f32).filter(|_| bbox.is_finite())?;
let to_page = p.initial_transform(true).to_kurbo();
let mapped = |key: &[u8]| {
let r = p.raw().get::<PdfRect>(key)?;
let r = to_page.transform_rect_bbox(Rect::new(r.x0, r.y0, r.x1, r.y1)).intersect(bbox);
if !(r.is_finite() && r.width() > 0.0 && r.height() > 0.0) {
return None;
}
KRect::from_ltrb((r.x0 - bbox.x0) as f32, (r.y0 - bbox.y0) as f32, (r.x1 - bbox.x0) as f32, (r.y1 - bbox.y0) as f32)
};
Some(size.with_trim_box(mapped(TRIM_BOX)).with_bleed_box(mapped(BLEED_BOX)).with_art_box(mapped(ART_BOX)))
}
/// The source's title, author, creation and modification dates, and `producer`.
fn metadata(doc: &Pdf, producer: &str) -> Metadata {
let info = doc.metadata();
let text = |bytes: &Option<Vec<u8>>| bytes.as_deref().map(|b| pdf_text(b).trim().to_string()).filter(|s| !s.is_empty());
Metadata {
producer: producer.to_string(),
title: text(&info.title),
authors: text(&info.author).into_iter().collect(),
created: info.creation_date.as_ref().map(date),
modified: info.modification_date.as_ref().map(date),
..Metadata::default()
}
}
fn date(d: &PdfDate) -> DateTime {
DateTime::new(d.year)
.month(d.month)
.day(d.day)
.hour(d.hour)
.minute(d.minute)
.second(d.second)
.utc_offset_hour(d.utc_offset_hour)
.utc_offset_minute(d.utc_offset_minute)
}
fn totals(pages: &[(usize, FlattenPageReport)]) -> FlattenTotals {
let mut t = FlattenTotals::default();
for (_, r) in pages {
t.flattened += r.flatten.flattened;
t.complex_regions += r.flatten.complex_regions;
t.complex_groups += r.flatten.complex_groups.len();
t.raster_images += r.raster.images;
t.raster_pixels += r.raster.pixels;
t.lowest_dpi = match (t.lowest_dpi, r.raster.lowest_dpi) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
t.images += r.read.images;
t.unreadable_images += r.read.unreadable_images;
t.dropped_tilings += r.read.dropped_tilings;
t.dropped_inks += r.read.dropped_inks;
t.dropped_annotations += r.read.dropped_annotations;
}
t
}
/// A page's printing condition, for reading, separating and writing.
struct PageColor {
/// With the output intent.
settings: SerializeSettings,
cms: CmsConversion,
condition: Fallback,
read: ReadColor,
}
/// The [`PageColor`]s of a document: one for the pages without their own output intents, one per
/// page with them.
struct Colors<'d> {
doc: &'d Pdf,
fallback: &'d Fallback,
cache: HashMap<Option<usize>, Rc<PageColor>>,
}
impl Colors<'_> {
fn get(&mut self, page: usize) -> Result<Rc<PageColor>, FlattenDocumentError> {
let own =
self.doc.pages().get(page).and_then(|p| p.raw().get::<Array<'_>>(OUTPUT_INTENTS)).is_some_and(|a| a.iter::<Dict<'_>>().next().is_some());
let key = own.then_some(page);
if let Some(color) = self.cache.get(&key) {
return Ok(color.clone());
}
let read = read_color(self.doc, page, self.fallback).map_err(|source| FlattenDocumentError::Read { page, source })?;
let (settings, cms, condition) = printing(self.doc, page, self.fallback).map_err(|source| FlattenDocumentError::Write { page, source })?;
let color = Rc::new(PageColor { settings, cms, condition, read });
self.cache.insert(key, color.clone());
Ok(color)
}
}
fn same_condition(a: &Fallback, b: &Fallback) -> bool {
match (a, b) {
(Fallback::Profile(a), Fallback::Profile(b)) => a == b,
_ => std::mem::discriminant(a) == std::mem::discriminant(b),
}
}
/// The output's serialize settings and printing condition for page `page`: its output intent
/// written again, else `fallback`'s profile, else FOGRA39.
fn printing(doc: &Pdf, page: usize, fallback: &Fallback) -> Result<(SerializeSettings, CmsConversion, Fallback), WriteError> {
let fogra39 = || -> Result<_, WriteError> {
// Reading the profile is the slow part of writing a small page; it is the usual one.
static SETTINGS: OnceLock<Option<SerializeSettings>> = OnceLock::new();
let settings = SETTINGS.get_or_init(|| print_settings::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).ok());
let settings = settings.clone().ok_or(WriteError::Pdf(pdf_write::WriteError::InvalidProfile))?;
Ok((settings, CmsConversion::fogra39(), Fallback::Fogra39))
};
let custom = |icc: &[u8], color: PrintColor<'_>| {
if icc == craft_cms::builtin::COATED_FOGRA39_ICC {
return fogra39().ok();
}
let cms = CmsConversion::new(icc).ok()?;
let settings = print_settings::settings(color).ok()?;
Some((settings, cms, Fallback::Profile(icc.into())))
};
let found = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plates::output_intent_profile(doc, page))).ok().flatten();
if let Some(intent) = found {
let color = PrintColor {
profile: &intent.icc,
condition_identifier: intent.identifier.as_deref().unwrap_or("Custom"),
condition: intent.condition.as_deref(),
registry_name: intent.registry_name.as_deref(),
info: intent.info.as_deref().or(intent.condition.as_deref()),
};
if let Some(found) = custom(&intent.icc, color) {
return Ok(found);
}
}
if let Fallback::Profile(icc) = fallback {
let color = PrintColor {
profile: icc,
condition_identifier: "Custom",
condition: None,
registry_name: None,
info: Some("Custom CMYK printing condition"),
};
if let Some(found) = custom(icc, color) {
return Ok(found);
}
}
fogra39()
}
+209
View File
@@ -0,0 +1,209 @@
//! Source fonts prepared for embedding again. The PDF-in adapter notes every outline glyph it
//! reads; after the page, [`FontCache::finish`] gives one [`TextFont`] per source font, or
//! `None` where the font's runs are drawn from their outlines.
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use craft_pdf::interpret::font::OutlineGlyph;
use craft_pdf::write::backend::geom::Point;
use craft_pdf::write::backend::page::PageSettings;
use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph};
use craft_pdf::write::backend::{Data, SerializeSettings};
use craft_pdf::write::{Metadata, WriteError, Writer, guard};
use super::{FontBytes, TextFont};
/// The fonts of one page, collected glyph by glyph.
#[derive(Default)]
pub struct FontCache {
fonts: HashMap<u128, Noted>,
}
struct Noted {
/// The font's own bytes and face index; `None` when the PDF doesn't embed the font or
/// hayro has no bytes for it.
source: Option<(FontBytes, u32)>,
/// Glyph id to advance in glyph space (1000 units per em), from the PDF's widths. Ordered,
/// so the trial embedding is deterministic.
advances: BTreeMap<u32, f32>,
}
impl FontCache {
pub fn new() -> Self {
Self::default()
}
/// Records a glyph seen on the page (font key, glyph id, PDF width) and returns the key.
pub fn note(&mut self, glyph: &OutlineGlyph) -> u128 {
let key = glyph.font_cache_key();
let noted = self.fonts.entry(key).or_insert_with(|| Noted {
// A substitute from the font resolver is not the file's font.
source: glyph.font_data().filter(|d| d.embedded).map(|d| (d.data, d.index)),
advances: BTreeMap::new(),
});
if noted.source.is_some() {
noted.advances.insert(glyph.glyph_id().to_u32(), glyph.advance_width().unwrap_or(0.0));
}
key
}
/// After the page is read: sniff, wrap bare CFF, trial-embed. `None` means: draw from
/// outlines. Every noted key is present.
pub fn finish(self) -> HashMap<u128, Option<Arc<TextFont>>> {
self.fonts.into_iter().map(|(key, noted)| (key, noted.prepare(key).map(Arc::new))).collect()
}
}
impl Noted {
fn prepare(self, key: u128) -> Option<TextFont> {
let (data, index) = self.source?;
let magic: [u8; 4] = (*data).as_ref().get(..4)?.try_into().ok()?;
let font = match &magic {
[0, 1, 0, 0] | b"OTTO" | b"true" | b"ttcf" => TextFont { data, index, key },
// The CFF wrap needs the widths of all glyphs used, hence once per page.
[1, _, _, _] => TextFont { data: Arc::new(wrap_cff((*data).as_ref(), &self.advances)?), index: 0, key },
_ => return None,
};
embeds(&font, self.advances.keys().copied()).then_some(font)
}
}
/// Whether krilla embeds `glyphs` of `font`. `Font::new` accepts fonts whose subsetting fails
/// later, in `Writer::finish`, which is too late to fall back for one font; a throwaway
/// one-page document finds those failures here.
fn embeds(font: &TextFont, glyphs: impl Iterator<Item = u32>) -> bool {
let glyphs: Vec<KrillaGlyph> = glyphs.map(|id| KrillaGlyph::new(GlyphId::new(id), 0.0, 0.0, 0.0, 0.0, 0..0, None)).collect();
let trial = || {
let font = Font::new(Data::from(font.data.clone()), font.index).ok_or(WriteError::InvalidFont)?;
let mut writer = Writer::new(SerializeSettings::default(), Metadata::default());
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(10.0, 10.0).ok_or(WriteError::InvalidFont)?);
let mut surface = page.surface();
surface.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, "", 10.0, false);
surface.finish();
page.finish();
writer.finish()
};
guard(trial).is_ok()
}
/// Wraps bare CFF (`FontFile3`) in the smallest OpenType font krilla loads: `CFF `, `head`,
/// `hhea`, `hmtx`, `maxp` 0.5 and `post` 3. `hmtx` holds `advances` (glyph space, 1000 units
/// per em) and 0 for the glyphs not listed; `unitsPerEm` follows the CFF `FontMatrix`.
pub(crate) fn wrap_cff(cff: &[u8], advances: &BTreeMap<u32, f32>) -> Option<Vec<u8>> {
let table = ttf_parser::cff::Table::parse(cff)?;
let glyphs = table.number_of_glyphs();
let upem = (1.0 / table.matrix().sx).round().clamp(16.0, 16384.0) as u16;
let scale = f32::from(upem) / 1000.0;
let be16 = |v: &mut Vec<u8>, x: u16| v.extend_from_slice(&x.to_be_bytes());
let be32 = |v: &mut Vec<u8>, x: u32| v.extend_from_slice(&x.to_be_bytes());
let mut head = Vec::new();
be32(&mut head, 0x0001_0000);
be32(&mut head, 0x0001_0000);
be32(&mut head, 0);
be32(&mut head, 0x5F0F_3CF5);
be16(&mut head, 0x000B);
be16(&mut head, upem);
head.extend_from_slice(&[0; 16]);
for v in [0, 0, upem, upem] {
be16(&mut head, v);
}
for v in [0, 8, 2, 0, 0] {
be16(&mut head, v);
}
let mut hmtx = Vec::new();
let mut max_advance = 0;
for gid in 0..u32::from(glyphs) {
let advance = advances.get(&gid).map_or(0, |a| (a * scale).round().clamp(0.0, 65535.0) as u16);
max_advance = max_advance.max(advance);
be16(&mut hmtx, advance);
be16(&mut hmtx, 0);
}
// krilla tells fonts apart by their metrics and `checksumAdjustment`, and two pages' wraps
// of one CFF differ only in `hmtx`: a hash of it keeps them apart in one document.
let hash = hmtx.iter().fold(0x811C_9DC5_u32, |h, b| (h ^ u32::from(*b)).wrapping_mul(0x0100_0193));
head[8..12].copy_from_slice(&hash.to_be_bytes());
let mut hhea = Vec::new();
be32(&mut hhea, 0x0001_0000);
be16(&mut hhea, (u32::from(upem) * 4 / 5) as u16);
be16(&mut hhea, (-(i32::from(upem) / 5)) as i16 as u16);
be16(&mut hhea, 0);
be16(&mut hhea, max_advance);
hhea.extend_from_slice(&[0; 4]);
be16(&mut hhea, max_advance);
be16(&mut hhea, 1);
hhea.extend_from_slice(&[0; 14]);
be16(&mut hhea, glyphs);
let mut maxp = Vec::new();
be32(&mut maxp, 0x0000_5000);
be16(&mut maxp, glyphs);
let mut post = Vec::new();
be32(&mut post, 0x0003_0000);
post.extend_from_slice(&[0; 28]);
// Tables in tag order, as the sfnt directory requires.
let tables: [(&[u8; 4], &[u8]); 6] = [(b"CFF ", cff), (b"head", &head), (b"hhea", &hhea), (b"hmtx", &hmtx), (b"maxp", &maxp), (b"post", &post)];
let mut out = Vec::new();
out.extend_from_slice(b"OTTO");
for v in [6, 64, 2, 32] {
be16(&mut out, v);
}
let mut offset = 12 + 16 * tables.len();
for (tag, data) in &tables {
out.extend_from_slice(*tag);
be32(&mut out, 0);
be32(&mut out, u32::try_from(offset).ok()?);
be32(&mut out, u32::try_from(data.len()).ok()?);
offset += data.len().next_multiple_of(4);
}
for (_, data) in &tables {
out.extend_from_slice(data);
out.resize(out.len().next_multiple_of(4), 0);
}
Some(out)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use craft_pdf::interpret::font::StandardFont;
#[test]
fn bare_cff_is_wrapped_in_an_opentype_font_with_the_pdf_widths() {
// hayro's Foxit Sans substitute is bare CFF, as `FontFile3/Type1C` holds it.
let (data, _) = StandardFont::Helvetica.get_font_data();
let cff = (*data).as_ref();
assert_eq!(cff[0], 1);
assert!(Font::new(Data::from(cff.to_vec()), 0).is_none(), "krilla takes OpenType only");
let advances = BTreeMap::from([(1, 278.0), (36, 667.4)]);
let wrapped = wrap_cff(cff, &advances).unwrap();
let face = ttf_parser::Face::parse(&wrapped, 0).unwrap();
assert_eq!(face.units_per_em(), 1000);
assert_eq!(face.number_of_glyphs(), ttf_parser::cff::Table::parse(cff).unwrap().number_of_glyphs());
let advance = |id| face.glyph_hor_advance(ttf_parser::GlyphId(id));
assert_eq!((advance(1), advance(36), advance(2)), (Some(278), Some(667), Some(0)));
let font = TextFont { data: Arc::new(wrapped.clone()), index: 0, key: 1 };
assert!(embeds(&font, advances.keys().copied()));
// Another page's glyphs with the same width sum: krilla must not take it for the same font.
let other = wrap_cff(cff, &BTreeMap::from([(1, 278.0), (37, 667.4)])).unwrap();
assert_ne!(Font::new(Data::from(wrapped), 0), Font::new(Data::from(other), 0));
}
#[test]
fn unreadable_bytes_draw_from_outlines() {
let noted = Noted { source: Some((Arc::new(b"%!PS-AdobeFont-1.0".to_vec()), 0)), advances: BTreeMap::from([(1, 500.0)]) };
assert!(noted.prepare(7).is_none());
let noted = Noted { source: Some((Arc::new(vec![1, 0, 4, 1, 0, 0]), 0)), advances: BTreeMap::new() };
assert!(noted.prepare(7).is_none(), "broken CFF");
}
}
File diff suppressed because it is too large. Load diff
+235
View File
@@ -0,0 +1,235 @@
//! The display list the transparency flattener reads and writes.
//!
//! Coordinates are page space in points, origin at the top left and y down, as krilla draws.
//! Opacity, blend modes, soft masks and clips live on [`GroupItem`]s only: a transparent or
//! clipped paint arrives as a group with one child. Strokes arrive outlined as fills.
//! Overprint stays a per-item attribute; the flattener applies it per plate.
use std::fmt;
use std::ops::Range;
use std::sync::Arc;
pub use kurbo;
use kurbo::{Affine, BezPath, Point, Rect};
use crate::ink::{InkColor, InkId, InkSet, Overprint};
pub use compose::{ComplexGroup, ComplexReason, ComplexRegion, FlattenError, FlattenOptions, FlattenReport, Flattened, flatten};
pub use document::{
FlattenDocumentError, FlattenDocumentOptions, FlattenDocumentReport, FlattenTotals, Unreadable, flatten_document, flatten_document_with_progress,
};
pub use geom::FillRule;
pub use raster::{FlattenPageError, FlattenPageOptions, FlattenPageReport, flatten_page};
mod compose;
mod document;
pub mod font;
pub mod geom;
pub mod pdf;
pub mod raster;
mod shade;
pub mod write;
/// One page's content as the flattener sees it.
#[derive(Clone, Debug, PartialEq)]
pub struct DisplayList {
/// The page box in page space.
pub page: Rect,
/// The inks the items' colors refer to.
pub inks: InkSet,
/// Items in painting order.
pub items: Vec<Item>,
}
/// A painted element.
#[derive(Clone, Debug, PartialEq)]
pub enum Item {
Fill(FillItem),
Text(TextItem),
Image(ImageItem),
Group(GroupItem),
}
/// A filled path.
#[derive(Clone, Debug, PartialEq)]
pub struct FillItem {
pub path: BezPath,
pub rule: FillRule,
pub paint: Paint,
pub overprint: Overprint,
}
/// A run of glyphs from one font at one size and transform, kept as live text when
/// written out.
#[derive(Clone, Debug, PartialEq)]
pub struct TextItem {
/// The font to embed again; `None` when the source font can't be re-embedded, and the
/// run is then written as `outlines`.
pub font: Option<Arc<TextFont>>,
/// The em size in text space units.
pub size: f32,
/// Text space to page space. Text space is y up, as in a PDF text matrix.
pub transform: Affine,
pub glyphs: Vec<PlacedGlyph>,
/// The Unicode text of the run, when the source had it; [`PlacedGlyph::text`] indexes it.
pub text: Option<String>,
/// The glyphs' filled outlines in page space. Always present: the flattener's geometry for
/// the run, and what is drawn when `font` is `None`.
pub outlines: BezPath,
pub paint: Paint,
pub overprint: Overprint,
}
/// A glyph of a [`TextItem`].
#[derive(Clone, Debug, PartialEq)]
pub struct PlacedGlyph {
/// The glyph id in [`TextFont::data`].
pub id: u32,
/// The glyph origin in text space.
pub origin: Point,
/// The bytes of [`TextItem::text`] this glyph shows; empty when it shows none.
pub text: Range<usize>,
}
/// Font bytes shared without copying, as hayro-interpret and krilla hold them.
pub type FontBytes = Arc<dyn AsRef<[u8]> + Send + Sync>;
/// An OpenType (TrueType or CFF outlines) font that krilla can embed.
#[derive(Clone)]
pub struct TextFont {
pub data: FontBytes,
/// The face index in a font collection; 0 for a single font.
pub index: u32,
/// Identifies the source font: runs with equal keys embed one font, so equal keys mean
/// equal bytes.
pub key: u128,
}
impl fmt::Debug for TextFont {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TextFont")
.field("bytes", &(*self.data).as_ref().len())
.field("index", &self.index)
.field("key", &format_args!("{:032x}", self.key))
.finish()
}
}
impl PartialEq for TextFont {
fn eq(&self, other: &Self) -> bool {
self.key == other.key && self.index == other.index
}
}
/// A sampled image, 8 bits per component, rows top to bottom.
#[derive(Clone, Debug, PartialEq)]
pub struct ImageItem {
pub space: ImageSpace,
/// Width and height in samples.
pub size: (u32, u32),
/// Interleaved components, `size.0 * size.1 * components` bytes.
pub samples: Arc<[u8]>,
/// The unit square to page space; (0, 0) is the top left sample.
pub transform: Affine,
/// Alpha, one byte per sample, at `size`.
pub smask: Option<Arc<[u8]>>,
pub overprint: Overprint,
/// The source's JPEG (DCTDecode) data, when `samples` are its decoding and the writer can
/// write it as it is: [`ImageSpace::Gray`] or [`ImageSpace::Cmyk`], no `smask`.
pub jpeg: Option<Arc<[u8]>>,
}
/// The components of an [`ImageItem`]'s samples.
#[derive(Clone, Debug, PartialEq)]
pub enum ImageSpace {
Gray,
Rgb,
Cmyk,
/// Tints of these inks, 255 = 100% ink, in this order.
Inks(Vec<InkId>),
}
/// A transparency group, or a plain clip when it is opaque, Normal and unmasked.
#[derive(Clone, Debug, PartialEq)]
pub struct GroupItem {
pub opacity: f32,
pub blend: BlendMode,
pub isolated: bool,
pub knockout: bool,
/// Nonzero clip in page space.
pub clip: Option<BezPath>,
pub mask: Option<Box<Mask>>,
/// The Unicode text the children show, for content drawn as items that is text in the
/// source, such as a colored Type 3 glyph.
pub actual_text: Option<String>,
pub children: Vec<Item>,
}
/// A soft mask (ISO 32000-2 §11.6.5).
#[derive(Clone, Debug, PartialEq)]
pub struct Mask {
pub kind: MaskKind,
pub group: GroupItem,
/// The luminosity mask's backdrop (`/BC`), what the group composites over; `None` for an
/// alpha mask.
pub backdrop: Option<InkColor>,
/// The transfer function (`/TR`) sampled at 256 evenly spaced mask values from 0 to 1, as
/// bytes; `None` for the identity.
pub transfer: Option<Arc<[u8]>>,
}
/// Which value of the mask group masks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MaskKind {
Alpha,
Luminosity,
}
/// What an item paints with.
#[derive(Clone, Debug, PartialEq)]
pub enum Paint {
Solid(InkColor),
/// Stops are offsets `0.0..=1.0` in increasing order. Function-based and mesh shadings
/// arrive as images.
Gradient {
stops: Vec<(f32, InkColor)>,
geometry: GradientGeometry,
},
}
/// Where a gradient runs, in gradient space.
#[derive(Clone, Debug, PartialEq)]
pub enum GradientGeometry {
Axial { start: Point, end: Point, extend: [bool; 2], transform: Affine },
Radial { start: Point, start_radius: f64, end: Point, end_radius: f64, extend: [bool; 2], transform: Affine },
}
/// PDF blend modes (ISO 32000-2 §11.3.5).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BlendMode {
#[default]
Normal,
Multiply,
Screen,
Overlay,
Darken,
Lighten,
ColorDodge,
ColorBurn,
HardLight,
SoftLight,
Difference,
Exclusion,
Hue,
Saturation,
Color,
Luminosity,
}
impl BlendMode {
/// Hue, Saturation, Color and Luminosity; on spot inks they act as Normal
/// (ISO 32000-1 §11.3.5.3).
pub fn is_non_separable(self) -> bool {
matches!(self, Self::Hue | Self::Saturation | Self::Color | Self::Luminosity)
}
}
File diff suppressed because it is too large. Load diff
+393
View File
@@ -0,0 +1,393 @@
//! Complex regions rasterized as ink images (plan tasks C3 and C4), and [`flatten_page`], which
//! flattens one page of a PDF.
//!
//! A region's image is its items written alone as a small PDF ([`page_pdf`]) and separated with
//! [`plates`], overprint on: one plane per ink, so spot inks stay spot inks and overprint applies
//! per plate as on press. The planes map to the list's inks by name and come back as an
//! [`ImageSpace::Inks`] image, which the writer clips to the region and writes as DeviceN.
//!
//! Backdrop: the sub-list is [`ComplexRegion::items`], in paint order, and nothing else. Those
//! are the owners of every shape covering the region, and a flattened group holds every item
//! whose box touches one of its members ([`geom::overlap_groups`](super::geom::overlap_groups)),
//! up to its last transparent item. Kept items are either in other groups, so their boxes are
//! clear of the region, or painted after it. So no kept item is under a complex region, and the
//! items listed are the region's whole backdrop.
//!
//! Seams:
//! - The image is snapped outward to the device pixel grid at the raster resolution (counted
//! from the page box's corner) and reaches one more device pixel on every side. The clip stays
//! the exact region path, so the image's edge pixels are never seen: no paper shows where the
//! clip edge falls between pixel centers, and anti-aliased edges of the image don't exist.
//! - Items render unclipped in the sub-list, so pixels along the region edge hold the full color
//! of the art on both sides of the edge, not a mix with paper.
//! - Neighboring complex regions are merged before rasterizing (`compose`), so their shared
//! edges fall inside one image.
//! - Regions larger than a plate render in tiles that abut on the device grid and are joined
//! into one image before it is written.
//!
//! Whole groups the region sweep failed on
//! ([`ComplexReason::Degenerate`](super::ComplexReason::Degenerate)) are clipped to their
//! members' boxes, which hold pixels nothing paints. Their image is opaque like any other: the
//! boxes are clear of every other group's items, so the paper is the only backdrop there, and
//! the plates of the items over paper print the same as the items would. A soft mask would leave
//! transparency on the page, which PDF/X-1a forbids.
//!
//! Ported from VectorCraft (MIT OR Apache-2.0) at commit c2b15982: `render_raster` in
//! `crates/engine/src/cmd/flatten.rs` (the image reaching a pixel past its area). VectorCraft
//! renders RGB over white; here the plates give each ink's tint directly.
use craft_pdf::OpenError;
use craft_pdf::interpret::color::CmykConversion;
use craft_pdf::write::backend::SerializeSettings;
use kurbo::{Affine, Rect, Shape as _};
use super::document::{FlattenDocumentError, FlattenDocumentOptions, flatten_document};
use super::pdf::{ReadError, ReadOptions, ReadReport};
use super::write::{WriteError, page_pdf};
use super::{
BlendMode, ComplexRegion, DisplayList, FillItem, FillRule, FlattenError, FlattenOptions, FlattenReport, GroupItem, ImageItem, ImageSpace, Item,
Mask, MaskKind, Paint,
};
use crate::ink::{Ink, InkColor, InkId, Overprint, Process};
use crate::plates::{Fallback, MAX_SPOT_INKS, Options, plates};
/// Pixels per complex region's image. A larger region rasterizes at a lower resolution;
/// [`RasterReport::lowest_dpi`] says how low.
pub const MAX_RASTER_PIXELS: u64 = 64 << 20;
/// Tile side in device pixels: tiles stay below [`crate::MAX_PLATE_PIXELS`], so `plates` renders
/// them at the resolution asked for.
const TILE: i64 = 1536;
/// What [`Rasterizer`] made.
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct RasterReport {
/// Images returned.
pub images: usize,
/// Device pixels of all images.
pub pixels: u64,
/// The lowest resolution used, when a region past [`MAX_RASTER_PIXELS`] needed less than
/// the one asked for.
pub lowest_dpi: Option<f32>,
}
/// Rasterizes the complex regions of a flattened [`DisplayList`]; [`Self::region`] is the
/// callback [`write_flattened`](super::write::write_flattened) takes.
pub struct Rasterizer<'a> {
list: &'a DisplayList,
settings: SerializeSettings,
printing: Fallback,
cms: &'a dyn CmykConversion,
dpi: f32,
report: RasterReport,
}
impl<'a> Rasterizer<'a> {
/// `settings` and `cms` are the output's, and `printing` is its printing condition: the
/// sub-lists are written without the output intent, which `plates` would decode and parse
/// for every region, and separated with `printing` as the fallback.
pub fn new(list: &'a DisplayList, settings: SerializeSettings, printing: Fallback, cms: &'a dyn CmykConversion, dpi: f32) -> Self {
let settings = SerializeSettings { output_intent: None, ..settings };
Self { list, settings, printing, cms, dpi, report: RasterReport::default() }
}
pub fn report(&self) -> &RasterReport {
&self.report
}
/// The image of `region`, or `None` when it prints no ink (nothing is under a complex
/// region to knock out).
pub fn region(&mut self, region: &ComplexRegion) -> Result<Option<ImageItem>, WriteError> {
let page = self.list.page;
let bounds = region.path.bounding_box();
if !(bounds.is_finite() && page.is_finite() && self.dpi.is_finite() && self.dpi > 0.0) {
return Ok(None);
}
let mut scale = f64::from(self.dpi) / 72.0;
let mut grid = Grid::new(bounds, page, scale);
let pixels = grid.pixels();
if pixels > MAX_RASTER_PIXELS {
scale *= (MAX_RASTER_PIXELS as f64 / pixels as f64).sqrt() * 0.999;
grid = Grid::new(bounds, page, scale);
let dpi = (scale * 72.0) as f32;
self.report.lowest_dpi = Some(self.report.lowest_dpi.map_or(dpi, |d| d.min(dpi)));
}
if grid.width() <= 0 || grid.height() <= 0 {
return Ok(None);
}
let items: Vec<Item> =
region.items.iter().filter_map(|&i| self.list.items.get(i)).map(|i| self.alpha_masks(i, &grid)).collect::<Result<_, _>>()?;
let planes = self.separate(&items, &grid, true)?;
let mut ids = Vec::new();
let mut used = Vec::new();
for ((id, _), plane) in self.list.inks.iter().zip(planes) {
if let Some(plane) = plane.filter(|p| p.iter().any(|v| *v > 0)) {
ids.push(id);
used.push(plane);
}
}
if ids.is_empty() {
return Ok(None);
}
let n = grid.len();
let mut samples = Vec::with_capacity(n * used.len());
for px in 0..n {
samples.extend(used.iter().map(|p| p[px]));
}
self.report.images += 1;
self.report.pixels += n as u64;
let r = grid.rect();
Ok(Some(ImageItem {
space: ImageSpace::Inks(ids),
size: (grid.width() as u32, grid.height() as u32),
samples: samples.into(),
transform: Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]),
smask: None,
overprint: Overprint::default(),
jpeg: None,
}))
}
/// `item` with its luminosity masks, and those of the groups in it, made alpha masks over
/// `grid` (see [`Self::luminosity`]).
fn alpha_masks(&self, item: &Item, grid: &Grid) -> Result<Item, WriteError> {
let Item::Group(g) = item else { return Ok(item.clone()) };
let mask = match g.mask.as_deref() {
Some(m) if m.kind == MaskKind::Luminosity => Some(Box::new(self.luminosity(m, grid)?)),
other => other.cloned().map(Box::new),
};
let children = g.children.iter().map(|c| self.alpha_masks(c, grid)).collect::<Result<_, _>>()?;
Ok(Item::Group(GroupItem {
opacity: g.opacity,
blend: g.blend,
isolated: g.isolated,
knockout: g.knockout,
clip: g.clip.clone(),
mask,
actual_text: g.actual_text.clone(),
children,
}))
}
/// A luminosity mask as an alpha mask: an image whose alpha is the mask's luminosity over
/// `grid`, its transfer function applied.
///
/// The mask's content arrives in inks, and a CMYK blending space has no exact black for the
/// renderer: K 100% (DeviceGray black, read as ink) has a luminosity of about 12% through
/// the printing condition. So the content is separated and its luminosity computed from the
/// inks with the ISO 32000-2 §10.4.2.2 formula, `1 - min(1, 0.3c + 0.59m + 0.11y + k)`,
/// spot inks through their CMYK equivalents.
fn luminosity(&self, m: &Mask, grid: &Grid) -> Result<Mask, WriteError> {
let r = grid.rect();
// Where nothing paints the mask is the backdrop's luminosity, black by default.
let backdrop = m.backdrop.clone().unwrap_or_else(|| InkColor::Tints(vec![(InkId::process(Process::Black), 1.0)]));
let content = [
Item::Fill(FillItem { path: r.to_path(0.1), rule: FillRule::NonZero, paint: Paint::Solid(backdrop), overprint: Overprint::default() }),
self.alpha_masks(&Item::Group(m.group.clone()), grid)?,
];
let planes = self.separate(&content, grid, false)?;
let weights: Vec<[f32; 4]> = self
.list
.inks
.iter()
.map(|(id, _)| match Process::ALL.get(id.index()) {
Some(p) => {
let mut w = [0.0; 4];
w[p.index()] = 1.0;
w
}
None => self.list.inks.spot_cmyk(id, self.cms).unwrap_or([0.0, 0.0, 0.0, 1.0]),
})
.collect();
let used: Vec<(&[u8], [f32; 4])> = planes.iter().zip(&weights).filter_map(|(p, w)| Some((p.as_deref()?, *w))).collect();
let alpha: Vec<u8> = (0..grid.len())
.map(|px| {
let mut cmyk = [0.0_f32; 4];
for (plane, w) in &used {
let t = f32::from(plane[px]) / 255.0;
for (v, w) in cmyk.iter_mut().zip(w) {
*v = 1.0 - (1.0 - *v) * (1.0 - t * w);
}
}
let [c, m_, y, k] = cmyk;
let gray = 1.0 - (0.3 * c + 0.59 * m_ + 0.11 * y + k).min(1.0);
let v = (gray * 255.0).round().clamp(0.0, 255.0) as u8;
m.transfer.as_ref().and_then(|t| t.get(usize::from(v)).copied()).unwrap_or(v)
})
.collect();
let n = alpha.len();
let image = ImageItem {
space: ImageSpace::Gray,
size: (grid.width() as u32, grid.height() as u32),
samples: vec![0; n].into(),
transform: Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]),
smask: Some(alpha.into()),
overprint: Overprint::default(),
jpeg: None,
};
let group = GroupItem {
opacity: 1.0,
blend: BlendMode::Normal,
isolated: false,
knockout: false,
clip: None,
mask: None,
actual_text: None,
children: vec![Item::Image(image)],
};
Ok(Mask { kind: MaskKind::Alpha, group, backdrop: None, transfer: None })
}
/// `items` separated over `grid`, tile by tile: per ink of the list, its plane, `None` for
/// inks the items don't print.
fn separate(&self, items: &[Item], grid: &Grid, overprint: bool) -> Result<Vec<Option<Vec<u8>>>, WriteError> {
let (w, h) = (grid.width() as usize, grid.height() as usize);
let mut planes: Vec<Option<Vec<u8>>> = vec![None; self.list.inks.len()];
let dpi = (grid.scale * 72.0) as f32;
// The renderer makes a page at least 1 pt on a side: thinner tiles are rendered at that
// size and cropped.
let min = grid.scale.ceil() as i64;
let mut ty = 0;
while ty < grid.height() {
let th = TILE.min(grid.height() - ty);
let mut tx = 0;
while tx < grid.width() {
let tw = TILE.min(grid.width() - tx);
let (rw, rh) = (tw.max(min), th.max(min));
// A quarter pixel more than the tile: the renderer floors the page size.
let (x0, y0) = grid.point(grid.x0 + tx, grid.y0 + ty);
let extra = 0.25 / grid.scale;
let tile = Rect::new(x0, y0, x0 + rw as f64 / grid.scale + extra, y0 + rh as f64 / grid.scale + extra);
let sub = DisplayList { page: tile, inks: self.list.inks.clone(), items: items.to_vec() };
let pdf = page_pdf(&sub, self.settings.clone(), self.cms)?;
let p = plates(&pdf, 0, dpi, &Options { overprint, fallback: self.printing.clone() })
.map_err(|e| WriteError::Region(format!("separating a complex region: {e}")))?;
if !p.unseparated.is_empty() {
let spots = p.inks.iter().filter(|(_, ink)| matches!(ink, Ink::Spot(_))).count();
return Err(WriteError::SpotInks { inks: spots + p.unseparated.len(), max: MAX_SPOT_INKS, unseparated: p.unseparated });
}
if (p.width as i64, p.height as i64) != (rw, rh) {
return Err(WriteError::Region(format!("a tile of {rw} × {rh} px separated at {} × {} px", p.width, p.height)));
}
for (pid, ink) in p.inks.iter() {
let Some(film) = p.plane(pid).filter(|f| f.iter().any(|v| *v > 0)) else { continue };
let id = self.list.inks.lookup(ink.name()).ok_or_else(|| WriteError::Region(format!("ink {} is not in the list", ink.name())))?;
let Some(plane) = planes.get_mut(id.index()) else { continue };
let plane = plane.get_or_insert_with(|| vec![0; w * h]);
for (row, src) in film.chunks_exact(rw as usize).take(th as usize).enumerate() {
let at = (ty as usize + row) * w + tx as usize;
if let (Some(dst), Some(src)) = (plane.get_mut(at..at + tw as usize), src.get(..tw as usize)) {
dst.copy_from_slice(src);
}
}
}
tx += tw;
}
ty += th;
}
Ok(planes)
}
}
/// A region's pixels on the device grid of the page: `x0..x1` and `y0..y1` count device pixels
/// from the page box's top left corner.
struct Grid {
page: Rect,
scale: f64,
x0: i64,
y0: i64,
x1: i64,
y1: i64,
}
impl Grid {
/// `bounds` snapped outward and grown by one pixel, within the page's pixels.
fn new(bounds: Rect, page: Rect, scale: f64) -> Self {
let px = |v: f64| v.clamp(-1e12, 1e12) as i64;
let (pw, ph) = (px((page.width() * scale).ceil()), px((page.height() * scale).ceil()));
Self {
page,
scale,
x0: px(((bounds.x0 - page.x0) * scale).floor() - 1.0).max(0),
y0: px(((bounds.y0 - page.y0) * scale).floor() - 1.0).max(0),
x1: px(((bounds.x1 - page.x0) * scale).ceil() + 1.0).min(pw),
y1: px(((bounds.y1 - page.y0) * scale).ceil() + 1.0).min(ph),
}
}
fn width(&self) -> i64 {
self.x1 - self.x0
}
fn height(&self) -> i64 {
self.y1 - self.y0
}
fn pixels(&self) -> u64 {
(self.width().max(0) as u64).saturating_mul(self.height().max(0) as u64)
}
fn len(&self) -> usize {
usize::try_from(self.pixels()).unwrap_or(usize::MAX)
}
/// Page space of device pixel corner (`x`, `y`).
fn point(&self, x: i64, y: i64) -> (f64, f64) {
(self.page.x0 + x as f64 / self.scale, self.page.y0 + y as f64 / self.scale)
}
fn rect(&self) -> Rect {
let (x0, y0) = self.point(self.x0, self.y0);
let (x1, y1) = self.point(self.x1, self.y1);
Rect::new(x0, y0, x1, y1)
}
}
/// How [`flatten_page`] reads and flattens.
#[derive(Clone, Debug, Default)]
pub struct FlattenPageOptions {
pub read: ReadOptions,
pub flatten: FlattenOptions,
}
/// What [`flatten_page`] did.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct FlattenPageReport {
pub read: ReadReport,
pub flatten: FlattenReport,
pub raster: RasterReport,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FlattenPageError {
#[error(transparent)]
Open(#[from] OpenError),
#[error(transparent)]
Read(#[from] ReadError),
#[error(transparent)]
Flatten(#[from] FlattenError),
#[error(transparent)]
Write(#[from] WriteError),
}
/// Page `page` (from 0) of `pdf` with its transparency flattened, as a one-page PDF: the
/// one-page case of [`flatten_document`].
pub fn flatten_page(pdf: &[u8], page: usize, opts: &FlattenPageOptions) -> Result<(Vec<u8>, FlattenPageReport), FlattenPageError> {
let doc =
FlattenDocumentOptions { read: opts.read.clone(), flatten: opts.flatten, pages: Some(page..page.saturating_add(1)), ..Default::default() };
let (bytes, mut report) = flatten_document(pdf, &doc).map_err(|e| match e {
FlattenDocumentError::Open(e) => FlattenPageError::Open(e),
FlattenDocumentError::PageRange { .. } => FlattenPageError::Read(ReadError::NoPage(page)),
FlattenDocumentError::Read { source, .. } => FlattenPageError::Read(source),
FlattenDocumentError::Flatten { source, .. } => FlattenPageError::Flatten(source),
FlattenDocumentError::Write { source, .. } => FlattenPageError::Write(source),
FlattenDocumentError::Serialize(e) => FlattenPageError::Write(WriteError::Pdf(e)),
})?;
let (_, report) = report.pages.pop().ok_or(FlattenPageError::Read(ReadError::Interpret))?;
Ok((bytes, report))
}
+828
View File
@@ -0,0 +1,828 @@
//! Raster work for the PDF-in adapter, all in ink values: gradient stops sampled from a shading
//! function, shadings rasterized, tiling patterns past the cell cap drawn, stencil masks traced.
use std::collections::HashMap;
use craft_pdf::interpret::color::{CmykSimulation, ColorSpace};
use craft_pdf::interpret::pattern::ShadingPattern;
use craft_pdf::interpret::shading::{ShadingFunction, ShadingType, Triangle};
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Vec2};
use super::{FillRule, GradientGeometry, ImageItem, ImageSpace, Item, Paint};
use crate::ink::{InkColor, InkId, InkSet, Overprint, Process};
/// Most a gradient's piecewise-linear stops may differ from the function, per ink.
const STOP_TOLERANCE: f32 = 0.5 / 255.0;
/// The narrowest interval between gradient stops; a discontinuous function gets a pair of stops
/// this close.
const MIN_STOP_STEP: f32 = 1.0 / 4096.0;
/// Sub-rows per pixel row when the canvas computes path coverage.
const SUBROWS: usize = 4;
/// Stops `(offset, values)` approximating `eval` on `0..=1` within [`STOP_TOLERANCE`]. `eval`
/// returns the same number of values everywhere; a failed evaluation reads as zeros.
pub(super) fn sample_stops(len: usize, eval: impl Fn(f32) -> Option<Vec<f32>>) -> Vec<(f32, Vec<f32>)> {
let f = |s: f32| {
let mut v = eval(s).unwrap_or_default();
v.resize(len, 0.0);
v
};
const START: usize = 8;
let mut stops = vec![(0.0, f(0.0))];
for k in 0..START {
let (a, b) = (k as f32 / START as f32, (k + 1) as f32 / START as f32);
let fa = stops.last().map(|s| s.1.clone()).unwrap_or_default();
refine(a, &fa, b, f(b), &f, &mut stops);
}
stops
}
fn refine(a: f32, fa: &[f32], b: f32, fb: Vec<f32>, f: &impl Fn(f32) -> Vec<f32>, out: &mut Vec<(f32, Vec<f32>)>) {
let m = (a + b) / 2.0;
let fm = f(m);
let err = |t: f32, ft: &[f32]| {
let u = (t - a) / (b - a);
fa.iter().zip(&fb).zip(ft).map(|((x, y), v)| (x + (y - x) * u - v).abs()).fold(0.0_f32, f32::max)
};
let within = err(m, &fm) <= STOP_TOLERANCE
&& [0.25, 0.75].iter().all(|q| {
let t = a + (b - a) * q;
err(t, &f(t)) <= STOP_TOLERANCE
});
if within || b - a <= MIN_STOP_STEP {
out.push((b, fb));
} else {
refine(a, fa, m, fm.clone(), f, out);
refine(m, &fm, b, fb, f, out);
}
}
/// `function` at `input`.
pub(super) fn eval(function: &ShadingFunction, input: &[f32]) -> Option<Vec<f32>> {
function.eval(&input.iter().copied().collect()).map(|v| v.to_vec())
}
/// A shading's colors sampled on a pixel grid.
pub(super) struct ShadeRaster {
pub width: u32,
pub height: u32,
/// `ink_space.len()` values per pixel.
pub values: Vec<f32>,
/// 255 where the shading (or its background) paints.
pub alpha: Vec<u8>,
}
/// The function-based or mesh shading `sp` over `area` (page space) at `scale` pixels per point,
/// with `/Background` under it when `background`. `None` for other shading types.
pub(super) fn rasterize_shading(
sp: &ShadingPattern,
area: Rect,
scale: f64,
background: bool,
simulation: Option<&CmykSimulation>,
) -> Option<ShadeRaster> {
let (w, h) = pixel_size(area, scale)?;
let cs: &ColorSpace = &sp.shading.color_space;
let n = cs.components().max(1);
let mut comps = vec![0.0_f32; w * h * n];
let mut covered = vec![false; w * h];
let to_px = Affine::scale(scale) * Affine::translate(-area.origin().to_vec2());
match sp.shading.shading_type.as_ref() {
ShadingType::FunctionBased { domain, matrix, function } => {
let inv = (to_px * sp.matrix * *matrix).inverse();
for y in 0..h {
for x in 0..w {
let r = inv * Point::new(x as f64 + 0.5, y as f64 + 0.5);
let (rx, ry) = (r.x as f32, r.y as f32);
if rx < domain[0] || rx > domain[1] || ry < domain[2] || ry > domain[3] {
continue;
}
if let Some(c) = eval(function, &[rx, ry]) {
set(&mut comps, &mut covered, n, y * w + x, &c);
}
}
}
}
ShadingType::TriangleMesh { triangles, function } => {
let mut raster = Triangles { w, h, n, comps: &mut comps, covered: &mut covered, function: function.as_ref() };
for t in triangles {
raster.draw(t, to_px * sp.matrix);
}
}
ShadingType::CoonsPatchMesh { patches, function } => {
let mut raster = Triangles { w, h, n, comps: &mut comps, covered: &mut covered, function: function.as_ref() };
let mut buffer = Vec::new();
for patch in patches {
buffer.clear();
patch.to_triangles(&mut buffer);
for t in &buffer {
raster.draw(t, to_px * sp.matrix);
}
}
}
ShadingType::TensorProductPatchMesh { patches, function } => {
let mut raster = Triangles { w, h, n, comps: &mut comps, covered: &mut covered, function: function.as_ref() };
let mut buffer = Vec::new();
for patch in patches {
buffer.clear();
patch.to_triangles(&mut buffer);
for t in &buffer {
raster.draw(t, to_px * sp.matrix);
}
}
}
ShadingType::RadialAxial { .. } | ShadingType::Dummy => return None,
}
if let Some(bg) = sp.shading.background.as_ref().filter(|_| background) {
for i in 0..w * h {
if !covered[i] {
set(&mut comps, &mut covered, n, i, bg);
}
}
}
// Only covered pixels are converted.
let mut colors = Vec::new();
for (i, c) in covered.iter().enumerate() {
if *c {
colors.extend_from_slice(&comps[i * n..(i + 1) * n]);
}
}
let mut converted = Vec::new();
cs.ink_values(&colors, simulation, &mut converted);
let len = cs.ink_space(simulation).len();
let mut values = vec![0.0_f32; w * h * len];
let mut src = converted.chunks_exact(len.max(1));
for (i, c) in covered.iter().enumerate() {
if *c
&& len > 0
&& let Some(v) = src.next()
{
values[i * len..(i + 1) * len].copy_from_slice(v);
}
}
let alpha = covered.iter().map(|c| if *c { 255 } else { 0 }).collect();
Some(ShadeRaster { width: w as u32, height: h as u32, values, alpha })
}
fn set(comps: &mut [f32], covered: &mut [bool], n: usize, i: usize, c: &[f32]) {
for (k, v) in comps[i * n..(i + 1) * n].iter_mut().enumerate() {
*v = c.get(k).copied().unwrap_or(0.0);
}
covered[i] = true;
}
/// The pixel grid covering `area` at `scale`: its size, or `None` when empty.
pub(super) fn pixel_size(area: Rect, scale: f64) -> Option<(usize, usize)> {
let (w, h) = ((area.width() * scale).ceil(), (area.height() * scale).ceil());
(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0).then_some((w as usize, h as usize))
}
/// Mesh triangles drawn into component buffers, later triangles over earlier ones.
struct Triangles<'a> {
w: usize,
h: usize,
n: usize,
comps: &'a mut [f32],
covered: &'a mut [bool],
function: Option<&'a ShadingFunction>,
}
impl Triangles<'_> {
fn draw(&mut self, t: &Triangle, to_px: Affine) {
let (a, b, c) = (to_px * t.p0.point, to_px * t.p1.point, to_px * t.p2.point);
let area2 = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y);
if !area2.is_finite() || area2.abs() < 1e-12 {
return;
}
let bbox = Rect::from_points(a, b).union_pt(c);
let x0 = (bbox.x0 - 0.5).ceil().max(0.0) as usize;
let y0 = (bbox.y0 - 0.5).ceil().max(0.0) as usize;
let x1 = ((bbox.x1 - 0.5).floor() + 1.0).clamp(0.0, self.w as f64) as usize;
let y1 = ((bbox.y1 - 0.5).floor() + 1.0).clamp(0.0, self.h as f64) as usize;
let mut color = Vec::with_capacity(self.n);
for y in y0..y1 {
for x in x0..x1 {
let p = Point::new(x as f64 + 0.5, y as f64 + 0.5);
let la = ((b.x - p.x) * (c.y - p.y) - (c.x - p.x) * (b.y - p.y)) / area2;
let lb = ((c.x - p.x) * (a.y - p.y) - (a.x - p.x) * (c.y - p.y)) / area2;
let lc = 1.0 - la - lb;
if la < -1e-9 || lb < -1e-9 || lc < -1e-9 {
continue;
}
color.clear();
let (ca, cb, cc) = (&t.p0.colors, &t.p1.colors, &t.p2.colors);
for k in 0..ca.len() {
let v =
la * f64::from(ca[k]) + lb * f64::from(cb.get(k).copied().unwrap_or(0.0)) + lc * f64::from(cc.get(k).copied().unwrap_or(0.0));
color.push(v as f32);
}
let value = match self.function {
Some(f) => match eval(f, &color[..color.len().min(1)]) {
Some(v) => v,
None => continue,
},
None => color.clone(),
};
set(self.comps, self.covered, self.n, y * self.w + x, &value);
}
}
}
}
/// `items` with every coordinate moved by `by`.
pub(super) fn translate(items: &mut [Item], by: Vec2) {
let t = Affine::translate(by);
for item in items {
match item {
Item::Fill(f) => {
f.path.apply_affine(t);
translate_paint(&mut f.paint, t);
}
Item::Text(text) => {
text.transform = t * text.transform;
text.outlines.apply_affine(t);
translate_paint(&mut text.paint, t);
}
Item::Image(im) => im.transform = t * im.transform,
Item::Group(g) => {
if let Some(clip) = g.clip.as_mut() {
clip.apply_affine(t);
}
if let Some(mask) = g.mask.as_mut() {
translate(&mut mask.group.children, by);
if let Some(clip) = mask.group.clip.as_mut() {
clip.apply_affine(t);
}
}
translate(&mut g.children, by);
}
}
}
}
fn translate_paint(paint: &mut Paint, t: Affine) {
if let Paint::Gradient { geometry, .. } = paint {
match geometry {
GradientGeometry::Axial { transform, .. } | GradientGeometry::Radial { transform, .. } => *transform = t * *transform,
}
}
}
/// The opaque samples (≥ 128) of a `w` × `h` mask as disjoint rectangles in sample space;
/// `None` past `cap` rectangles.
pub(super) fn trace_stencil(mask: &[u8], w: usize, h: usize, cap: usize) -> Option<Vec<Rect>> {
let mut done = Vec::new();
let mut open: HashMap<(usize, usize), usize> = HashMap::new();
for y in 0..=h {
let mut next = HashMap::new();
if y < h {
let row = mask.get(y * w..(y + 1) * w)?;
let mut x = 0;
while x < w {
if row[x] >= 128 {
let start = x;
while x < w && row[x] >= 128 {
x += 1;
}
let top = open.remove(&(start, x)).unwrap_or(y);
next.insert((start, x), top);
} else {
x += 1;
}
}
}
for ((x0, x1), top) in open.drain() {
done.push(Rect::new(x0 as f64, top as f64, x1 as f64, y as f64));
if done.len() > cap {
return None;
}
}
open = next;
}
Some(done)
}
/// `src` (`sw` × `sh`) resampled bilinearly to `dw` × `dh`.
pub(super) fn resample(src: &[u8], (sw, sh): (usize, usize), (dw, dh): (usize, usize)) -> Vec<u8> {
let at = |x: usize, y: usize| f64::from(src.get(y.min(sh - 1) * sw + x.min(sw - 1)).copied().unwrap_or(0));
let mut out = Vec::with_capacity(dw * dh);
for y in 0..dh {
let fy = ((y as f64 + 0.5) * sh as f64 / dh as f64 - 0.5).max(0.0);
let (y0, ty) = (fy.floor() as usize, fy.fract());
for x in 0..dw {
let fx = ((x as f64 + 0.5) * sw as f64 / dw as f64 - 0.5).max(0.0);
let (x0, tx) = (fx.floor() as usize, fx.fract());
let top = at(x0, y0) * (1.0 - tx) + at(x0 + 1, y0) * tx;
let bottom = at(x0, y0 + 1) * (1.0 - tx) + at(x0 + 1, y0 + 1) * tx;
out.push((top * (1.0 - ty) + bottom * ty).round().clamp(0.0, 255.0) as u8);
}
}
out
}
/// A raster of every ink in an [`InkSet`] with alpha (values premultiplied), for content drawn
/// as an image.
/// Transparency groups draw as plain groups: their clip and opacity apply; blend modes and soft
/// masks are left out.
pub(super) struct Canvas<'a> {
w: usize,
h: usize,
inks: &'a InkSet,
n: usize,
/// Page space to pixels.
to_px: Affine,
values: Vec<f32>,
alpha: Vec<f32>,
}
impl<'a> Canvas<'a> {
pub fn new(area: Rect, scale: f64, inks: &'a InkSet) -> Option<Self> {
let (w, h) = pixel_size(area, scale)?;
let n = inks.len();
let to_px = Affine::scale(scale) * Affine::translate(-area.origin().to_vec2());
let pixels = w.checked_mul(h)?;
Some(Self { w, h, inks, n, to_px, values: vec![0.0; pixels.checked_mul(n)?], alpha: vec![0.0; pixels] })
}
pub fn size(&self) -> (usize, usize) {
(self.w, self.h)
}
/// The unit square to page space, as [`ImageItem::transform`].
pub fn image_transform(&self) -> Affine {
self.to_px.inverse() * Affine::scale_non_uniform(self.w as f64, self.h as f64)
}
pub fn draw(&mut self, items: &[Item]) {
self.draw_clipped(items, None, 1.0);
}
fn draw_clipped(&mut self, items: &[Item], clip: Option<&[f32]>, opacity: f32) {
for item in items {
match item {
Item::Fill(f) => self.fill(&f.path, f.rule, &f.paint, f.overprint, clip, opacity),
Item::Text(t) => self.fill(&t.outlines, FillRule::NonZero, &t.paint, t.overprint, clip, opacity),
Item::Image(im) => self.image(im, clip, opacity),
Item::Group(g) => {
let own = g.clip.as_ref().map(|c| {
let mut cov = self.coverage(c, FillRule::NonZero);
if let Some(outer) = clip {
cov.iter_mut().zip(outer).for_each(|(a, b)| *a *= b);
}
cov
});
self.draw_clipped(&g.children, own.as_deref().or(clip), opacity * g.opacity.clamp(0.0, 1.0));
}
}
}
}
/// Dense values and the inks a paint sets, as the compositor reads a flat paint.
fn dense(&self, color: &InkColor, overprint: Overprint) -> Option<(Vec<f32>, Vec<bool>)> {
let n = self.n;
let (mut value, mut named) = (vec![0.0; n], vec![false; n]);
match color {
InkColor::None => return None,
InkColor::All(t) => {
value.fill(t.clamp(0.0, 1.0));
named.fill(true);
}
InkColor::Tints(tints) => {
for &(id, t) in tints {
if let (Some(v), Some(k)) = (value.get_mut(id.index()), named.get_mut(id.index())) {
*v = t.clamp(0.0, 1.0);
*k = true;
}
}
if overprint.nonzero && color.as_cmyk().is_some() {
for (k, v) in named.iter_mut().zip(&value).take(4) {
*k = *v > 0.0;
}
}
}
}
if !overprint.fill {
named.fill(true);
}
Some((value, named))
}
fn put(&mut self, i: usize, cov: f32, value: &[f32], named: &[bool]) {
if cov <= 0.0 {
return;
}
let cov = cov.min(1.0);
for k in 0..self.n {
if named[k] {
let v = &mut self.values[i * self.n + k];
*v = *v * (1.0 - cov) + value[k] * cov;
}
}
self.alpha[i] += cov * (1.0 - self.alpha[i]);
}
fn fill(&mut self, path: &BezPath, rule: FillRule, paint: &Paint, overprint: Overprint, clip: Option<&[f32]>, opacity: f32) {
let cov = self.coverage(path, rule);
let factor = |i: usize| cov[i] * clip.map_or(1.0, |c| c[i]) * opacity;
match paint {
Paint::Solid(color) => {
let Some((value, named)) = self.dense(color, overprint) else { return };
for i in 0..self.w * self.h {
self.put(i, factor(i), &value, &named);
}
}
Paint::Gradient { stops, geometry } => {
let dense: Vec<(f32, Vec<f32>, Vec<bool>)> =
stops.iter().filter_map(|(s, c)| self.dense(c, overprint).map(|(v, k)| (*s, v, k))).collect();
let Some(first) = dense.first() else { return };
let mut named = first.2.clone();
for d in &dense {
named.iter_mut().zip(&d.2).for_each(|(a, b)| *a |= b);
}
let from_px = self.to_px.inverse();
let mut value = vec![0.0; self.n];
for y in 0..self.h {
for x in 0..self.w {
let i = y * self.w + x;
let f = factor(i);
if f <= 0.0 {
continue;
}
let p = from_px * Point::new(x as f64 + 0.5, y as f64 + 0.5);
let Some(s) = gradient_offset(geometry, p) else { continue };
interpolate_stops(&dense, s, &mut value);
self.put(i, f, &value, &named);
}
}
}
}
}
fn image(&mut self, im: &ImageItem, clip: Option<&[f32]>, opacity: f32) {
let (iw, ih) = (im.size.0 as usize, im.size.1 as usize);
let inv = (self.to_px * im.transform).inverse();
let ids: Vec<InkId> = match &im.space {
ImageSpace::Gray => vec![InkId::process(Process::Black)],
ImageSpace::Cmyk => Process::ALL.iter().map(|p| InkId::process(*p)).collect(),
ImageSpace::Inks(ids) => ids.clone(),
ImageSpace::Rgb => return,
};
let comps = if im.space == ImageSpace::Gray { 1 } else { ids.len() };
let mut named = vec![!im.overprint.fill; self.n];
for id in &ids {
if let Some(k) = named.get_mut(id.index()) {
*k = true;
}
}
if im.space == ImageSpace::Gray && !im.overprint.fill {
named.fill(true);
} else if im.space == ImageSpace::Gray {
named.iter_mut().take(4).for_each(|k| *k = true);
}
let mut value = vec![0.0; self.n];
for y in 0..self.h {
for x in 0..self.w {
let u = inv * Point::new(x as f64 + 0.5, y as f64 + 0.5);
if !(0.0..1.0).contains(&u.x) || !(0.0..1.0).contains(&u.y) {
continue;
}
let (sx, sy) = ((u.x * iw as f64) as usize, (u.y * ih as f64) as usize);
let s = sy * iw + sx;
let Some(sample) = im.samples.get(s * comps..(s + 1) * comps) else { continue };
value.fill(0.0);
if im.space == ImageSpace::Gray {
value[Process::Black.index()] = 1.0 - f32::from(sample[0]) / 255.0;
} else {
for (id, v) in ids.iter().zip(sample) {
if let Some(slot) = value.get_mut(id.index()) {
*slot = f32::from(*v) / 255.0;
}
}
}
let a = im.smask.as_ref().and_then(|m| m.get(s)).map_or(1.0, |a| f32::from(*a) / 255.0);
let i = y * self.w + x;
self.put(i, a * clip.map_or(1.0, |c| c[i]) * opacity, &value, &named);
}
}
}
/// The fraction of each pixel `path` covers.
fn coverage(&self, path: &BezPath, rule: FillRule) -> Vec<f32> {
let mut edges: Vec<(Point, Point)> = Vec::new();
let (mut start, mut last) = (Point::ZERO, Point::ZERO);
kurbo::flatten(self.to_px * path.clone(), 0.1, |el| match el {
PathEl::MoveTo(p) => {
if last != start {
edges.push((last, start));
}
(start, last) = (p, p);
}
PathEl::LineTo(p) => {
edges.push((last, p));
last = p;
}
PathEl::ClosePath => {
if last != start {
edges.push((last, start));
}
last = start;
}
_ => {}
});
if last != start {
edges.push((last, start));
}
let mut cov = vec![0.0_f32; self.w * self.h];
let mut crossings: Vec<(f64, i32)> = Vec::new();
for sy in 0..self.h * SUBROWS {
let y = (sy as f64 + 0.5) / SUBROWS as f64;
crossings.clear();
for (a, b) in &edges {
let (lo, hi, dir) = if a.y <= b.y { (a, b, 1) } else { (b, a, -1) };
if y >= lo.y && y < hi.y {
crossings.push((lo.x + (y - lo.y) / (hi.y - lo.y) * (hi.x - lo.x), dir));
}
}
crossings.sort_by(|a, b| a.0.total_cmp(&b.0));
let row = sy / SUBROWS;
let mut winding = 0;
for k in 0..crossings.len() {
winding += crossings[k].1;
let inside = match rule {
FillRule::NonZero => winding != 0,
FillRule::EvenOdd => winding % 2 != 0,
};
if inside && let Some(next) = crossings.get(k + 1) {
self.span(&mut cov, row, crossings[k].0, next.0);
}
}
}
cov
}
fn span(&self, cov: &mut [f32], row: usize, xa: f64, xb: f64) {
let (xa, xb) = (xa.clamp(0.0, self.w as f64), xb.clamp(0.0, self.w as f64));
if xb <= xa {
return;
}
let weight = 1.0 / SUBROWS as f32;
let last = (xb.ceil() as usize).min(self.w);
for px in xa.floor() as usize..last {
let overlap = xb.min(px as f64 + 1.0) - xa.max(px as f64);
if overlap > 0.0 {
cov[row * self.w + px] += overlap as f32 * weight;
}
}
}
/// The canvas as an image in every ink of the set, and its alpha when not opaque throughout.
pub fn into_image(self, overprint: Overprint) -> ImageItem {
let transform = self.image_transform();
let ids: Vec<InkId> = self.inks.iter().map(|(id, _)| id).collect();
// Values are premultiplied by alpha; samples are not.
let n = self.n.max(1);
let samples: Vec<u8> = self
.values
.iter()
.enumerate()
.map(|(k, v)| {
let a = self.alpha[k / n];
let v = if a > 0.0 { v / a } else { 0.0 };
(v.clamp(0.0, 1.0) * 255.0).round() as u8
})
.collect();
let opaque = self.alpha.iter().all(|a| *a >= 1.0 - 1e-4);
let smask = (!opaque).then(|| self.alpha.iter().map(|a| (a.clamp(0.0, 1.0) * 255.0).round() as u8).collect());
ImageItem {
space: ImageSpace::Inks(ids),
size: (self.w as u32, self.h as u32),
samples: samples.into(),
transform,
smask,
overprint,
jpeg: None,
}
}
/// The canvas sample nearest page point `p`: premultiplied values and alpha.
pub fn sample(&self, p: Point) -> Option<(&[f32], f32)> {
let q = self.to_px * p;
if q.x < 0.0 || q.y < 0.0 {
return None;
}
let (x, y) = (q.x as usize, q.y as usize);
if x >= self.w || y >= self.h {
return None;
}
let i = y * self.w + x;
Some((&self.values[i * self.n..(i + 1) * self.n], self.alpha[i]))
}
/// Composites the premultiplied values `value` with alpha `a` over pixel (`x`, `y`),
/// knocking out every ink.
pub fn over(&mut self, x: usize, y: usize, value: &[f32], a: f32) {
let i = y * self.w + x;
for (v, s) in self.values[i * self.n..(i + 1) * self.n].iter_mut().zip(value) {
*v = *v * (1.0 - a) + s;
}
self.alpha[i] += a * (1.0 - self.alpha[i]);
}
/// The page point at the center of pixel (`x`, `y`).
pub fn center(&self, x: usize, y: usize) -> Point {
self.to_px.inverse() * Point::new(x as f64 + 0.5, y as f64 + 0.5)
}
}
/// Where page point `p` falls on the gradient, `0..=1`; `None` where it paints nothing.
fn gradient_offset(geometry: &GradientGeometry, p: Point) -> Option<f64> {
let clamp = |s: f64, extend: [bool; 2]| {
if s < 0.0 {
extend[0].then_some(0.0)
} else if s > 1.0 {
extend[1].then_some(1.0)
} else {
Some(s)
}
};
match geometry {
GradientGeometry::Axial { start, end, extend, transform } => {
let q = transform.inverse() * p;
let d = *end - *start;
let len2 = d.hypot2();
if len2 <= f64::EPSILON {
return None;
}
clamp((q - *start).dot(d) / len2, *extend)
}
GradientGeometry::Radial { start, start_radius, end, end_radius, extend, transform } => {
let q = transform.inverse() * p;
let (cd, pd, dr, r0) = (*end - *start, q - *start, end_radius - start_radius, *start_radius);
let a = cd.hypot2() - dr * dr;
let b = pd.dot(cd) + r0 * dr;
let c = pd.hypot2() - r0 * r0;
let candidates = if a.abs() < 1e-9 {
if b.abs() < 1e-12 {
return None;
}
[c / (2.0 * b), f64::NAN]
} else {
let disc = b * b - a * c;
if disc < 0.0 {
return None;
}
let root = disc.sqrt();
let (s1, s2) = ((b + root) / a, (b - root) / a);
[s1.max(s2), s1.min(s2)]
};
candidates.into_iter().filter(|s| s.is_finite() && r0 + s * dr >= 0.0).find_map(|s| clamp(s, *extend))
}
}
}
fn interpolate_stops(stops: &[(f32, Vec<f32>, Vec<bool>)], s: f64, out: &mut [f32]) {
let s = s as f32;
let k = stops.iter().position(|st| st.0 >= s).unwrap_or(stops.len() - 1);
let (hi, lo) = (&stops[k], &stops[k.saturating_sub(1)]);
let u = if hi.0 > lo.0 { ((s - lo.0) / (hi.0 - lo.0)).clamp(0.0, 1.0) } else { 1.0 };
for (o, (a, b)) in out.iter_mut().zip(lo.1.iter().zip(&hi.1)) {
*o = a + (b - a) * u;
}
}
/// The unit square of a raster image placed over `area` at `scale`, as [`ImageItem::transform`].
pub(super) fn area_transform(area: Rect, scale: f64, (w, h): (usize, usize)) -> Affine {
Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(w as f64 / scale, h as f64 / scale)
}
/// A gradient stop's color from ink values.
pub(super) fn stop_color(ids: &StopInks, values: &[f32]) -> InkColor {
match ids {
StopInks::Gray => {
let k = 1.0 - values.first().copied().unwrap_or(1.0);
InkColor::Tints(Process::ALL.iter().map(|p| (InkId::process(*p), if *p == Process::Black { k } else { 0.0 })).collect())
}
StopInks::Inks(ids) => InkColor::Tints(ids.iter().zip(values).map(|(id, v)| (*id, *v)).collect()),
}
}
/// What ink values mean in a gradient stop.
pub(super) enum StopInks {
/// A gray level, painted as black.
Gray,
Inks(Vec<InkId>),
}
/// Where a gradient with `extend` paints, in page space, as a clip for the same gradient
/// extended at both ends; `None` when it extends at both. `bounds` is the page area that can
/// show. Exact for axial gradients; for radial ones it is the convex hull of the circles the
/// gradient sweeps, which is where it paints.
pub(super) fn gradient_extent(geometry: &GradientGeometry, extend: [bool; 2], bounds: Rect) -> Option<BezPath> {
if extend == [true, true] {
return None;
}
let (transform, path) = match geometry {
GradientGeometry::Axial { start, end, transform, .. } => {
let inv = transform.inverse();
let r = inv.transform_rect_bbox(bounds);
let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
let d = *end - *start;
let len = d.hypot();
if len.is_nan() || len <= 0.0 {
return None;
}
let normal = Vec2::new(-d.y, d.x) / len;
let s: Vec<f64> = corners.iter().map(|c| (*c - *start).dot(d) / (len * len)).collect();
let t: Vec<f64> = corners.iter().map(|c| (*c - *start).dot(normal)).collect();
let (smin, smax) = s.iter().fold((0.0_f64, 1.0_f64), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
let (tmin, tmax) = t.iter().fold((0.0_f64, 0.0_f64), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
let a = if extend[0] { smin - 1.0 } else { 0.0 };
let b = if extend[1] { smax + 1.0 } else { 1.0 };
let at = |s: f64, t: f64| *start + d * s + normal * t;
let mut path = BezPath::new();
path.move_to(at(a, tmin - 1.0));
path.line_to(at(b, tmin - 1.0));
path.line_to(at(b, tmax + 1.0));
path.line_to(at(a, tmax + 1.0));
path.close_path();
(*transform, path)
}
GradientGeometry::Radial { start, start_radius, end, end_radius, transform, .. } => {
let inv = transform.inverse();
let r = inv.transform_rect_bbox(bounds);
let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
let circle = |s: f64| (*start + (*end - *start) * s, start_radius + s * (end_radius - start_radius));
let dr = end_radius - start_radius;
// How far an extended end reaches: to where the radius is 0, or until the circle
// holds the visible area.
let reach = |from: f64, dir: f64| {
if dr * dir < 0.0 {
return -start_radius / dr;
}
let mut s = from + dir;
for _ in 0..64 {
let (c, radius) = circle(s);
if corners.iter().all(|p| (*p - c).hypot() <= radius) {
break;
}
s = from + (s - from) * 2.0;
}
s
};
let a = if extend[0] { reach(0.0, -1.0) } else { 0.0 };
let b = if extend[1] { reach(1.0, 1.0) } else { 1.0 };
(*transform, hull_of_circles(circle(a), circle(b)))
}
};
Some(transform * path)
}
/// The convex hull of two circles, circumscribed by a polygon.
fn hull_of_circles((c0, r0): (Point, f64), (c1, r1): (Point, f64)) -> BezPath {
const SIDES: usize = 256;
let grow = 1.0 / (std::f64::consts::PI / SIDES as f64).cos();
let mut points = Vec::with_capacity(2 * SIDES);
for (c, r) in [(c0, r0.max(0.0)), (c1, r1.max(0.0))] {
for k in 0..SIDES {
let a = k as f64 * std::f64::consts::TAU / SIDES as f64;
points.push(c + Vec2::new(a.cos(), a.sin()) * r * grow);
}
}
points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
let cross = |o: Point, a: Point, b: Point| (a - o).cross(b - o);
let mut hull: Vec<Point> = Vec::new();
for pass in 0..2 {
let floor = hull.len();
let iter: Box<dyn Iterator<Item = &Point>> = if pass == 0 { Box::new(points.iter()) } else { Box::new(points.iter().rev()) };
for p in iter {
while hull.len() >= floor + 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], *p) <= 0.0 {
hull.pop();
}
hull.push(*p);
}
hull.pop();
}
let mut path = BezPath::new();
for (k, p) in hull.iter().enumerate() {
if k == 0 {
path.move_to(*p);
} else {
path.line_to(*p);
}
}
path.close_path();
path
}
/// The rectangle's corners in page space through `t`, as a closed path.
pub(super) fn rect_path(r: Rect, t: Affine) -> BezPath {
let mut path = BezPath::new();
path.move_to(t * Point::new(r.x0, r.y0));
path.line_to(t * Point::new(r.x1, r.y0));
path.line_to(t * Point::new(r.x1, r.y1));
path.line_to(t * Point::new(r.x0, r.y1));
path.close_path();
path
}
+777
View File
@@ -0,0 +1,777 @@
//! Writes a display list, or a [`Flattened`] page, as PDF content through krilla.
//!
//! Colors print as [`InkSet::resolve`] makes them (aliases, spots printed as process color)
//! and are written by [`to_pdf_color`], with one exception: an overprinting process-only
//! color that names fewer than four process inks is written as a DeviceN color of just the
//! inks it names. DeviceCMYK would set the unnamed plates to 0 under `OPM 0`, and `OPM 1`
//! would also leave alone the plates of named inks at 0; a DeviceN color paints exactly its
//! colorants when it overprints (ISO 32000-2 §8.6.7), whatever `OPM` says.
//!
//! Overprint is written as `op`/`OP`/`OPM` on each item, never as blend modes or knockout
//! groups.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use craft_pdf::interpret::color::CmykConversion;
use craft_pdf::write::backend::blend::{self as kblend, GroupOptions};
use craft_pdf::write::backend::color::device_n::{self, Colorant, DeviceNSpace};
use craft_pdf::write::backend::color::{Color as PdfColor, RegularColor, cmyk};
use craft_pdf::write::backend::configure::PdfVersion;
use craft_pdf::write::backend::geom::{Path, PathBuilder, Point as KPoint, Size, Transform};
use craft_pdf::write::backend::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
use craft_pdf::write::backend::mask::{Mask as KMask, MaskType, TransferFunction};
use craft_pdf::write::backend::num::NormalizedF32;
use craft_pdf::write::backend::page::PageSettings;
use craft_pdf::write::backend::paint::{Fill, FillRule as KFillRule, LinearGradient, Paint as KPaint, RadialGradient, SpreadMethod, Stop};
use craft_pdf::write::backend::surface::Surface;
use craft_pdf::write::backend::tagging::{ContentTag, SpanTag};
use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph};
use craft_pdf::write::backend::{Data, SerializeSettings};
use craft_pdf::write::{self as pdf_write, Metadata, Writer};
use kurbo::{Affine, BezPath, PathEl};
use super::{
BlendMode, ComplexRegion, DisplayList, FillItem, FillRule, Flattened, FontBytes, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, Mask,
MaskKind, Paint, TextFont, TextItem,
};
use crate::ink::{Ink, InkColor, InkId, InkSet, Overprint, Process, to_pdf_color};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum WriteError {
#[error(transparent)]
Pdf(#[from] pdf_write::WriteError),
/// An ink isn't in the set or a spot's Lab alternate can't be converted.
#[error("color {0:?} can't be written")]
Color(InkColor),
/// A DeviceN space would have more colorants than the output's PDF version takes
/// ([`max_colorants`]).
#[error("a DeviceN space of {colorants} colorants; this output takes at most {max}")]
Colorants { colorants: usize, max: usize },
/// A complex region paints more spot inks than [`plates`](crate::plates::plates) separates;
/// `unseparated` are the ones past the limit.
#[error("a complex region paints {inks} spot inks; at most {max} are separated, not {}", some_names(.unseparated))]
SpotInks { inks: usize, max: usize, unseparated: Vec<String> },
#[error("the page box is empty or not finite")]
Page,
#[error("image: {0}")]
Image(&'static str),
/// Display-list features the writer doesn't write yet.
#[error("not written yet: {0}")]
Unsupported(&'static str),
/// Raised by a [`write_flattened`] region callback.
#[error("complex region: {0}")]
Region(String),
}
/// The most colorants a DeviceN space takes at `version`: 8 up to PDF 1.4 (implementation
/// limits, PDF 1.4 reference Appendix C), 32 from PDF 1.5.
pub fn max_colorants(version: PdfVersion) -> usize {
match version {
PdfVersion::Pdf14 => 8,
_ => DeviceNSpace::MAX_COLORANTS,
}
}
fn some_names(names: &[String]) -> String {
let mut listed = names.iter().take(3).map(String::as_str).collect::<Vec<_>>().join(", ");
if names.len() > 3 {
listed.push_str(", …");
}
listed
}
/// Draws `items` on `surface` in page space, with colors of `inks`, for an output of PDF
/// `version`. `cms` converts spot inks' Lab alternates into the printing condition. On error,
/// the items before the failing one are drawn and the surface is left balanced.
pub fn draw(surface: &mut Surface<'_>, inks: &InkSet, items: &[Item], cms: &dyn CmykConversion, version: PdfVersion) -> Result<(), WriteError> {
let mut painter = Painter::new(inks, cms, version);
items.iter().try_for_each(|item| painter.item(surface, item))
}
/// Draws `flat.items` in order and, before item `at` of each complex region, the image
/// `raster` returns for it, clipped to the region's path and without overprint. `raster`
/// returns an [`ImageItem`] whose space is [`ImageSpace::Inks`] of `list.inks` and which
/// covers the region, or `None` to leave the region empty. `version` is the output's PDF
/// version.
pub fn write_flattened(
surface: &mut Surface<'_>,
list: &DisplayList,
flat: &Flattened,
cms: &dyn CmykConversion,
version: PdfVersion,
raster: &mut dyn FnMut(&ComplexRegion) -> Result<Option<ImageItem>, WriteError>,
) -> Result<(), WriteError> {
Painter::new(&list.inks, cms, version).flattened(surface, flat, raster)
}
/// [`write_flattened`] for one page of a document whose pages share `fonts`.
pub fn write_flattened_with_fonts(
surface: &mut Surface<'_>,
list: &DisplayList,
flat: &Flattened,
cms: &dyn CmykConversion,
version: PdfVersion,
fonts: &mut FontMap,
raster: &mut dyn FnMut(&ComplexRegion) -> Result<Option<ImageItem>, WriteError>,
) -> Result<(), WriteError> {
let mut painter = Painter::new(&list.inks, cms, version);
painter.shared = Some(fonts);
painter.flattened(surface, flat, raster)
}
/// krilla fonts shared by the pages of one document, so that a font used on several pages is
/// embedded once. Runs share a font only when their [`TextFont`] bytes are equal: equal keys on
/// two pages can name different bytes (per-page widths of a wrapped bare CFF).
#[derive(Default)]
pub struct FontMap {
/// By [`TextFont::key`] and face index: each font program seen, `None` where krilla can't
/// read it.
fonts: HashMap<(u128, u32), Vec<Shared>>,
}
type Shared = (FontBytes, Option<Font>);
impl FontMap {
pub fn new() -> Self {
Self::default()
}
/// Distinct font programs seen, including those krilla can't read.
pub fn len(&self) -> usize {
self.fonts.values().map(Vec::len).sum()
}
pub fn is_empty(&self) -> bool {
self.fonts.is_empty()
}
fn get(&mut self, tf: &TextFont) -> Option<Font> {
let same = |d: &FontBytes| std::ptr::addr_eq(Arc::as_ptr(d), Arc::as_ptr(&tf.data)) || (**d).as_ref() == (*tf.data).as_ref();
let entries = self.fonts.entry((tf.key, tf.index)).or_default();
if let Some((_, font)) = entries.iter().find(|(d, _)| same(d)) {
return font.clone();
}
let font = Font::new(Data::from(tf.data.clone()), tf.index);
entries.push((tf.data.clone(), font.clone()));
font
}
}
impl Painter<'_, '_> {
fn flattened(
&mut self,
surface: &mut Surface<'_>,
flat: &Flattened,
raster: &mut dyn FnMut(&ComplexRegion) -> Result<Option<ImageItem>, WriteError>,
) -> Result<(), WriteError> {
let mut regions: Vec<&ComplexRegion> = flat.complex.iter().collect();
regions.sort_by_key(|r| r.at);
let mut regions = regions.into_iter().peekable();
for at in 0..=flat.items.len() {
while let Some(region) = regions.next_if(|r| r.at <= at || at == flat.items.len()) {
let Some(image) = raster(region)? else { continue };
let Some(clip) = to_path(&region.path) else { continue };
surface.push_clip_path(&clip, &KFillRule::NonZero);
let drawn = self.image(surface, &ImageItem { overprint: Overprint::default(), ..image });
surface.pop();
drawn?;
}
if let Some(item) = flat.items.get(at) {
self.item(surface, item)?;
}
}
Ok(())
}
}
/// `list` as a one-page PDF with `settings`.
pub fn page_pdf(list: &DisplayList, settings: SerializeSettings, cms: &dyn CmykConversion) -> Result<Vec<u8>, WriteError> {
let page = list.page;
let size = PageSettings::from_wh(page.width() as f32, page.height() as f32).filter(|_| page.is_finite()).ok_or(WriteError::Page)?;
let version = settings.configuration.version();
pdf_write::guard(|| {
let mut writer = Writer::new(settings, Metadata::default());
let mut page_builder = writer.document_mut().start_page_with(size);
let mut surface = page_builder.surface();
let shifted = page.x0 != 0.0 || page.y0 != 0.0;
if shifted {
surface.push_transform(&Transform::from_translate(-page.x0 as f32, -page.y0 as f32));
}
let drawn = draw(&mut surface, &list.inks, &list.items, cms, version);
if shifted {
surface.pop();
}
surface.finish();
page_builder.finish();
drawn?;
Ok(writer.finish()?)
})
}
struct Painter<'a, 'f> {
inks: &'a InkSet,
cms: &'a dyn CmykConversion,
/// [`max_colorants`] of the output.
max_colorants: usize,
/// The document's fonts, consulted on a miss in `fonts`.
shared: Option<&'f mut FontMap>,
/// `None` for fonts krilla can't read; their runs are written as outlines. Keyed by
/// [`TextFont::key`] for one page only: a font built for another page may have the same
/// key and other bytes (per-page widths of a wrapped bare CFF).
fonts: HashMap<(u128, u32), Option<Font>>,
/// Inside an `/ActualText` span, which can't nest.
tagged: bool,
}
impl<'a> Painter<'a, '_> {
fn new(inks: &'a InkSet, cms: &'a dyn CmykConversion, version: PdfVersion) -> Self {
Self { inks, cms, max_colorants: max_colorants(version), shared: None, fonts: HashMap::new(), tagged: false }
}
/// An error when a DeviceN space of `colorants` is more than the output takes.
fn fits(&self, colorants: usize) -> Result<(), WriteError> {
if colorants > self.max_colorants {
return Err(WriteError::Colorants { colorants, max: self.max_colorants });
}
Ok(())
}
fn item(&mut self, s: &mut Surface<'_>, item: &Item) -> Result<(), WriteError> {
self.item_with_opacity(s, item, 1.0)
}
/// `opacity` is a group's, applied to a fill or text drawn alone in it.
fn item_with_opacity(&mut self, s: &mut Surface<'_>, item: &Item, opacity: f32) -> Result<(), WriteError> {
match item {
Item::Fill(f) => self.fill(s, f, opacity),
Item::Text(t) => self.text(s, t, opacity),
Item::Image(im) => self.image(s, im),
Item::Group(g) => self.group(s, g),
}
}
fn fill(&mut self, s: &mut Surface<'_>, f: &FillItem, opacity: f32) -> Result<(), WriteError> {
let Some(path) = to_path(&f.path) else { return Ok(()) };
let rule = match f.rule {
FillRule::NonZero => KFillRule::NonZero,
FillRule::EvenOdd => KFillRule::EvenOdd,
};
let (paint, overprint) = self.paint(&f.paint, f.overprint, Affine::IDENTITY)?;
with_overprint(s, overprint, |s| {
s.set_fill(Some(Fill { paint, opacity: normalized(opacity), rule }));
s.draw_path(&path);
});
Ok(())
}
fn text(&mut self, s: &mut Surface<'_>, t: &TextItem, opacity: f32) -> Result<(), WriteError> {
let text = t.text.as_deref().unwrap_or("");
let font = t.font.as_deref().and_then(|tf| self.font(tf));
let (Some(font), Some(first)) = (font, t.glyphs.first()) else {
let Some(path) = to_path(&t.outlines) else { return Ok(()) };
let (paint, overprint) = self.paint(&t.paint, t.overprint, Affine::IDENTITY)?;
let tag = !self.tagged && t.text.is_some();
with_overprint(s, overprint, |s| {
if tag {
s.start_tagged(ContentTag::Span(SpanTag { actual_text: Some(text), ..SpanTag::empty() }));
}
s.set_fill(Some(Fill { paint, opacity: normalized(opacity), rule: KFillRule::NonZero }));
s.draw_path(&path);
if tag {
s.end_tagged();
}
});
return Ok(());
};
let size = f64::from(t.size);
if !(size.is_finite() && size > 0.0) {
return Ok(());
}
let glyphs: Vec<KrillaGlyph> = t
.glyphs
.iter()
.enumerate()
.map(|(i, g)| {
let next_x = t.glyphs.get(i + 1).map_or(g.origin.x, |n| n.origin.x);
let range = if text.get(g.text.clone()).is_some() { g.text.clone() } else { 0..0 };
KrillaGlyph::new(GlyphId::new(g.id), ((next_x - g.origin.x) / size) as f32, 0.0, (g.origin.y / size) as f32, 0.0, range, None)
})
.collect();
// Text space is y up; krilla places glyphs y down.
let ctm = t.transform * Affine::scale_non_uniform(1.0, -1.0);
let (paint, overprint) = self.paint(&t.paint, t.overprint, ctm)?;
with_overprint(s, overprint, |s| {
s.set_fill(Some(Fill { paint, opacity: normalized(opacity), rule: KFillRule::NonZero }));
s.push_transform(&to_transform(ctm));
s.draw_glyphs(KPoint::from_xy(first.origin.x as f32, 0.0), &glyphs, font, text, t.size, false);
s.pop();
});
Ok(())
}
fn font(&mut self, tf: &TextFont) -> Option<Font> {
if let Some(font) = self.fonts.get(&(tf.key, tf.index)) {
return font.clone();
}
let font = match self.shared.as_deref_mut() {
Some(shared) => shared.get(tf),
None => Font::new(Data::from(tf.data.clone()), tf.index),
};
self.fonts.insert((tf.key, tf.index), font.clone());
font
}
fn image(&mut self, s: &mut Surface<'_>, im: &ImageItem) -> Result<(), WriteError> {
let (w, h) = im.size;
let pixels = usize::try_from(u64::from(w) * u64::from(h)).map_err(|_| WriteError::Image("too large"))?;
if pixels == 0 {
return Ok(());
}
let draw = |s: &mut Surface<'_>, image: Image| {
let Some(unit) = Size::from_wh(1.0, 1.0) else { return };
with_overprint(s, im.overprint, |s| {
s.push_transform(&to_transform(im.transform));
s.draw_image(image, unit);
s.pop();
});
};
if let Some(image) = passthrough(im) {
draw(s, image);
return Ok(());
}
let (space, components, device_n, samples) = match &im.space {
ImageSpace::Gray => (ImageColorspace::Luma, 1, None, im.samples.clone()),
ImageSpace::Rgb => (ImageColorspace::Rgb, 3, None, im.samples.clone()),
ImageSpace::Cmyk => (ImageColorspace::Cmyk, 4, None, im.samples.clone()),
ImageSpace::Inks(ids) => {
if im.samples.len() != pixels.saturating_mul(ids.len()) {
return Err(WriteError::Image("sample count doesn't match the size and inks"));
}
let (space, samples) = self.ink_samples(ids, &im.samples)?;
(ImageColorspace::DeviceN, space.colorants().len(), Some(space), samples)
}
};
if samples.len() != pixels.saturating_mul(components) {
return Err(WriteError::Image("sample count doesn't match the size and space"));
}
if im.smask.as_ref().is_some_and(|a| a.len() != pixels) {
return Err(WriteError::Image("smask size doesn't match the image"));
}
let image = Image::from_custom(Samples { samples, alpha: im.smask.clone(), size: im.size, space, device_n }, false)
.map_err(|_| WriteError::Image("krilla rejected it"))?;
draw(s, image);
Ok(())
}
/// Samples of `ids` as samples of the inks they print on: each ink's tint moves to the
/// inks [`InkSet::resolve`] gives a full tint of it, and tints on one ink combine as
/// overlapping halftones, as `resolve` combines them.
fn ink_samples(&self, ids: &[InkId], samples: &Arc<[u8]>) -> Result<(DeviceNSpace, Arc<[u8]>), WriteError> {
let mut weights: Vec<Vec<(InkId, f32)>> = Vec::with_capacity(ids.len());
for &id in ids {
let full = InkColor::Tints(vec![(id, 1.0)]);
match self.inks.resolve(&full, self.cms) {
InkColor::Tints(t) => weights.push(t),
_ => return Err(WriteError::Color(full)),
}
}
let out: Vec<InkId> = weights.iter().flatten().map(|(id, _)| *id).collect::<BTreeSet<_>>().into_iter().collect();
self.fits(out.len())?;
let colorants = out
.iter()
.map(|&id| self.colorant(id).ok_or_else(|| WriteError::Color(InkColor::Tints(vec![(id, 1.0)]))))
.collect::<Result<Vec<_>, _>>()?;
let space = DeviceNSpace::new(colorants).ok_or(WriteError::Image("krilla rejected its DeviceN space"))?;
let renames = weights.iter().all(|w| matches!(w[..], [(_, t)] if t == 1.0)) && out.len() == ids.len();
if renames && weights.iter().zip(&out).all(|(w, o)| w.first().is_some_and(|(id, _)| id == o)) {
return Ok((space, samples.clone()));
}
// Column of each ink's weights in `out`.
let routes: Vec<Vec<(usize, f32)>> =
weights.iter().map(|w| w.iter().filter_map(|(id, t)| out.iter().position(|o| o == id).map(|i| (i, *t))).collect()).collect();
let n = ids.len();
let mut result = Vec::with_capacity(samples.len() / n.max(1) * out.len());
let mut keep = vec![1.0_f32; out.len()];
for px in samples.chunks_exact(n.max(1)) {
keep.fill(1.0);
for (v, route) in px.iter().zip(&routes) {
let v = f32::from(*v) / 255.0;
for &(i, t) in route {
if let Some(k) = keep.get_mut(i) {
*k *= 1.0 - v * t;
}
}
}
result.extend(keep.iter().map(|k| byte(1.0 - k)));
}
Ok((space, result.into()))
}
fn group(&mut self, s: &mut Surface<'_>, g: &GroupItem) -> Result<(), WriteError> {
if g.children.is_empty() {
return Ok(());
}
let mut pushed = 0;
let clip = match &g.clip {
Some(clip) => match to_path(clip) {
Some(path) => Some(path),
// An empty clip shows nothing.
None => return Ok(()),
},
None => None,
};
if let Some(clip) = clip {
s.push_clip_path(&clip, &KFillRule::NonZero);
pushed += 1;
}
let tag = match &g.actual_text {
Some(text) if !self.tagged => {
s.start_tagged(ContentTag::Span(SpanTag { actual_text: Some(text), ..SpanTag::empty() }));
self.tagged = true;
true
}
_ => false,
};
let drawn = self.group_content(s, g, &mut pushed);
for _ in 0..pushed {
s.pop();
}
if tag {
s.end_tagged();
self.tagged = false;
}
drawn
}
/// Pushes the group's blend mode, mask, opacity and attributes, counted in `pushed`, and
/// draws the children.
fn group_content(&mut self, s: &mut Surface<'_>, g: &GroupItem, pushed: &mut usize) -> Result<(), WriteError> {
let opacity = unit(g.opacity);
let blended = g.blend != BlendMode::Normal;
if blended {
s.push_blend_mode(blend_mode(g.blend));
*pushed += 1;
}
// One fill or text run alone in a plain group: opacity and blend mode on the paint
// itself composite the same.
if let ([child @ (Item::Fill(_) | Item::Text(_))], false, false, None) = (&g.children[..], g.isolated, g.knockout, &g.mask) {
return self.item_with_opacity(s, child, opacity);
}
let mut isolated = false;
if let Some(mask) = &g.mask {
let mask = self.mask(s, mask, opacity)?;
s.push_mask(mask);
*pushed += 1;
} else if opacity < 1.0 && g.isolated {
s.push_opacity(normalized(opacity));
*pushed += 1;
isolated = true;
} else if opacity < 1.0 || (blended && !g.isolated && !g.knockout) {
// krilla writes opacity as an isolated group and a group without attributes as a
// plain form; a constant soft mask makes a non-isolated transparency group.
let mask = constant_mask(s, opacity);
s.push_mask(mask);
*pushed += 1;
}
if g.knockout || (g.isolated && !isolated) {
s.push_group(GroupOptions { isolated: g.isolated, knockout: g.knockout });
*pushed += 1;
}
g.children.iter().try_for_each(|child| self.item(s, child))
}
/// `m` as a krilla mask, with `opacity` folded into its transfer function.
fn mask(&mut self, s: &mut Surface<'_>, m: &Mask, opacity: f32) -> Result<KMask, WriteError> {
let mut builder = s.stream_builder();
let mut sub = builder.surface();
let tagged = std::mem::replace(&mut self.tagged, true);
let drawn = self.group(&mut sub, &m.group);
self.tagged = tagged;
sub.finish();
let stream = builder.finish();
drawn?;
let kind = match m.kind {
MaskKind::Alpha => MaskType::Alpha,
MaskKind::Luminosity => MaskType::Luminosity,
};
let mut mask = KMask::new(stream, kind);
if let (MaskKind::Luminosity, Some(backdrop)) = (m.kind, &m.backdrop) {
// The backdrop is a color of the CMYK blending space, so spots count as process.
let mut process = self.inks.clone();
process.set_all_to_process(true);
let cmyk = process.resolve(backdrop, self.cms).as_cmyk().ok_or_else(|| WriteError::Color(backdrop.clone()))?;
let [c, m, y, k] = cmyk.map(byte);
mask = mask.with_backdrop(cmyk::Color::new(c, m, y, k));
}
let transfer = match &m.transfer {
Some(samples) => Some(samples.iter().map(|v| f32::from(*v) / 255.0 * opacity).collect()),
None if opacity < 1.0 => Some(vec![0.0, opacity]),
None => None,
};
if let Some(transfer) = transfer {
mask = mask.with_transfer(TransferFunction::Sampled(transfer.into_iter().map(normalized).collect()));
}
Ok(mask)
}
/// `paint` as krilla draws it under `ctm` (page space from the drawing space), and the
/// overprint to draw it with.
fn paint(&self, paint: &Paint, overprint: Overprint, ctm: Affine) -> Result<(KPaint, Overprint), WriteError> {
match paint {
Paint::Solid(color) => {
let (color, overprint) = self.solid(color, overprint)?;
Ok((color.into(), overprint))
}
Paint::Gradient { stops, geometry } => Ok((self.gradient(stops, geometry, overprint, ctm)?, overprint)),
}
}
fn solid(&self, color: &InkColor, overprint: Overprint) -> Result<(PdfColor, Overprint), WriteError> {
let resolved = self.inks.resolve(color, self.cms);
if let InkColor::Tints(tints) = &resolved
&& overprint.fill
&& let Some(cmyk) = resolved.as_cmyk()
{
let named: Vec<usize> =
(0..4).filter(|&i| tints.iter().any(|(id, _)| id.index() == i) && (!overprint.nonzero || cmyk[i] > 0.0)).collect();
if named.len() < 4 {
if named.is_empty() {
// Zero DeviceCMYK under OPM 1 leaves every plate alone.
return Ok((RegularColor::Cmyk(cmyk::Color::new(0, 0, 0, 0)).into(), Overprint { nonzero: true, ..overprint }));
}
return Ok((self.process_device_n(&named, cmyk).ok_or_else(|| WriteError::Color(color.clone()))?, overprint));
}
}
if let InkColor::Tints(tints) = &resolved {
// More inks than the output's limit include a spot, so `to_pdf_color` makes a
// DeviceN space of them.
self.fits(tints.iter().map(|(id, _)| *id).collect::<BTreeSet<_>>().len())?;
}
let pdf = to_pdf_color(&resolved, self.inks, self.cms).ok_or_else(|| WriteError::Color(color.clone()))?;
Ok((pdf, overprint))
}
/// A DeviceN color of process inks `named` (indices in plate order) at their `cmyk` tints.
fn process_device_n(&self, named: &[usize], cmyk: [f32; 4]) -> Option<PdfColor> {
let colorants = named.iter().map(|&i| Process::ALL.get(i).map(|p| process_colorant(*p))).collect::<Option<Vec<_>>>()?;
let tints = named.iter().map(|&i| cmyk.get(i).copied().map(byte)).collect::<Option<Vec<_>>>()?;
Some(device_n::Color::new(tints, DeviceNSpace::new(colorants)?)?.into())
}
/// Stops in one color space: each lists every ink some stop lists, so krilla writes them
/// all in the space a color of those inks gets.
fn gradient(&self, stops: &[(f32, InkColor)], geometry: &GradientGeometry, overprint: Overprint, ctm: Affine) -> Result<KPaint, WriteError> {
let mut resolved: Vec<(f32, BTreeMap<InkId, f32>)> = Vec::with_capacity(stops.len());
for (offset, color) in stops {
let InkColor::Tints(tints) = self.inks.resolve(color, self.cms) else {
return Err(WriteError::Unsupported("a gradient stop of the All or None colorant"));
};
let mut merged = BTreeMap::new();
for (id, t) in tints {
let v = merged.entry(id).or_insert(0.0);
*v = 1.0 - (1.0 - *v) * (1.0 - unit(t));
}
resolved.push((*offset, merged));
}
let all: BTreeSet<InkId> = resolved.iter().flat_map(|(_, m)| m.keys().copied()).collect();
self.fits(all.len())?;
let partial_process = overprint.fill && all.len() < 4 && all.iter().all(|id| id.index() < 4);
let mut krilla_stops = Vec::with_capacity(resolved.len());
for ((offset, merged), (_, original)) in resolved.iter().zip(stops) {
let tint = |id: &InkId| merged.get(id).copied().unwrap_or(0.0);
let color = if partial_process {
let named: Vec<usize> = all.iter().map(|id| id.index()).collect();
let mut cmyk = [0.0; 4];
for id in &all {
if let Some(c) = cmyk.get_mut(id.index()) {
*c = tint(id);
}
}
self.process_device_n(&named, cmyk)
} else {
to_pdf_color(&InkColor::Tints(all.iter().map(|id| (*id, tint(id))).collect()), self.inks, self.cms)
};
let color = color.ok_or_else(|| WriteError::Color(original.clone()))?;
krilla_stops.push(Stop { offset: normalized(*offset), color, opacity: NormalizedF32::ONE });
}
let to_drawing = |t: &Affine| to_transform(ctm.inverse() * *t);
Ok(match geometry {
GradientGeometry::Axial { start, end, extend: [true, true], transform } => LinearGradient {
x1: start.x as f32,
y1: start.y as f32,
x2: end.x as f32,
y2: end.y as f32,
transform: to_drawing(transform),
spread_method: SpreadMethod::Pad,
stops: krilla_stops,
anti_alias: false,
}
.into(),
GradientGeometry::Radial { start, start_radius, end, end_radius, extend: [true, true], transform } => RadialGradient {
fx: start.x as f32,
fy: start.y as f32,
fr: *start_radius as f32,
cx: end.x as f32,
cy: end.y as f32,
cr: *end_radius as f32,
transform: to_drawing(transform),
spread_method: SpreadMethod::Pad,
stops: krilla_stops,
anti_alias: false,
}
.into(),
_ => return Err(WriteError::Unsupported("a gradient that doesn't extend at both ends")),
})
}
/// Ink `id` as a DeviceN colorant; a spot carries its CMYK alternate.
fn colorant(&self, id: InkId) -> Option<Colorant> {
match self.inks.get(id)? {
Ink::Process(p) => Some(process_colorant(*p)),
Ink::Spot(spot) => {
let [c, m, y, k] = self.inks.spot_cmyk(id, self.cms)?.map(byte);
(!spot.name.is_empty()).then(|| Colorant::Spot { name: spot.name.clone(), alternate: cmyk::Color::new(c, m, y, k) })
}
}
}
}
/// A soft mask of `opacity` everywhere: an empty luminosity group whose transfer function
/// maps every value to `opacity`.
fn constant_mask(s: &mut Surface<'_>, opacity: f32) -> KMask {
let builder = s.stream_builder();
let stream = builder.finish();
let o = normalized(opacity);
KMask::new(stream, MaskType::Luminosity).with_transfer(TransferFunction::Sampled(vec![o, o]))
}
fn with_overprint(s: &mut Surface<'_>, overprint: Overprint, draw: impl FnOnce(&mut Surface<'_>)) {
if overprint == Overprint::default() {
draw(s);
return;
}
s.push_overprint(kblend::Overprint { fill: overprint.fill, stroke: overprint.stroke, nonzero: overprint.nonzero });
draw(s);
s.pop();
}
fn process_colorant(p: Process) -> Colorant {
Colorant::Process(match p {
Process::Cyan => device_n::Process::Cyan,
Process::Magenta => device_n::Process::Magenta,
Process::Yellow => device_n::Process::Yellow,
Process::Black => device_n::Process::Black,
})
}
fn blend_mode(b: BlendMode) -> kblend::BlendMode {
use kblend::BlendMode as K;
match b {
BlendMode::Normal => K::Normal,
BlendMode::Multiply => K::Multiply,
BlendMode::Screen => K::Screen,
BlendMode::Overlay => K::Overlay,
BlendMode::Darken => K::Darken,
BlendMode::Lighten => K::Lighten,
BlendMode::ColorDodge => K::ColorDodge,
BlendMode::ColorBurn => K::ColorBurn,
BlendMode::HardLight => K::HardLight,
BlendMode::SoftLight => K::SoftLight,
BlendMode::Difference => K::Difference,
BlendMode::Exclusion => K::Exclusion,
BlendMode::Hue => K::Hue,
BlendMode::Saturation => K::Saturation,
BlendMode::Color => K::Color,
BlendMode::Luminosity => K::Luminosity,
}
}
fn to_transform(a: Affine) -> Transform {
let [a, b, c, d, e, f] = a.as_coeffs().map(|v| v as f32);
Transform::from_row(a, b, c, d, e, f)
}
/// `None` for an empty path, which paints nothing.
fn to_path(path: &BezPath) -> Option<Path> {
let mut b = PathBuilder::new();
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32),
PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32),
PathEl::QuadTo(p1, p) => b.quad_to(p1.x as f32, p1.y as f32, p.x as f32, p.y as f32),
PathEl::CurveTo(p1, p2, p) => b.cubic_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32, p.x as f32, p.y as f32),
PathEl::ClosePath => b.close(),
}
}
b.finish()
}
fn unit(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
}
fn normalized(v: f32) -> NormalizedF32 {
NormalizedF32::new(unit(v)).unwrap_or(NormalizedF32::ONE)
}
fn byte(v: f32) -> u8 {
(unit(v) * 255.0).round() as u8
}
/// Image samples for krilla.
#[derive(Clone)]
struct Samples {
samples: Arc<[u8]>,
alpha: Option<Arc<[u8]>>,
size: (u32, u32),
space: ImageColorspace,
device_n: Option<DeviceNSpace>,
}
impl Hash for Samples {
fn hash<H: Hasher>(&self, state: &mut H) {
self.samples.hash(state);
self.alpha.hash(state);
self.size.hash(state);
self.space.hash(state);
self.device_n.hash(state);
}
}
impl CustomImage for Samples {
fn color_channel(&self) -> &[u8] {
&self.samples
}
fn alpha_channel(&self) -> Option<&[u8]> {
self.alpha.as_deref()
}
fn bits_per_component(&self) -> BitsPerComponent {
BitsPerComponent::Eight
}
fn size(&self) -> (u32, u32) {
self.size
}
fn icc_profile(&self) -> Option<&[u8]> {
None
}
fn color_space(&self) -> ImageColorspace {
self.space
}
fn device_n_space(&self) -> Option<DeviceNSpace> {
self.device_n.clone()
}
}
/// `im`'s JPEG data as a krilla image, written as it is (DCTDecode), when it has one that
/// decodes to its size. The embedded ICC profile is left out, as the samples path leaves it.
fn passthrough(im: &ImageItem) -> Option<Image> {
if im.smask.is_some() || !matches!(im.space, ImageSpace::Gray | ImageSpace::Cmyk) {
return None;
}
let data: Arc<dyn AsRef<[u8]> + Send + Sync> = Arc::new(im.jpeg.clone()?);
let image = Image::from_jpeg_with_icc(Data::from(data), None, false).ok()?;
(image.size() == im.size).then_some(image)
}
+1 -1
View File
@@ -99,7 +99,7 @@ impl InkId {
}
/// The id of process ink `p`, the same in every [`InkSet`].
pub fn process(p: Process) -> Self {
pub const fn process(p: Process) -> Self {
Self(p as u16)
}
}
+2
View File
@@ -7,6 +7,8 @@
pub mod cms;
pub mod composite;
pub mod coverage;
#[cfg(feature = "flatten")]
pub mod flatten;
pub mod ink;
pub mod plates;
+48 -14
View File
@@ -222,7 +222,7 @@ fn plate_scale(w: f32, h: f32, scale: f32) -> f32 {
if s.is_finite() && s > 0.0 { s } else { f32::MIN_POSITIVE }
}
fn fallback(fallback: &Fallback) -> Result<(Option<CmykSimulation>, Conversion), Error> {
pub(crate) fn fallback(fallback: &Fallback) -> Result<(Option<CmykSimulation>, Conversion), Error> {
let (conversion, icc): (CmsConversion, &[u8]) = match fallback {
Fallback::DeviceFormulas => return Ok((None, Conversion::DeviceFormulas)),
Fallback::Fogra39 => (CmsConversion::fogra39(), craft_cms::builtin::COATED_FOGRA39_ICC),
@@ -264,7 +264,7 @@ fn text_entry(dict: &Dict<'_>, key: &[u8]) -> Option<String> {
/// A PDF text string (ISO 32000-2 §7.9.2.2): UTF-16BE or UTF-8 after its byte order mark, else
/// PDFDocEncoding, read here as Latin-1 (they differ only in rarely used punctuation).
fn pdf_text(bytes: &[u8]) -> String {
pub(crate) fn pdf_text(bytes: &[u8]) -> String {
if let Some(utf16) = bytes.strip_prefix(&[0xFE, 0xFF]) {
let units: Vec<u16> = utf16.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
return String::from_utf16_lossy(&units);
@@ -278,18 +278,8 @@ fn pdf_text(bytes: &[u8]) -> String {
/// The printing condition of page `page` (ISO 32000-2 §14.11.5): of the page's own
/// `/OutputIntents` (PDF 2.0), else the catalog's, the first whose `/DestOutputProfile` is a
/// CMYK profile craft-cms converts into, a `GTS_PDFX` intent before the others.
fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)> {
fn list<'a>(intents: Option<Array<'a>>) -> Vec<Dict<'a>> {
intents.iter().flat_map(|a| a.iter::<Dict<'a>>()).take(MAX_OUTPUT_INTENTS).collect()
}
let xref = pdf.xref();
let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::<Array<'_>>(OUTPUT_INTENTS)));
if listed.is_empty() {
listed = list(xref.get::<Dict<'_>>(xref.root_id()).and_then(|catalog| catalog.get::<Array<'_>>(OUTPUT_INTENTS)));
}
// A stable sort: the PDF/X intent first, the others in their order.
listed.sort_by_key(|d| d.get::<Name<'_>>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX"));
listed.iter().find_map(|intent| {
pub(crate) fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)> {
output_intents(pdf, page).iter().find_map(|intent| {
let icc = intent.get::<Stream<'_>>(DEST_OUTPUT_PROFILE)?.decoded_within(MAX_PROFILE_BYTES).ok()?;
let simulation = CmsConversion::new(&icc).ok()?.simulation(&icc);
let condition = text_entry(intent, OUTPUT_CONDITION_IDENTIFIER);
@@ -303,6 +293,50 @@ fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)>
})
}
/// The output intent [`output_intent`] picks, as written in the PDF.
#[cfg(feature = "flatten")]
pub(crate) struct IntentProfile {
pub icc: Vec<u8>,
pub identifier: Option<String>,
pub condition: Option<String>,
pub registry_name: Option<String>,
pub info: Option<String>,
}
/// The output intent [`output_intent`] picks: its profile bytes and entries, to write the same
/// intent again.
#[cfg(feature = "flatten")]
pub(crate) fn output_intent_profile(pdf: &Pdf, page: usize) -> Option<IntentProfile> {
output_intents(pdf, page).iter().find_map(|intent| {
let icc = intent.get::<Stream<'_>>(DEST_OUTPUT_PROFILE)?.decoded_within(MAX_PROFILE_BYTES).ok()?;
if *icc != *craft_cms::builtin::COATED_FOGRA39_ICC && CmsConversion::new(&icc).is_err() {
return None;
}
Some(IntentProfile {
icc: icc.into_owned(),
identifier: text_entry(intent, OUTPUT_CONDITION_IDENTIFIER),
condition: text_entry(intent, OUTPUT_CONDITION),
registry_name: text_entry(intent, b"RegistryName"),
info: text_entry(intent, b"Info"),
})
})
}
/// The page's own `/OutputIntents` (PDF 2.0), else the catalog's, a `GTS_PDFX` intent first.
fn output_intents(pdf: &Pdf, page: usize) -> Vec<Dict<'_>> {
fn list<'a>(intents: Option<Array<'a>>) -> Vec<Dict<'a>> {
intents.iter().flat_map(|a| a.iter::<Dict<'a>>()).take(MAX_OUTPUT_INTENTS).collect()
}
let xref = pdf.xref();
let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::<Array<'_>>(OUTPUT_INTENTS)));
if listed.is_empty() {
listed = list(xref.get::<Dict<'_>>(xref.root_id()).and_then(|catalog| catalog.get::<Array<'_>>(OUTPUT_INTENTS)));
}
// A stable sort: the PDF/X intent first, the others in their order.
listed.sort_by_key(|d| d.get::<Name<'_>>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX"));
listed
}
#[cfg(test)]
mod tests {
use super::*;
Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

+379
View File
@@ -0,0 +1,379 @@
//! Whole documents flattened: pages, boxes, metadata, output intent and shared fonts carry over,
//! and each page's plates match the source.
//!
//! The text cases need Inter from `CRAFT_PDF_TEST_FONT_DIR` or `~/.cache/craft-pdf-test-fonts`,
//! and are skipped when it is missing.
#![cfg(feature = "flatten")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use std::time::Instant;
use craft_pdf::syntax::object::dict::keys::{BLEED_BOX, DEST_OUTPUT_PROFILE, OUTPUT_CONDITION_IDENTIFIER, OUTPUT_INTENTS, TRIM_BOX};
use craft_pdf::syntax::object::{Array, Dict, Rect as PdfRect, Stream, String as PdfString};
use craft_pdf::write::backend;
use craft_pdf::write::backend::color::cmyk;
use craft_pdf::write::backend::color::device_n::{self, Colorant, DeviceNSpace, Process};
use craft_pdf::write::backend::configure::PdfVersion;
use craft_pdf::write::backend::geom::{PathBuilder, Point, Rect as KRect};
use craft_pdf::write::backend::metadata::DateTime;
use craft_pdf::write::backend::num::NormalizedF32;
use craft_pdf::write::backend::page::PageSettings;
use craft_pdf::write::backend::paint::Fill;
use craft_pdf::write::backend::surface::Surface;
use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph};
use craft_pdf::write::backend::{GroupColorSpace, SerializeSettings};
use craft_pdf::write::print::{self, PrintColor};
use craft_pdf::write::{Metadata, Writer};
use craft_plate_test::reference;
use craft_plate_test::structure;
use craft_prepress::flatten::{
FlattenDocumentOptions, FlattenOptions, FlattenPageOptions, flatten_document, flatten_document_with_progress, flatten_page,
};
use craft_prepress::{Options, Plates, plates};
const DPI: f32 = 36.0;
const SIZES: [(f32, f32); 3] = [(200.0, 160.0), (150.0, 200.0), (220.0, 120.0)];
fn inter() -> Option<Vec<u8>> {
let dir = std::env::var_os(reference::FONT_DIR_ENV)
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/craft-pdf-test-fonts")))?;
let font = std::fs::read(dir.join(reference::FONT_FILE)).ok();
if font.is_none() {
eprintln!("skipped: no {} in {}", reference::FONT_FILE, dir.display());
}
font
}
fn fill(c: u8, m: u8, y: u8, k: u8) -> Fill {
Fill { paint: cmyk::Color::new(c, m, y, k).into(), ..Default::default() }
}
fn rect(s: &mut Surface<'_>, x0: f32, y0: f32, x1: f32, y1: f32, f: Fill) {
let mut b = PathBuilder::new();
b.push_rect(tiny_rect(x0, y0, x1, y1));
s.set_fill(Some(f));
s.draw_path(&b.finish().unwrap());
}
fn tiny_rect(x0: f32, y0: f32, x1: f32, y1: f32) -> craft_pdf::write::backend::geom::Rect {
KRect::from_ltrb(x0, y0, x1, y1).unwrap()
}
fn text(s: &mut Surface<'_>, font: &Font, face: &ttf_parser::Face<'_>, at: (f32, f32), size: f32, string: &str) {
let upem = f32::from(face.units_per_em());
let glyphs: Vec<KrillaGlyph> = string
.char_indices()
.map(|(i, c)| {
let id = face.glyph_index(c).unwrap();
let advance = f32::from(face.glyph_hor_advance(id).unwrap()) / upem;
KrillaGlyph::new(GlyphId::new(u32::from(id.0)), advance, 0.0, 0.0, 0.0, i..i + c.len_utf8(), None)
})
.collect();
s.set_fill(Some(fill(0, 0, 0, 255)));
s.draw_glyphs(Point::from_xy(at.0, at.1), &glyphs, font.clone(), string, size, false);
}
/// Three pages of different sizes: transparency beside text, no transparency, and transparency
/// over text in page 1's font. Page 1 has a TrimBox and a BleedBox.
fn three_pages(font_bytes: &[u8]) -> Vec<u8> {
let face = ttf_parser::Face::parse(font_bytes, 0).unwrap();
let font = craft_pdf::write::font(font_bytes, 0, None).unwrap();
let metadata = Metadata {
title: Some("Flatten test".into()),
authors: vec!["A. Writer".into()],
created: Some(DateTime::new(2026).month(10).day(20).hour(9).minute(30).second(0).utc_offset_hour(2)),
modified: Some(DateTime::new(2026).month(10).day(21).hour(16).minute(5).second(0).utc_offset_hour(2)),
..Metadata::default()
};
// DeviceCMYK blending and no output intent: parsing a profile takes half a second in a debug build.
let settings = SerializeSettings { no_device_cs: false, group_color_space: GroupColorSpace::Cmyk, ..SerializeSettings::default() };
let mut writer = Writer::new(settings, metadata);
let mut page = |settings: PageSettings, draw: &dyn Fn(&mut Surface<'_>)| {
let mut page = writer.document_mut().start_page_with(settings);
let mut s = page.surface();
draw(&mut s);
s.finish();
page.finish();
};
let size = |i: usize| PageSettings::from_wh(SIZES[i].0, SIZES[i].1).unwrap();
page(size(0).with_trim_box(Some(tiny_rect(10.0, 10.0, 190.0, 150.0))).with_bleed_box(Some(tiny_rect(5.0, 5.0, 195.0, 155.0))), &|s| {
rect(s, 20.0, 20.0, 120.0, 100.0, fill(255, 0, 0, 0));
s.push_opacity(NormalizedF32::new(0.5).unwrap());
rect(s, 70.0, 60.0, 170.0, 130.0, fill(0, 255, 0, 0));
s.pop();
text(s, &font, &face, (10.0, 150.0), 10.0, "Hello");
});
page(size(1), &|s| {
rect(s, 10.0, 10.0, 140.0, 90.0, fill(0, 0, 255, 0));
text(s, &font, &face, (10.0, 150.0), 14.0, "Opaque");
});
page(size(2), &|s| {
text(s, &font, &face, (20.0, 60.0), 30.0, "Hello");
s.push_opacity(NormalizedF32::new(0.4).unwrap());
rect(s, 40.0, 25.0, 120.0, 80.0, fill(255, 0, 0, 0));
s.pop();
text(s, &font, &face, (20.0, 110.0), 12.0, "World");
});
drop(page);
writer.finish().unwrap()
}
fn opts() -> FlattenDocumentOptions {
FlattenDocumentOptions { flatten: FlattenOptions { raster_dpi: 72.0, ..FlattenOptions::default() }, ..FlattenDocumentOptions::default() }
}
fn separate(pdf: &[u8], page: usize) -> Plates {
plates(pdf, page, DPI, &Options::default()).unwrap()
}
fn tint(p: &Plates, name: &str, x: f32, y: f32) -> u8 {
let s = p.dpi / 72.0;
p.inks.lookup(name).and_then(|id| p.tint(id, (x * s) as u32, (y * s) as u32)).unwrap_or(0)
}
/// No transparency groups, soft masks in graphics states, blend modes or constant alpha.
fn assert_opaque(pdf: &[u8]) {
let s = structure::summarize(pdf).unwrap();
assert!(s.groups.iter().all(|g| g.s != "Transparency"), "transparency groups: {:?}", s.groups);
for gs in &s.ext_g_states {
assert!(gs.smask.as_deref().is_none_or(|m| m == "None" || m.is_empty()), "soft mask: {gs:?}");
assert!(gs.bm.as_deref().is_none_or(|b| b == "Normal" || b == "Compatible"), "blend mode: {gs:?}");
assert!(gs.alpha_fill.is_none_or(|a| a >= 1.0) && gs.alpha_stroke.is_none_or(|a| a >= 1.0), "alpha: {gs:?}");
}
}
fn has_text(pdf: &craft_pdf::syntax::Pdf, page: usize) -> bool {
let pages = pdf.pages();
let mut ops = pages.get(page).unwrap().operations();
let mut found = false;
while let Some(op) = ops.next() {
found |= matches!(&**op.operator, b"Tj" | b"TJ");
}
found
}
fn count(haystack: &[u8], needle: &[u8]) -> usize {
haystack.windows(needle.len()).filter(|w| *w == needle).count()
}
fn page_box(pdf: &craft_pdf::syntax::Pdf, page: usize, key: &[u8]) -> Option<[f64; 4]> {
pdf.pages().get(page)?.raw().get::<PdfRect>(key).map(|r| [r.x0, r.y0, r.x1, r.y1])
}
#[test]
fn a_document_flattens_page_by_page_and_keeps_boxes_metadata_and_shared_fonts() {
let Some(font) = inter() else { return };
let source = three_pages(&font);
let start = Instant::now();
let (out, report) = flatten_document(&source, &opts()).unwrap();
eprintln!("flatten_document: {:?}, totals {:?}", start.elapsed(), report.totals);
let doc = craft_pdf::open_slice(&out, None).unwrap();
let src = craft_pdf::open_slice(&source, None).unwrap();
assert_eq!(doc.pages().len(), 3);
for (i, (w, h)) in SIZES.iter().enumerate() {
assert_eq!(doc.pages()[i].render_dimensions(), (*w, *h), "page {i}");
}
assert_eq!(report.pages.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [0, 1, 2]);
assert!(report.pages[0].1.flatten.flattened > 0 && report.pages[2].1.flatten.flattened > 0);
assert_eq!(report.pages[1].1.flatten.flattened, 0);
assert!(report.unflattened.is_empty() && report.other_intents.is_empty());
assert_opaque(&out);
for page in 0..3 {
assert!(has_text(&doc, page), "page {page} has live text");
}
// One embedded program for the font all three pages use.
assert_eq!(count(&source, b"/FontFile2"), 1);
assert_eq!(count(&out, b"/FontFile2"), 1, "the shared font is embedded once");
for key in [TRIM_BOX, BLEED_BOX] {
assert_eq!(page_box(&doc, 0, key), page_box(&src, 0, key));
assert!(page_box(&doc, 0, key).is_some());
assert_eq!(page_box(&doc, 1, key), None);
}
let info = doc.metadata();
assert_eq!(info.title.as_deref(), Some(b"Flatten test".as_slice()));
assert_eq!(info.author.as_deref(), Some(b"A. Writer".as_slice()));
let created = info.creation_date.unwrap();
assert_eq!((created.year, created.month, created.day, created.hour, created.minute), (2026, 10, 20, 9, 30));
let modified = info.modification_date.unwrap();
assert_eq!((modified.year, modified.month, modified.day, modified.hour, modified.minute), (2026, 10, 21, 16, 5));
let points: [&[(f32, f32)]; 3] = [
&[(40.0, 40.0), (100.0, 80.0), (150.0, 120.0), (190.0, 15.0)],
&[(50.0, 50.0), (100.0, 180.0)],
&[(110.0, 30.0), (115.0, 75.0), (180.0, 50.0)],
];
for (page, points) in points.iter().enumerate() {
let (a, b) = (separate(&source, page), separate(&out, page));
for &(x, y) in *points {
for ink in ["Cyan", "Magenta", "Yellow", "Black"] {
let (want, got) = (tint(&a, ink, x, y), tint(&b, ink, x, y));
assert!(want.abs_diff(got) <= 3, "page {page} {ink} at ({x}, {y}): {got}, the source has {want}");
}
}
}
let single: usize = (0..3)
.map(|p| {
let opts = FlattenPageOptions { flatten: opts().flatten, ..FlattenPageOptions::default() };
flatten_page(&source, p, &opts).unwrap().0.len()
})
.sum();
eprintln!("source {} bytes; flattened document {} bytes; three flatten_page outputs {single} bytes", source.len(), out.len());
}
#[test]
fn a_page_range_flattens_only_those_pages() {
let Some(font) = inter() else { return };
let t = Instant::now();
let source = three_pages(&font);
eprintln!("three_pages {:?}", t.elapsed());
let mut calls = Vec::new();
let opts = FlattenDocumentOptions { pages: Some(1..3), ..opts() };
let t = Instant::now();
let (out, report) = flatten_document_with_progress(&source, &opts, &mut |done, total| calls.push((done, total))).unwrap();
eprintln!("flatten {:?}", t.elapsed());
let doc = craft_pdf::open_slice(&out, None).unwrap();
assert_eq!(doc.pages().len(), 2);
assert_eq!(doc.pages()[0].render_dimensions(), SIZES[1]);
assert_eq!(doc.pages()[1].render_dimensions(), SIZES[2]);
assert_eq!(report.pages.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [1, 2]);
assert_eq!(calls, [(1, 2), (2, 2)]);
let opts = FlattenDocumentOptions { pages: Some(2..4), ..FlattenDocumentOptions::default() };
assert!(flatten_document(&source, &opts).is_err());
}
#[test]
fn the_source_output_intent_is_written_once() {
let color = PrintColor {
profile: craft_cms::builtin::COATED_CMYK_ICC,
condition_identifier: "Test coated",
condition: Some("A coated test condition"),
registry_name: None,
info: Some("A coated test condition"),
};
let mut writer = Writer::new(print::settings(color).unwrap(), Metadata::default());
for _ in 0..2 {
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(100.0, 100.0).unwrap());
let mut s = page.surface();
rect(&mut s, 10.0, 10.0, 60.0, 60.0, fill(255, 0, 0, 0));
s.push_opacity(NormalizedF32::new(0.5).unwrap());
rect(&mut s, 30.0, 30.0, 90.0, 90.0, fill(0, 0, 0, 255));
s.pop();
s.finish();
page.finish();
}
let source = writer.finish().unwrap();
let (out, report) = flatten_document(&source, &opts()).unwrap();
assert!(report.other_intents.is_empty());
assert_opaque(&out);
let doc = craft_pdf::open_slice(&out, None).unwrap();
let xref = doc.xref();
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
let intents: Vec<Dict<'_>> = catalog.get::<Array<'_>>(OUTPUT_INTENTS).unwrap().iter::<Dict<'_>>().collect();
assert_eq!(intents.len(), 1);
let id = intents[0].get::<PdfString<'_>>(OUTPUT_CONDITION_IDENTIFIER).unwrap();
assert_eq!(id.as_bytes(), b"Test coated");
let icc = intents[0].get::<Stream<'_>>(DEST_OUTPUT_PROFILE).unwrap().decoded().unwrap();
assert_eq!(&*icc, craft_cms::builtin::COATED_CMYK_ICC);
assert_eq!(count(&out, b"/DestOutputProfile"), 1);
}
/// PDF/X-1a output: the source's DeviceN fill (cyan and a spot, NChannel at 1.7) is written at
/// 1.4 without the 1.6 NChannel attributes, and the PDF/X version goes to Info and XMP.
#[test]
fn pdf_x1a_output_is_pdf_14_without_nchannel() {
let gold = cmyk::Color::new(0, 51, 255, 26);
let space = DeviceNSpace::new(vec![Colorant::Process(Process::Cyan), Colorant::Spot { name: "Gold".to_owned(), alternate: gold }]).unwrap();
let color = device_n::Color::new(vec![255, 128], space).unwrap();
let mut writer = Writer::new(print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap(), Metadata::default());
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(100.0, 100.0).unwrap());
let mut s = page.surface();
rect(&mut s, 10.0, 10.0, 60.0, 60.0, Fill { paint: backend::color::Color::from(color).into(), ..Default::default() });
s.push_opacity(NormalizedF32::new(0.5).unwrap());
rect(&mut s, 30.0, 30.0, 90.0, 90.0, fill(0, 0, 0, 255));
s.pop();
s.finish();
page.finish();
let source = writer.finish().unwrap();
assert!(count(&source, b"/NChannel") > 0);
let opts = FlattenDocumentOptions {
version: PdfVersion::Pdf14,
pdfx_version: Some("PDF/X-1:2003".to_owned()),
pdfx_conformance: Some("PDF/X-1a:2003".to_owned()),
..opts()
};
let (out, _) = flatten_document(&source, &opts).unwrap();
assert_opaque(&out);
assert!(out.starts_with(b"%PDF-1.4"));
assert_eq!(count(&out, b"NChannel"), 0);
assert_eq!(count(&out, b"/GTS_PDFXVersion(PDF/X-1:2003)"), 1);
assert_eq!(count(&out, b"/GTS_PDFXConformance(PDF/X-1a:2003)"), 1);
assert_eq!(count(&out, b"<pdfxid:GTS_PDFXVersion>PDF/X-1:2003</pdfxid:GTS_PDFXVersion>"), 1);
assert_eq!(count(&out, b"<pdf:PDFVersion>1.4</pdf:PDFVersion>"), 1);
let gold_plate = separate(&out, 0);
assert!(tint(&gold_plate, "Gold", 20.0, 20.0) > 100);
}
/// A 16 × 16 Adobe CMYK JPEG of C 100%, and a 16 × 16 gray JPEG of 25% gray (ImageMagick).
const CYAN_JPEG: &[u8] = include_bytes!("fixtures/cyan-cmyk.jpg");
const GRAY_JPEG: &[u8] = include_bytes!("fixtures/gray-25.jpg");
#[test]
fn untouched_jpeg_images_are_written_as_they_are() {
let image = |cs: &str, decode: &str, data: &[u8]| {
let mut obj = format!(
"<< /Type /XObject /Subtype /Image /Width 16 /Height 16 /ColorSpace /{cs} {decode} /BitsPerComponent 8 \
/Filter /DCTDecode /Length {} >> stream\n",
data.len()
)
.into_bytes();
obj.extend_from_slice(data);
obj.extend_from_slice(b"\nendstream");
obj
};
// The images, and a half-transparent black square away from them.
let content = "q 50 0 0 50 10 10 cm /Im1 Do Q q 50 0 0 50 70 10 cm /Im2 Do Q \
q /GS1 gs 0 0 0 1 k 140 10 50 50 re f Q";
let objects =
[image("DeviceCMYK", "/Decode [1 0 1 0 1 0 1 0]", CYAN_JPEG), image("DeviceGray", "", GRAY_JPEG), b"<< /Type /ExtGState /ca 0.5 >>".to_vec()];
let mut pdf = b"%PDF-1.7\n".to_vec();
pdf.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
pdf.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
pdf.extend_from_slice(
b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 70] /Contents 4 0 R \
/Resources << /XObject << /Im1 5 0 R /Im2 6 0 R >> /ExtGState << /GS1 7 0 R >> >> >> endobj\n",
);
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
for (i, body) in objects.iter().enumerate() {
pdf.extend_from_slice(format!("{} 0 obj ", i + 5).as_bytes());
pdf.extend_from_slice(body);
pdf.extend_from_slice(b" endobj\n");
}
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
let (out, report) = flatten_document(&pdf, &opts()).unwrap();
assert_eq!(report.totals.images, 2);
assert!(report.totals.flattened > 0);
assert_opaque(&out);
assert_eq!(count(&out, CYAN_JPEG), 1, "the CMYK JPEG is written as it is");
assert_eq!(count(&out, GRAY_JPEG), 1, "the gray JPEG is written as it is");
assert_eq!(count(&out, b"/DCTDecode"), 2);
let (a, b) = (separate(&pdf, 0), separate(&out, 0));
for (x, y) in [(35.0, 35.0), (95.0, 35.0), (165.0, 35.0)] {
for ink in ["Cyan", "Magenta", "Yellow", "Black"] {
let (want, got) = (tint(&a, ink, x, y), tint(&b, ink, x, y));
assert!(want.abs_diff(got) <= 3, "{ink} at ({x}, {y}): {got}, the source has {want}");
}
}
assert!(tint(&b, "Cyan", 35.0, 35.0) > 240 && tint(&b, "Black", 95.0, 35.0) > 150);
}
+613
View File
@@ -0,0 +1,613 @@
//! The PDF-in adapter on plate-test scenes and hand-written pages: display list structure, ink
//! colors, overprint and group attributes, and plates of opaque items written back.
#![cfg(feature = "flatten")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use craft_pdf::write::backend::blend::Overprint as KOverprint;
use craft_pdf::write::backend::geom::PathBuilder;
use craft_pdf::write::backend::page::PageSettings;
use craft_pdf::write::backend::paint::{Fill, FillRule as KFillRule};
use craft_pdf::write::{Metadata, Writer};
use craft_plate_test::reference::{self, Context};
use craft_plate_test::scene;
use craft_prepress::flatten::kurbo::{BezPath, PathEl, Point, Shape};
use craft_prepress::flatten::pdf::{ReadOptions, ReadReport, read_page};
use craft_prepress::flatten::{
BlendMode, DisplayList, FillItem, FillRule, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, MaskKind, Paint,
};
use craft_prepress::{CmsConversion, InkColor, InkId, Options, Overprint, Process, plates, to_pdf_color};
fn scene_pdf(id: &str) -> Vec<u8> {
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
let s = scenes.iter().find(|s| s.id == id).unwrap();
reference::write(s, &Context { scenes: &scenes, font: None }).unwrap().pdf
}
fn read(pdf: &[u8]) -> (DisplayList, ReadReport) {
read_page(pdf, 0, &ReadOptions::default()).unwrap()
}
/// Every non-group item with the groups it is in, outermost first.
fn leaves(items: &[Item]) -> Vec<(Vec<&GroupItem>, &Item)> {
fn walk<'a>(items: &'a [Item], path: &mut Vec<&'a GroupItem>, out: &mut Vec<(Vec<&'a GroupItem>, &'a Item)>) {
for item in items {
match item {
Item::Group(g) => {
path.push(g);
walk(&g.children, path, out);
path.pop();
}
other => out.push((path.clone(), other)),
}
}
}
let mut out = Vec::new();
walk(items, &mut Vec::new(), &mut out);
out
}
fn fill_items(list: &DisplayList) -> Vec<(Vec<&GroupItem>, &FillItem)> {
leaves(&list.items)
.into_iter()
.filter_map(|(groups, item)| match item {
Item::Fill(f) => Some((groups, f)),
_ => None,
})
.collect()
}
fn ink(p: Process) -> InkId {
InkId::process(p)
}
fn cmyk_color(c: f32, m: f32, y: f32, k: f32) -> InkColor {
InkColor::Tints(vec![(ink(Process::Cyan), c), (ink(Process::Magenta), m), (ink(Process::Yellow), y), (ink(Process::Black), k)])
}
fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Paint {
Paint::Solid(cmyk_color(c, m, y, k))
}
fn op(fill: bool, nonzero: bool) -> Overprint {
Overprint { fill, stroke: fill, nonzero }
}
#[test]
fn black_overprints_cyan() {
let (list, report) = read(&scene_pdf("S01"));
let fills = fill_items(&list);
assert_eq!(fills.len(), 2, "two rects (text is skipped without a font): {fills:?}");
assert_eq!(fills[0].1.paint, cmyk(1.0, 0.0, 0.0, 0.0));
assert_eq!(fills[0].1.overprint, op(false, false));
assert_eq!(fills[1].1.paint, cmyk(0.0, 0.0, 0.0, 1.0));
assert_eq!(fills[1].1.overprint, op(true, true));
assert_eq!(fills[1].1.path.bounding_box().size().width.round(), 100.0);
assert_eq!((report.images, report.gradients), (0, 0));
}
#[test]
fn spot_inks_join_the_set() {
let (list, _) = read(&scene_pdf("S04"));
let gold = list.inks.lookup("Gold").unwrap();
let fills = fill_items(&list);
let Paint::Solid(InkColor::Tints(tints)) = &fills[1].1.paint else { panic!() };
assert!(matches!(tints[..], [(id, t)] if id == gold && (t - 0.5).abs() < 0.01), "{tints:?}");
assert!(fills[1].1.overprint.fill);
let alternate = list.inks.spots().find(|(id, _)| *id == gold).unwrap().1.alternate;
assert_eq!(alternate, craft_prepress::Alternate::Cmyk([0.0, 0.2, 0.6, 0.2]));
// An overprinting stroke carries `OP` in `fill`.
let (list, _) = read(&scene_pdf("D06"));
let gold = list.inks.lookup("Gold").unwrap();
let stroke = fill_items(&list).into_iter().find(|(_, f)| f.paint == Paint::Solid(InkColor::Tints(vec![(gold, 1.0)]))).unwrap().1;
assert_eq!(stroke.overprint, op(true, false));
// The 10 pt stroke outlined around a 110 × 60 rect.
assert_eq!(stroke.path.bounding_box().size().width.round(), 120.0);
}
#[test]
fn registration_is_all_inks() {
let (list, _) = read(&scene_pdf("S13"));
let all: Vec<_> = fill_items(&list).into_iter().filter(|(_, f)| f.paint == Paint::Solid(InkColor::All(1.0))).collect();
assert_eq!(all.len(), 2, "the registration fill and stroke");
}
#[test]
fn knockout_and_isolated_groups() {
let (list, _) = read(&scene_pdf("S09"));
let fills = fill_items(&list);
let ellipses: Vec<_> = fills.iter().filter(|(groups, _)| groups.iter().any(|g| g.knockout)).collect();
assert_eq!(ellipses.len(), 2);
for (groups, _) in ellipses {
// The writer puts each ellipse in an opacity group of its own, inside the knockout group.
let at = groups.iter().position(|g| g.knockout).unwrap();
assert!(groups[at + 1..].iter().any(|g| g.opacity == 0.5));
}
let (list, _) = read(&scene_pdf("S10"));
let fills = fill_items(&list);
let magenta = fills.iter().find(|(_, f)| f.paint == cmyk(0.0, 1.0, 0.0, 0.0)).unwrap();
assert!(magenta.0.iter().any(|g| g.isolated));
assert_eq!(magenta.0.last().unwrap().blend, BlendMode::Multiply);
let yellow = fills.iter().find(|(_, f)| f.paint == cmyk(0.0, 0.0, 1.0, 0.0)).unwrap();
assert!(yellow.0.iter().any(|g| g.isolated));
assert!(yellow.0.iter().all(|g| g.blend == BlendMode::Normal));
}
#[test]
fn soft_masks_keep_backdrop_and_transfer() {
let (list, _) = read(&scene_pdf("M1"));
let (groups, _) = fill_items(&list).into_iter().find(|(_, f)| f.paint == cmyk(0.0, 1.0, 0.0, 0.0)).unwrap();
let mask = groups.iter().find_map(|g| g.mask.as_deref()).unwrap();
assert_eq!(mask.kind, MaskKind::Luminosity);
assert!(mask.group.isolated);
assert_eq!(leaves(&mask.group.children).len(), 1, "the mask's rect");
// The scene's backdrop is white (no black ink).
assert_eq!(mask.backdrop, Some(cmyk_color(0.0, 0.0, 0.0, 0.0)));
let (list, _) = read(&scene_pdf("M2"));
let (groups, _) = fill_items(&list).into_iter().find(|(_, f)| f.paint == cmyk(0.0, 1.0, 0.0, 0.0)).unwrap();
let mask = groups.iter().find_map(|g| g.mask.as_deref()).unwrap();
let transfer = mask.transfer.as_deref().unwrap();
assert_eq!((transfer.len(), transfer[0], transfer[255]), (256, 255, 0));
}
#[test]
fn shadow_over_spot_counts_images() {
let (list, report) = read(&scene_pdf("S11"));
assert_eq!(report.images, 3, "the split image and two feather images in masks");
let shadows: Vec<_> = fill_items(&list).into_iter().filter(|(_, f)| f.paint == cmyk(0.0, 0.0, 0.0, 1.0)).collect();
assert_eq!(shadows.len(), 2);
for (groups, _) in shadows {
let masked = groups.iter().find(|g| g.mask.is_some()).unwrap();
assert_eq!(masked.blend, BlendMode::Multiply);
let mask = masked.mask.as_deref().unwrap();
assert_eq!(mask.kind, MaskKind::Luminosity);
assert_eq!(mask.backdrop, Some(cmyk_color(0.0, 0.0, 0.0, 1.0)));
// The mask's content is the feather image, alpha and all.
let feathers: Vec<_> =
leaves(&mask.group.children).into_iter().filter_map(|(_, i)| if let Item::Image(im) = i { Some(im) } else { None }).collect();
assert_eq!(feathers.len(), 1, "{:?}", mask.group.children);
assert!(groups.iter().any(|g| g.opacity == 0.75));
}
}
/// A one-page PDF from raw content and resources, objects numbered from 5.
fn raw_pdf(content: &str, resources: &str, objects: &[String]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
out.extend_from_slice(
format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(),
);
out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
for (i, body) in objects.iter().enumerate() {
out.extend_from_slice(format!("{} 0 obj {body} endobj\n", i + 5).as_bytes());
}
out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
out
}
fn stream(dict: &str, data: &str) -> String {
format!("<< {dict} /Length {} >> stream\n{data}\nendstream", data.len())
}
#[test]
fn text_clips_strokes_and_process_color() {
let to_unicode = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /T3 def /CMapType 2 def \
1 begincodespacerange <00> <FF> endcodespacerange 2 beginbfchar <41> <0041> <42> <0042> endbfchar \
endcmap CMapName currentdict /CMap defineresource pop end end";
let objects = [
// 5: a Type 3 font with a shape glyph (A) and a colored one (B).
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \
/CharProcs << /sq 6 0 R /cg 7 0 R >> /Encoding << /Type /Encoding /Differences [65 /sq /cg] >> \
/FirstChar 65 /LastChar 66 /Widths [1000 1000] /ToUnicode 8 0 R >>"
.to_string(),
stream("", "1000 0 0 0 1000 1000 d1 0 0 1000 1000 re f"),
stream("", "1000 0 d0 0 1 0 0 k 0 0 1000 1000 re f"),
stream("", to_unicode),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
];
let content = "BT /F1 20 Tf 10 90 Td (AAB) Tj ET \
BT /F2 24 Tf 100 90 Td (Hi) Tj ET \
q 0 0 100 50 re 25 10 50 30 re W* n 0 0 0 1 k 0 0 100 50 re f Q \
0.5 G 4 w [6 3] 0 d 110 10 m 290 10 l S \
1 0 0 rg 200 30 10 10 re f";
let pdf = raw_pdf(content, "/Font << /F1 5 0 R /F2 9 0 R >>", &objects);
let (list, report) = read(&pdf);
let items = leaves(&list.items);
// The two shape glyphs form one run; the colored glyph is a group with its text.
let Item::Text(t3) = items[0].1 else { panic!("{:?}", items[0]) };
assert_eq!((t3.glyphs.len(), t3.text.as_deref(), t3.font.is_none()), (2, Some("AA"), true));
assert_eq!(t3.size.round(), 20.0);
assert_eq!(t3.glyphs[1].origin.x.round(), 20.0, "origins in text space");
assert_eq!(t3.paint, cmyk(0.0, 0.0, 0.0, 1.0), "DeviceGray 0 is solid black only");
let colored = items[1].0.last().unwrap();
assert_eq!(colored.actual_text.as_deref(), Some("B"));
assert!(matches!(items[1].1, Item::Fill(f) if f.paint == cmyk(0.0, 1.0, 0.0, 0.0)));
let Item::Text(hi) = items[2].1 else { panic!("{:?}", items[2]) };
assert_eq!(hi.text.as_deref(), Some("Hi"));
assert_eq!(hi.glyphs[1].text, 1..2);
assert!(!hi.outlines.is_empty());
// Text space is y up: the run's transform flips y into page space.
assert!(hi.transform.as_coeffs()[3] < 0.0);
// The even-odd clip, as a nonzero path with the hole kept.
let (groups, _) = &items[3];
let clip = groups.last().unwrap().clip.as_ref().unwrap();
assert_eq!(clip.winding(Point::new(50.0, 95.0)), 0, "the hole");
assert_ne!(clip.winding(Point::new(10.0, 115.0)), 0);
// The dashed stroke, outlined: 20 dashes of 6 pt in 180 pt, 4 pt wide.
let Item::Fill(dashes) = items[4].1 else { panic!() };
assert_eq!(dashes.paint, cmyk(0.0, 0.0, 0.0, 0.5));
assert_eq!(dashes.path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count(), 20);
assert_eq!(dashes.path.bounding_box().height().round(), 4.0);
// RGB converts through FOGRA39: red is mostly magenta and yellow.
let Item::Fill(red) = items[5].1 else { panic!() };
let Paint::Solid(InkColor::Tints(tints)) = &red.paint else { panic!() };
assert_eq!(tints.len(), 4);
assert!(tints[1].1 > 0.8 && tints[2].1 > 0.8 && tints[0].1 < 0.2, "{tints:?}");
assert!(matches!(report.conversion, plates::Conversion::Fallback { .. }));
assert_eq!(items.len(), 6);
}
fn images(list: &DisplayList) -> Vec<(Vec<&GroupItem>, &ImageItem)> {
leaves(&list.items)
.into_iter()
.filter_map(|(groups, item)| match item {
Item::Image(im) => Some((groups, im)),
_ => None,
})
.collect()
}
/// The ink tints of `im` at page point `p` (nearest sample).
fn tints_at(im: &ImageItem, p: Point) -> Vec<(InkId, f32)> {
let u = im.transform.inverse() * p;
let (w, h) = (im.size.0 as usize, im.size.1 as usize);
let (x, y) = (((u.x * w as f64) as usize).min(w - 1), ((u.y * h as f64) as usize).min(h - 1));
let ids: Vec<InkId> = match &im.space {
ImageSpace::Gray => vec![ink(Process::Black)],
ImageSpace::Cmyk => Process::ALL.iter().map(|p| ink(*p)).collect(),
ImageSpace::Inks(ids) => ids.clone(),
ImageSpace::Rgb => panic!("RGB image"),
};
let n = if im.space == ImageSpace::Gray { 1 } else { ids.len() };
let s = &im.samples[(y * w + x) * n..(y * w + x + 1) * n];
if im.space == ImageSpace::Gray {
return vec![(ids[0], 1.0 - f32::from(s[0]) / 255.0)];
}
ids.into_iter().zip(s).map(|(id, v)| (id, f32::from(*v) / 255.0)).collect()
}
fn tint(tints: &[(InkId, f32)], id: InkId) -> f32 {
tints.iter().find(|t| t.0 == id).map_or(0.0, |t| t.1)
}
fn hex_image(dict: &str, hex: &str) -> String {
stream(&format!("/Type /XObject /Subtype /Image /Filter /ASCIIHexDecode {dict}"), &format!("{hex}>"))
}
const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.2 0.6 0.2] /N 1 >>]";
#[test]
fn cmyk_image_keeps_its_samples() {
let objects = [hex_image("/Width 2 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8", "FF000000 00804020")];
let pdf = raw_pdf("q 100 0 0 50 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &objects);
let (list, report) = read(&pdf);
let ims = images(&list);
assert_eq!(ims.len(), 1);
let im = ims[0].1;
assert_eq!((&im.space, im.size, &im.samples[..]), (&ImageSpace::Cmyk, (2, 1), &[255, 0, 0, 0, 0, 128, 64, 32][..]));
assert_eq!(im.transform * Point::ORIGIN, Point::new(10.0, 60.0), "(0, 0) is the top left sample");
assert_eq!(im.transform * Point::new(1.0, 1.0), Point::new(110.0, 110.0));
assert!(im.smask.is_none());
assert_eq!((report.images, report.converted_images), (1, 0));
}
#[test]
fn cmyk_image_overprints_without_nonzero_mode() {
let objects = [hex_image("/Width 2 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8", "FF000000 00804020")];
let pdf = raw_pdf("/GS1 gs q 100 0 0 50 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >> /ExtGState << /GS1 << /op true /OPM 1 >> >>", &objects);
let (list, _) = read(&pdf);
assert_eq!(images(&list)[0].1.overprint, op(true, false), "nonzero overprint mode is not for images");
}
#[test]
fn separation_image_is_its_ink_with_decode_applied() {
let objects = [hex_image(&format!("/Width 2 /Height 1 /ColorSpace {GOLD} /BitsPerComponent 8 /Decode [1 0]"), "FF40")];
let pdf = raw_pdf("/GS1 gs q 100 0 0 50 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >> /ExtGState << /GS1 << /op true /OPM 1 >> >>", &objects);
let (list, _) = read(&pdf);
let gold = list.inks.lookup("Gold").unwrap();
let im = images(&list)[0].1;
assert_eq!(im.space, ImageSpace::Inks(vec![gold]));
assert_eq!(&im.samples[..], &[0, 0xBF]);
assert_eq!(im.overprint, op(true, false), "nonzero overprint mode is for DeviceCMYK only");
}
#[test]
fn rgb_image_converts_to_cmyk() {
let objects = [hex_image("/Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", "FF0000")];
let pdf = raw_pdf("q 10 0 0 10 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &objects);
let (list, report) = read(&pdf);
let im = images(&list)[0].1;
assert_eq!(im.space, ImageSpace::Cmyk);
assert!(im.samples[1] > 200 && im.samples[2] > 200 && im.samples[0] < 50, "{:?}", im.samples);
assert_eq!(report.converted_images, 1);
}
#[test]
fn image_smask_is_resampled_to_the_image() {
let objects = [
hex_image("/Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R", "00FF8040"),
hex_image("/Width 1 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", "FF00"),
];
let pdf = raw_pdf("q 20 0 0 20 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &objects);
let (list, report) = read(&pdf);
let im = images(&list)[0].1;
assert_eq!((&im.space, &im.samples[..]), (&ImageSpace::Gray, &[0, 255, 128, 64][..]));
assert_eq!(im.smask.as_deref(), Some(&[255, 255, 0, 0][..]));
assert_eq!(report.resampled_masks, 1);
}
#[test]
fn spot_stencil_is_a_traced_fill() {
// Sample 0 paints: the left half of each row.
let objects = [hex_image("/Width 8 /Height 2 /ImageMask true /BitsPerComponent 1", "0F0F")];
let pdf = raw_pdf("/CS1 cs 1 scn q 80 0 0 20 10 10 cm /Im1 Do Q", &format!("/XObject << /Im1 5 0 R >> /ColorSpace << /CS1 {GOLD} >>"), &objects);
let (list, report) = read(&pdf);
let gold = list.inks.lookup("Gold").unwrap();
let fills = fill_items(&list);
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].1.paint, Paint::Solid(InkColor::Tints(vec![(gold, 1.0)])));
let bbox = fills[0].1.path.bounding_box();
assert_eq!((bbox.x0, bbox.x1, bbox.y0, bbox.y1), (10.0, 50.0, 90.0, 110.0));
assert_eq!((report.stencils, images(&list).len()), (1, 0));
}
/// The gradient's ink values at offset `s`, interpolated between stops.
fn stop_tints(stops: &[(f32, InkColor)], s: f32) -> Vec<(InkId, f32)> {
let k = stops.iter().position(|st| st.0 >= s).unwrap();
let (lo, hi) = (&stops[k.saturating_sub(1)], &stops[k]);
let u = if hi.0 > lo.0 { (s - lo.0) / (hi.0 - lo.0) } else { 1.0 };
let (InkColor::Tints(a), InkColor::Tints(b)) = (&lo.1, &hi.1) else { panic!() };
a.iter().zip(b).map(|(x, y)| (x.0, x.1 + (y.1 - x.1) * u)).collect()
}
#[test]
fn axial_spot_to_paper_shading_is_a_gradient() {
// Gold from 100% to paper, quadratic: 1 - s².
let objects = [format!(
"<< /ShadingType 2 /ColorSpace {GOLD} /Coords [0 0 100 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 2 >> >>"
)];
let pdf = raw_pdf("q 0 0 100 100 re W n /Sh1 sh Q", "/Shading << /Sh1 5 0 R >>", &objects);
let (list, report) = read(&pdf);
let gold = list.inks.lookup("Gold").unwrap();
let fills = fill_items(&list);
assert_eq!(fills.len(), 1);
let Paint::Gradient { stops, geometry } = &fills[0].1.paint else { panic!("{:?}", fills[0].1.paint) };
for s in [0.25_f32, 0.5, 0.75] {
let t = stop_tints(stops, s);
assert!((tint(&t, gold) - (1.0 - s * s)).abs() < 1.0 / 255.0, "at {s}: {t:?}");
}
assert!(stops.len() < 64, "{} stops", stops.len());
let GradientGeometry::Axial { start, end, extend, .. } = geometry else { panic!() };
assert_eq!((*start, *end, *extend), (Point::new(0.0, 0.0), Point::new(100.0, 0.0), [true, true]));
assert_eq!(report.gradients, 1);
}
#[test]
fn radial_process_shading_is_clipped_to_its_extent() {
let objects = ["<< /ShadingType 3 /ColorSpace /DeviceCMYK /Coords [50 50 0 50 50 40] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0 0] /C1 [0 1 0 0] /N 1 >> >>".to_string()];
let pdf = raw_pdf("/Sh1 sh", "/Shading << /Sh1 5 0 R >>", &objects);
let (list, _) = read(&pdf);
let fills = fill_items(&list);
assert_eq!(fills.len(), 1);
let (groups, fill) = &fills[0];
let Paint::Gradient { stops, geometry } = &fill.paint else { panic!() };
assert!(matches!(geometry, GradientGeometry::Radial { extend: [true, true], end_radius, .. } if *end_radius == 40.0));
let mid = stop_tints(stops, 0.5);
assert!((tint(&mid, ink(Process::Cyan)) - 0.5).abs() < 0.01 && (tint(&mid, ink(Process::Magenta)) - 0.5).abs() < 0.01);
assert_eq!(mid.len(), 4);
// Without Extend it paints within the outer circle only (page space: y flipped).
let extent = groups.iter().rev().find_map(|g| g.clip.as_ref()).unwrap();
assert_ne!(extent.winding(Point::new(50.0, 70.0)), 0);
assert_ne!(extent.winding(Point::new(89.5, 70.0)), 0);
assert_eq!(extent.winding(Point::new(91.0, 70.0)), 0);
}
#[test]
fn coons_mesh_shading_rasterizes() {
// One square patch over 0..100 with C, M, Y and K corners.
let points = "00 00 00 55 00 AA 00 FF 55 FF AA FF FF FF FF AA FF 55 FF 00 AA 00 55 00";
let colors = "FF000000 00FF0000 0000FF00 000000FF";
let objects = [hex_image_free(
"/ShadingType 6 /ColorSpace /DeviceCMYK /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 100 0 100 0 1 0 1 0 1 0 1]",
&format!("00 {points} {colors}"),
)];
let pdf = raw_pdf("q 1 0 0 1 10 10 cm /Sh1 sh Q", "/Shading << /Sh1 5 0 R >>", &objects);
let (list, report) = read(&pdf);
let ims = images(&list);
assert_eq!((ims.len(), report.rasterized_shadings), (1, 1));
let im = ims[0].1;
assert_eq!(im.space, ImageSpace::Cmyk);
let corner = tints_at(im, Point::new(11.0, 109.0));
assert!(tint(&corner, ink(Process::Cyan)) > 0.95, "{corner:?}");
let center = tints_at(im, Point::new(60.0, 60.0));
for p in Process::ALL {
assert!((tint(&center, ink(p)) - 0.25).abs() < 0.03, "{center:?}");
}
assert_eq!(im.smask.as_ref().unwrap()[0], 0, "outside the patch");
}
fn hex_image_free(dict: &str, hex: &str) -> String {
stream(&format!("{dict} /Filter /ASCIIHexDecode"), &format!("{hex}>"))
}
#[test]
fn tiling_pattern_expands_into_cells() {
let objects = [
stream("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 20 /YStep 20 /Resources << >>", "1 0 0 0 k 0 0 10 10 re f"),
stream(
"/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0.25 0.25] /XStep 0.5 /YStep 0.5 /Resources << >>",
"0 1 0 0 k 0 0 0.25 0.25 re f",
),
];
let pdf = raw_pdf("/Pattern cs /P1 scn 0 0 90 30 re f /P2 scn 100 0 90 30 re f", "/Pattern << /P1 5 0 R /P2 6 0 R >>", &objects);
let (list, report) = read(&pdf);
// Cells start every 20 pt from 0: five across 90 pt, two up 30 pt.
assert_eq!(report.tiling_cells, 10);
let cyan = fill_items(&list).into_iter().filter(|(_, f)| f.paint == cmyk(1.0, 0.0, 0.0, 0.0)).count();
assert_eq!(cyan, 10);
// 180 × 60 cells are past the cap: an image, a quarter magenta on average.
assert_eq!(report.rasterized_tilings, 1);
let im = images(&list)[0].1;
let a = im.smask.as_ref().unwrap();
let mean = a.iter().map(|v| f64::from(*v)).sum::<f64>() / a.len() as f64 / 255.0;
assert!((mean - 0.25).abs() < 0.05, "{mean}");
}
#[test]
fn tiling_cells_past_the_item_budget_rasterize() {
// Each of the 99 × 99 outer cells holds an expanded 15 × 15 inner pattern: 2.2 million
// items in all.
let objects = [
stream(
"/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 1 /YStep 1 /Resources << /Pattern << /P2 6 0 R >> >>",
"/Pattern cs /P2 scn 0 0 1 1 re f",
),
stream(
"/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0.02 0.02] /XStep 0.07 /YStep 0.07 /Resources << >>",
"1 0 0 0 k 0 0 0.02 0.02 re f",
),
];
let pdf = raw_pdf("/Pattern cs /P1 scn 0.5 0.5 98 98 re f", "/Pattern << /P1 5 0 R >>", &objects);
let (list, report) = read(&pdf);
assert_eq!(report.tiling_cells, 225, "only the inner pattern expands");
assert_eq!(report.rasterized_tilings, 1);
assert_eq!(images(&list).len(), 1);
}
#[test]
fn deeply_overlapping_tiling_cells_paint_nothing() {
// Every point lies in 6 × 10^14 cells.
let objects = [stream(
"/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 100000 100000] /XStep 0.004 /YStep 0.004 /Resources << >>",
"1 0 0 0 k 0 0 1 1 re f",
)];
let pdf = raw_pdf("/Pattern cs /P1 scn 0 0 90 30 re f", "/Pattern << /P1 5 0 R >>", &objects);
let (list, report) = read(&pdf);
assert_eq!((report.tiling_cells, report.rasterized_tilings, report.dropped_tilings), (0, 0, 1));
assert!(images(&list).is_empty());
}
#[test]
fn dashes_past_the_budget_stroke_solid() {
// A billion dashes.
let pdf = raw_pdf("[0.000001] 0 d 0 0 m 1000 0 l S", "", &[]);
let (list, _) = read(&pdf);
let fills = fill_items(&list);
assert_eq!(fills.len(), 1);
assert!(fills[0].1.path.elements().len() < 20, "{:?}", fills[0].1.path);
}
#[test]
fn plate_test_gradients_and_meshes_read() {
let (list, report) = read(&scene_pdf("S14"));
let gold = list.inks.lookup("Gold").unwrap();
assert_eq!((report.gradients, report.unreadable_images), (2, 0));
let duotone = images(&list)[0].1;
let at = tints_at(duotone, Point::new(100.0, 60.0));
assert!((tint(&at, ink(Process::Black)) - 0.25).abs() < 0.1 && (tint(&at, gold) - 0.15).abs() < 0.1, "{at:?}");
for (_, f) in fill_items(&list) {
assert!(matches!(f.paint, Paint::Gradient { .. }), "{:?}", f.paint);
}
let (list, report) = read(&scene_pdf("S17"));
let gold = list.inks.lookup("Gold").unwrap();
assert_eq!(report.rasterized_shadings, 1);
let mesh = images(&list)[0].1;
let expect = [
((24.0, 24.0), vec![(gold, 0.95)]),
((216.0, 24.0), vec![(ink(Process::Cyan), 0.95)]),
((120.0, 80.0), vec![(gold, 0.375), (ink(Process::Cyan), 0.25), (ink(Process::Magenta), 0.25)]),
];
for ((x, y), inks) in expect {
let at = tints_at(mesh, Point::new(x, y));
for (id, v) in inks {
assert!((tint(&at, id) - v).abs() < 0.1, "({x}, {y}): {at:?}");
}
}
}
fn write_fills(list: &DisplayList) -> Vec<u8> {
let cms = CmsConversion::fogra39();
let mut writer = Writer::new(Default::default(), Metadata::default());
let size = list.page.size();
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(size.width as f32, size.height as f32).unwrap());
let mut surface = page.surface();
for (groups, item) in leaves(&list.items) {
assert!(groups.iter().all(|g| g.opacity == 1.0 && g.mask.is_none() && g.clip.is_none()), "opaque scenes only");
let Item::Fill(f) = item else { panic!("{item:?}") };
let Paint::Solid(color) = &f.paint else { panic!() };
let o = f.overprint;
surface.push_overprint(KOverprint { fill: o.fill, stroke: o.stroke, nonzero: o.nonzero });
let rule = if f.rule == FillRule::EvenOdd { KFillRule::EvenOdd } else { KFillRule::NonZero };
surface.set_fill(Some(Fill { paint: to_pdf_color(color, &list.inks, &cms).unwrap().into(), rule, ..Default::default() }));
surface.draw_path(&krilla_path(&f.path));
surface.pop();
}
surface.finish();
page.finish();
writer.finish().unwrap()
}
fn krilla_path(path: &BezPath) -> craft_pdf::write::backend::geom::Path {
let mut b = PathBuilder::new();
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32),
PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32),
PathEl::QuadTo(p1, p) => b.quad_to(p1.x as f32, p1.y as f32, p.x as f32, p.y as f32),
PathEl::CurveTo(p1, p2, p) => b.cubic_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32, p.x as f32, p.y as f32),
PathEl::ClosePath => b.close(),
}
}
b.finish().unwrap()
}
#[test]
fn opaque_items_separate_as_the_source() {
for id in ["S01", "S04", "D06", "S13"] {
let source = scene_pdf(id);
let (list, _) = read(&source);
let copy = write_fills(&list);
let (a, b) = (plates(&source, 0, 36.0, &Options::default()).unwrap(), plates(&copy, 0, 36.0, &Options::default()).unwrap());
assert_eq!(a.planes.len(), b.planes.len(), "{id}: plates");
for (k, (pa, pb)) in a.planes.iter().zip(&b.planes).enumerate() {
let differ = pa.iter().zip(pb).filter(|(x, y)| x.abs_diff(**y) > 8).count();
assert!(differ * 100 < pa.len(), "{id}: plate {k} differs in {differ} of {} pixels", pa.len());
}
}
}
#[test]
fn print_flagged_annotations_left_out_are_counted() {
let ap = stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", "0 0 0 1 k 0 0 10 10 re f");
let annot = |flags: u32| format!("<< /Type /Annot /Subtype /Stamp /Rect [10 10 20 20] /F {flags} /AP << /N 5 0 R >> >>");
// Print; Print and Hidden; no flags (screen only); Print without an appearance.
let objects = [ap, annot(4), annot(4 | 2), annot(0), "<< /Type /Annot /Subtype /Text /Rect [0 0 5 5] /F 4 >>".to_string()];
// `raw_pdf` puts its argument inside the page's /Resources; this closes that dictionary to
// add /Annots to the page.
let pdf = raw_pdf("", ">> /Annots [6 0 R 7 0 R 8 0 R 9 0 R] /PieceInfo <<", &objects);
let (list, report) = read_page(&pdf, 0, &ReadOptions::default()).unwrap();
assert!(list.items.is_empty());
assert_eq!(report.dropped_annotations, 1);
let (list, report) = read_page(&pdf, 0, &ReadOptions { annotations: true, ..ReadOptions::default() }).unwrap();
assert!(!list.items.is_empty());
assert_eq!(report.dropped_annotations, 0);
}
+38
View File
@@ -0,0 +1,38 @@
//! Local plate check of the flattener: every plate-test scene written, flattened with
//! [`flatten_page`] at 300 dpi, separated with Ghostscript and judged against the scene, with no
//! transparency left in the flattened page. Run it with `scripts/plate-check.sh --flatten`; it
//! never runs in CI.
#![cfg(feature = "flatten")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use craft_plate_test::reference::{self, Context};
use craft_plate_test::report::{self, Status};
use craft_plate_test::{gs, scene};
use craft_prepress::flatten::{FlattenPageOptions, flatten_page};
#[test]
#[ignore = "needs Ghostscript; run scripts/plate-check.sh --flatten"]
fn every_scene_separates_as_expected_flattened() {
let Some(version) = gs::version() else {
println!("note: Ghostscript ({:?}) not found; set {} or install gs", gs::program(), gs::GS_ENV);
return;
};
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
let ctx = Context::from_env(&scenes);
if ctx.font.is_none() {
println!("note: {} not set; text points are skipped", reference::FONT_DIR_ENV);
}
let opts = FlattenPageOptions::default();
let flatten = |pdf: &[u8]| flatten_page(pdf, 0, &opts).map(|(flat, _)| flat).map_err(|e| e.to_string());
let report = report::run_flattened(&ctx, version, &flatten);
let path = report::write(&report, &report::output_dir().join("flattened")).unwrap();
for r in &report.scenes {
let pending = r.pending.as_deref().map(|t| format!(" (pending {t})")).unwrap_or_default();
let flat = r.flattened.as_ref().map(|f| format!(", flattened {:?} in {:.0} ms", f.status, f.millis)).unwrap_or_default();
println!("{} {:?}{flat}{pending}", r.id, r.status);
}
println!("report: {}", path.display());
let wrong = report.unexpected();
assert!(wrong.is_empty(), "see {}:\n{}", path.display(), wrong.join("\n"));
assert!(report.scenes.iter().any(|r| r.flattened.as_ref().is_some_and(|f| f.status == Status::Pass)));
}
+416
View File
@@ -0,0 +1,416 @@
//! Complex regions rasterized as ink images, and plate-test scenes flattened end to end: the
//! plates of a scene and of its flattened page match at the scene's sample points, and the
//! flattened page has no transparency left.
//!
//! D10 needs Inter from `CRAFT_PDF_TEST_FONT_DIR` or `~/.cache/craft-pdf-test-fonts`, and is
//! skipped when it is missing.
#![cfg(feature = "flatten")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use std::time::Instant;
use craft_pdf::write::backend::SerializeSettings;
use craft_pdf::write::backend::configure::PdfVersion;
use craft_pdf::write::backend::page::PageSettings;
use craft_pdf::write::print::{self, PrintColor};
use craft_pdf::write::{Metadata, Writer, configuration};
use craft_plate_test::reference::{self, Context};
use craft_plate_test::{scene, structure};
use craft_prepress::flatten::kurbo::{Affine, BezPath, Point, Rect, Shape};
use craft_prepress::flatten::raster::Rasterizer;
use craft_prepress::flatten::write::{WriteError, page_pdf, write_flattened};
use craft_prepress::flatten::{
BlendMode, ComplexReason, ComplexRegion, DisplayList, FillItem, FillRule, FlattenOptions, FlattenPageOptions, Flattened, GradientGeometry,
GroupItem, Item, Paint, flatten, flatten_page,
};
use craft_prepress::{Alternate, CmsConversion, Fallback, Ink, InkColor, InkId, InkSet, Options, Overprint, Plates, Process, Spot, plates};
const C: InkId = InkId::process(Process::Cyan);
const K: InkId = InkId::process(Process::Black);
fn settings() -> SerializeSettings {
// Reading the profile takes a quarter second in a debug build.
static SETTINGS: OnceLock<SerializeSettings> = OnceLock::new();
SETTINGS.get_or_init(|| print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap()).clone()
}
fn separate(pdf: &[u8], dpi: f32) -> Plates {
plates(pdf, 0, dpi, &Options::default()).unwrap()
}
/// Ink `name`'s tint at (`x`, `y`) in points, 0 when the page doesn't use the ink.
fn tint(p: &Plates, name: &str, x: f32, y: f32) -> u8 {
let s = p.dpi / 72.0;
p.inks.lookup(name).and_then(|id| p.tint(id, (x * s) as u32, (y * s) as u32)).unwrap_or(0)
}
/// The flattened page has no transparency left ([`structure::transparency`]).
fn assert_opaque(pdf: &[u8]) {
let left = structure::transparency(&structure::summarize(pdf).unwrap());
assert!(left.is_empty(), "transparency left: {left:?}");
}
fn font() -> Option<Arc<Vec<u8>>> {
let dir = std::env::var_os(reference::FONT_DIR_ENV)
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/craft-pdf-test-fonts")))?;
std::fs::read(dir.join(reference::FONT_FILE)).ok().map(Arc::new)
}
/// Scene `id` written by the reference writer and flattened with complex regions at 72 dpi: the
/// plates match at every judged sample point within `tol` (0–255), or the flattened page meets the
/// scene's expectation there. The renderer's source page can differ from the scene where it
/// converts a mask's CMYK luminosity through the printing condition (S11's K100 `/BC` reads as
/// about 12% luminosity); the flattener uses the DeviceCMYK formula, as Acrobat does. Returns the worst
/// difference from the source.
fn scene_round_trip(id: &str, tol: u8) -> u8 {
scene_planes(id, tol).0
}
/// [`scene_round_trip`], with the source's and the flattened page's plates.
fn scene_planes(id: &str, tol: u8) -> (u8, Plates, Plates) {
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
let s = scenes.iter().find(|s| s.id == id).unwrap();
let pdf = reference::write(s, &Context { scenes: &scenes, font: font() }).unwrap().pdf;
let opts = FlattenPageOptions { flatten: FlattenOptions { raster_dpi: 72.0, ..FlattenOptions::default() }, ..FlattenPageOptions::default() };
let start = Instant::now();
let (flat, report) = flatten_page(&pdf, 0, &opts).unwrap();
eprintln!("{id}: flatten_page {:?}, {:?}", start.elapsed(), report.raster);
assert_opaque(&flat);
let (a, b) = (separate(&pdf, 72.0), separate(&flat, 72.0));
let mut worst = 0;
for point in s.expect.points.iter().filter(|p| !p.info) {
let [x, y] = point.at;
for name in s.expect.plates.iter().map(String::as_str) {
let (want, got) = (tint(&a, name, x, y), tint(&b, name, x, y));
if want.abs_diff(got) <= tol {
worst = worst.max(want.abs_diff(got));
continue;
}
let meets = |v: u8| {
let pct = f32::from(v) / 2.55;
match point.inks.get(name).copied().unwrap_or(scene::Level::Value(0.0)) {
scene::Level::Value(e) => (pct - e).abs() <= point.tol,
scene::Level::Range([lo, hi]) => pct >= lo - point.tol && pct <= hi + point.tol,
}
};
assert!(meets(got), "{id} {name} at ({x}, {y}): {got}, the source has {want}");
eprintln!("{id} {name} at ({x}, {y}): {got} meets the scene, the source renders {want}");
}
}
eprintln!("{id}: worst ink difference {worst}/255");
(worst, a, b)
}
#[test]
fn s06_half_black_over_cyan() {
scene_round_trip("S06", 3);
}
#[test]
fn s07_spot_multiply() {
scene_round_trip("S07", 3);
}
#[test]
fn s08_non_separable_blend_over_a_spot() {
scene_round_trip("S08", 3);
}
#[test]
fn s10_isolated_multiply() {
scene_round_trip("S10", 3);
}
#[test]
fn s11_shadow_over_spot() {
scene_round_trip("S11", 6);
}
#[test]
fn s14_duotone_gradients() {
scene_round_trip("S14", 6);
}
#[test]
fn m1_luminosity_mask_rasterizes() {
scene_round_trip("M1", 3);
}
#[test]
fn m2_mask_transfer_rasterizes() {
scene_round_trip("M2", 3);
}
#[test]
fn f02_hidden_art_leaves_no_trace() {
// vectorcraft#2161: no pixel of the opaque card and box, anti-aliased as viewers draw them,
// differs from the source: art hidden under them doesn't cut them into pieces whose edges
// show.
let (_, a, b) = scene_planes("F02", 3);
let s = a.dpi / 72.0;
let mut off = Vec::new();
for (x0, x1) in [(22.0, 168.0), (182.0, 338.0)] {
for y in (22.0 * s) as u32..(158.0 * s) as u32 {
for x in (x0 * s) as u32..(x1 * s) as u32 {
for name in ["Cyan", "Magenta", "Yellow", "Black"] {
let tint = |p: &Plates| p.inks.lookup(name).and_then(|id| p.tint(id, x, y)).unwrap_or(0);
if tint(&a) != tint(&b) {
off.push(format!("{name} {} vs {} at pixel ({x}, {y})", tint(&b), tint(&a)));
}
}
}
}
}
assert!(off.is_empty(), "{} pixels differ, e.g. {:?}", off.len(), &off[..off.len().min(8)]);
}
#[test]
fn d10_text_over_transparency_stays_live() {
if font().is_none() {
eprintln!("skipped: no test font");
return;
}
scene_round_trip("D10", 3);
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
let s = scenes.iter().find(|s| s.id == "D10").unwrap();
let pdf = reference::write(s, &Context { scenes: &scenes, font: font() }).unwrap().pdf;
let (flat, _) = flatten_page(&pdf, 0, &FlattenPageOptions::default()).unwrap();
let doc = craft_pdf::open_slice(&flat, None).unwrap();
let pages = doc.pages();
let mut ops = Vec::new();
let mut it = pages.first().unwrap().operations();
while let Some(op) = it.next() {
ops.push(String::from_utf8_lossy(op.operator).into_owned());
}
assert!(ops.iter().any(|op| op == "Tj" || op == "TJ"), "text drawn as text: {ops:?}");
}
fn spot_inks() -> (InkSet, InkId) {
let mut inks = InkSet::new();
let gold = inks.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.3, 1.0, 0.0]), solidity: None })).unwrap();
(inks, gold)
}
fn solid(r: Rect, tints: &[(InkId, f32)]) -> Item {
Item::Fill(FillItem {
path: r.to_path(0.1),
rule: FillRule::NonZero,
paint: Paint::Solid(InkColor::Tints(tints.to_vec())),
overprint: Overprint::default(),
})
}
fn group(opacity: f32, blend: BlendMode, children: Vec<Item>) -> Item {
Item::Group(GroupItem { opacity, blend, isolated: false, knockout: false, clip: None, mask: None, actual_text: None, children })
}
fn ramp(r: Rect, from: InkColor, to: InkColor) -> Item {
Item::Fill(FillItem {
path: r.to_path(0.1),
rule: FillRule::NonZero,
paint: Paint::Gradient {
stops: vec![(0.0, from), (1.0, to)],
geometry: GradientGeometry::Axial {
start: Point::new(r.x0, 0.0),
end: Point::new(r.x1, 0.0),
extend: [true; 2],
transform: Affine::IDENTITY,
},
},
overprint: Overprint::default(),
})
}
/// `flat` written with complex regions rasterized at `dpi`.
fn write(list: &DisplayList, flat: &Flattened, dpi: f32) -> Vec<u8> {
write_at(list, flat, dpi, PdfVersion::default()).unwrap()
}
/// [`write`] for an output of PDF `version`.
fn write_at(list: &DisplayList, flat: &Flattened, dpi: f32, version: PdfVersion) -> Result<Vec<u8>, WriteError> {
let cms = CmsConversion::fogra39();
let mut raster = Rasterizer::new(list, settings(), Fallback::Fogra39, &cms, dpi);
let output = SerializeSettings { configuration: configuration(version, None).unwrap(), ..settings() };
let mut writer = Writer::new(output, Metadata::default());
let size = PageSettings::from_wh(list.page.width() as f32, list.page.height() as f32).unwrap();
let mut page_builder = writer.document_mut().start_page_with(size);
let mut surface = page_builder.surface();
let drawn = write_flattened(&mut surface, list, flat, &cms, version, &mut |r| raster.region(r));
surface.finish();
page_builder.finish();
drawn?;
Ok(writer.finish().unwrap())
}
/// A page of 2 pt stripes, one of `process` tints and one of each of `count` spot inks, under a
/// half-transparent ramp: a complex region that paints them all.
fn many_inks(count: usize, process: &[(InkId, f32)]) -> (DisplayList, Flattened) {
let mut inks = InkSet::new();
let stripe = |i: usize| Rect::new(2.0 * i as f64, 0.0, 2.0 * (i + 1) as f64, 10.0);
let mut items = vec![solid(stripe(0), process)];
for i in 0..count {
let spot = Spot { name: format!("Spot {i}"), alternate: Alternate::Cmyk([0.0, 0.0, 0.0, 0.1]), solidity: None };
items.push(solid(stripe(i + 1), &[(inks.add(Ink::Spot(spot)).unwrap(), 0.5)]));
}
let width = 2.0 * (count + 1) as f64;
let ramp = ramp(Rect::new(0.0, 0.0, width, 10.0), InkColor::Tints(vec![(K, 0.0)]), InkColor::Tints(vec![(K, 1.0)]));
items.push(group(0.5, BlendMode::Multiply, vec![ramp]));
let list = DisplayList { page: Rect::new(0.0, 0.0, width, 10.0), inks, items };
let flat = flatten(&list, &FlattenOptions::default()).unwrap();
assert!(!flat.complex.is_empty());
(list, flat)
}
#[test]
fn a_region_of_more_spot_inks_than_plates_separates_is_an_error() {
let (list, flat) = many_inks(33, &[(K, 0.5)]);
match write_at(&list, &flat, 72.0, PdfVersion::default()) {
Err(WriteError::SpotInks { inks: 33, max: 32, unseparated }) => assert_eq!(unseparated.len(), 1),
other => panic!("{:?}", other.map(|_| ())),
}
}
#[test]
fn a_raster_region_of_nine_inks_needs_pdf_1_5() {
let (list, flat) = many_inks(5, &[(C, 0.5), (InkId::process(Process::Magenta), 0.5), (InkId::process(Process::Yellow), 0.5), (K, 0.5)]);
match write_at(&list, &flat, 72.0, PdfVersion::Pdf14) {
Err(WriteError::Colorants { colorants: 9, max: 8 }) => {}
other => panic!("{:?}", other.map(|_| ())),
}
let p = separate(&write_at(&list, &flat, 72.0, PdfVersion::Pdf17).unwrap(), 72.0);
assert!(tint(&p, "Spot 4", 11.0, 5.0) > 0);
}
#[test]
fn gradient_under_multiply_over_a_spot_prints_the_same_rasterized() {
// A K ramp multiplied over Gold and cyan, half transparent: complex regions over a spot and
// a process backdrop, with a kept K fill above them.
let (inks, gold) = spot_inks();
let list = DisplayList {
page: Rect::new(0.0, 0.0, 100.0, 100.0),
inks,
items: vec![
solid(Rect::new(0.0, 0.0, 50.0, 100.0), &[(gold, 1.0)]),
solid(Rect::new(50.0, 0.0, 100.0, 100.0), &[(C, 0.6)]),
group(
0.5,
BlendMode::Multiply,
vec![ramp(Rect::new(10.0, 20.0, 90.0, 80.0), InkColor::Tints(vec![(K, 0.0)]), InkColor::Tints(vec![(K, 1.0)]))],
),
solid(Rect::new(40.0, 45.0, 60.0, 55.0), &[(K, 1.0)]),
],
};
let flat = flatten(&list, &FlattenOptions::default()).unwrap();
assert_eq!(flat.complex.len(), 1, "touching complex regions merge: {:?}", flat.complex.iter().map(|c| &c.items).collect::<Vec<_>>());
let start = Instant::now();
let out = write(&list, &flat, 72.0);
eprintln!("ramp: written in {:?}", start.elapsed());
assert_opaque(&out);
let (a, b) = (separate(&page_pdf(&list, settings(), &CmsConversion::fogra39()).unwrap(), 72.0), separate(&out, 72.0));
let mut worst = 0;
for (x, y) in [(5.0, 50.0), (20.0, 30.0), (30.0, 70.0), (45.0, 40.0), (50.0, 50.0), (55.0, 60.0), (70.0, 30.0), (85.0, 75.0), (95.0, 50.0)] {
for name in ["Cyan", "Magenta", "Yellow", "Black", "Gold"] {
let (want, got) = (tint(&a, name, x, y), tint(&b, name, x, y));
assert!(want.abs_diff(got) <= 3, "{name} at ({x}, {y}): {got}, expected {want}");
worst = worst.max(want.abs_diff(got));
}
}
eprintln!("ramp: worst ink difference {worst}/255");
}
#[test]
fn abutting_complex_regions_leave_no_paper_at_their_edge() {
// Two regions cut off the grid at x = 50.3, each its own image of the same 60% K fill.
let list = DisplayList {
page: Rect::new(0.0, 0.0, 100.0, 100.0),
inks: InkSet::new(),
items: vec![solid(Rect::new(10.0, 10.0, 90.0, 90.0), &[(K, 0.6)])],
};
let region = |r: Rect| ComplexRegion { path: r.to_path(0.1), items: vec![0], at: 0, whole: None };
let flat = Flattened {
items: Vec::new(),
complex: vec![region(Rect::new(10.0, 10.0, 50.3, 90.0)), region(Rect::new(50.3, 10.0, 90.0, 90.0))],
report: Default::default(),
};
let dpi = 36.0;
let p = separate(&write(&list, &flat, dpi), 2.0 * dpi);
let k = p.inks.lookup("Black").unwrap();
let edge = (50.3 * p.dpi / 72.0) as u32;
for y in (12.0 * p.dpi / 72.0) as u32..(88.0 * p.dpi / 72.0) as u32 {
for x in edge.saturating_sub(1)..=edge + 1 {
let v = p.tint(k, x, y).unwrap();
assert!(v >= 153 / 2, "K {v} at pixel ({x}, {y})");
}
}
}
#[test]
fn a_degenerate_group_is_an_opaque_image_over_paper() {
// The region's path is a box larger than the half-black disc in it, with cyan beside it.
// Nothing else is under the box, so its image needs no soft mask: paper where the disc
// doesn't paint, the disc's own color where it does.
let disc = kurbo_circle(Point::new(50.0, 50.0), 20.0);
let list = DisplayList {
page: Rect::new(0.0, 0.0, 100.0, 100.0),
inks: InkSet::new(),
items: vec![
solid(Rect::new(0.0, 0.0, 20.0, 100.0), &[(C, 1.0)]),
group(
0.5,
BlendMode::Normal,
vec![Item::Fill(FillItem {
path: disc,
rule: FillRule::NonZero,
paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])),
overprint: Overprint::default(),
})],
),
],
};
let flat = Flattened {
items: vec![list.items[0].clone()],
complex: vec![ComplexRegion {
path: Rect::new(25.0, 25.0, 75.0, 75.0).to_path(0.1),
items: vec![1],
at: 1,
whole: Some(ComplexReason::Degenerate),
}],
report: Default::default(),
};
let out = write(&list, &flat, 72.0);
assert_opaque(&out);
let p = separate(&out, 72.0);
// A corner of the box, outside the disc: paper.
assert!(tint(&p, "Cyan", 28.0, 28.0) <= 3 && tint(&p, "Black", 28.0, 28.0) <= 3);
// The disc: K 50%.
assert!(tint(&p, "Black", 50.0, 50.0).abs_diff(128) <= 3 && tint(&p, "Cyan", 50.0, 50.0) <= 3);
// The cyan beside the box, untouched.
assert!(tint(&p, "Cyan", 10.0, 50.0) >= 250);
}
fn kurbo_circle(c: Point, r: f64) -> BezPath {
craft_prepress::flatten::kurbo::Circle::new(c, r).to_path(0.01)
}
#[test]
fn regions_thinner_than_a_point_rasterize() {
// At 300 dpi the hairline region is a tile of 3 px, 0.72 pt; the strip's last tile is 3 px
// wide.
let list = DisplayList {
page: Rect::new(0.0, 0.0, 400.0, 40.0),
inks: InkSet::new(),
items: vec![group(0.5, BlendMode::Normal, vec![solid(Rect::new(10.0, 10.0, 390.0, 30.0), &[(K, 1.0)])])],
};
let region = |r: Rect| ComplexRegion { path: r.to_path(0.1), items: vec![0], at: 0, whole: None };
let flat = Flattened {
items: Vec::new(),
complex: vec![region(Rect::new(20.0, 20.0, 20.1, 25.0)), region(Rect::new(10.0, 10.0, 378.71, 15.0))],
report: Default::default(),
};
let p = separate(&write(&list, &flat, 300.0), 300.0);
assert!(tint(&p, "Black", 20.05, 22.0) > 0, "the hairline prints");
assert!(tint(&p, "Black", 378.4, 12.0).abs_diff(128) <= 3, "the last tile prints");
}
+209
View File
@@ -0,0 +1,209 @@
//! Times the flattener in stages on every plate-test scene and on a synthetic heavy page, at
//! 300 dpi. A report, not a benchmark: run it in release with
//! `cargo test --release -p craft-prepress --features flatten --test flatten_timings -- --ignored --nocapture`.
#![cfg(feature = "flatten")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use craft_pdf::write::backend::SerializeSettings;
use craft_pdf::write::backend::page::PageSettings;
use craft_pdf::write::print::{self, PrintColor};
use craft_pdf::write::{Metadata, Writer};
use craft_plate_test::reference::{self, Context};
use craft_plate_test::scene;
use craft_prepress::flatten::kurbo::{Affine, Circle, Point, Rect, Shape};
use craft_prepress::flatten::pdf::{ReadOptions, read_page};
use craft_prepress::flatten::raster::Rasterizer;
use craft_prepress::flatten::write::{page_pdf, write_flattened};
use craft_prepress::flatten::{
BlendMode, DisplayList, FillItem, FillRule, FlattenOptions, FlattenPageOptions, GradientGeometry, GroupItem, Item, Paint, flatten, flatten_page,
};
use craft_prepress::{CmsConversion, Fallback, InkColor, InkId, InkSet, Options, Overprint, Process, plates};
const DPI: f32 = 300.0;
#[derive(Default)]
struct Stages {
read: Duration,
flatten: Duration,
raster: Duration,
write: Duration,
total: Duration,
regions: usize,
whole: String,
pixels: u64,
fills: usize,
complex: usize,
}
/// The page read, flattened, and written with its complex regions rasterized, each stage timed;
/// `total` is [`flatten_page`] in one call.
fn stages(pdf: &[u8]) -> Stages {
let mut st = Stages::default();
let start = Instant::now();
let opts = FlattenPageOptions { flatten: FlattenOptions { raster_dpi: DPI, ..FlattenOptions::default() }, ..FlattenPageOptions::default() };
flatten_page(pdf, 0, &opts).unwrap();
st.total = start.elapsed();
let start = Instant::now();
let (list, _) = read_page(pdf, 0, &ReadOptions::default()).unwrap();
st.read = start.elapsed();
written(&list, &mut st);
st
}
/// Flattens and writes `list`, timing the stages into `st`.
fn written(list: &DisplayList, st: &mut Stages) -> Vec<u8> {
let start = Instant::now();
let flat = flatten(list, &FlattenOptions { raster_dpi: DPI, ..FlattenOptions::default() }).unwrap();
st.flatten = start.elapsed();
st.fills = flat.report.fills;
st.regions = flat.report.regions;
st.whole = flat.report.complex_groups.iter().map(|g| format!("{:?}", g.reason)).collect::<Vec<_>>().join(", ");
st.complex = flat.complex.len();
let settings = settings();
let cms = CmsConversion::fogra39();
let start = Instant::now();
let mut raster = Rasterizer::new(list, settings.clone(), Fallback::Fogra39, &cms, DPI);
let mut raster_time = Duration::ZERO;
let version = settings.configuration.version();
let mut writer = Writer::new(settings, Metadata::default());
let size = PageSettings::from_wh(list.page.width() as f32, list.page.height() as f32).unwrap();
let mut page_builder = writer.document_mut().start_page_with(size);
let mut surface = page_builder.surface();
write_flattened(&mut surface, list, &flat, &cms, version, &mut |r| {
let t = Instant::now();
let image = raster.region(r);
raster_time += t.elapsed();
image
})
.unwrap();
surface.finish();
page_builder.finish();
let pdf = writer.finish().unwrap();
st.raster = raster_time;
st.write = start.elapsed() - raster_time;
st.pixels = raster.report().pixels;
pdf
}
fn settings() -> SerializeSettings {
static SETTINGS: OnceLock<SerializeSettings> = OnceLock::new();
SETTINGS.get_or_init(|| print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap()).clone()
}
fn ms(d: Duration) -> String {
format!("{:.1}", d.as_secs_f64() * 1000.0)
}
fn row(name: &str, st: &Stages) -> String {
format!(
"| {name} | {} | {} | {} | {} | {} | {} | {} | {}{} | {:.1} |",
ms(st.total),
ms(st.read),
ms(st.flatten),
ms(st.raster),
ms(st.write),
st.regions,
st.fills,
st.complex,
if st.whole.is_empty() { String::new() } else { format!(" ({})", st.whole) },
st.pixels as f64 / 1e6
)
}
/// About 1,000 shapes up to `size` pt on an A4 page, 100 of them transparent (opacity or
/// Multiply); `gradients` of the transparent ones are filled with a ramp. Positions come from a fixed
/// sequence, so every run draws the same page.
fn heavy(size: f64, gradients: usize) -> DisplayList {
let mut seed = 0x2545_f491_u64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % 10_000) as f64 / 10_000.0
};
let ink = |i: usize| InkId::process(Process::ALL[i % 4]);
let mut items = Vec::new();
let mut transparent = 0;
for i in 0..1000 {
let (x, y) = (20.0 + next() * 520.0, 20.0 + next() * 780.0);
let (w, h) = (2.0 + next() * (size - 2.0), 2.0 + next() * (size - 2.0));
let path = if i % 3 == 0 { Circle::new(Point::new(x, y), w / 2.0).to_path(0.1) } else { Rect::new(x, y, x + w, y + h).to_path(0.1) };
let color = InkColor::Tints(vec![(ink(i), 0.2 + next() as f32 * 0.8), (ink(i + 1), next() as f32 * 0.5)]);
let paint = if i % 10 == 9 && transparent < gradients {
Paint::Gradient {
stops: vec![(0.0, color), (1.0, InkColor::Tints(vec![(ink(i + 2), 1.0)]))],
geometry: GradientGeometry::Axial {
start: Point::new(x, y),
end: Point::new(x + w, y),
extend: [true; 2],
transform: Affine::IDENTITY,
},
}
} else {
Paint::Solid(color)
};
let fill = Item::Fill(FillItem { path, rule: FillRule::NonZero, paint, overprint: Overprint::default() });
if i % 10 == 9 {
transparent += 1;
let blend = if i % 40 == 9 { BlendMode::Multiply } else { BlendMode::Normal };
items.push(Item::Group(GroupItem {
opacity: 0.3 + next() as f32 * 0.5,
blend,
isolated: false,
knockout: false,
clip: None,
mask: None,
actual_text: None,
children: vec![fill],
}));
} else {
items.push(fill);
}
}
DisplayList { page: Rect::new(0.0, 0.0, 595.0, 842.0), inks: InkSet::new(), items }
}
#[test]
#[ignore = "timing report; run in release"]
fn flatten_timings() {
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
let ctx = Context::from_env(&scenes);
// Load the profiles once, so the first scene doesn't pay for it.
let _ = stages(&reference::write(&scenes[0], &ctx).unwrap().pdf);
println!(
"| Page | flatten_page ms | read | flatten | rasterize | write | regions | fills | complex | Mpx |\n|---|---|---|---|---|---|---|---|---|---|"
);
for s in scenes.iter().filter(|s| s.pending.is_none()) {
let pdf = reference::write(s, &ctx).unwrap().pdf;
println!("{}", row(&s.id, &stages(&pdf)));
}
let pages = [
("heavy, 2–65 pt, flat paints", 65.0, 0),
("heavy, 2–65 pt, 10 gradients", 65.0, 10),
("heavy, 2–20 pt, flat paints", 20.0, 0),
("heavy, 2–20 pt, 10 gradients", 20.0, 10),
];
for (name, size, gradients) in pages {
let list = heavy(size, gradients);
let start = Instant::now();
let pdf = page_pdf(&list, settings(), &CmsConversion::fogra39()).unwrap();
let wrote = start.elapsed();
let start = Instant::now();
let p = plates(&pdf, 0, DPI, &Options::default()).unwrap();
// What rasterizing a region as large as the page costs: writing its items as a PDF and
// separating that.
println!("<!-- {name}: page_pdf {} ms, plates {} ms for {} x {} px -->", ms(wrote), ms(start.elapsed()), p.width, p.height);
println!("{}", row(name, &stages(&pdf)));
let mut st = Stages::default();
let start = Instant::now();
written(&list, &mut st);
st.total = start.elapsed();
println!("{}", row(&format!("{name} (display list)"), &st));
}
}
+472
View File
@@ -0,0 +1,472 @@
//! The flattener's writer: display lists written with krilla and separated again by `plates`.
//! Pages are 100 pt square and separated at 36 dpi; sample points are in points.
//!
//! Text cases with an embedded font need Inter from `CRAFT_PDF_TEST_FONT_DIR` or
//! `~/.cache/craft-pdf-test-fonts`, and are skipped when it is missing.
#![cfg(feature = "flatten")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use craft_pdf::interpret::font::Glyph;
use craft_pdf::interpret::hayro_cmap::BfString;
use craft_pdf::interpret::{
BlendMode as HBlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint as HPaint, PathDrawMode,
SoftMask, TransformExt, interpret_page,
};
use craft_pdf::syntax::object::{Dict, Name, Stream};
use craft_pdf::write::backend::SerializeSettings;
use craft_pdf::write::backend::configure::PdfVersion;
use craft_pdf::write::backend::page::PageSettings;
use craft_pdf::write::print::{self, PrintColor};
use craft_pdf::write::{Metadata, Writer};
use craft_prepress::flatten::kurbo::{Affine, BezPath, Point, Rect, Shape};
use craft_prepress::flatten::write::{WriteError, page_pdf, write_flattened};
use craft_prepress::flatten::{
BlendMode, ComplexRegion, DisplayList, FillItem, FillRule, FlattenOptions, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, Paint,
PlacedGlyph, TextFont, TextItem, flatten,
};
use craft_prepress::{Alternate, CmsConversion, Ink, InkColor, InkId, InkSet, Options, Overprint, Plates, Process, Spot, plates};
const C: InkId = InkId::process(Process::Cyan);
const M: InkId = InkId::process(Process::Magenta);
const Y: InkId = InkId::process(Process::Yellow);
const K: InkId = InkId::process(Process::Black);
const OVERPRINT: Overprint = Overprint { fill: true, stroke: false, nonzero: false };
fn fill(r: Rect, tints: &[(InkId, f32)], overprint: Overprint) -> Item {
Item::Fill(FillItem { path: r.to_path(0.1), rule: FillRule::NonZero, paint: Paint::Solid(InkColor::Tints(tints.to_vec())), overprint })
}
fn solid(r: Rect, tints: &[(InkId, f32)]) -> Item {
fill(r, tints, Overprint::default())
}
fn group(opacity: f32, children: Vec<Item>) -> Item {
Item::Group(GroupItem {
opacity,
blend: BlendMode::Normal,
isolated: false,
knockout: false,
clip: None,
mask: None,
actual_text: None,
children,
})
}
fn page(inks: InkSet, items: Vec<Item>) -> DisplayList {
DisplayList { page: Rect::new(0.0, 0.0, 100.0, 100.0), inks, items }
}
fn pdf(list: &DisplayList) -> Vec<u8> {
// Reading the profile takes a quarter second in a debug build.
static SETTINGS: OnceLock<SerializeSettings> = OnceLock::new();
let settings = SETTINGS.get_or_init(|| print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap());
page_pdf(list, settings.clone(), &CmsConversion::fogra39()).unwrap()
}
/// `list` flattened, with nothing left for the raster step, written.
fn flat_pdf(list: &DisplayList) -> Vec<u8> {
let flat = flatten(list, &FlattenOptions::default()).unwrap();
assert!(flat.complex.is_empty());
pdf(&DisplayList { items: flat.items, ..list.clone() })
}
fn separate(pdf: &[u8]) -> Plates {
plates(pdf, 0, 36.0, &Options::default()).unwrap()
}
/// Ink `name`'s tint at (`x`, `y`) in points, 0 when the page doesn't use the ink.
fn tint(p: &Plates, name: &str, x: u32, y: u32) -> u8 {
p.inks.lookup(name).and_then(|id| p.tint(id, x / 2, y / 2)).unwrap_or(0)
}
/// Each ink's tint at (`x`, `y`) is within 3 of `expected`.
fn assert_inks(p: &Plates, x: u32, y: u32, expected: &[(&str, u8)]) {
for (name, want) in expected {
let got = tint(p, name, x, y);
assert!(got.abs_diff(*want) <= 3, "{name} at ({x}, {y}): {got}, expected {want}");
}
}
#[test]
fn half_black_over_cyan_in_a_group_composites_the_same_flattened_or_not() {
let list = page(
InkSet::new(),
vec![solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(C, 1.0)]), group(0.5, vec![solid(Rect::new(40.0, 0.0, 100.0, 100.0), &[(K, 1.0)])])],
);
for written in [pdf(&list), flat_pdf(&list)] {
let p = separate(&written);
assert_inks(&p, 50, 50, &[("Cyan", 128), ("Black", 128)]);
assert_inks(&p, 20, 50, &[("Cyan", 255), ("Black", 0)]);
assert_inks(&p, 80, 50, &[("Cyan", 0), ("Black", 128)]);
}
}
#[test]
fn group_opacity_applies_to_the_group_not_to_each_child() {
// Two overlapping K100 fills in a 50% group: K50 where they overlap, not K75.
let list = page(
InkSet::new(),
vec![group(0.5, vec![solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(K, 1.0)]), solid(Rect::new(40.0, 0.0, 100.0, 100.0), &[(K, 1.0)])])],
);
for written in [pdf(&list), flat_pdf(&list)] {
let p = separate(&written);
assert_inks(&p, 50, 50, &[("Black", 128)]);
assert_inks(&p, 20, 50, &[("Black", 128)]);
}
}
#[test]
fn black_overprint_keeps_the_cyan_under_it() {
let list = page(
InkSet::new(),
vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), fill(Rect::new(50.0, 0.0, 100.0, 100.0), &[(K, 1.0)], OVERPRINT)],
);
for written in [pdf(&list), flat_pdf(&list)] {
let p = separate(&written);
assert_inks(&p, 75, 50, &[("Cyan", 255), ("Black", 255)]);
assert_inks(&p, 25, 50, &[("Cyan", 255), ("Black", 0)]);
}
}
#[test]
fn overprinting_process_color_leaves_the_plates_of_inks_it_doesnt_list() {
let over = |tints: &[(InkId, f32)], nonzero: bool| {
let list = page(
InkSet::new(),
vec![
solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]),
fill(Rect::new(50.0, 0.0, 100.0, 100.0), tints, Overprint { nonzero, ..OVERPRINT }),
],
);
separate(&pdf(&list))
};
// Magenta and yellow only: cyan stays, black stays empty.
assert_inks(&over(&[(M, 1.0), (Y, 0.5)], false), 75, 50, &[("Cyan", 255), ("Magenta", 255), ("Yellow", 128), ("Black", 0)]);
// A listed cyan at 0 knocks cyan out under OPM 0 ...
assert_inks(&over(&[(C, 0.0), (M, 1.0)], false), 75, 50, &[("Cyan", 0), ("Magenta", 255)]);
// ... and leaves it under OPM 1.
assert_inks(&over(&[(C, 0.0), (M, 1.0)], true), 75, 50, &[("Cyan", 255), ("Magenta", 255)]);
// Nothing named paints nothing.
assert_inks(&over(&[(C, 0.0)], true), 75, 50, &[("Cyan", 255)]);
}
fn gold() -> Spot {
Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.2, 0.8, 0.1]), solidity: None }
}
#[test]
fn spot_fills_print_on_their_plate_or_where_the_ink_manager_sends_them() {
let mut inks = InkSet::new();
let gold = inks.add(Ink::Spot(gold())).unwrap();
let list = page(inks.clone(), vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(gold, 0.8)])]);
assert_inks(&separate(&pdf(&list)), 50, 50, &[("Gold", 204), ("Cyan", 0), ("Magenta", 0), ("Yellow", 0)]);
let mut aliased = inks.clone();
assert!(aliased.alias(gold, M));
let p = separate(&pdf(&DisplayList { inks: aliased, ..list.clone() }));
assert!(p.inks.lookup("Gold").is_none());
assert_inks(&p, 50, 50, &[("Magenta", 204), ("Yellow", 0)]);
let mut process = inks;
assert!(process.set_to_process(gold, true));
let p = separate(&pdf(&DisplayList { inks: process, ..list }));
assert!(p.inks.lookup("Gold").is_none());
assert_inks(&p, 50, 50, &[("Cyan", 0), ("Magenta", 41), ("Yellow", 163), ("Black", 20)]);
}
// ---- text ----------------------------------------------------------------------------------
const SAMPLE: &str = "Hamburg";
fn inter() -> Option<Vec<u8>> {
let dir = std::env::var_os("CRAFT_PDF_TEST_FONT_DIR")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/craft-pdf-test-fonts")));
let data = dir.as_ref().and_then(|d| std::fs::read(d.join("Inter-Regular.ttf")).ok());
if data.is_none() {
println!("note: Inter-Regular.ttf not found in {dir:?}; case skipped");
}
data
}
struct Outline(BezPath);
impl ttf_parser::OutlineBuilder for Outline {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((f64::from(x), f64::from(y)));
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((f64::from(x), f64::from(y)));
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.0.quad_to((f64::from(x1), f64::from(y1)), (f64::from(x), f64::from(y)));
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.0.curve_to((f64::from(x1), f64::from(y1)), (f64::from(x2), f64::from(y2)), (f64::from(x), f64::from(y)));
}
fn close(&mut self) {
self.0.close_path();
}
}
/// `SAMPLE` in K100 at 18 pt on a baseline at (5, 60), with its outlines.
fn text_run(font: &[u8], embed: bool) -> TextItem {
let face = ttf_parser::Face::parse(font, 0).unwrap();
let upem = f64::from(face.units_per_em());
let size = 18.0;
let transform = Affine::new([1.0, 0.0, 0.0, -1.0, 5.0, 60.0]);
let (mut x, mut glyphs, mut outlines) = (0.0, Vec::new(), BezPath::new());
for (i, c) in SAMPLE.char_indices() {
let id = face.glyph_index(c).unwrap();
glyphs.push(PlacedGlyph { id: u32::from(id.0), origin: Point::new(x, 0.0), text: i..i + c.len_utf8() });
let mut outline = Outline(BezPath::new());
face.outline_glyph(id, &mut outline);
outlines.extend((transform * Affine::translate((x, 0.0)) * Affine::scale(size / upem) * outline.0).iter());
x += f64::from(face.glyph_hor_advance(id).unwrap()) * size / upem;
}
TextItem {
font: embed.then(|| Arc::new(TextFont { data: Arc::new(font.to_vec()), index: 0, key: 1 })),
size: size as f32,
transform,
glyphs,
text: Some(SAMPLE.to_owned()),
outlines,
paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])),
overprint: Overprint::default(),
}
}
#[derive(Default)]
struct Reader {
unicode: String,
actual_text: String,
}
impl<'a> Device<'a> for Reader {
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
fn set_blend_mode(&mut self, _: HBlendMode) {}
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &HPaint<'a>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: HBlendMode) {}
fn draw_glyph(&mut self, glyph: &Glyph<'a>, _: Affine, _: Affine, _: &HPaint<'a>, _: &GlyphDrawMode) {
if let Glyph::Outline(o) = glyph {
match o.as_unicode() {
Some(BfString::Char(c)) => self.unicode.push(c),
Some(BfString::String(s)) => self.unicode.push_str(&s),
None => {}
}
}
}
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
fn actual_text(&mut self, text: &[u8]) {
self.actual_text.push_str(&match text {
[0xFE, 0xFF, rest @ ..] => String::from_utf16_lossy(&rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect::<Vec<_>>()),
_ => text.iter().map(|&b| char::from(b)).collect(),
});
}
}
fn read_text(pdf: &[u8]) -> Reader {
let doc = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap();
let page = &doc.pages()[0];
let cache = InterpreterCache::new();
let bbox = Rect::new(0.0, 0.0, 100.0, 100.0);
let mut context = Context::new(page.initial_transform(true).to_kurbo(), bbox, &cache, page.xref(), InterpreterSettings::default());
let mut reader = Reader::default();
interpret_page(page, &mut context, &mut reader);
reader
}
#[test]
fn text_with_a_font_is_live_and_prints_like_its_outlines() {
let Some(font) = inter() else { return };
let live = pdf(&page(InkSet::new(), vec![Item::Text(text_run(&font, true))]));
let outlined = pdf(&page(InkSet::new(), vec![Item::Text(text_run(&font, false))]));
assert_eq!(read_text(&live).unicode, SAMPLE);
let (a, b) = (separate(&live), separate(&outlined));
let (a, b) = (a.plane(K).unwrap(), b.plane(K).unwrap());
assert!(a.iter().map(|v| u64::from(*v)).sum::<u64>() > 255 * 50, "the text prints");
let differ = a.iter().zip(b).filter(|(x, y)| x.abs_diff(**y) > 64).count();
assert!(differ <= a.len() / 200, "{differ} pixels differ between glyphs and outlines");
}
#[test]
fn text_without_a_font_is_drawn_as_outlines_with_its_text() {
let mut run = TextItem {
font: None,
size: 18.0,
transform: Affine::new([1.0, 0.0, 0.0, -1.0, 5.0, 60.0]),
glyphs: Vec::new(),
text: Some(SAMPLE.to_owned()),
outlines: Rect::new(5.0, 45.0, 80.0, 60.0).to_path(0.1),
paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])),
overprint: Overprint::default(),
};
if let Some(font) = inter() {
run = TextItem { font: None, ..text_run(&font, false) };
}
let written = pdf(&page(InkSet::new(), vec![Item::Text(run)]));
let read = read_text(&written);
assert_eq!(read.actual_text, SAMPLE);
assert!(read.unicode.is_empty());
}
// ---- structure -----------------------------------------------------------------------------
/// Transparency left on a page: groups, soft masks, blend modes other than Normal and
/// constant opacity below 1, in its resources and those of the forms it uses.
fn transparency(pdf: &[u8]) -> Vec<String> {
let doc = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap();
let page = &doc.pages()[0];
let mut found = Vec::new();
if page.raw().get::<Dict<'_>>(b"Group".as_slice()).is_some() {
found.push("page group".to_owned());
}
visit(&page.resources().x_objects, &page.resources().ext_g_states, &mut found, 0);
found
}
fn visit(x_objects: &Dict<'_>, states: &Dict<'_>, found: &mut Vec<String>, depth: usize) {
assert!(depth < 16);
for key in states.keys() {
let Some(state) = states.get::<Dict<'_>>(key.as_ref()) else { continue };
if state.get::<Dict<'_>>(b"SMask".as_slice()).is_some() {
found.push("soft mask".to_owned());
}
if let Some(bm) = state.get::<Name<'_>>(b"BM".as_slice())
&& bm.as_str() != "Normal"
{
found.push(format!("blend mode {}", bm.as_str()));
}
for alpha in ["ca", "CA"] {
if state.get::<f32>(alpha.as_bytes()).is_some_and(|a| a < 1.0) {
found.push(alpha.to_owned());
}
}
}
for key in x_objects.keys() {
let Some(x) = x_objects.get::<Stream<'_>>(key.as_ref()) else { continue };
let dict = x.dict();
if dict.get::<Dict<'_>>(b"Group".as_slice()).is_some() {
found.push("group".to_owned());
}
if let Some(res) = dict.get::<Dict<'_>>(b"Resources".as_slice()) {
let sub = |key: &[u8]| res.get::<Dict<'_>>(key).unwrap_or_default();
visit(&sub(b"XObject"), &sub(b"ExtGState"), found, depth + 1);
}
}
}
#[test]
fn a_flattened_page_has_no_transparency_left() {
let multiply = Item::Group(GroupItem {
blend: BlendMode::Multiply,
isolated: true,
..match group(0.7, vec![solid(Rect::new(30.0, 30.0, 90.0, 90.0), &[(M, 1.0)]), solid(Rect::new(50.0, 10.0, 95.0, 60.0), &[(Y, 0.6)])]) {
Item::Group(g) => g,
_ => unreachable!(),
}
});
let list = page(
InkSet::new(),
vec![
solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(C, 1.0)]),
group(0.5, vec![solid(Rect::new(40.0, 0.0, 100.0, 100.0), &[(K, 1.0)])]),
multiply,
],
);
let unflattened = transparency(&pdf(&list));
assert!(unflattened.iter().any(|t| t == "ca") && unflattened.iter().any(|t| t.starts_with("blend mode")), "{unflattened:?}");
assert_eq!(transparency(&flat_pdf(&list)), Vec::<String>::new());
}
// ---- images, gradients and complex regions ------------------------------------------------
fn axial(from: InkColor, to: InkColor) -> Paint {
Paint::Gradient {
stops: vec![(0.0, from), (1.0, to)],
geometry: GradientGeometry::Axial {
start: Point::new(0.0, 0.0),
end: Point::new(100.0, 0.0),
extend: [true, true],
transform: Affine::IDENTITY,
},
}
}
#[test]
fn an_overprinting_cyan_gradient_keeps_the_yellow_under_it() {
let ramp = Item::Fill(FillItem {
path: Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1),
rule: FillRule::NonZero,
paint: axial(InkColor::Tints(vec![(C, 0.0)]), InkColor::Tints(vec![(C, 1.0)])),
overprint: OVERPRINT,
});
let p = separate(&pdf(&page(InkSet::new(), vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(Y, 1.0)]), ramp])));
assert_inks(&p, 26, 50, &[("Cyan", 66), ("Yellow", 255)]);
assert_inks(&p, 74, 50, &[("Cyan", 189), ("Yellow", 255)]);
}
#[test]
fn ink_images_print_through_the_ink_manager() {
let mut inks = InkSet::new();
let gold = inks.add(Ink::Spot(gold())).unwrap();
assert!(inks.alias(gold, C));
let image = Item::Image(ImageItem {
space: ImageSpace::Inks(vec![C, gold]),
size: (2, 1),
samples: Arc::from(&[128, 128, 0, 255][..]),
transform: Affine::new([100.0, 0.0, 0.0, 100.0, 0.0, 0.0]),
smask: None,
overprint: Overprint::default(),
jpeg: None,
});
let p = separate(&pdf(&page(inks, vec![image])));
// Two half tints on one plate overlap as halftones: 1 − 0.5 × 0.5.
assert_inks(&p, 25, 50, &[("Cyan", 191)]);
assert_inks(&p, 75, 50, &[("Cyan", 255)]);
assert!(p.inks.lookup("Gold").is_none());
}
#[test]
fn complex_regions_are_drawn_where_the_flattened_items_put_them() {
let ramp = Item::Fill(FillItem {
path: Rect::new(40.0, 0.0, 100.0, 100.0).to_path(0.1),
rule: FillRule::NonZero,
paint: axial(InkColor::Tints(vec![(K, 0.2)]), InkColor::Tints(vec![(K, 1.0)])),
overprint: Overprint::default(),
});
let list = page(InkSet::new(), vec![solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(C, 1.0)]), group(0.5, vec![ramp])]);
let flat = flatten(&list, &FlattenOptions::default()).unwrap();
assert!(!flat.complex.is_empty());
// A stand-in for the raster step: magenta over each region's bounds.
let mut raster = |region: &ComplexRegion| -> Result<Option<ImageItem>, WriteError> {
let b = region.path.bounding_box();
Ok(Some(ImageItem {
space: ImageSpace::Inks(vec![M]),
size: (1, 1),
samples: Arc::from(&[255][..]),
transform: Affine::new([b.width(), 0.0, 0.0, b.height(), b.x0, b.y0]),
smask: None,
overprint: Overprint::default(),
jpeg: None,
}))
};
let settings = print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap();
let mut writer = Writer::new(settings, Metadata::default());
let mut page_builder = writer.document_mut().start_page_with(PageSettings::from_wh(100.0, 100.0).unwrap());
let mut surface = page_builder.surface();
write_flattened(&mut surface, &list, &flat, &CmsConversion::fogra39(), PdfVersion::default(), &mut raster).unwrap();
surface.finish();
page_builder.finish();
let p = separate(&writer.finish().unwrap());
assert_inks(&p, 20, 50, &[("Cyan", 255), ("Magenta", 0)]);
assert_inks(&p, 50, 50, &[("Cyan", 0), ("Magenta", 255)]);
assert_inks(&p, 80, 50, &[("Cyan", 0), ("Magenta", 255), ("Black", 0)]);
}
+521
View File
@@ -0,0 +1,521 @@
//! Text read from a PDF through hayro-interpret and written again with krilla: as live text
//! when the font can be embedded again, as outlines with `/ActualText` otherwise. Covers what
//! the PDF-in adapter relies on: glyph ids, positions, Unicode and plates survive the trip.
//!
//! Fonts not in the repository are read from paths and their cases are skipped when missing:
//! Inter from `CRAFT_PDF_TEST_FONT_DIR`, a CFF-flavored OpenType font from
//! `CRAFT_PREPRESS_CFF_FONT` (default: Debian's `fonts-urw-base35` Nimbus Sans). The Type1C
//! case embeds hayro's Foxit Sans, which is bare CFF.
#![cfg(feature = "flatten")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use craft_pdf::interpret::font::{Glyph, OutlineFontData, StandardFont};
use craft_pdf::interpret::hayro_cmap::BfString;
use craft_pdf::interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt,
interpret_page,
};
use craft_pdf::write::backend::color::cmyk;
use craft_pdf::write::backend::geom::{PathBuilder, Point as KPoint, Transform};
use craft_pdf::write::backend::page::PageSettings;
use craft_pdf::write::backend::paint::Fill;
use craft_pdf::write::backend::surface::Surface;
use craft_pdf::write::backend::tagging::{ContentTag, SpanTag};
use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph};
use craft_pdf::write::backend::{self, Data};
use craft_pdf::write::{Metadata, Writer};
use craft_prepress::flatten::font::FontCache;
use craft_prepress::flatten::kurbo::{Affine, BezPath, PathEl, Rect, Shape};
use craft_prepress::flatten::{PlacedGlyph, TextFont, TextItem};
use craft_prepress::{InkColor, InkId, Options, Overprint, Process, plates};
const W: f64 = 300.0;
const H: f64 = 120.0;
const SAMPLE: &str = "Hamburgefonstiv 0123";
// ---- fonts ---------------------------------------------------------------------------------
fn read_font(path: Option<PathBuf>, what: &str) -> Option<Vec<u8>> {
let data = path.as_ref().and_then(|p| std::fs::read(p).ok());
if data.is_none() {
println!("note: {what} not found at {path:?}; case skipped");
}
data
}
fn inter() -> Option<Vec<u8>> {
read_font(std::env::var_os("CRAFT_PDF_TEST_FONT_DIR").map(|d| PathBuf::from(d).join("Inter-Regular.ttf")), "Inter (CRAFT_PDF_TEST_FONT_DIR)")
}
fn cff_otf() -> Option<Vec<u8>> {
let path = std::env::var_os("CRAFT_PREPRESS_CFF_FONT")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/usr/share/fonts/opentype/urw-base35/NimbusSans-Regular.otf"));
read_font(Some(path), "a CFF OpenType font (CRAFT_PREPRESS_CFF_FONT)")
}
// ---- writing -------------------------------------------------------------------------------
fn black() -> Fill {
Fill { paint: cmyk::Color::new(0, 0, 0, 255).into(), ..Default::default() }
}
fn write_page(draw: impl FnOnce(&mut Surface<'_>)) -> Vec<u8> {
let settings = backend::SerializeSettings { compress_content_streams: false, ..Default::default() };
let mut writer = Writer::new(settings, Metadata::default());
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(W as f32, H as f32).unwrap());
let mut surface = page.surface();
draw(&mut surface);
surface.finish();
page.finish();
writer.finish().unwrap()
}
/// The source document: `SAMPLE` mapped through the font's cmap (no shaping), once on a
/// baseline and once rotated and scaled.
fn source_pdf(font_bytes: &[u8]) -> Vec<u8> {
let face = ttf_parser::Face::parse(font_bytes, 0).unwrap();
let upem = f32::from(face.units_per_em());
let glyphs: Vec<_> = SAMPLE
.char_indices()
.map(|(i, c)| {
let id = face.glyph_index(c).unwrap();
let advance = f32::from(face.glyph_hor_advance(id).unwrap()) / upem;
KrillaGlyph::new(GlyphId::new(u32::from(id.0)), advance, 0.0, 0.0, 0.0, i..i + c.len_utf8(), None)
})
.collect();
let font = craft_pdf::write::font(font_bytes, 0, None).unwrap();
write_page(|s| {
s.set_fill(Some(black()));
s.draw_glyphs(KPoint::from_xy(10.0, 40.0), &glyphs, font.clone(), SAMPLE, 24.0, false);
s.push_transform(&Transform::from_row(0.96, -0.26, 0.26, 0.96, 20.0, 100.0));
s.draw_glyphs(KPoint::from_xy(0.0, 0.0), &glyphs, font, SAMPLE, 13.0, false);
s.pop();
})
}
fn to_krilla(a: Affine) -> Transform {
let [a, b, c, d, e, f] = a.as_coeffs().map(|v| v as f32);
Transform::from_row(a, b, c, d, e, f)
}
fn to_krilla_path(path: &BezPath) -> Option<backend::geom::Path> {
let mut b = PathBuilder::new();
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32),
PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32),
PathEl::QuadTo(p1, p) => b.quad_to(p1.x as f32, p1.y as f32, p.x as f32, p.y as f32),
PathEl::CurveTo(p1, p2, p) => b.cubic_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32, p.x as f32, p.y as f32),
PathEl::ClosePath => b.close(),
}
}
b.finish()
}
/// Writes text runs as the flattener's output would: live text through krilla when the run
/// has a font, else its outlines with the Unicode text as `/ActualText`.
fn rewrite(items: &[TextItem]) -> Vec<u8> {
let mut fonts: HashMap<u128, Font> = HashMap::new();
write_page(|s| {
s.set_fill(Some(black()));
for item in items {
let text = item.text.as_deref().unwrap_or("");
match &item.font {
Some(tf) => {
let font = fonts
.entry(tf.key)
.or_insert_with(|| Font::new(Data::from(tf.data.clone()), tf.index).expect("krilla reads the font"))
.clone();
let size = item.size as f64;
let glyphs: Vec<_> = item
.glyphs
.iter()
.enumerate()
.map(|(i, g)| {
let next_x = item.glyphs.get(i + 1).map_or(g.origin.x, |n| n.origin.x);
let x_advance = ((next_x - g.origin.x) / size) as f32;
KrillaGlyph::new(GlyphId::new(g.id), x_advance, 0.0, (g.origin.y / size) as f32, 0.0, g.text.clone(), None)
})
.collect();
// Text space is y up; krilla's glyph placement is y down.
s.push_transform(&to_krilla(item.transform * Affine::scale_non_uniform(1.0, -1.0)));
s.draw_glyphs(KPoint::from_xy(0.0, 0.0), &glyphs, font, text, item.size, false);
s.pop();
}
None => {
let span = SpanTag { actual_text: Some(text), ..SpanTag::empty() };
s.start_tagged(ContentTag::Span(span));
if let Some(path) = to_krilla_path(&item.outlines) {
s.draw_path(&path);
}
s.end_tagged();
}
}
}
})
}
/// A one-page PDF from raw content and resources, objects numbered from 5.
fn raw_pdf(content: &str, resources: &str, objects: &[String]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
out.extend_from_slice(
format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(),
);
out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
for (i, body) in objects.iter().enumerate() {
out.extend_from_slice(format!("{} 0 obj {body} endobj\n", i + 5).as_bytes());
}
out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
out
}
fn stream(dict: &str, data: &str) -> String {
format!("<< {dict} /Length {} >> stream\n{data}\nendstream", data.len())
}
// ---- reading -------------------------------------------------------------------------------
/// What the PDF-in adapter needs from one drawn glyph.
struct Drawn {
/// The font's key in the [`FontCache`]; `None` for Type3 glyphs.
key: Option<u128>,
/// What hayro hands out for the font, for the checks.
data: Option<OutlineFontData>,
id: u32,
/// Glyph space (1000 units per em, y up) to page space.
matrix: Affine,
unicode: Option<String>,
vertical: bool,
type3: bool,
outline: BezPath,
}
#[derive(Default)]
struct Recorder {
glyphs: Vec<Drawn>,
actual_text: String,
fonts: FontCache,
}
#[derive(Default)]
struct PathSink(BezPath);
impl Device<'_> for PathSink {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'_>, _: &PathDrawMode) {
self.0.extend((transform * path.clone()).iter());
}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) {}
fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
fn unicode(s: BfString) -> String {
match s {
BfString::Char(c) => c.to_string(),
BfString::String(s) => s,
}
}
impl<'a> Device<'a> for Recorder {
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &Paint<'a>, mode: &GlyphDrawMode) {
if !matches!(mode, GlyphDrawMode::Fill) {
return;
}
let matrix = transform * glyph_transform;
let drawn = match glyph {
Glyph::Outline(o) => Drawn {
key: Some(self.fonts.note(o)),
data: o.font_data(),
id: o.glyph_id().to_u32(),
matrix,
unicode: o.as_unicode().map(unicode),
vertical: o.is_vertical(),
type3: false,
outline: matrix * o.outline(),
},
Glyph::Type3(t) => {
let mut sink = PathSink::default();
t.interpret(&mut sink, transform, glyph_transform, paint);
Drawn { key: None, data: None, id: 0, matrix, unicode: t.as_unicode().map(unicode), vertical: false, type3: true, outline: sink.0 }
}
};
self.glyphs.push(drawn);
}
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
fn actual_text(&mut self, text: &[u8]) {
self.actual_text.push_str(&pdf_text_string(text));
}
}
fn pdf_text_string(bytes: &[u8]) -> String {
match bytes {
[0xFE, 0xFF, rest @ ..] => {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
String::from_utf16_lossy(&units)
}
_ => bytes.iter().map(|&b| char::from(b)).collect(),
}
}
fn read(pdf: &[u8]) -> Recorder {
let doc = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap();
let page = &doc.pages()[0];
let cache = InterpreterCache::new();
let mut context =
Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, W, H), &cache, page.xref(), InterpreterSettings::default());
let mut recorder = Recorder::default();
interpret_page(page, &mut context, &mut recorder);
recorder
}
// ---- runs, as the adapter builds them ------------------------------------------------------
fn same_linear(a: Affine, b: Affine) -> bool {
let (a, b) = (a.as_coeffs(), b.as_coeffs());
let scale = a[..4].iter().fold(0.0f64, |m, v| m.max(v.abs()));
a[..4].iter().zip(&b[..4]).all(|(x, y)| (x - y).abs() <= scale * 1e-6)
}
/// Groups drawn glyphs into runs: one font and one text matrix (up to translation) each.
fn text_items(drawn: &[Drawn], fonts: &HashMap<u128, Option<Arc<TextFont>>>) -> Vec<TextItem> {
let mut items: Vec<TextItem> = Vec::new();
for d in drawn {
let font = d.key.and_then(|key| fonts[&key].clone());
let linear = d.matrix * Affine::scale(1000.0);
let continues = items
.last()
.is_some_and(|last| last.font == font && last.font.is_some() && same_linear(last.transform * Affine::scale(last.size as f64), linear));
if !continues {
let size = linear.determinant().abs().sqrt();
let origin = d.matrix.translation();
let [a, b, c, dd, ..] = linear.as_coeffs();
items.push(TextItem {
font: font.clone(),
size: size as f32,
transform: Affine::new([a / size, b / size, c / size, dd / size, origin.x, origin.y]),
glyphs: Vec::new(),
text: Some(String::new()),
outlines: BezPath::new(),
paint: craft_prepress::flatten::Paint::Solid(InkColor::Tints(vec![(InkId::process(Process::Black), 1.0)])),
overprint: Overprint::default(),
});
}
let item = items.last_mut().unwrap();
let text = item.text.as_mut().unwrap();
let start = text.len();
text.push_str(d.unicode.as_deref().unwrap_or(""));
let origin = item.transform.inverse() * d.matrix.translation().to_point();
item.glyphs.push(PlacedGlyph { id: d.id, origin, text: start..text.len() });
item.outlines.extend(d.outline.iter());
}
items
}
// ---- checks --------------------------------------------------------------------------------
fn black_plate(pdf: &[u8]) -> Vec<u8> {
let p = plates(pdf, 0, 72.0, &Options::default()).unwrap();
p.plane(InkId::process(Process::Black)).unwrap().to_vec()
}
/// Mean and largest per-pixel difference of the black plates.
fn plate_difference(a: &[u8], b: &[u8]) -> (f64, u8) {
assert_eq!(a.len(), b.len());
let max = a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
let mean = a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::<f64>() / a.len() as f64;
(mean, max)
}
fn text_of(r: &Recorder) -> String {
r.glyphs.iter().filter_map(|g| g.unicode.as_deref()).collect()
}
/// Reads `source`, writes it again from the runs, and checks positions, outlines, text and
/// plates. Returns the runs, the source and the rewrite as read.
fn round_trip(source: &[u8]) -> (Vec<TextItem>, Recorder, Recorder) {
let mut before = read(source);
assert!(!before.glyphs.is_empty());
let items = text_items(&before.glyphs, &std::mem::take(&mut before.fonts).finish());
let pdf = rewrite(&items);
let after = read(&pdf);
let (mean, max) = plate_difference(&black_plate(source), &black_plate(&pdf));
println!("black plates: mean difference {mean:.4}, largest {max}");
assert!(mean < 0.01 && max <= 4, "plates differ: mean {mean}, max {max}");
if items.iter().all(|i| i.font.is_some()) {
assert_eq!(after.glyphs.len(), before.glyphs.len());
for (b, a) in before.glyphs.iter().zip(&after.glyphs) {
let (pa, pb) = (a.matrix.translation(), b.matrix.translation());
assert!((pa - pb).hypot() < 1e-3, "glyph origin {pa:?} vs {pb:?}");
assert!(same_linear(a.matrix, b.matrix));
let (ba, bb) = (a.outline.bounding_box(), b.outline.bounding_box());
assert!((ba.min_x() - bb.min_x()).abs() < 1e-3 && (ba.max_y() - bb.max_y()).abs() < 1e-3, "outline {ba:?} vs {bb:?}");
}
assert_eq!(text_of(&after), text_of(&before), "Unicode survives");
} else {
assert_eq!(after.actual_text, text_of(&before), "Unicode survives as ActualText");
}
(items, before, after)
}
/// krilla keeps glyph ids when the source is one of its own subsets: those are compact in
/// first-use order already.
fn assert_same_ids(before: &Recorder, after: &Recorder) {
assert!(before.glyphs.iter().zip(&after.glyphs).all(|(b, a)| a.id == b.id), "glyph ids match");
}
/// Whether the rewrite has the text as live text in an embedded CFF font.
fn has_live_cff(after: &Recorder) -> bool {
after.glyphs.iter().all(|g| g.data.as_ref().is_some_and(|d| d.embedded && (*d.data).as_ref().starts_with(&[1])))
}
#[test]
fn truetype_text_reembeds_as_live_text() {
let Some(font) = inter() else { return };
let source = source_pdf(&font);
let (items, before, after) = round_trip(&source);
assert_eq!(items.len(), 2, "two runs: one per text matrix");
assert!(items.iter().all(|i| i.font.is_some()));
assert_same_ids(&before, &after);
assert_eq!(text_of(&after), SAMPLE.repeat(2));
}
#[test]
fn cff_text_reembeds_after_wrapping_the_bare_cff() {
let Some(font) = cff_otf() else { return };
let source = source_pdf(&font);
assert!(String::from_utf8_lossy(&source).contains("/CIDFontType0C"), "krilla writes CFF as bare CIDFontType0C");
let (items, before, after) = round_trip(&source);
let data = before.glyphs[0].data.as_ref().unwrap().data.clone();
assert_eq!((*data).as_ref()[0], 1, "hayro hands out the bare CFF");
assert!(Font::new(Data::from(data), 0).is_none(), "krilla needs an OpenType wrapper");
assert!(items.iter().all(|i| i.font.is_some()));
assert!(has_live_cff(&after), "the rewrite embeds CFF again, not outlines");
assert_same_ids(&before, &after);
assert_eq!(text_of(&after), SAMPLE.repeat(2));
}
const TYPE3_CMAP: &str = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /T3 def \
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def 1 begincodespacerange <00> <FF> endcodespacerange \
2 beginbfchar <41> <0041> <42> <0042> endbfchar endcmap CMapName currentdict /CMapResource defineresource pop end end";
#[test]
fn type3_text_falls_back_to_outlines_with_actual_text() {
let font = "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \
/CharProcs << /a 6 0 R /b 7 0 R >> /Encoding << /Type /Encoding /Differences [65 /a /b] >> \
/FirstChar 65 /LastChar 66 /Widths [1000 1000] /Resources << >> /ToUnicode 8 0 R >>";
let objects = [
font.to_owned(),
stream("", "1000 0 0 0 800 800 d1 0 0 800 800 re f"),
stream("", "1000 0 0 0 800 800 d1 0 0 m 800 0 l 400 800 l f"),
stream("", TYPE3_CMAP),
];
let source = raw_pdf("0 0 0 1 k BT /F1 30 Tf 10 30 Td (ABBA) Tj ET", "/Font << /F1 5 0 R >>", &objects);
let (items, before, _) = round_trip(&source);
assert!(before.glyphs.iter().all(|g| g.type3 && g.key.is_none()));
assert!(items.iter().all(|i| i.font.is_none()));
}
#[test]
fn standard_14_text_has_no_font_bytes_and_falls_back_to_outlines() {
let source = raw_pdf(
"0 0 0 1 k BT /F1 24 Tf 10 40 Td (Hamburg) Tj ET",
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>",
&[],
);
let (items, before, _) = round_trip(&source);
assert!(before.glyphs.iter().all(|g| !g.type3 && g.data.is_none()), "hayro returns no bytes for standard fonts");
assert!(items.iter().all(|i| i.font.is_none()));
assert_eq!(text_of(&before), "Hamburg");
}
#[test]
fn vertical_text_reembeds_as_horizontal_glyphs_at_their_positions() {
let Some(font) = cff_otf().or_else(inter) else { return };
let mut source = source_pdf(&font);
let at: Vec<usize> = source.windows(11).enumerate().filter(|(_, w)| *w == b"/Identity-H").map(|(i, _)| i).collect();
assert_eq!(at.len(), 1, "one Type0 font");
// Same length, so the cross-reference offsets stay valid.
source[at[0] + 10] = b'V';
let (items, before, after) = round_trip(&source);
assert!(before.glyphs.iter().all(|g| g.vertical));
assert!(items.iter().all(|i| i.font.is_some()));
assert_same_ids(&before, &after);
assert!(after.glyphs.iter().all(|g| !g.vertical), "krilla writes horizontal fonts only");
}
const IDENTITY_CMAP: &str = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /U def \
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def 1 begincodespacerange <0000> <FFFF> endcodespacerange \
2 beginbfchar <0041> <0041> <0042> <0042> endbfchar endcmap CMapName currentdict /CMapResource defineresource pop end end";
#[test]
fn a_non_embedded_cid_font_falls_back_to_outlines() {
let objects = [
"<< /Type /Font /Subtype /Type0 /BaseFont /NotEmbedded /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode 8 0 R >>".to_owned(),
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /NotEmbedded /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
/FontDescriptor 7 0 R >>"
.to_owned(),
"<< /Type /FontDescriptor /FontName /NotEmbedded /Flags 32 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 \
/CapHeight 700 /StemV 80 >>"
.to_owned(),
stream("", IDENTITY_CMAP),
];
let source = raw_pdf("0 0 0 1 k BT /F1 24 Tf 10 40 Td <00410042> Tj ET", "/Font << /F1 5 0 R >>", &objects);
let (items, before, _) = round_trip(&source);
// hayro hands out the font resolver's substitute, marked as not embedded.
assert!(before.glyphs.iter().all(|g| g.data.as_ref().is_some_and(|d| !d.embedded)));
assert!(items.iter().all(|i| i.font.is_none()));
assert_eq!(text_of(&before), "AB");
}
/// A simple font with `FontFile3/Type1C`: hayro's Foxit Sans substitute, which is bare CFF,
/// hex-encoded so the PDF stays text. The widths are made up; positions follow them.
fn type1c_pdf() -> Vec<u8> {
let (data, _) = StandardFont::Helvetica.get_font_data();
let hex: String = (*data).as_ref().iter().map(|b| format!("{b:02x}")).collect();
let widths: Vec<String> = (32..=126).map(|c| (400 + c % 7 * 50).to_string()).collect();
let objects = [
format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /ChromSansOTF /FirstChar 32 /LastChar 126 /Widths [{}] \
/Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
widths.join(" ")
),
"<< /Type /FontDescriptor /FontName /ChromSansOTF /Flags 32 /FontBBox [-166 -225 1000 931] /ItalicAngle 0 /Ascent 718 \
/Descent -207 /CapHeight 718 /StemV 88 /FontFile3 7 0 R >>"
.to_owned(),
stream("/Subtype /Type1C /Filter /ASCIIHexDecode", &format!("{hex}>")),
];
let content = format!("0 0 0 1 k BT /F1 24 Tf 10 40 Td ({SAMPLE}) Tj ET BT /F1 13 Tf 0.96 0.26 -0.26 0.96 20 80 Tm ({SAMPLE}) Tj ET");
raw_pdf(&content, "/Font << /F1 5 0 R >>", &objects)
}
#[test]
fn type1c_text_reembeds_after_wrapping_the_cff() {
let source = type1c_pdf();
let (items, before, after) = round_trip(&source);
assert!(before.glyphs.iter().all(|g| g.data.as_ref().is_some_and(|d| d.embedded && (*d.data).as_ref()[0] == 1)), "bare CFF from the file");
assert_eq!(items.len(), 2);
assert!(items.iter().all(|i| i.font.is_some()));
assert!(has_live_cff(&after), "the rewrite embeds CFF again, not outlines");
assert_eq!(text_of(&after), SAMPLE.repeat(2));
}
+16
View File
@@ -12,14 +12,30 @@ workspace = true
[dependencies]
egui = { workspace = true }
log = "0.4"
serde = { version = "1", features = ["derive"], optional = true }
# Fading a native window while it is dragged (`NSWindow.alphaValue`); winit already uses these.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSResponder", "NSWindow"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSString"] }
[features]
serde = ["dep:serde"]
# Floating windows may move to their own OS windows (desktop egui backends with viewports).
native-windows = []
# `docking::testing`: a headless driver for application docking tests.
testing = []
[dev-dependencies]
egui = { workspace = true, features = ["default_fonts"] }
serde_json = "1"
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
egui_kittest = { version = "0.37", features = ["wgpu"] }
eframe = { version = "0.37", default-features = false, features = ["wgpu", "x11", "default_fonts"] }
image = { version = "0.25", default-features = false, features = ["png"] }
[[example]]
name = "docking_native"
required-features = ["native-windows"]
+7
View File
@@ -107,6 +107,13 @@ indices and widths, overflow indices, and the actual reserved overflow width. It
tabs before hiding them, keeps input order and retains the selected tab. Tiny strips may reduce
its width to zero. Negative/non-finite dimensions become zero and large dimensions are bounded.
The work is linear in tab count with at most 32 fitting passes; the caller caps its own tab count.
Docking tab strips use this policy, with `DockStyle::min_tab_width` as the minimum.
`tabs::fit_whole(natural_widths, selected, available, overflow_width)` keeps every shown tab at its
natural width and sends the rest to overflow. Tabs are shown in order up to the first that does not
fit; the selected tab is always shown in its logical place, taking the place of the last tabs that
fit when it lies past them. Only a selected tab wider than the strip minus the overflow button is
narrowed.
`tabs::Tab` registers a tab at a supplied rectangle and invokes the application's painter:
+61
View File
@@ -0,0 +1,61 @@
//! Docking with floating panels in their own OS windows. Run with
//! `cargo run -p craft-ui --example docking_native --features native-windows`.
//!
//! Prints every action it applies and every native window's place, for checking moves by
//! hand or under a headless X server.
use craft_ui::docking::{DockArea, DockContent, DockStyle, Floating, Layout, Node};
use craft_ui::layout::{SplitAxis, SplitSize};
struct Panels;
impl DockContent<u32> for Panels {
fn body(&mut self, ui: &mut egui::Ui, panel: &u32) {
ui.label(format!("Body of panel {panel}"));
}
fn logical_label(&self, panel: &u32) -> String {
format!("Panel {panel}")
}
}
struct App {
layout: Layout<u32>,
}
impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let style = DockStyle::from_ui(ui);
let out = DockArea::new(egui::Id::unique("dock")).show_content(ui, &self.layout, &style, &mut Panels);
if !out.actions.is_empty() {
println!("actions {:?}", out.actions);
if let Err(error) = self.layout.apply_all(out.actions, |_| Default::default()) {
println!("rejected {error:?}");
}
for window in &self.layout.floating {
println!("window {} panels {:?} native {:?}", window.id, window.panels().collect::<Vec<_>>(), window.native);
}
}
if !out.forwarded_events.is_empty() {
println!("forwarded {:?} {:?}", out.forwarded_events, out.forwarded_modifiers);
}
}
}
fn main() -> eframe::Result {
let tabs = |panels: &[u32]| Node::Tabs { panels: panels.to_vec(), active: 0 };
let layout = Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::Ratio(0.5),
first: Box::new(tabs(&[1, 2])),
second: Box::new(tabs(&[3, 4])),
}),
floating: vec![Floating::single(vec![5, 6], 0, [300.0, 200.0, 320.0, 260.0]), Floating::single(vec![7], 0, [700.0, 300.0, 260.0, 200.0])],
};
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([1000.0, 700.0]).with_position([0.0, 0.0]),
..Default::default()
};
eframe::run_native("Docking", options, Box::new(|_| Ok(Box::new(App { layout }))))
}
+2 -2
View File
@@ -390,7 +390,7 @@ fn dock_layout(panels: u32, with_floating: bool) -> Layout<u32> {
}),
second: Box::new(leaf(2)),
}),
floating: vec![Floating { panels: vec![3], active: 0, rect: [80.0, 70.0, 300.0, 220.0] }],
floating: vec![Floating::single(vec![3], 0, [80.0, 70.0, 300.0, 220.0]).with_id(1)],
}
} else {
let all: Vec<u32> = (0..panels).collect();
@@ -662,7 +662,7 @@ fn run_case(case: Case<'_>, fonts: &egui::FontDefinitions) -> Result<Value, Box<
let dock_id = Id::unique(("ui-perf", case.label));
let layout = dock_layout(case.panels, case.floating);
layout.validate()?;
let floating: BTreeSet<u32> = layout.floating.iter().flat_map(|group| group.panels.iter().copied()).collect();
let floating: BTreeSet<u32> = layout.floating.iter().flat_map(|group| group.panels().copied()).collect();
let mut scene =
Scene { dock_id, side_panel: case.side_panel, style: None, error: None, dock_rect: Rect::NOTHING, left_rect: None, central_rect: None };
let frames = {
+441
View File
@@ -0,0 +1,441 @@
//! Floating window chrome: an opaque frame with anchored resize handles, and a title bar with an
//! optional title, ▾ menu button and ×. [`super::DockArea`] draws its floating windows with
//! these; other hosts (task panes) use them with their own ids and actions. Nothing here changes
//! a layout: the caller turns the responses into its own commands.
use std::sync::Arc;
use egui::{
Color32, Context, FontId, Galley, Id, Key, LayerId, Order, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2,
};
use super::{DockStyle, PanelLimits, resize::AnchoredResize};
/// Height of a floating window's title bar.
pub const TITLE_HEIGHT: f32 = 14.0;
/// Names of the resize handles, appended to the frame id: `id.with(name)`.
pub const RESIZE_HANDLES: [&str; 8] =
["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left", "resize-top", "resize-top-left", "resize-top-right"];
/// Height of the top resize band of a resizable frame. The frame's contents start below it, so
/// the band takes no moves or clicks from a title bar.
pub const RESIZE_BAND: f32 = 4.0;
/// Colors and font of the frame and title bar.
#[derive(Clone, Debug)]
pub struct ChromeStyle {
/// Painted under the contents, so the window stays opaque when the contents are not.
pub background: Color32,
pub stroke: Stroke,
pub title_background: Color32,
pub text: Color32,
pub weak_text: Color32,
pub accent: Color32,
pub font: FontId,
/// Mirror the title bar: buttons at the left, the title at the right.
pub right_to_left: bool,
}
impl ChromeStyle {
pub fn from_ui(ui: &Ui) -> Self {
Self::from_dock(&DockStyle::from_ui(ui))
}
pub fn from_dock(style: &DockStyle) -> Self {
Self {
background: style.floating_background,
stroke: style.floating_stroke,
title_background: style.tab_background,
text: style.text,
weak_text: style.inactive_text,
accent: style.accent,
font: style.font.clone(),
right_to_left: style.right_to_left,
}
}
}
/// Resize handle hit rects and the edges they move: every side and corner. The top handles
/// fill the band above the contents; the others lie over the contents' edges.
fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 8] {
let band_bottom = (rect.top() + RESIZE_BAND).min(rect.bottom());
let left_corner = (rect.left() + 12.0).min(rect.right());
let right_corner = (rect.right() - 12.0).max(rect.left());
[
("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)),
(
"resize-left",
Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)),
vec2(-1.0, 0.0),
),
(
"resize-right",
Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)),
vec2(1.0, 0.0),
),
(
"resize-bottom",
Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())),
vec2(0.0, 1.0),
),
(
"resize-bottom-left",
Rect::from_min_max(pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), pos2(left_corner, rect.bottom())),
vec2(-1.0, 1.0),
),
("resize-top", Rect::from_min_max(pos2(left_corner, rect.top()), pos2(right_corner, band_bottom)), vec2(0.0, -1.0)),
("resize-top-left", Rect::from_min_max(rect.min, pos2(left_corner, band_bottom)), vec2(-1.0, -1.0)),
("resize-top-right", Rect::from_min_max(pos2(right_corner, rect.top()), pos2(rect.right(), band_bottom)), vec2(1.0, -1.0)),
]
}
/// An opaque in-window frame on its own `Order::Middle` layer, `id`, at `rect`.
pub struct FloatingFrame<'a> {
id: Id,
rect: Rect,
bounds: Option<Rect>,
limits: PanelLimits,
resizable: bool,
resize_label: &'a str,
}
/// What a [`FloatingFrame`] did this frame.
pub struct FrameResponse<R> {
pub inner: R,
/// Where the frame was drawn, inside its bounds.
pub rect: Rect,
pub layer: LayerId,
/// The rect a resize handle asks for: pointer drags anchored at the opposite edges, or
/// arrow keys on a focused handle (8 pt per press). Escape and focus loss end a resize.
pub resized: Option<Rect>,
/// The handle whose pointer resize started this frame.
pub resize_started: Option<Id>,
/// Every handle's response, for focus and gesture bookkeeping.
pub handles: Vec<Response>,
}
impl<'a> FloatingFrame<'a> {
pub fn new(id: Id, rect: Rect) -> Self {
Self { id, rect, bounds: None, limits: PanelLimits::default(), resizable: true, resize_label: "Resize window" }
}
/// The frame stays inside `bounds`; the default is the viewport's content rect.
pub fn bounds(mut self, bounds: Rect) -> Self {
self.bounds = Some(bounds);
self
}
/// Size limits of a resize, top resize band included. The frame itself is drawn at the
/// given rect.
pub fn limits(mut self, limits: PanelLimits) -> Self {
self.limits = limits;
self
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.resizable = resizable;
self
}
/// Accessibility label of the resize handles.
pub fn resize_label(mut self, label: &'a str) -> Self {
self.resize_label = label;
self
}
pub fn layer(id: Id) -> LayerId {
LayerId::new(Order::Middle, id)
}
/// Draw the frame, then `add_contents` with the rect for its contents (the frame's rect below
/// the top resize band when resizable), then the outline and handles.
pub fn show<R>(self, ctx: &Context, style: &ChromeStyle, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> FrameResponse<R> {
let bounds = self.bounds.unwrap_or_else(|| ctx.content_rect());
let rect = clamp(self.rect, bounds);
let area = egui::Area::new(self.id)
.fade_in(false)
.order(Order::Middle)
.fixed_pos(rect.min)
.default_size(rect.size())
.movable(false)
.constrain_to(bounds);
let shown = area.show(ctx, |ui| {
// Drawn at `rect` itself, also in egui's first (sizing) pass of a new area.
ui.set_min_size(rect.size());
ui.allocate_rect(rect, Sense::hover());
ui.painter().rect_filled(rect, 0.0, style.background);
let contents =
if self.resizable { Rect::from_min_max(pos2(rect.left(), (rect.top() + RESIZE_BAND).min(rect.bottom())), rect.max) } else { rect };
let inner = add_contents(ui, contents);
ui.painter().rect_stroke(rect, 0.0, style.stroke, StrokeKind::Outside);
let (resized, resize_started, handles) = if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) };
FrameResponse { inner, rect, layer: Self::layer(self.id), resized, resize_started, handles }
});
shown.inner
}
fn handles(&self, ui: &mut Ui, rect: Rect, bounds: Rect, style: &ChromeStyle) -> (Option<Rect>, Option<Id>, Vec<Response>) {
let mut resized = None;
let mut started = None;
let mut responses = Vec::new();
let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits };
let canceled = ui.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ui.ctx());
for (name, handle, direction) in handles(rect) {
if !super::targets::usable(handle) {
continue;
}
let cursor = if direction.x == 0.0 {
egui::CursorIcon::ResizeVertical
} else if direction.y == 0.0 {
egui::CursorIcon::ResizeHorizontal
} else if direction.x == direction.y {
egui::CursorIcon::ResizeNwSe
} else {
egui::CursorIcon::ResizeNeSw
};
let response = ui.interact(handle, self.id.with(name), Sense::drag()).on_hover_cursor(cursor);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), self.resize_label));
let anchor_id = response.id.with("pointer-anchor");
crate::layout::drag_focus(ui, &response);
if response.drag_started()
&& let Some(origin) = ui.input(|i| i.pointer.press_origin())
&& let Some(anchor) = AnchoredResize::new(rect, origin, direction)
{
ui.ctx().data_mut(|data| data.insert_temp(anchor_id, anchor));
started = Some(response.id);
}
let mut delta = Vec2::ZERO;
if response.has_focus() {
ui.memory_mut(|memory| {
memory.set_focus_lock_filter(
response.id,
egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() },
)
});
ui.input_mut(|input| {
for (key, step) in [
(Key::ArrowLeft, vec2(-8.0, 0.0)),
(Key::ArrowRight, vec2(8.0, 0.0)),
(Key::ArrowUp, vec2(0.0, -8.0)),
(Key::ArrowDown, vec2(0.0, 8.0)),
] {
if input.consume_key(egui::Modifiers::NONE, key) {
delta += step;
}
}
});
}
let next = if response.dragged() || response.drag_stopped() {
ui.ctx()
.data(|data| data.get_temp::<AnchoredResize>(anchor_id))
.and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, bounds, limits)))
} else if delta != Vec2::ZERO {
AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, bounds, limits))
} else {
None
};
if response.enabled()
&& !canceled
&& let Some(next) = next.filter(|next| *next != rect)
{
resized = Some(next);
}
if canceled || !ui.input(|input| input.pointer.primary_down()) {
ui.ctx().data_mut(|data| data.remove::<AnchoredResize>(anchor_id));
}
if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) {
ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, style.accent));
}
responses.push(response);
}
(resized, started, responses)
}
}
/// A drop preview: a 2 pt accent outline inside `rect`, filled with 15% accent when `filled`.
/// Docking and the pane host draw every destination and drag ghost with it.
pub fn drop_preview(painter: &egui::Painter, rect: Rect, accent: Color32, filled: bool) {
let fill = if filled { accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT };
painter.rect(rect, 0.0, fill, Stroke::new(2.0, accent), StrokeKind::Inside);
}
/// Keep `rect` inside `bounds`, shrinking it when it is larger.
pub fn clamp(rect: Rect, bounds: Rect) -> Rect {
if !super::targets::usable(bounds) {
return rect;
}
let size = rect.size().max(vec2(1.0, 1.0)).min(bounds.size());
let min = rect.min.max(bounds.min).min(bounds.max - size);
Rect::from_min_size(min, size)
}
/// `text` on one line no wider than `width`, cut at the end with '…'. egui does not wrap a
/// host's pre-laid-out `WidgetText::Galley` again, so that one is laid out again from its job.
pub(super) fn truncated(ui: &Ui, text: WidgetText, width: f32, font: FontId) -> Arc<Galley> {
let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), width, font);
if galley.size().x <= width + 0.5 {
return galley;
}
let mut job = (*galley.job).clone();
job.wrap = egui::text::TextWrapping::truncate_at_width(width);
ui.painter().layout_job(job)
}
/// A title bar: an optional title, an optional ▾ menu button and an optional ×, by default 10 pt
/// in from the right edge, mirrored when [`ChromeStyle::right_to_left`] is set. The bar senses
/// clicks and drags; double-clicks are the caller's.
pub struct TitleBar<'a> {
id: Id,
move_label: &'a str,
title: Option<WidgetText>,
menu: Option<&'a str>,
close: Option<&'a str>,
/// The ×'s hit area and glyph, where the caller places them.
close_at: Option<(Rect, Rect)>,
movable: bool,
}
/// The parts of a [`TitleBar`] that were shown.
pub struct TitleResponse {
/// Drag to move; double-click is the caller's (docking minimizes every group).
pub bar: Response,
pub menu: Option<Response>,
pub close: Option<Response>,
}
impl<'a> TitleBar<'a> {
/// `id` is the bar's own; the buttons use `id.with("menu")` and `id.with("close")`.
/// `move_label` names the bar for accessibility.
pub fn new(id: Id, move_label: &'a str) -> Self {
Self { id, move_label, title: None, menu: None, close: None, close_at: None, movable: true }
}
/// Text at the leading edge, cut with '…' to fit.
pub fn title(mut self, title: impl Into<WidgetText>) -> Self {
self.title = Some(title.into());
self
}
/// Show a ▾ button with this accessibility label.
pub fn menu(mut self, label: &'a str) -> Self {
self.menu = Some(label);
self
}
/// Show a × button with this accessibility label.
pub fn close(mut self, label: &'a str) -> Self {
self.close = Some(label);
self
}
/// Place the × at `glyph`, taking clicks in `hit`: rects as drawn, already mirrored in a
/// right-to-left layout. By default the × is a square the bar's height, its center 10 pt
/// in from the trailing edge.
pub fn close_at(mut self, hit: Rect, glyph: Rect) -> Self {
self.close_at = Some((hit, glyph));
self
}
/// Whether the bar shows the grab cursor. It senses drags either way.
pub fn movable(mut self, movable: bool) -> Self {
self.movable = movable;
self
}
pub fn show(self, ui: &mut Ui, rect: Rect, style: &ChromeStyle) -> TitleResponse {
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
painter.rect_filled(rect, 0.0, style.title_background);
let side = rect.height();
// Laid out left to right, then mirrored in a right-to-left layout.
let mirror = |r: Rect| {
if style.right_to_left {
Rect::from_x_y_ranges((rect.left() + rect.right() - r.right())..=(rect.left() + rect.right() - r.left()), r.y_range())
} else {
r
}
};
let close_rect = self.close.map(|_| match self.close_at {
Some((hit, _)) => mirror(hit),
None => Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side)),
});
let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0);
let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom())));
let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left());
let bar_rect = mirror(Rect::from_min_max(rect.min, pos2(buttons.max(rect.left()), rect.bottom())));
let (close_rect, menu_rect) = (close_rect.map(mirror), menu_rect.map(mirror));
let bar = ui.interact(bar_rect, self.id, Sense::click_and_drag());
let bar = if self.movable { bar.on_hover_cursor(egui::CursorIcon::Grab) } else { bar };
bar.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), self.move_label));
if let Some(title) = self.title {
let galley = truncated(ui, title, (bar_rect.width() - 12.0).max(0.0), style.font.clone());
let x = if style.right_to_left { bar_rect.right() - 6.0 - galley.size().x } else { bar_rect.left() + 6.0 };
painter.galley(pos2(x, rect.center().y - galley.size().y * 0.5), galley, style.text);
}
if bar.has_focus() {
painter.rect_stroke(bar_rect.shrink(1.0), 0.0, Stroke::new(1.0, style.accent), StrokeKind::Inside);
}
let button = |ui: &mut Ui, rect: Rect, name: &str, label: &str| {
let response = ui.interact(rect, self.id.with(name), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label));
let color = if response.hovered() || response.has_focus() { style.text } else { style.weak_text };
(response, Stroke::new(1.2, color))
};
let menu = menu_rect.zip(self.menu).map(|(menu_rect, label)| {
let (response, stroke) = button(ui, menu_rect, "menu", label);
let c = menu_rect.center();
painter.add(egui::Shape::convex_polygon(vec![c + vec2(-3.5, -1.5), c + vec2(3.5, -1.5), c + vec2(0.0, 2.5)], stroke.color, Stroke::NONE));
response
});
let close = close_rect.zip(self.close).map(|(close_rect, label)| {
let (response, stroke) = button(ui, close_rect, "close", label);
let r = self.close_at.map_or_else(|| Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0))), |(_, glyph)| glyph);
painter.line_segment([r.left_top(), r.right_bottom()], stroke);
painter.line_segment([r.right_top(), r.left_bottom()], stroke);
response
});
TitleResponse { bar, menu, close }
}
}
/// One step of a title-bar move, from [`live_move`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MoveStep {
/// The rect follows the pointer, inside the bounds.
Moving(Rect),
/// Escape or window focus loss ended the move: the rect it started from.
Canceled(Rect),
/// Released over `pointer`, with the rect where it was dropped.
Dropped { rect: Rect, pointer: Pos2 },
}
/// Move `rect` with a drag on `bar` (usually [`TitleResponse::bar`]). The rect and the press
/// position at drag start are kept in egui memory under the bar's id, so the window follows the
/// pointer without drift. Escape or window focus loss ends the move and returns the start rect;
/// the rest of that drag is ignored. `None` when the bar is not being moved.
pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Option<MoveStep> {
let key = bar.id.with("live-move");
if bar.drag_started()
&& let Some(origin) = ctx.input(|input| input.pointer.press_origin())
{
ctx.data_mut(|data| data.insert_temp(key, (rect, origin)));
}
let (start, origin) = ctx.data(|data| data.get_temp::<(Rect, Pos2)>(key))?;
let forget = || ctx.data_mut(|data| data.remove::<(Rect, Pos2)>(key));
if ctx.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ctx) {
forget();
return Some(MoveStep::Canceled(start));
}
let pointer = ctx.pointer_latest_pos().unwrap_or(origin);
let moved = clamp(start.translate(pointer - origin), bounds);
if bar.drag_stopped() {
forget();
return Some(MoveStep::Dropped { rect: moved, pointer });
}
if bar.dragged() {
return Some(MoveStep::Moving(moved));
}
// The drag ended without a release on this bar (the bar was not shown).
forget();
None
}
+590
View File
@@ -0,0 +1,590 @@
//! The shared docking command vocabulary (feature `serde`).
//!
//! Applications deserialize a control command's JSON params, with its id under `"command"`,
//! into [`DockCommand`] and apply it. craft-ui does not depend on `serde_json`.
//!
//! ```ignore
//! let mut params = params.clone();
//! params["command"] = json!(id);
//! let command: DockCommand<PanelId> = serde_json::from_value(params)?;
//! let space = float_space(ctx, DOCK_ID).unwrap_or_else(|| FloatSpace::new(ctx.content_rect()));
//! command.apply(&mut app.workspace, style.right_to_left, &space, |p| app.permissions(p))?;
//! ```
use std::hash::Hash;
use serde::{Deserialize, Deserializer, Serialize};
use super::{
Action, DockError, FloatSpace, Layout, Permissions, Placement, Side, Target, Workspace, Zone, dock_model::Place, surface::DEFAULT_FLOAT,
};
use crate::layout::SplitSize;
/// A docking command, tagged by its control id under `"command"`.
///
/// Ids and params (`?` marks optional ones):
///
/// | Id | Params |
/// |---|---|
/// | `window.panel.layout` | `{action}`: a serialized [`Action`] |
/// | `window.panel.move` | `{panel, anchor, zone?, before?, group?, placement?}` |
/// | `window.panel.float` | `{panel, x?, y?, width?, height?, group?, column?}` |
/// | `window.panel.dock` | `{panel, anchor?, zone?, before?, group?, column?, side?}` |
/// | `window.panel.activate`, `.close`, `.closeGroup`, `.closeOthers` | `{panel}` |
/// | `window.panel.minimize` | `{panel, minimized?}`; without `minimized` it toggles |
/// | `window.panel.iconic` | `{panel, iconic?}`; without `iconic` it toggles |
/// | `window.panel.window` | `{panel, native?}` (desktop) |
/// | `window.workspace`, `.new`, `.delete` | `{name}` |
/// | `window.workspace.reset`, `ui.dock.reset` | `{}` |
/// | `window.workspace.lock` | `{locked?}`; without `locked` it toggles |
/// | `ui.dock` | `{action}`, or `{operation, …}`, see [`DockOperation`] |
///
/// `zone` is `tab` (the default for `move` and `dock`), `center`, `left`, `right`, `top`,
/// `bottom`, `above` or `below` (a new group in a dock column, or a split in a floating window). The side
/// zones map onto the anchor's container with [`Placement::for_zone`]. `before`
/// names the tab to insert before; `null` appends. `group` moves the panel's whole group,
/// `column` its dock column. `side` is `Start` or `End`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "command", bound(serialize = "P: Serialize", deserialize = "P: Deserialize<'de>"))]
#[non_exhaustive]
pub enum DockCommand<P> {
#[serde(rename = "window.panel.layout")]
Layout { action: Action<P> },
#[serde(rename = "window.panel.move")]
Move {
panel: P,
anchor: P,
#[serde(default, skip_serializing_if = "Option::is_none")]
zone: Option<ZoneName>,
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
before: Option<Option<P>>,
#[serde(default)]
group: bool,
/// A serialized [`Placement`]; it takes precedence over `zone` and `before`.
#[serde(default, skip_serializing_if = "Option::is_none")]
placement: Option<Placement<P>>,
},
#[serde(rename = "window.panel.float")]
Float {
panel: P,
#[serde(default, skip_serializing_if = "Option::is_none")]
x: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
y: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
width: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
height: Option<f32>,
#[serde(default)]
group: bool,
#[serde(default)]
column: bool,
},
/// To `anchor`; else to a new column of the dock on `side` (a new dock when it has none);
/// else with a `left`, `right`, `top` or `bottom` zone to a new frame on that outer edge
/// ([`Placement::Edge`]); else a floating panel joins the first dock group (or frame) as a
/// tab, and a docked one stays. A hidden panel without `anchor` and `side` is shown at its
/// last location, then moved to the edge a zone names.
#[serde(rename = "window.panel.dock")]
Dock {
panel: P,
#[serde(default, skip_serializing_if = "Option::is_none")]
anchor: Option<P>,
#[serde(default, skip_serializing_if = "Option::is_none")]
zone: Option<ZoneName>,
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
before: Option<Option<P>>,
#[serde(default)]
group: bool,
#[serde(default)]
column: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
side: Option<Side>,
},
/// Select a panel; a hidden one is shown at its last location first.
#[serde(rename = "window.panel.activate")]
Activate { panel: P },
#[serde(rename = "window.panel.close")]
Close { panel: P },
#[serde(rename = "window.panel.closeGroup")]
CloseGroup { panel: P },
#[serde(rename = "window.panel.closeOthers")]
CloseOthers { panel: P },
#[serde(rename = "window.panel.minimize")]
Minimize {
panel: P,
#[serde(default, skip_serializing_if = "Option::is_none")]
minimized: Option<bool>,
},
#[serde(rename = "window.panel.iconic")]
Iconic {
panel: P,
#[serde(default, skip_serializing_if = "Option::is_none")]
iconic: Option<bool>,
},
/// Move the panel's floating window to its own OS window (`native`, the default) or back.
/// A docked panel's group floats first.
#[serde(rename = "window.panel.window")]
Window {
panel: P,
#[serde(default, skip_serializing_if = "Option::is_none")]
native: Option<bool>,
},
/// Switch to a named workspace. The application owns named workspaces.
#[serde(rename = "window.workspace")]
Workspace { name: String },
#[serde(rename = "window.workspace.new")]
NewWorkspace { name: String },
#[serde(rename = "window.workspace.delete")]
DeleteWorkspace { name: String },
/// Reset the current workspace to its saved or built-in arrangement (application-owned).
#[serde(rename = "window.workspace.reset", alias = "ui.dock.reset")]
ResetWorkspace {},
#[serde(rename = "window.workspace.lock")]
Lock {
#[serde(default, skip_serializing_if = "Option::is_none")]
locked: Option<bool>,
},
/// The `ui.dock` shape of LightCraft, PDFCraft, CADCraft and SoundCraft.
#[serde(rename = "ui.dock")]
UiDock(UiDock<P>),
}
/// `ui.dock` params: a raw action, or an operation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged, bound(serialize = "P: Serialize", deserialize = "P: Deserialize<'de>"))]
pub enum UiDock<P> {
Action { action: Action<P> },
Operation(DockOperation<P>),
}
/// A `ui.dock` operation, tagged by `operation`. `move` takes `target` (the anchor), `zone`
/// (default `center`) and `before`, which only applies to a center move.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "operation", rename_all = "camelCase", bound(serialize = "P: Serialize", deserialize = "P: Deserialize<'de>"))]
#[non_exhaustive]
pub enum DockOperation<P> {
/// Show a panel at its last location and select it.
Open {
panel: P,
},
Close {
panel: P,
},
Activate {
panel: P,
},
Float {
panel: P,
rect: [f32; 4],
},
MoveFloating {
panel: P,
rect: [f32; 4],
},
Move {
panel: P,
target: P,
#[serde(default, skip_serializing_if = "Option::is_none")]
zone: Option<ZoneName>,
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
before: Option<Option<P>>,
},
SetStackOpen {
panel: P,
open: bool,
},
/// `height` is required; `null` clears it.
ResizeStack {
panel: P,
#[serde(deserialize_with = "required")]
height: Option<f32>,
},
ResizeSplit {
path: Vec<bool>,
size: SplitSize,
},
}
/// A drop zone by name, as control protocols write it. PascalCase [`Zone`] names also parse.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ZoneName {
Tab,
#[serde(alias = "Center")]
Center,
#[serde(alias = "Left")]
Left,
#[serde(alias = "Right")]
Right,
#[serde(alias = "Top")]
Top,
#[serde(alias = "Bottom")]
Bottom,
Above,
Below,
}
/// A present field, `null` included: `Some(None)` for `null`, `None` when absent.
fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> Result<Option<T>, D::Error> {
T::deserialize(deserializer).map(Some)
}
/// A required field that may be `null`.
fn required<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> Result<T, D::Error> {
T::deserialize(deserializer)
}
/// Where a command puts what it moves relative to `anchor`: `zone` and `before`, with side
/// zones mapped by [`Placement::for_zone`]. `default` is the zone when none is given.
struct Zoned<'a, P> {
layout: &'a Layout<P>,
right_to_left: bool,
}
impl<P: Clone + Eq + Hash> Zoned<'_, P> {
fn placement(&self, anchor: &P, zone: Option<ZoneName>, before: &Option<Option<P>>, default: ZoneName) -> Result<Placement<P>, DockError> {
let side = |zone| Placement::for_zone(self.layout, anchor, zone, self.right_to_left);
Ok(match (zone.unwrap_or(default), before.clone()) {
(ZoneName::Tab | ZoneName::Center, Some(before)) => Placement::Tab { before },
(ZoneName::Tab, None) => Placement::Tab { before: None },
(ZoneName::Center, None) => Placement::Split(Zone::Center),
(_, Some(_)) => return Err(DockError::InvalidPlacement),
(ZoneName::Left, None) => side(Zone::Left)?,
(ZoneName::Right, None) => side(Zone::Right)?,
(ZoneName::Top, None) => side(Zone::Top)?,
(ZoneName::Bottom, None) => side(Zone::Bottom)?,
(ZoneName::Above, None) => Placement::Group { below: false, height: None },
(ZoneName::Below, None) => Placement::Group { below: true, height: None },
})
}
}
/// How much of the layout a command moves.
#[derive(Clone, Copy)]
enum Unit {
Panel,
Group,
Column,
}
impl Unit {
fn of(group: bool, column: bool) -> Self {
if column {
Self::Column
} else if group {
Self::Group
} else {
Self::Panel
}
}
/// The action that moves this unit of `panel` to `to`. A floating panel's column is its
/// whole window.
fn to<P: Clone + Eq + Hash>(self, layout: &Layout<P>, panel: &P, to: Target<P>) -> Result<Action<P>, DockError> {
let floating = matches!(layout.place(panel), Some(Place::Floating { .. }));
let panel = panel.clone();
Ok(match (self, to) {
(Self::Panel, Target::At { anchor, placement }) => Action::Move { panel, anchor, placement },
(Self::Panel, to) => Action::MovePanel { panel, to },
(Self::Group, to) => Action::MoveGroup { panel, to },
(Self::Column, to) if floating => Action::MoveWindow { panel, to },
(Self::Column, to) => Action::MoveColumn { panel, to },
})
}
}
impl<P> DockCommand<P> {
/// Every command id, for an application's command registry.
pub const IDS: &'static [&'static str] = &[
"window.panel.layout",
"window.panel.move",
"window.panel.float",
"window.panel.dock",
"window.panel.activate",
"window.panel.close",
"window.panel.closeGroup",
"window.panel.closeOthers",
"window.panel.minimize",
"window.panel.iconic",
"window.panel.window",
"window.workspace",
"window.workspace.new",
"window.workspace.delete",
"window.workspace.reset",
"window.workspace.lock",
"ui.dock",
"ui.dock.reset",
];
/// Named workspaces and Reset belong to the application, which handles these commands
/// before [`Self::apply`].
pub fn is_app_owned(&self) -> bool {
matches!(self, Self::Workspace { .. } | Self::NewWorkspace { .. } | Self::DeleteWorkspace { .. } | Self::ResetWorkspace {})
}
}
impl<P: Clone + Eq + Hash> DockCommand<P> {
/// The layout actions this command makes on `workspace`, to apply all or none.
/// Showing a hidden panel and Lock change more than the layout, and named workspaces
/// belong to the application: those return [`DockError::NotAllowed`]; use [`Self::apply`].
/// A panel or anchor that is not shown returns [`DockError::MissingPanel`] or
/// [`DockError::MissingTarget`].
///
/// `left`, `right`, `top` and `bottom` zones follow [`Placement::for_zone`]: beside a docked
/// anchor they make a new column or group, and `right_to_left` is the
/// [`super::DockStyle::right_to_left`] the layout is drawn with.
///
/// `space` is where `window.panel.float` floats what it moves when it gives no rect: at the
/// size a drag would float it at, inside the dock area and clear of the app's menu bar. Pass
/// [`super::float_space`] of the area that draws `workspace`. Given `x`, `y`, `width` and
/// `height` replace the matching values, and a group or column that is already a whole
/// floating window keeps its rect.
pub fn to_actions(&self, workspace: &Workspace<P>, right_to_left: bool, space: &FloatSpace<P>) -> Result<Vec<Action<P>>, DockError> {
let layout = &workspace.layout;
let zoned = Zoned { layout, right_to_left };
let shown = |panel: &P| if layout.contains(panel) { Ok(()) } else { Err(DockError::MissingPanel) };
let one = |action: Action<P>| Ok(vec![action]);
match self {
Self::Layout { action } | Self::UiDock(UiDock::Action { action }) => one(action.clone()),
Self::Move { panel, anchor, zone, before, group, placement: given } => {
shown(panel)?;
let placement = match given {
Some(placement) => placement.clone(),
None => zoned.placement(anchor, *zone, before, ZoneName::Tab)?,
};
one(Unit::of(*group, false).to(layout, panel, Target::At { anchor: anchor.clone(), placement })?)
}
Self::Float { panel, x, y, width, height, group, column } => {
shown(panel)?;
let given = [*x, *y, *width, *height];
let window = layout.floating.iter().find(|w| w.contains(panel)).map(|w| w.rect);
let rect = |base: [f32; 4]| {
let mut rect = base;
for (value, given) in rect.iter_mut().zip(given) {
*value = given.unwrap_or(*value);
}
rect
};
let any = given.iter().any(Option::is_some);
let unit = Unit::of(*group, *column);
let lone = layout.floating.iter().any(|w| w.contains(panel) && w.panels().count() == 1);
let whole =
layout.floating.iter().any(|w| w.contains(panel) && matches!(w.root, super::Node::Tabs { .. } | super::Node::Stack { .. }));
// The rect a drag would float the unit at, inside the area.
let placed = || {
let docked = layout.location(panel).ok().and_then(|l| l.group).and_then(|g| g.column).map(|c| c.width);
let fallback = egui::vec2(docked.filter(|_| !matches!(unit, Unit::Panel)).unwrap_or(DEFAULT_FLOAT.x), DEFAULT_FLOAT.y);
let size = match unit {
Unit::Panel => space.panel(panel),
Unit::Group => space.group(panel),
Unit::Column => space.column(panel),
};
rect(space.place(size.unwrap_or(fallback)))
};
match (unit, window) {
(Unit::Panel, Some(base)) if lone => one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }),
(Unit::Column, Some(base)) => one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }),
// Without given values the window keeps its rect and native state.
(Unit::Group, Some(base)) if whole => one(unit.to(layout, panel, Target::Float { rect: any.then(|| rect(base)) })?),
(Unit::Panel, _) => one(Action::Float { panel: panel.clone(), rect: placed() }),
(unit, _) => one(unit.to(layout, panel, Target::Float { rect: Some(placed()) })?),
}
}
Self::Dock { panel, anchor, zone, before, group, column, side } => {
shown(panel)?;
let unit = Unit::of(*group, *column);
if let Some(anchor) = anchor {
let placement = zoned.placement(anchor, *zone, before, ZoneName::Tab)?;
return one(unit.to(layout, panel, Target::At { anchor: anchor.clone(), placement })?);
}
if let Some(side) = side {
let to = match last_column(layout, *side) {
Some(column) => match column.into_iter().find(|p| !members(layout, panel, unit).contains(p)) {
Some(anchor) => Target::At { anchor, placement: Placement::Column { after: true } },
// What moves already is that dock's last column.
None => return Ok(Vec::new()),
},
None => Target::NewDock { side: *side },
};
let unit = match (unit, &to) {
(Unit::Panel, Target::NewDock { .. }) if group_len(layout, panel) == Some(1) => Unit::Group,
(unit, _) => unit,
};
return one(unit.to(layout, panel, to)?);
}
let members = members(layout, panel, unit);
if let Some(zone) = self.edge() {
if before.is_some() {
return Err(DockError::InvalidPlacement);
}
let anchor = layout
.panels()
.into_iter()
.find(|p| !members.contains(p) && matches!(layout.place(p), Some(Place::Dock { .. } | Place::Node(_))))
.cloned()
.ok_or(DockError::MissingTarget)?;
return one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Edge(zone) })?);
}
if !matches!(layout.place(panel), Some(Place::Floating { .. })) {
return Ok(Vec::new());
}
let panels = layout.panels();
let free = || panels.iter().copied().filter(|p| !members.contains(p));
let docked = free().find(|p| matches!(layout.place(p), Some(Place::Dock { .. })));
let framed = || free().find(|p| matches!(layout.place(p), Some(Place::Node(_))));
let anchor = docked.or_else(framed).cloned().ok_or(DockError::MissingTarget)?;
one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Tab { before: None } })?)
}
Self::Activate { panel } => {
shown(panel)?;
one(Action::Activate { panel: panel.clone() })
}
Self::Close { panel } => one(Action::Close { panel: panel.clone() }),
Self::CloseGroup { panel } => one(Action::CloseGroup { panel: panel.clone() }),
Self::CloseOthers { panel } => one(Action::CloseOthers { panel: panel.clone() }),
Self::Minimize { panel, minimized } => {
let place = layout.place(panel).ok_or(DockError::MissingPanel)?;
// An accordion entry is minimized while it is closed.
let current = match &place {
Place::Node(path) => match layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, path)) {
Some(super::Node::Stack { entries }) => entries.iter().any(|e| &e.panel == panel && !e.open),
_ => false,
},
Place::Floating { window, .. } => layout.floating.get(*window).is_some_and(|w| w.minimized),
place => layout.group(place).is_some_and(|g| g.minimized),
};
one(Action::SetMinimized { panel: panel.clone(), minimized: minimized.unwrap_or(!current) })
}
Self::Iconic { panel, iconic } => {
let location = layout.location(panel)?;
let current = location.group.and_then(|g| g.column).is_some_and(|c| c.iconic);
one(Action::SetIconic { panel: panel.clone(), iconic: iconic.unwrap_or(!current) })
}
Self::Window { panel, native } => {
shown(panel)?;
if !native.unwrap_or(true) {
return one(Action::SetNative { panel: panel.clone(), rect: None });
}
let mut actions = Vec::new();
let mut scratch = layout.clone();
if !scratch.floating.iter().any(|w| w.contains(panel)) {
let float = Action::MoveGroup { panel: panel.clone(), to: Target::Float { rect: None } };
scratch.apply(float.clone())?;
actions.push(float);
}
let rect = scratch.floating.iter().find(|w| w.contains(panel)).map(|w| w.rect).ok_or(DockError::MissingPanel)?;
actions.push(Action::SetNative { panel: panel.clone(), rect: Some(rect) });
Ok(actions)
}
Self::UiDock(UiDock::Operation(operation)) => match operation {
DockOperation::Open { panel } | DockOperation::Activate { panel } => {
shown(panel)?;
one(Action::Activate { panel: panel.clone() })
}
DockOperation::Close { panel } => one(Action::Close { panel: panel.clone() }),
DockOperation::Float { panel, rect } => one(Action::Float { panel: panel.clone(), rect: *rect }),
DockOperation::MoveFloating { panel, rect } => one(Action::MoveFloating { panel: panel.clone(), rect: *rect }),
DockOperation::Move { panel, target, zone, before } => {
let placement = zoned.placement(target, *zone, before, ZoneName::Center)?;
one(Action::Move { panel: panel.clone(), anchor: target.clone(), placement })
}
DockOperation::SetStackOpen { panel, open } => one(Action::SetStackOpen { panel: panel.clone(), open: *open }),
DockOperation::ResizeStack { panel, height } => one(Action::ResizeStack { panel: panel.clone(), height: *height }),
DockOperation::ResizeSplit { path, size } => one(Action::ResizeSplit { path: path.clone(), size: *size }),
},
Self::Workspace { .. } | Self::NewWorkspace { .. } | Self::DeleteWorkspace { .. } | Self::ResetWorkspace {} | Self::Lock { .. } => {
Err(DockError::NotAllowed)
}
}
}
/// Apply the command to `workspace`, all or nothing, and return the panels it closed so
/// the application can release their state. Showing a hidden panel restores its last
/// location, and a panel never laid out opens as [`Action::Open`] without an anchor does
/// ([`Workspace::set_visible`]); Lock sets [`Workspace::locked`]. Application-owned commands
/// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing.
/// `space` is where `window.panel.float` floats, as for [`Self::to_actions`].
pub fn apply(
&self,
workspace: &mut Workspace<P>,
right_to_left: bool,
space: &FloatSpace<P>,
permissions: impl Fn(&P) -> Permissions,
) -> Result<Vec<P>, DockError> {
let show = match self {
Self::Activate { panel }
| Self::Dock { panel, anchor: None, side: None, .. }
| Self::UiDock(UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel })) => Some(panel),
_ => None,
};
if let Some(panel) = show.filter(|p| !workspace.layout.contains(p)) {
if self.edge().is_none() {
workspace.set_visible(panel.clone(), true, None, &permissions)?;
return Ok(Vec::new());
}
// Shown, then moved to the edge, all or nothing.
let mut shown = workspace.clone();
shown.set_visible(panel.clone(), true, None, &permissions)?;
let closed = shown.apply_all(self.to_actions(&shown, right_to_left, space)?, permissions)?;
*workspace = shown;
return Ok(closed);
}
if let Self::Lock { locked } = self {
workspace.locked = locked.unwrap_or(!workspace.locked);
return Ok(Vec::new());
}
let actions = self.to_actions(workspace, right_to_left, space)?;
workspace.apply_all(actions, permissions)
}
}
impl<P> DockCommand<P> {
/// The outer edge `window.panel.dock` goes to: a side zone without an anchor or side.
fn edge(&self) -> Option<Zone> {
match self {
Self::Dock { anchor: None, side: None, zone: Some(zone), .. } => match zone {
ZoneName::Left => Some(Zone::Left),
ZoneName::Right => Some(Zone::Right),
ZoneName::Top => Some(Zone::Top),
ZoneName::Bottom => Some(Zone::Bottom),
_ => None,
},
_ => None,
}
}
}
/// The panels of the last column of the dock on `side`, or `None` without such a dock.
fn last_column<P: Clone + Eq + Hash>(layout: &Layout<P>, side: Side) -> Option<Vec<P>> {
let path = layout.dock_path(side)?;
let node = layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, &path))?;
match node {
super::Node::Dock { columns, .. } => Some(columns.last()?.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect()),
_ => None,
}
}
fn group_len<P: Clone + Eq + Hash>(layout: &Layout<P>, panel: &P) -> Option<usize> {
layout.members(panel, super::dock_model::Unit::Group).ok().map(|members| members.len())
}
/// The panels that move with `panel`.
fn members<P: Clone + Eq + Hash>(layout: &Layout<P>, panel: &P, unit: Unit) -> Vec<P> {
match unit {
Unit::Panel => vec![panel.clone()],
Unit::Group => layout.members(panel, super::dock_model::Unit::Group).unwrap_or_default(),
Unit::Column => match layout.place(panel) {
Some(Place::Dock { path, column, .. }) => match layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, &path)) {
Some(super::Node::Dock { columns, .. }) => {
columns.get(column).map(|c| c.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect()).unwrap_or_default()
}
_ => Vec::new(),
},
Some(Place::Floating { .. }) => {
layout.floating.iter().find(|w| w.contains(panel)).map(|w| w.panels().cloned().collect()).unwrap_or_default()
}
_ => vec![panel.clone()],
},
}
}
+446
View File
@@ -0,0 +1,446 @@
//! `DockCommand`: every documented shape parses and maps to atomic layout actions.
#![cfg(feature = "serde")]
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::*;
use crate::layout::{SplitAxis, SplitSize};
type P = String;
fn s(panel: &str) -> P {
panel.to_owned()
}
fn permissions(panel: &P) -> Permissions {
if panel == "canvas" { Permissions::PROTECTED } else { Permissions::default() }
}
/// A start dock with one column of `[layers, channels]` and `[history]` beside the canvas, a
/// floating `color`, and `swatches` hidden from history's group.
fn workspace() -> Workspace<P> {
let groups = vec![Group::new(vec![s("layers"), s("channels")], 0), Group::new(vec![s("history"), s("swatches")], 0)];
let dock = Node::Dock { side: Side::Start, columns: vec![Column::new(groups, 240.0)] };
let canvas = Node::Tabs { panels: vec![s("canvas")], active: 0 };
let layout = Layout {
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(canvas) }),
floating: vec![Floating::single(vec![s("color")], 0, [300.0, 200.0, 200.0, 150.0])],
};
let mut workspace = Workspace::new(layout);
workspace.apply(Action::Close { panel: s("swatches") }, permissions).unwrap();
workspace
}
/// An 800x600 dock area that has drawn nothing.
fn space() -> FloatSpace<P> {
FloatSpace::new(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)))
}
fn parse(params: Value) -> Result<DockCommand<P>, serde_json::Error> {
serde_json::from_value(params)
}
/// The actions `params` makes on a fresh workspace.
fn actions(params: Value) -> Vec<Action<P>> {
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
command.to_actions(&workspace(), false, &space()).unwrap_or_else(|e| panic!("{params}: {e}"))
}
/// Apply `params` to a fresh workspace; return it and the panels closed.
fn applied(params: Value) -> (Workspace<P>, Vec<P>) {
let mut workspace = workspace();
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
let closed = command.apply(&mut workspace, false, &space(), permissions).unwrap_or_else(|e| panic!("{params}: {e}"));
(workspace, closed)
}
/// `params` parses but applying it fails with `error` and changes nothing.
fn rejected(params: Value, error: DockError) {
let mut workspace = workspace();
let before = workspace.clone();
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
assert_eq!(command.apply(&mut workspace, false, &space(), permissions), Err(error), "{params}");
assert_eq!(workspace, before, "{params}");
}
fn tab(before: Option<&str>) -> Placement<P> {
Placement::Tab { before: before.map(s) }
}
#[test]
fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
let mut seen = BTreeSet::new();
let mut id = |params: Value| {
seen.insert(params["command"].as_str().unwrap().to_owned());
params
};
// The raw action form, in both protocols.
let raw = json!({"MoveFloating": {"panel": "color", "rect": [44, 66, 360, 450]}});
let moved = vec![Action::MoveFloating { panel: s("color"), rect: [44.0, 66.0, 360.0, 450.0] }];
assert_eq!(actions(id(json!({"command": "window.panel.layout", "action": raw}))), moved);
assert_eq!(actions(id(json!({"command": "ui.dock", "action": raw}))), moved);
// move: tab by default, before a tab or appended, zones, a serialized placement, a group.
let mv = |placement| vec![Action::Move { panel: s("history"), anchor: s("layers"), placement }];
let base = json!({"command": "window.panel.move", "panel": "history", "anchor": "layers"});
let with = |extra: Value| {
let mut params = base.clone();
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
params
};
assert_eq!(actions(id(base.clone())), mv(tab(None)));
assert_eq!(actions(with(json!({"before": "channels"}))), mv(tab(Some("channels"))));
assert_eq!(actions(with(json!({"zone": "center", "before": null}))), mv(tab(None)));
assert_eq!(actions(with(json!({"zone": "center"}))), mv(Placement::Split(Zone::Center)));
assert_eq!(actions(with(json!({"zone": "Left"}))), mv(Placement::Column { after: false }));
assert_eq!(actions(with(json!({"zone": "below"}))), mv(Placement::Group { below: true, height: None }));
assert_eq!(actions(with(json!({"placement": {"Column": {"after": true}}}))), mv(Placement::Column { after: true }));
assert_eq!(
actions(with(json!({"group": true, "zone": "above"}))),
vec![Action::MoveGroup {
panel: s("history"),
to: Target::At { anchor: s("layers"), placement: Placement::Group { below: false, height: None } }
}]
);
// float: a docked panel, a lone floating panel, a group and a column.
assert_eq!(
actions(id(json!({"command": "window.panel.float", "panel": "layers", "x": 10, "y": 20}))),
vec![Action::Float { panel: s("layers"), rect: [10.0, 20.0, 260.0, 320.0] }]
);
assert_eq!(
actions(json!({"command": "window.panel.float", "panel": "color", "width": 320})),
vec![Action::MoveFloating { panel: s("color"), rect: [300.0, 200.0, 320.0, 150.0] }]
);
assert_eq!(
actions(json!({"command": "window.panel.float", "panel": "layers", "group": true})),
vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([24.0, 24.0, 240.0, 320.0]) } }]
);
assert_eq!(
actions(json!({"command": "window.panel.float", "panel": "history", "column": true})),
vec![Action::MoveColumn { panel: s("history"), to: Target::Float { rect: Some([24.0, 24.0, 240.0, 320.0]) } }]
);
// dock: to an anchor, to a side, back into the dock, and a hidden panel to its last place.
assert_eq!(
actions(id(json!({"command": "window.panel.dock", "panel": "color", "anchor": "history", "zone": "tab"}))),
vec![Action::Move { panel: s("color"), anchor: s("history"), placement: tab(None) }]
);
assert_eq!(
actions(json!({"command": "window.panel.dock", "panel": "color", "side": "End"})),
vec![Action::MoveGroup { panel: s("color"), to: Target::NewDock { side: Side::End } }]
);
let new_column = Target::At { anchor: s("layers"), placement: Placement::Column { after: true } };
assert_eq!(
actions(json!({"command": "window.panel.dock", "panel": "color", "column": true, "side": "Start"})),
vec![Action::MoveWindow { panel: s("color"), to: new_column.clone() }]
);
assert_eq!(
actions(json!({"command": "window.panel.dock", "panel": "history", "group": true, "side": "Start"})),
vec![Action::MoveGroup { panel: s("history"), to: new_column }]
);
assert_eq!(
actions(json!({"command": "window.panel.dock", "panel": "color"})),
vec![Action::Move { panel: s("color"), anchor: s("layers"), placement: tab(None) }]
);
assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "layers"})), vec![]);
let (shown, _) = applied(json!({"command": "window.panel.dock", "panel": "swatches"}));
assert!(shown.layout.contains(&s("swatches")) && shown.hidden.is_empty());
// activate shows a hidden panel; close actions report what they closed.
let (shown, _) = applied(id(json!({"command": "window.panel.activate", "panel": "swatches"})));
assert!(shown.layout.contains(&s("swatches")));
assert_eq!(actions(json!({"command": "window.panel.activate", "panel": "channels"})), vec![Action::Activate { panel: s("channels") }]);
assert_eq!(applied(id(json!({"command": "window.panel.close", "panel": "channels"}))).1, vec![s("channels")]);
assert_eq!(applied(id(json!({"command": "window.panel.closeGroup", "panel": "channels"}))).1, vec![s("layers"), s("channels")]);
assert_eq!(applied(id(json!({"command": "window.panel.closeOthers", "panel": "channels"}))).1, vec![s("layers")]);
// minimize and iconic toggle without a value.
assert_eq!(
actions(id(json!({"command": "window.panel.minimize", "panel": "layers"}))),
vec![Action::SetMinimized { panel: s("layers"), minimized: true }]
);
assert_eq!(
actions(json!({"command": "window.panel.minimize", "panel": "layers", "minimized": false})),
vec![Action::SetMinimized { panel: s("layers"), minimized: false }]
);
assert_eq!(
actions(id(json!({"command": "window.panel.iconic", "panel": "history"}))),
vec![Action::SetIconic { panel: s("history"), iconic: true }]
);
// window: a floating window goes native at its rect; a docked group floats first.
assert_eq!(
actions(id(json!({"command": "window.panel.window", "panel": "color"}))),
vec![Action::SetNative { panel: s("color"), rect: Some([300.0, 200.0, 200.0, 150.0]) }]
);
let docked = actions(json!({"command": "window.panel.window", "panel": "layers"}));
assert!(
matches!(docked.as_slice(), [Action::MoveGroup { to: Target::Float { rect: None }, .. }, Action::SetNative { rect: Some(_), .. }]),
"{docked:?}"
);
assert_eq!(
actions(json!({"command": "window.panel.window", "panel": "color", "native": false})),
vec![Action::SetNative { panel: s("color"), rect: None }]
);
// Named workspaces and Reset belong to the application.
for params in [
json!({"command": "window.workspace", "name": "Painting"}),
json!({"command": "window.workspace.new", "name": "Mine"}),
json!({"command": "window.workspace.delete", "name": "Mine"}),
json!({"command": "window.workspace.reset"}),
json!({"command": "ui.dock.reset"}),
] {
let command = parse(id(params.clone())).unwrap();
assert!(command.is_app_owned(), "{params}");
rejected(params, DockError::NotAllowed);
}
assert_eq!(parse(json!({"command": "ui.dock.reset"})).unwrap(), DockCommand::ResetWorkspace {});
// Lock toggles or sets.
assert!(applied(id(json!({"command": "window.workspace.lock"}))).0.locked);
assert!(!applied(json!({"command": "window.workspace.lock", "locked": false})).0.locked);
// The small apps' ui.dock operations.
let op = |params: Value| {
let mut params = params;
params["command"] = json!("ui.dock");
params
};
let (shown, _) = applied(op(json!({"operation": "open", "panel": "swatches"})));
assert!(shown.layout.contains(&s("swatches")));
assert_eq!(actions(op(json!({"operation": "close", "panel": "layers"}))), vec![Action::Close { panel: s("layers") }]);
assert_eq!(actions(op(json!({"operation": "activate", "panel": "channels"}))), vec![Action::Activate { panel: s("channels") }]);
assert_eq!(
actions(op(json!({"operation": "float", "panel": "layers", "rect": [1, 2, 300, 200]}))),
vec![Action::Float { panel: s("layers"), rect: [1.0, 2.0, 300.0, 200.0] }]
);
assert_eq!(
actions(op(json!({"operation": "moveFloating", "panel": "color", "rect": [1, 2, 300, 200]}))),
vec![Action::MoveFloating { panel: s("color"), rect: [1.0, 2.0, 300.0, 200.0] }]
);
let to_layers = |placement| vec![Action::Move { panel: s("color"), anchor: s("layers"), placement }];
assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers"}))), to_layers(Placement::Split(Zone::Center)));
assert_eq!(
actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "left"}))),
to_layers(Placement::Column { after: false })
);
assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": null}))), to_layers(tab(None)));
assert_eq!(
actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": "channels"}))),
to_layers(tab(Some("channels")))
);
assert_eq!(
actions(op(json!({"operation": "setStackOpen", "panel": "layers", "open": false}))),
vec![Action::SetStackOpen { panel: s("layers"), open: false }]
);
assert_eq!(
actions(op(json!({"operation": "resizeStack", "panel": "layers", "height": null}))),
vec![Action::ResizeStack { panel: s("layers"), height: None }]
);
assert_eq!(
actions(op(json!({"operation": "resizeStack", "panel": "layers", "height": 120}))),
vec![Action::ResizeStack { panel: s("layers"), height: Some(120.0) }]
);
assert_eq!(
actions(op(json!({"operation": "resizeSplit", "path": [], "size": {"Ratio": 0.35}}))),
vec![Action::ResizeSplit { path: vec![], size: SplitSize::Ratio(0.35) }]
);
let ids: BTreeSet<String> = DockCommand::<P>::IDS.iter().map(|id| (*id).to_owned()).collect();
assert_eq!(seen, ids, "one case per id");
// Bad params: parse errors, or a DockError with nothing changed.
for params in [
json!({"command": "window.panel.unknown", "panel": "layers"}),
json!({"command": "window.panel.move", "panel": "layers"}),
json!({"command": "window.panel.move", "panel": "layers", "anchor": "history", "zone": "middle"}),
json!({"command": "window.panel.minimize", "panel": "layers", "minimized": "yes"}),
op(json!({"operation": "spin", "panel": "layers"})),
op(json!({"operation": "float", "panel": "layers", "rect": [1, 2, 3]})),
op(json!({"operation": "resizeStack", "panel": "layers"})),
op(json!({"operation": "setStackOpen", "panel": "layers"})),
op(json!({"operation": "move", "panel": "layers"})),
] {
assert!(parse(params.clone()).is_err(), "{params}");
}
rejected(json!({"command": "window.panel.move", "panel": "layers", "anchor": "nowhere"}), DockError::MissingTarget);
rejected(json!({"command": "window.panel.move", "panel": "nowhere", "anchor": "layers"}), DockError::MissingPanel);
rejected(
json!({"command": "window.panel.move", "panel": "layers", "anchor": "history", "zone": "left", "before": null}),
DockError::InvalidPlacement,
);
rejected(json!({"command": "window.panel.float", "panel": "layers", "width": 1e30}), DockError::InvalidGeometry);
rejected(json!({"command": "window.panel.move", "panel": "canvas", "anchor": "layers"}), DockError::NotAllowed);
rejected(op(json!({"operation": "moveFloating", "panel": "color", "rect": [0, 0, -5, 10]})), DockError::InvalidGeometry);
rejected(
op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "top", "before": "channels"})),
DockError::InvalidPlacement,
);
rejected(op(json!({"operation": "resizeSplit", "path": [true, true, true], "size": {"Ratio": 0.5}})), DockError::MissingTarget);
// A docked group cannot float into a native window when locked.
let mut locked = workspace();
locked.locked = true;
let before = locked.clone();
assert!(parse(json!({"command": "window.panel.window", "panel": "layers"})).unwrap().apply(&mut locked, false, &space(), permissions).is_err());
assert_eq!(locked, before);
}
#[test]
fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_direction() {
let command =
|zone: &str, anchor: &str| parse(json!({"command": "window.panel.move", "panel": "history", "anchor": anchor, "zone": zone})).unwrap();
let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl, &space()) {
Ok(actions) => match actions.as_slice() {
[Action::Move { placement, .. }] => Ok(placement.clone()),
other => panic!("{other:?}"),
},
Err(error) => Err(error),
};
// A start dock is on the left edge left to right, on the right edge right to left.
assert_eq!(placed("right", "layers", false), Ok(Placement::Column { after: true }));
assert_eq!(placed("right", "layers", true), Ok(Placement::Column { after: false }));
assert_eq!(placed("left", "layers", true), Ok(Placement::Column { after: true }));
assert_eq!(placed("top", "layers", false), Ok(Placement::Group { below: false, height: None }));
// A floating window is a tree like the docked layout's.
assert_eq!(placed("bottom", "color", false), Ok(Placement::Split(Zone::Bottom)));
assert_eq!(placed("left", "color", false), Ok(Placement::Split(Zone::Left)));
assert_eq!(placed("left", "canvas", true), Ok(Placement::Split(Zone::Left)));
let (workspace, _) = applied(json!({"command": "window.panel.move", "panel": "history", "anchor": "layers", "zone": "right"}));
let Some(Node::Split { first, .. }) = &workspace.layout.root else { panic!() };
let Node::Dock { columns, .. } = &**first else { panic!() };
assert_eq!(columns.len(), 2);
assert_eq!(columns[1].groups[0].panels, vec![s("history")]);
}
/// `layers` prefers 200x150 when it floats.
struct Sized;
impl DockContent<P> for Sized {
fn body(&mut self, _: &mut egui::Ui, _: &P) {}
fn permissions(&self, panel: &P) -> Permissions {
permissions(panel)
}
fn floating_size(&self, panel: &P) -> Option<egui::Vec2> {
(panel == "layers").then_some(egui::vec2(200.0, 150.0))
}
}
#[test]
fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() {
// The area starts under a 30 pt menu bar.
let area = egui::Rect::from_min_max(egui::pos2(0.0, 30.0), egui::pos2(800.0, 600.0));
let d = testing::DockDriver::new(workspace().layout, Sized).with_area(area);
let space = float_space::<P>(&d.ctx, d.id).expect("the area records its float space");
assert_eq!(space.area, area);
let float = |params: Value| parse(params).unwrap().to_actions(&workspace(), false, &space).unwrap();
let panel = float(json!({"command": "window.panel.float", "panel": "layers"}));
// The floating_size width, and an empty body's least fitted height under its 150 points.
assert_eq!(panel, vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }], "floating_size, inset in the area");
let [Action::MoveGroup { to: Target::Float { rect: Some([x, y, width, height]) }, .. }] =
float(json!({"command": "window.panel.float", "panel": "history", "group": true}))[..]
else {
panic!()
};
assert_eq!([x, y], [24.0, 54.0]);
assert!(width > 100.0 && width <= 240.0 && height <= 320.0, "the docked group's size, capped: {width}x{height}");
// Given values stay exactly; a lone floating window keeps its rect.
let given = float(json!({"command": "window.panel.float", "panel": "layers", "x": 5, "y": 6, "width": 100, "height": 90}));
assert_eq!(given, vec![Action::Float { panel: s("layers"), rect: [5.0, 6.0, 100.0, 90.0] }]);
let color = float(json!({"command": "window.panel.float", "panel": "color", "group": true}));
assert_eq!(color, vec![Action::MoveGroup { panel: s("color"), to: Target::Float { rect: None } }]);
// Without recorded sizes a unit floats at the layout's size, shrunk into a small area.
let small = FloatSpace::new(egui::Rect::from_min_size(egui::pos2(0.0, 30.0), egui::vec2(200.0, 200.0)));
let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions(&workspace(), false, &small);
assert_eq!(applied, Ok(vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([0.0, 30.0, 200.0, 200.0]) } }]));
// A host seeds the size of a panel the area never drew; a recorded size still wins.
let seeded = FloatSpace::new(area).with_panel_size(s("layers"), egui::vec2(180.0, 240.0));
let applied = parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions(&workspace(), false, &seeded);
assert_eq!(applied, Ok(vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 180.0, 240.0] }]));
let recorded = space.clone().with_panel_size(s("layers"), egui::vec2(180.0, 240.0));
assert_eq!(float_with(&recorded, "layers"), vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }]);
}
fn float_with(space: &FloatSpace<P>, panel: &str) -> Vec<Action<P>> {
parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions(&workspace(), false, space).unwrap()
}
#[test]
fn dock_to_a_side_without_a_dock_takes_one_panel_out_of_a_shared_group() {
let params = json!({"command": "window.panel.dock", "panel": "layers", "side": "End"});
assert_eq!(actions(params.clone()), vec![Action::MovePanel { panel: s("layers"), to: Target::NewDock { side: Side::End } }]);
let (workspace, _) = applied(params);
let side = |panel: &str| workspace.layout.location(&s(panel)).unwrap().group.and_then(|g| g.column).map(|c| c.side);
assert_eq!(side("layers"), Some(Side::End));
assert_eq!(side("channels"), Some(Side::Start));
}
#[test]
fn minimize_without_a_value_toggles_an_accordion_entry_both_ways() {
let entry = |panel: &str| StackEntry { panel: s(panel), open: true, height: None };
let mut workspace = Workspace::new(Layout { root: Some(Node::Stack { entries: vec![entry("layers"), entry("history")] }), floating: Vec::new() });
let toggle = parse(json!({"command": "window.panel.minimize", "panel": "layers"})).unwrap();
toggle.apply(&mut workspace, false, &space(), permissions).unwrap();
assert_eq!(workspace.layout.panel_state(&s("layers")), PanelState::Open);
toggle.apply(&mut workspace, false, &space(), permissions).unwrap();
assert_eq!(workspace.layout.panel_state(&s("layers")), PanelState::Front);
}
#[test]
fn dock_with_a_side_zone_and_no_anchor_makes_an_outer_edge_frame() {
let edge = |panel: &str, zone| Action::Move { panel: s(panel), anchor: s("layers"), placement: Placement::Edge(zone) };
assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "color", "zone": "left"})), vec![edge("color", Zone::Left)]);
assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "history", "zone": "bottom"})), vec![edge("history", Zone::Bottom)]);
let (moved, _) = applied(json!({"command": "window.panel.dock", "panel": "color", "zone": "right"}));
assert!(matches!(moved.layout.place(&s("color")), Some(crate::docking::dock_model::Place::Node(_))), "{:?}", moved.layout);
let (shown, _) = applied(json!({"command": "window.panel.dock", "panel": "swatches", "zone": "top"}));
assert!(
matches!(shown.layout.place(&s("swatches")), Some(crate::docking::dock_model::Place::Node(_))) && shown.hidden.is_empty(),
"{:?}",
shown.layout
);
rejected(json!({"command": "window.panel.dock", "panel": "color", "zone": "left", "before": null}), DockError::InvalidPlacement);
}
#[test]
fn dock_to_the_side_a_panel_already_leads_the_last_column_of() {
// Layers leads the dock's only column: it moves out to a new column after it.
let (workspace, _) = applied(json!({"command": "window.panel.dock", "panel": "layers", "side": "Start"}));
assert!(matches!(workspace.layout.place(&s("layers")), Some(dock_model::Place::Dock { column: 1, .. })));
assert!(matches!(workspace.layout.place(&s("channels")), Some(dock_model::Place::Dock { column: 0, .. })));
// The whole column already is the last one: nothing to do.
assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "layers", "column": true, "side": "Start"})), vec![]);
}
#[test]
fn showing_a_panel_never_laid_out_opens_it_in_the_first_dock_group_or_at_an_edge() {
// "brushes" is in neither the layout nor `hidden`.
for params in [
json!({"command": "window.panel.activate", "panel": "brushes"}),
json!({"command": "window.panel.dock", "panel": "brushes"}),
json!({"command": "ui.dock", "operation": "open", "panel": "brushes"}),
] {
let (shown, _) = applied(params.clone());
assert!(
matches!(shown.layout.place(&s("brushes")), Some(dock_model::Place::Dock { column: 0, group: 0, .. })),
"{params}: {:?}",
shown.layout
);
assert_eq!(shown.layout.panel_state(&s("brushes")), PanelState::Front, "{params}");
}
// No dock, and the only frame is a protected canvas: a new frame at the edge, not a tab.
let mut workspace = Workspace::new(Layout { root: Some(Node::Tabs { panels: vec![s("canvas")], active: 0 }), floating: Vec::new() });
let command = parse(json!({"command": "window.panel.activate", "panel": "brushes"})).unwrap();
command.apply(&mut workspace, false, &space(), permissions).unwrap();
assert!(matches!(workspace.layout.place(&s("brushes")), Some(dock_model::Place::Node(_))), "{:?}", workspace.layout);
assert_ne!(workspace.layout.place(&s("brushes")), workspace.layout.place(&s("canvas")));
}
+75
View File
@@ -0,0 +1,75 @@
//! Which of the application's windows lies under a point on the desktop (feature
//! `native-windows`). Points and rects are in desktop logical points.
use egui::{Pos2, Rect};
/// Where a desktop point falls.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Over {
/// The floating native window with this [`super::Floating::id`], at this point in its own
/// coordinates.
Window(u64, Pos2),
/// The main window, at this point in its coordinates.
Main(Pos2),
/// None of the application's windows.
Desktop,
}
/// The window under `point`: the first of `windows` (floating native windows by id and desktop
/// rect, front first) that holds it, where the first `front` of them lie in front of `main`
/// (the main window's content) and the rest behind it. The window `dragged` follows the pointer
/// by its bar and is never its own target.
pub(super) fn window_at(point: Pos2, windows: &[(u64, Rect)], front: usize, main: Option<Rect>, dragged: Option<u64>) -> Over {
if !point.is_finite() {
return Over::Desktop;
}
let (before, behind) = windows.split_at(front.min(windows.len()));
let hit = |windows: &[(u64, Rect)]| {
windows.iter().find(|(id, rect)| Some(*id) != dragged && rect.contains(point)).map(|(id, rect)| Over::Window(*id, point - rect.min.to_vec2()))
};
hit(before)
.or_else(|| main.filter(|main| main.contains(point)).map(|main| Over::Main(point - main.min.to_vec2())))
.or_else(|| hit(behind))
.unwrap_or(Over::Desktop)
}
#[cfg(test)]
mod tests {
use egui::{pos2, vec2};
use super::*;
fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
Rect::from_min_size(pos2(x, y), vec2(w, h))
}
#[test]
fn the_front_window_wins_then_the_main_window_then_the_desktop() {
let main = Some(rect(100.0, 50.0, 800.0, 600.0));
// Window 2 lies over window 1, and both over the main window.
let windows = [(2, rect(300.0, 100.0, 200.0, 200.0)), (1, rect(250.0, 80.0, 200.0, 200.0))];
assert_eq!(window_at(pos2(350.0, 150.0), &windows, 2, main, None), Over::Window(2, pos2(50.0, 50.0)));
assert_eq!(window_at(pos2(260.0, 90.0), &windows, 2, main, None), Over::Window(1, pos2(10.0, 10.0)));
assert_eq!(window_at(pos2(120.0, 60.0), &windows, 2, main, None), Over::Main(pos2(20.0, 10.0)));
assert_eq!(window_at(pos2(20.0, 20.0), &windows, 2, main, None), Over::Desktop);
// A floating window outside the main window.
let windows = [(3, rect(1000.0, 0.0, 100.0, 100.0))];
assert_eq!(window_at(pos2(1050.0, 40.0), &windows, 2, main, None), Over::Window(3, pos2(50.0, 40.0)));
// Unknown main window, a point that is not one.
assert_eq!(window_at(pos2(120.0, 60.0), &[], 0, None, None), Over::Desktop);
assert_eq!(window_at(pos2(f32::NAN, 60.0), &windows, 2, main, None), Over::Desktop);
// A normal window behind the main window is reached only past the main window's edge.
let windows = [(4, rect(50.0, 40.0, 200.0, 200.0))];
assert_eq!(window_at(pos2(120.0, 60.0), &windows, 0, main, None), Over::Main(pos2(20.0, 10.0)));
assert_eq!(window_at(pos2(60.0, 45.0), &windows, 0, main, None), Over::Window(4, pos2(10.0, 5.0)));
}
#[test]
fn the_dragged_window_is_looked_through() {
let main = Some(rect(0.0, 0.0, 800.0, 600.0));
let windows = [(2, rect(300.0, 100.0, 200.0, 200.0)), (1, rect(250.0, 80.0, 200.0, 200.0))];
assert_eq!(window_at(pos2(350.0, 150.0), &windows, 2, main, Some(2)), Over::Window(1, pos2(100.0, 70.0)));
assert_eq!(window_at(pos2(350.0, 150.0), &windows, 2, main, Some(1)), Over::Window(2, pos2(50.0, 50.0)));
assert_eq!(window_at(pos2(480.0, 280.0), &windows, 2, main, Some(2)), Over::Main(pos2(480.0, 280.0)));
}
}
+857
View File
@@ -0,0 +1,857 @@
//! Docks, columns, groups and floating windows: their types and the structural edits behind
//! group, column and window moves.
//!
//! A group, column or floating window is named by a panel it contains. Edits run on a proposed
//! copy of the layout, so a helper that fails part-way leaves nothing behind.
use std::{collections::HashMap, hash::Hash};
use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, MAX_PANELS, Node, Placement, Zone, valid_height, valid_rect, valid_width};
use crate::layout::{SplitAxis, SplitSize};
/// Width of a new column when nothing better is known.
pub(super) const DEFAULT_COLUMN_WIDTH: f32 = 240.0;
/// Rect of a new floating window when the caller gives none and the source has no size.
const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0];
/// The window edge a [`Node::Dock`] sits on, in reading order so right-to-left interfaces
/// mirror. `Start` is the edge of a horizontal split's first child (left in a left-to-right
/// layout); the renderer maps it to a physical edge from the layout direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Side {
Start,
End,
}
/// Tabs that move, minimize and size together in a dock column.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(bound(deserialize = "P: serde::Deserialize<'de>")))]
pub struct Group<P> {
pub panels: Vec<P>,
pub active: usize,
/// Only the tab strip shows. `height` is kept for when the group expands again.
#[cfg_attr(feature = "serde", serde(default))]
pub minimized: bool,
/// Preferred height in points, set when the user resizes the group; `None` shares the free
/// height of the column or stack, or in a dock column takes
/// [`DockContent::dock_heights`](crate::docking::DockContent::dock_heights).
#[cfg_attr(feature = "serde", serde(default))]
pub height: Option<f32>,
}
impl<P> Group<P> {
pub fn new(panels: Vec<P>, active: usize) -> Self {
Self { panels, active, minimized: false, height: None }
}
pub fn active_panel(&self) -> Option<&P> {
self.panels.get(self.active)
}
}
/// One column of a dock: groups from top to bottom.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(bound(deserialize = "P: serde::Deserialize<'de>")))]
pub struct Column<P> {
pub groups: Vec<Group<P>>,
/// Expanded width in points.
pub width: f32,
/// Collapsed to one icon per panel.
#[cfg_attr(feature = "serde", serde(default))]
pub iconic: bool,
/// Width while iconic; labels show beside the icons when it is wide enough.
#[cfg_attr(feature = "serde", serde(default))]
pub iconic_width: Option<f32>,
}
impl<P> Column<P> {
pub fn new(groups: Vec<Group<P>>, width: f32) -> Self {
Self { groups, width, iconic: false, iconic_width: None }
}
}
/// Where a group, column or floating window goes.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Target<P> {
/// Relative to the panel `anchor`, which must not be part of what moves.
At { anchor: P, placement: Placement<P> },
/// A new dock on an edge that has none (or an empty one). The root becomes the other side
/// of a horizontal split.
NewDock { side: Side },
/// A new floating window. `None` keeps the source's size at a default position.
Float { rect: Option<[f32; 4]> },
}
impl<P: Clone + Eq + Hash> Placement<P> {
/// The placement for a drop zone on `anchor`'s group, in the vocabulary its container
/// accepts. `zone` is physical: `Left` is the left half on screen in either direction.
///
/// | Anchor in | `Center` | `Top`, `Bottom` | `Left`, `Right` |
/// |---|---|---|---|
/// | the split tree or a floating window | [`Placement::Split`] | `Split` | `Split` |
/// | a dock column | `Split(Center)` (join as tabs) | [`Placement::Group`] above or below | [`Placement::Column`] on that side |
///
/// A dock's `Column { after }` counts from its window edge, which depends on the dock's
/// side and `right_to_left` (see [`Side`]); this picks `after` so the new column lands on
/// the zone's side of the anchor's column.
pub fn for_zone(layout: &Layout<P>, anchor: &P, zone: Zone, right_to_left: bool) -> Result<Self, DockError> {
let place = layout.place(anchor).ok_or(DockError::MissingTarget)?;
Ok(match (place, zone) {
(Place::Node(_) | Place::Floating { .. }, zone) | (_, zone @ Zone::Center) => Placement::Split(zone),
(Place::Dock { .. }, Zone::Top | Zone::Bottom) => Placement::Group { below: zone == Zone::Bottom, height: None },
(Place::Dock { path, .. }, _) => {
let side = match layout.root.as_ref().and_then(|root| node_at(root, &path)) {
Some(Node::Dock { side, .. }) => *side,
_ => return Err(DockError::MissingTarget),
};
// The physical edge of the dock: Start is left in a left-to-right layout.
let left_edge = (side == Side::Start) != right_to_left;
// `after` is away from the window edge: rightwards for a left dock.
Placement::Column { after: (zone == Zone::Right) == left_edge }
}
})
}
}
/// What the Window menu shows for a panel. The model does not track the z-order of floating
/// windows, so `Front` means "showing": the active tab of an expanded group in an expanded column.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PanelState {
Hidden,
Open,
Front,
}
/// The group a closed panel was in, when that group was in a dock.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GroupLocation {
pub minimized: bool,
pub height: Option<f32>,
/// The column, when the group was docked.
pub column: Option<ColumnLocation>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ColumnLocation {
pub side: Side,
pub width: f32,
pub iconic: bool,
pub iconic_width: Option<f32>,
}
/// Where a panel is. Paths lead from the root: false is a split's first child, true its second.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Place {
/// A `Tabs` or `Stack` node.
Node(Vec<bool>),
Dock {
path: Vec<bool>,
column: usize,
group: usize,
},
/// A `Tabs` or `Stack` node of a floating window's tree.
Floating {
window: usize,
path: Vec<bool>,
},
}
impl Place {
/// The tree (`None` for the docked layout, else a floating window) and the path of a
/// `Tabs` or `Stack` node.
pub(super) fn tree_path(&self) -> Option<(Option<usize>, &[bool])> {
match self {
Place::Node(path) => Some((None, path)),
Place::Floating { window, path } => Some((Some(*window), path)),
Place::Dock { .. } => None,
}
}
}
/// Which container a whole-container action names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Unit {
Group,
Column,
Window,
}
/// Groups in transit, with the size and state of the container they came from.
pub(super) struct Moving<P> {
pub groups: Vec<Group<P>>,
/// A floating window's whole tree, whose leaves are `groups`. A split or a new window
/// takes the tree; docks take the groups.
pub node: Option<Node<P>>,
pub minimized: bool,
pub width: Option<f32>,
pub iconic: bool,
pub iconic_width: Option<f32>,
pub rect: Option<[f32; 4]>,
pub native: Option<[f32; 4]>,
/// The id of a whole floating window in transit, which a new window keeps.
pub id: Option<u64>,
}
impl<P> Moving<P> {
pub fn group(group: Group<P>) -> Self {
Self { groups: vec![group], node: None, minimized: false, width: None, iconic: false, iconic_width: None, rect: None, native: None, id: None }
}
fn into_column(self, width: f32) -> Column<P> {
Column { groups: self.groups, width: self.width.unwrap_or(width), iconic: self.iconic, iconic_width: self.iconic_width }
}
}
pub(super) fn node_at<'a, P>(mut node: &'a Node<P>, path: &[bool]) -> Option<&'a Node<P>> {
for second_child in path {
let Node::Split { first, second, .. } = node else { return None };
node = if *second_child { second } else { first };
}
Some(node)
}
pub(super) fn node_at_mut<'a, P>(mut node: &'a mut Node<P>, path: &[bool]) -> Option<&'a mut Node<P>> {
for second_child in path {
let Node::Split { first, second, .. } = node else { return None };
node = if *second_child { second } else { first };
}
Some(node)
}
/// Every `Tabs`, `Stack` and `Dock` node of a tree with its path, first to last. Stops at
/// [`MAX_NODES`] for an invalid tree.
pub(super) fn leaves<P>(root: &Node<P>) -> Vec<(&Node<P>, Vec<bool>)> {
let mut out = Vec::new();
let mut visit = vec![(root, Vec::new())];
let mut visited = 0usize;
while let Some((node, path)) = visit.pop() {
visited = visited.saturating_add(1);
if visited > MAX_NODES {
break;
}
match node {
Node::Split { first, second, .. } => {
let mut second_path = path.clone();
second_path.push(true);
let mut first_path = path;
first_path.push(false);
visit.push((second, second_path));
visit.push((first, first_path));
}
leaf => out.push((leaf, path)),
}
}
out
}
/// The panels of a tree in order, without recursion; at most [`MAX_PANELS`].
pub(super) fn tree_panels<P>(root: &Node<P>) -> Vec<&P> {
let mut panels = Vec::new();
for (leaf, _) in leaves(root) {
let room = MAX_PANELS.saturating_sub(panels.len());
match leaf {
Node::Tabs { panels: group, .. } => panels.extend(group.iter().take(room)),
Node::Stack { entries } => panels.extend(entries.iter().map(|e| &e.panel).take(room)),
Node::Dock { columns, .. } => panels.extend(columns.iter().flat_map(|c| &c.groups).flat_map(|g| &g.panels).take(room)),
Node::Split { .. } => {}
}
}
panels
}
/// The shown panel of a tree's first group.
#[cfg(feature = "native-windows")]
pub(super) fn first_active<P>(root: &Node<P>) -> Option<&P> {
match leaves(root).into_iter().next()?.0 {
Node::Tabs { panels, active } => panels.get(*active),
Node::Stack { entries } => entries.first().map(|e| &e.panel),
Node::Split { .. } | Node::Dock { .. } => None,
}
}
/// The groups of a tree, first to last: what a dock column takes from a floating window.
pub(super) fn tree_groups<P: Clone>(root: &Node<P>) -> Vec<Group<P>> {
let mut groups = Vec::new();
for (leaf, _) in leaves(root) {
match leaf {
Node::Tabs { panels, active } => groups.push(Group::new(panels.clone(), *active)),
Node::Stack { entries } => groups.push(Group::new(entries.iter().map(|e| e.panel.clone()).collect(), 0)),
Node::Dock { columns, .. } => groups.extend(columns.iter().flat_map(|c| c.groups.iter().cloned())),
Node::Split { .. } => {}
}
}
groups
}
/// Groups stacked top to bottom as a tree: each group over a vertical split of the ones below
/// it, sized by their heights, with an equal share where a group has none.
pub(super) fn stack_tree<P>(groups: Vec<Group<P>>) -> Option<Node<P>> {
let height = |g: &Group<P>| g.height.filter(|h| h.is_finite() && *h > 0.0);
let known: Vec<f32> = groups.iter().filter_map(height).collect();
let share = if known.is_empty() { 1.0 } else { known.iter().sum::<f32>() / known.len() as f32 };
let weights: Vec<f32> = groups.iter().map(|g| height(g).unwrap_or(share)).collect();
let mut node = None;
let mut below = 0.0;
for (group, weight) in groups.into_iter().zip(weights).rev() {
let tabs = Node::Tabs { panels: group.panels, active: group.active };
node = Some(match node {
None => tabs,
Some(rest) => {
let ratio = (weight / (weight + below)).clamp(0.0, 1.0);
Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(ratio), first: Box::new(tabs), second: Box::new(rest) }
}
});
below += weight;
}
node
}
/// Put `new` on the `zone` side of the frame at `path`: the frame splits into two halves and
/// every other frame keeps its size.
pub(super) fn split_frame<P>(root: &mut Node<P>, path: &[bool], new: Node<P>, zone: Zone) -> bool {
let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
let new_first = matches!(zone, Zone::Top | Zone::Left);
let Some(node) = node_at_mut(root, path) else { return false };
let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 });
let (first, second) = if new_first { (new, old) } else { (old, new) };
*node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) };
true
}
/// Insert `new` at `index` and select the member at `index + selected`.
fn splice<P>(panels: &mut Vec<P>, active: &mut usize, index: usize, new: Vec<P>, selected: usize) {
let index = index.min(panels.len());
let count = new.len();
panels.splice(index..index, new);
*active = index + selected.min(count.saturating_sub(1));
}
impl<P: Clone + Eq + Hash> Layout<P> {
pub(super) fn place(&self, panel: &P) -> Option<Place> {
for (w, window) in self.floating.iter().enumerate() {
for (leaf, path) in leaves(&window.root) {
if leaf_holds(leaf, panel) {
return Some(Place::Floating { window: w, path });
}
}
}
for (leaf, path) in self.root.as_ref().map(leaves).unwrap_or_default() {
match leaf {
Node::Dock { columns, .. } => {
for (c, column) in columns.iter().enumerate() {
if let Some(g) = column.groups.iter().position(|group| group.panels.contains(panel)) {
return Some(Place::Dock { path, column: c, group: g });
}
}
}
leaf if leaf_holds(leaf, panel) => return Some(Place::Node(path)),
_ => {}
}
}
None
}
/// [`Self::place`] of every panel, from one traversal: the renderer looks up many panels a
/// frame.
pub(super) fn places(&self) -> HashMap<P, Place> {
fn note<P: Clone + Eq + Hash>(places: &mut HashMap<P, Place>, leaf: &Node<P>, place: &dyn Fn() -> Place) {
match leaf {
Node::Tabs { panels, .. } => {
for panel in panels {
places.entry(panel.clone()).or_insert_with(place);
}
}
Node::Stack { entries } => {
for entry in entries {
places.entry(entry.panel.clone()).or_insert_with(place);
}
}
_ => {}
}
}
let mut places = HashMap::new();
for (w, window) in self.floating.iter().enumerate() {
for (leaf, path) in leaves(&window.root) {
note(&mut places, leaf, &|| Place::Floating { window: w, path: path.clone() });
}
}
for (leaf, path) in self.root.as_ref().map(leaves).unwrap_or_default() {
if let Node::Dock { columns, .. } = leaf {
for (c, column) in columns.iter().enumerate() {
for (g, group) in column.groups.iter().enumerate() {
for panel in &group.panels {
places.entry(panel.clone()).or_insert_with(|| Place::Dock { path: path.clone(), column: c, group: g });
}
}
}
} else {
note(&mut places, leaf, &|| Place::Node(path.clone()));
}
}
places
}
/// The docked layout (`None`) or a floating window's tree.
pub(super) fn tree(&self, window: Option<usize>) -> Option<&Node<P>> {
match window {
None => self.root.as_ref(),
Some(window) => self.floating.get(window).map(|w| &w.root),
}
}
pub(super) fn tree_mut(&mut self, window: Option<usize>) -> Option<&mut Node<P>> {
match window {
None => self.root.as_mut(),
Some(window) => self.floating.get_mut(window).map(|w| &mut w.root),
}
}
/// The docked layout's tree, then each floating window's.
pub(super) fn trees_mut(&mut self) -> impl Iterator<Item = &mut Node<P>> {
self.root.iter_mut().chain(self.floating.iter_mut().map(|w| &mut w.root))
}
/// The `Tabs` or `Stack` node at a tree place.
pub(super) fn node_of(&self, place: &Place) -> Option<&Node<P>> {
let (window, path) = place.tree_path()?;
node_at(self.tree(window)?, path)
}
fn node_of_mut(&mut self, place: &Place) -> Option<&mut Node<P>> {
let (window, path) = place.tree_path()?;
node_at_mut(self.tree_mut(window)?, path)
}
pub(super) fn dock_path(&self, side: Side) -> Option<Vec<bool>> {
let mut visit: Vec<(&Node<P>, Vec<bool>)> = self.root.iter().map(|node| (node, Vec::new())).collect();
let mut visited = 0usize;
while let Some((node, path)) = visit.pop() {
visited = visited.saturating_add(1);
if visited > MAX_NODES {
return None;
}
match node {
Node::Split { first, second, .. } => {
let mut second_path = path.clone();
second_path.push(true);
let mut first_path = path;
first_path.push(false);
visit.push((second, second_path));
visit.push((first, first_path));
}
Node::Dock { side: s, .. } if *s == side => return Some(path),
_ => {}
}
}
None
}
/// The node an edge split wraps: the root, or the part between the docks.
pub(in crate::docking) fn edge_path(&self) -> Vec<bool> {
let mut path = Vec::new();
let mut node = self.root.as_ref();
while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node {
if path.len() > MAX_DEPTH {
break;
}
let second_side = match (&**first, &**second) {
(Node::Dock { .. }, _) => true,
(_, Node::Dock { .. }) => false,
_ => break,
};
path.push(second_side);
node = Some(if second_side { second } else { first });
}
path
}
pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec<Column<P>>> {
match self.root.as_mut().and_then(|root| node_at_mut(root, path)) {
Some(Node::Dock { columns, .. }) => Some(columns),
_ => None,
}
}
pub(super) fn column(&self, place: &Place) -> Option<&Column<P>> {
let Place::Dock { path, column, .. } = place else { return None };
match self.root.as_ref().and_then(|root| node_at(root, path)) {
Some(Node::Dock { columns, .. }) => columns.get(*column),
_ => None,
}
}
pub(super) fn column_mut(&mut self, place: &Place) -> Option<&mut Column<P>> {
let Place::Dock { path, column, .. } = place else { return None };
self.columns_mut(path)?.get_mut(*column)
}
/// The dock group at `place`; `None` for a `Tabs` or `Stack` node.
pub(super) fn group(&self, place: &Place) -> Option<&Group<P>> {
match place {
Place::Dock { group, .. } => self.column(place)?.groups.get(*group),
Place::Node(_) | Place::Floating { .. } => None,
}
}
pub(super) fn group_mut(&mut self, place: &Place) -> Option<&mut Group<P>> {
match place {
Place::Dock { group, .. } => self.column_mut(place)?.groups.get_mut(*group),
Place::Node(_) | Place::Floating { .. } => None,
}
}
/// Remove the node at `path` of the docked layout; its parent split collapses into the
/// sibling.
pub(super) fn take_node(&mut self, path: &[bool]) -> Option<Node<P>> {
self.take_node_in(None, path)
}
/// [`Self::take_node`] in the docked layout (`None`) or a floating window, which closes
/// when its root goes.
fn take_node_in(&mut self, window: Option<usize>, path: &[bool]) -> Option<Node<P>> {
let Some((second_child, parent_path)) = path.split_last() else {
return match window {
None => self.root.take(),
Some(window) => (window < self.floating.len()).then(|| self.floating.remove(window).root),
};
};
let parent = self.tree_mut(window).and_then(|root| node_at_mut(root, parent_path))?;
if !matches!(parent, Node::Split { .. }) {
return None;
}
match std::mem::replace(parent, Node::Tabs { panels: Vec::new(), active: 0 }) {
Node::Split { first, second, .. } => {
let (taken, kept) = if *second_child { (second, first) } else { (first, second) };
*parent = *kept;
Some(*taken)
}
other => {
*parent = other;
None
}
}
}
/// The members of the group, column or floating window containing `panel`, in order.
/// An accordion entry is a group of one.
pub(super) fn members(&self, panel: &P, unit: Unit) -> Result<Vec<P>, DockError> {
let place = self.place(panel).ok_or(DockError::MissingPanel)?;
match (unit, &place) {
(Unit::Group, Place::Node(_) | Place::Floating { .. }) => match self.node_of(&place) {
Some(Node::Tabs { panels, .. }) => Ok(panels.clone()),
Some(Node::Stack { .. }) => Ok(vec![panel.clone()]),
_ => Err(DockError::MissingPanel),
},
(Unit::Group, _) => self.group(&place).map(|g| g.panels.clone()).ok_or(DockError::MissingPanel),
(Unit::Column, Place::Dock { .. }) => {
let column = self.column(&place).ok_or(DockError::MissingPanel)?;
Ok(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect())
}
(Unit::Window, Place::Floating { window, .. }) => {
let window = self.floating.get(*window).ok_or(DockError::MissingPanel)?;
Ok(window.panels().cloned().collect())
}
(Unit::Column | Unit::Window, _) => Err(DockError::InvalidPlacement),
}
}
pub(super) fn take_unit(&mut self, panel: &P, unit: Unit) -> Result<Moving<P>, DockError> {
match unit {
Unit::Group => self.take_group(panel),
Unit::Column => self.take_column(panel),
Unit::Window => self.take_window(panel),
}
}
fn take_group(&mut self, panel: &P) -> Result<Moving<P>, DockError> {
let place = self.place(panel).ok_or(DockError::MissingPanel)?;
match place {
Place::Node(_) | Place::Floating { .. } => {
let (window, path) = place.tree_path().map(|(w, p)| (w, p.to_vec())).ok_or(DockError::MissingPanel)?;
// A group leaving a floating window keeps its width; the window's only group
// takes the window's rect along.
let source = window.and_then(|w| self.floating.get(w)).map(|w| (w.rect, w.native, w.minimized, w.id));
let whole = |moving: Moving<P>| match source {
Some((rect, native, minimized, id)) if path.is_empty() => {
Moving { width: rect.get(2).copied(), rect: Some(rect), native, minimized, id: Some(id), ..moving }
}
Some((rect, ..)) => Moving { width: rect.get(2).copied(), ..moving },
None => moving,
};
if let Some(Node::Stack { entries }) = self.node_of_mut(&place)
&& entries.len() > 1
&& let Some(index) = entries.iter().position(|e| &e.panel == panel)
{
let entry = entries.remove(index);
let group = Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height };
let moving = whole(Moving::group(group));
return Ok(Moving { rect: None, native: None, id: None, ..moving });
}
match self.take_node_in(window, &path) {
Some(Node::Tabs { panels, active }) => Ok(whole(Moving::group(Group::new(panels, active)))),
Some(Node::Stack { mut entries }) if entries.len() == 1 => {
let entry = entries.remove(0);
Ok(whole(Moving::group(Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height })))
}
_ => Err(DockError::MissingPanel),
}
}
Place::Dock { path, column, group } => {
let columns = self.columns_mut(&path).ok_or(DockError::MissingPanel)?;
let source = columns.get_mut(column).ok_or(DockError::MissingPanel)?;
if group >= source.groups.len() {
return Err(DockError::MissingPanel);
}
let taken = source.groups.remove(group);
let width = source.width;
if source.groups.is_empty() {
columns.remove(column);
}
if columns.is_empty() {
self.take_node(&path);
}
Ok(Moving { width: Some(width), ..Moving::group(taken) })
}
}
}
fn take_column(&mut self, panel: &P) -> Result<Moving<P>, DockError> {
let Some(Place::Dock { path, column, .. }) = self.place(panel) else { return Err(DockError::InvalidPlacement) };
let columns = self.columns_mut(&path).ok_or(DockError::MissingPanel)?;
if column >= columns.len() {
return Err(DockError::MissingPanel);
}
let taken = columns.remove(column);
if columns.is_empty() {
self.take_node(&path);
}
Ok(Moving {
groups: taken.groups,
node: None,
minimized: false,
width: Some(taken.width),
iconic: taken.iconic,
iconic_width: taken.iconic_width,
rect: None,
native: None,
id: None,
})
}
fn take_window(&mut self, panel: &P) -> Result<Moving<P>, DockError> {
let Some(Place::Floating { window, .. }) = self.place(panel) else { return Err(DockError::InvalidPlacement) };
if window >= self.floating.len() {
return Err(DockError::MissingPanel);
}
let taken = self.floating.remove(window);
Ok(Moving {
groups: tree_groups(&taken.root),
node: Some(taken.root),
minimized: taken.minimized,
width: taken.rect.get(2).copied(),
iconic: false,
iconic_width: None,
rect: Some(taken.rect),
native: taken.native,
id: Some(taken.id),
})
}
/// Insert groups taken by [`Self::take_unit`] (or a new single-panel group).
pub(super) fn insert_moving(&mut self, moving: Moving<P>, to: Target<P>) -> Result<(), DockError> {
if moving.groups.is_empty() || moving.groups.iter().any(|g| g.panels.is_empty()) {
return Err(DockError::MissingPanel);
}
match to {
Target::Float { rect } => {
let native = if rect.is_none() { moving.native } else { None };
let rect = rect.or(moving.rect).unwrap_or_else(|| {
let [x, y, width, height] = DEFAULT_FLOAT;
[x, y, moving.width.unwrap_or(width), height]
});
if !valid_rect(rect) {
return Err(DockError::InvalidGeometry);
}
let root = match moving.node {
Some(node) => node,
None => stack_tree(moving.groups).ok_or(DockError::MissingPanel)?,
};
let window = Floating { native, minimized: moving.minimized, ..Floating::new(root, rect) };
self.floating.push(Floating { id: moving.id.unwrap_or(window.id), ..window });
Ok(())
}
Target::NewDock { side } => {
if let Some(path) = self.dock_path(side) {
let columns = self.columns_mut(&path).ok_or(DockError::MissingTarget)?;
if !columns.is_empty() {
return Err(DockError::InvalidPlacement);
}
columns.push(moving.into_column(DEFAULT_COLUMN_WIDTH));
return Ok(());
}
let column = moving.into_column(DEFAULT_COLUMN_WIDTH);
self.add_dock(side, column);
Ok(())
}
Target::At { anchor, placement } => {
let place = self.place(&anchor).ok_or(DockError::MissingTarget)?;
match placement {
Placement::Tab { before } => {
let index = match &before {
Some(before) => {
if self.place(before).as_ref() != Some(&place) {
return Err(DockError::MissingTarget);
}
self.index_in(&place, before)
}
None => None,
};
self.merge(&place, index, moving)
}
Placement::Split(Zone::Center) => {
let index = self.index_in(&place, &anchor).map(|i| i + 1);
self.merge(&place, index, moving)
}
Placement::Split(zone) => {
let (window, path) = place.tree_path().ok_or(DockError::InvalidPlacement)?;
let new = match moving.node {
Some(node) => node,
None => {
let (panels, active) = flatten(moving.groups);
Node::Tabs { panels, active }
}
};
let root = self.tree_mut(window).ok_or(DockError::MissingTarget)?;
if split_frame(root, path, new, zone) { Ok(()) } else { Err(DockError::MissingTarget) }
}
// A floating window is a tree: a group above or below splits the anchor's group.
Placement::Group { below, .. } if matches!(place, Place::Floating { .. }) => {
let zone = if below { Zone::Bottom } else { Zone::Top };
self.insert_moving(moving, Target::At { anchor, placement: Placement::Split(zone) })
}
Placement::Group { below, height } => {
if !valid_height(height) {
return Err(DockError::InvalidGeometry);
}
let mut moving = moving;
if height.is_some() {
moving.groups.iter_mut().for_each(|group| group.height = height);
}
let (groups, index) = match &place {
Place::Dock { group, .. } => (&mut self.column_mut(&place).ok_or(DockError::MissingTarget)?.groups, *group),
Place::Node(_) | Place::Floating { .. } => return Err(DockError::InvalidPlacement),
};
let index = (index + usize::from(below)).min(groups.len());
groups.splice(index..index, moving.groups);
Ok(())
}
Placement::Edge(zone) => {
if zone == Zone::Center || matches!(place, Place::Floating { .. }) {
return Err(DockError::InvalidPlacement);
}
let path = self.edge_path();
let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?;
if matches!(node, Node::Dock { .. }) {
return Err(DockError::InvalidPlacement);
}
let (panels, active) = flatten(moving.groups);
let new = Node::Tabs { panels, active };
let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 });
let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
let (first, second, size) = if matches!(zone, Zone::Top | Zone::Left) { (new, old, 0.25) } else { (old, new, 0.75) };
*node = Node::Split { axis, size: SplitSize::Ratio(size), first: Box::new(first), second: Box::new(second) };
Ok(())
}
Placement::Column { after } => {
let Place::Dock { path, column, .. } = &place else { return Err(DockError::InvalidPlacement) };
let columns = self.columns_mut(path).ok_or(DockError::MissingTarget)?;
let width = columns.get(*column).map_or(DEFAULT_COLUMN_WIDTH, |c| c.width);
let index = (column + usize::from(after)).min(columns.len());
columns.insert(index, moving.into_column(width));
Ok(())
}
}
}
}
}
/// Wrap the root in a horizontal split with a new dock on `side`. The split's fixed size is
/// the column width.
pub(super) fn add_dock(&mut self, side: Side, column: Column<P>) {
let width = column.width;
let dock = Node::Dock { side, columns: vec![column] };
self.root = Some(match self.root.take() {
None => dock,
Some(root) => {
let (first, second, size) = match side {
Side::Start => (dock, root, SplitSize::FixedFirst(width)),
Side::End => (root, dock, SplitSize::FixedSecond(width)),
};
Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) }
}
});
}
fn index_in(&self, place: &Place, panel: &P) -> Option<usize> {
match place {
Place::Node(_) | Place::Floating { .. } => match self.node_of(place)? {
Node::Tabs { panels, .. } => panels.iter().position(|p| p == panel),
Node::Stack { entries } => entries.iter().position(|e| &e.panel == panel),
_ => None,
},
_ => self.group(place)?.panels.iter().position(|p| p == panel),
}
}
/// Join every moving panel to the group at `place`, before `index` or at the end. The first
/// moving group's active panel becomes active.
fn merge(&mut self, place: &Place, index: Option<usize>, moving: Moving<P>) -> Result<(), DockError> {
let (panels, selected) = flatten(moving.groups);
match place {
Place::Node(_) | Place::Floating { .. } => match self.node_of_mut(place) {
Some(Node::Tabs { panels: target, active }) => {
splice(target, active, index.unwrap_or(target.len()), panels, selected);
Ok(())
}
Some(Node::Stack { entries }) => {
let index = index.unwrap_or(entries.len()).min(entries.len());
entries.splice(index..index, panels.into_iter().map(|panel| super::StackEntry { panel, open: true, height: None }));
Ok(())
}
_ => Err(DockError::MissingTarget),
},
_ => {
let group = self.group_mut(place).ok_or(DockError::MissingTarget)?;
let end = group.panels.len();
splice(&mut group.panels, &mut group.active, index.unwrap_or(end), panels, selected);
Ok(())
}
}
}
}
fn leaf_holds<P: PartialEq>(leaf: &Node<P>, panel: &P) -> bool {
match leaf {
Node::Tabs { panels, .. } => panels.contains(panel),
Node::Stack { entries } => entries.iter().any(|e| &e.panel == panel),
Node::Split { .. } | Node::Dock { .. } => false,
}
}
/// All panels in order, and the index of the first group's active panel among them.
fn flatten<P>(groups: Vec<Group<P>>) -> (Vec<P>, usize) {
let selected = groups.first().map_or(0, |g| g.active.min(g.panels.len().saturating_sub(1)));
(groups.into_iter().flat_map(|g| g.panels).collect(), selected)
}
pub(super) fn valid_column<P>(column: &Column<P>) -> bool {
valid_width(column.width) && column.iconic_width.is_none_or(valid_width)
}
+764
View File
@@ -0,0 +1,764 @@
//! [D] Docks: columns, stacked groups, seams, column bars and their drops.
use super::*;
use crate::layout::{SplitAxis, SplitSize};
use egui::{Context, Event, Id, Modifiers, PointerButton, Pos2, RawInput, Rect, UiBuilder, pos2, vec2};
const AREA: &str = "dock-area";
fn group(panels: &[u32]) -> Group<u32> {
Group::new(panels.to_vec(), 0)
}
/// A dock on `side` with `columns` (each a list of groups, 240 wide) beside canvas 0.
fn docked(side: Side, columns: &[&[&[u32]]]) -> Layout<u32> {
let dock = Node::Dock { side, columns: columns.iter().map(|groups| Column::new(groups.iter().map(|g| group(g)).collect(), 240.0)).collect() };
let canvas = Node::Tabs { panels: vec![0], active: 0 };
let (first, second, size) = match side {
Side::Start => (dock, canvas, SplitSize::FixedFirst(240.0)),
Side::End => (canvas, dock, SplitSize::FixedSecond(240.0)),
};
Layout { root: Some(Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) }), floating: Vec::new() }
}
fn with_floating(mut layout: Layout<u32>, windows: &[(u32, [f32; 4])]) -> Layout<u32> {
layout.floating.extend(windows.iter().map(|(panel, rect)| Floating::single(vec![*panel], 0, *rect)));
layout
}
fn columns(layout: &Layout<u32>, side: Side) -> Vec<Vec<Vec<u32>>> {
fn find(node: &Node<u32>, side: Side) -> Option<Vec<Vec<Vec<u32>>>> {
match node {
Node::Dock { side: s, columns } if *s == side => {
Some(columns.iter().map(|c| c.groups.iter().map(|g| g.panels.clone()).collect()).collect())
}
Node::Split { first, second, .. } => find(first, side).or_else(|| find(second, side)),
_ => None,
}
}
layout.root.as_ref().and_then(|root| find(root, side)).unwrap_or_default()
}
fn column(layout: &Layout<u32>, index: usize) -> Column<u32> {
fn find(node: &Node<u32>) -> Option<&Vec<Column<u32>>> {
match node {
Node::Dock { columns, .. } => Some(columns),
Node::Split { first, second, .. } => find(first).or_else(|| find(second)),
_ => None,
}
}
layout.root.as_ref().and_then(find).and_then(|c| c.get(index)).cloned().unwrap()
}
#[derive(Default)]
struct TestContent {
min_height: f32,
}
impl DockContent<u32> for TestContent {
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
ui.label("Body");
}
fn permissions(&self, panel: &u32) -> Permissions {
if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() }
}
fn limits(&self, _: &u32) -> PanelLimits {
PanelLimits { min: vec2(0.0, self.min_height), ..PanelLimits::default() }
}
}
/// Renders a layout in a 600x600 area of an 800x600 window and applies what it emits.
struct Driver {
ctx: Context,
layout: Layout<u32>,
content: TestContent,
focused: bool,
right_to_left: bool,
shapes: Vec<egui::epaint::ClippedShape>,
time: f64,
}
impl Driver {
fn new(layout: Layout<u32>) -> Self {
Self::with(layout, TestContent::default(), false)
}
fn with(layout: Layout<u32>, content: TestContent, right_to_left: bool) -> Self {
Self::start(Self { ctx: Context::default(), layout, content, focused: true, right_to_left, shapes: Vec::new(), time: 0.0 })
}
/// A window egui never heard has focus (macOS can make it key without a `Focused` event).
fn never_focused(layout: Layout<u32>) -> Self {
Self::start(Self {
ctx: Context::default(),
layout,
content: TestContent::default(),
focused: false,
right_to_left: false,
shapes: Vec::new(),
time: 0.0,
})
}
fn start(mut driver: Self) -> Self {
// Floating areas settle after their sizing pass.
for _ in 0..3 {
driver.step(vec![]);
}
driver
}
fn frame(&mut self, events: Vec<Event>) -> DockOutput<u32> {
let mut output = DockOutput::default();
self.time += 1.0 / 60.0;
let input = RawInput {
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
events,
focused: self.focused,
time: Some(self.time),
..Default::default()
};
let (layout, content, rtl) = (&self.layout, &mut self.content, self.right_to_left);
let mut full = self.ctx.run_ui(input, |ui| {
let mut style = DockStyle::from_ui(ui);
style.right_to_left = rtl;
ui.scope_builder(UiBuilder::new().max_rect(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 600.0))), |ui| {
output = DockArea::new(Id::unique(AREA)).show_content(ui, layout, &style, content);
});
});
full.textures_delta.clear();
self.shapes = full.shapes;
output
}
fn step(&mut self, events: Vec<Event>) -> DockOutput<u32> {
let output = self.frame(events);
assert!(output.error.is_none(), "{output:?}");
let permissions = |p: &u32| self.content.permissions(p);
for action in &output.actions {
self.layout.apply_with_permissions(action.clone(), permissions).unwrap();
}
output
}
fn rect(&self, name: &str, panel: u32) -> Rect {
self.ctx.read_response(Id::unique(AREA).with((name, Some(&panel)))).unwrap().rect
}
fn tab(&self, panel: u32) -> Rect {
self.ctx.read_response(Id::unique(AREA).with(("tab", &panel))).unwrap().rect
}
fn hold(&mut self, from: Pos2, to: Pos2) -> DockOutput<u32> {
self.step(vec![Event::PointerMoved(from)]);
self.step(vec![button(from, true)]);
self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]);
self.step(vec![Event::PointerMoved(to)])
}
fn drag(&mut self, from: Pos2, to: Pos2) -> DockOutput<u32> {
self.hold(from, to);
self.step(vec![button(to, false)])
}
fn click(&mut self, at: Pos2) -> DockOutput<u32> {
self.step(vec![Event::PointerMoved(at), button(at, true)]);
self.step(vec![button(at, false)])
}
/// Two frames: egui reports a moved widget's rect a frame late.
fn settle(&mut self) {
self.step(vec![]);
self.step(vec![]);
}
fn wait(&mut self) {
for _ in 0..45 {
self.step(vec![]);
}
}
/// Whether the last frame painted the filled insertion line `line`.
fn painted(&self, line: Rect) -> bool {
self.shapes.iter().any(|clipped| match &clipped.shape {
egui::Shape::Rect(shape) => shape.rect == line && shape.fill != egui::Color32::TRANSPARENT,
_ => false,
})
}
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
fn key(key: egui::Key) -> Event {
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }
}
fn moved(actions: &[Action<u32>]) -> Option<(u32, &Target<u32>)> {
actions.iter().find_map(|a| match a {
Action::MoveGroup { panel, to } | Action::MoveColumn { panel, to } => Some((*panel, to)),
_ => None,
})
}
fn hline(y: f32, x: std::ops::RangeInclusive<f32>) -> Rect {
Rect::from_x_y_ranges(x, (y - 2.0)..=(y + 2.0))
}
fn vline(x: f32, y: std::ops::RangeInclusive<f32>) -> Rect {
Rect::from_x_y_ranges((x - 2.0)..=(x + 2.0), y)
}
/// A floating group's drag bar.
fn bar(d: &Driver, panel: u32) -> Pos2 {
d.rect("drag-bar", panel).center()
}
// Geometry of `docked(Start, ..)`: the dock is 0..240 wide, its column bar 14 high, and two open
// groups share 14..600, meeting at y = 307.
#[test]
fn group_dropped_on_a_seam_inserts_between_and_shows_a_horizontal_line() {
let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]);
let mut d = Driver::new(layout);
assert_eq!(d.tab(3).top(), 307.0);
let seam = pos2(120.0, 309.0);
d.hold(bar(&d, 5), seam);
assert!(d.painted(hline(307.0, 0.0..=240.0)), "a horizontal line across the column at the seam");
let out = d.step(vec![button(seam, false)]);
assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } })));
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![5], vec![3]]]);
assert!(d.layout.floating.is_empty());
}
#[test]
fn a_tab_dropped_on_a_seam_or_column_edge_moves_only_that_panel() {
let mut d = Driver::new(docked(Side::Start, &[&[&[2, 3], &[4]]]));
let out = d.drag(d.tab(3).center(), pos2(120.0, 309.0));
assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 2, placement: Placement::Group { below: true, height: None } }]);
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3], vec![4]]]);
d.settle();
d.drag(d.tab(4).center(), pos2(246.0, 200.0));
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3]], vec![vec![4]]]);
}
#[test]
fn group_dropped_on_a_top_or_bottom_band_stacks_above_or_below() {
for (at, expected) in [
// The top quarter of group 2's body, and the bottom quarter of group 3's.
(pos2(120.0, 14.0 + 26.0 + 20.0), vec![vec![5], vec![2], vec![3]]),
(pos2(120.0, 590.0), vec![vec![2], vec![3], vec![5]]),
// The middle of a body joins as tabs.
(pos2(120.0, 450.0), vec![vec![2], vec![3, 5]]),
] {
let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]);
let mut d = Driver::new(layout);
d.drag(bar(&d, 5), at);
assert_eq!(columns(&d.layout, Side::Start), vec![expected], "{at:?}");
}
}
#[test]
fn group_dropped_on_a_column_edge_makes_a_new_column() {
// The dock's inner edge: a column after the existing one, previewed full height.
let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]);
let mut d = Driver::new(layout.clone());
let edge = pos2(246.0, 200.0);
d.hold(bar(&d, 5), edge);
assert!(d.painted(vline(240.0, 0.0..=600.0)), "a vertical line the full height of the dock");
let out = d.step(vec![button(edge, false)]);
assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Column { after: true } })));
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3]], vec![vec![5]]]);
assert_eq!(column(&d.layout, 1).width, 150.0, "the new column keeps the window's width");
// The window edge of the outermost column: a column before it.
let mut d = Driver::new(layout);
d.drag(bar(&d, 5), pos2(3.0, 200.0));
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![5]], vec![vec![2], vec![3]]]);
}
#[test]
fn group_dropped_on_the_window_edge_without_a_dock_makes_a_new_dock() {
for (side, at, new) in [(Side::Start, pos2(595.0, 200.0), Side::End), (Side::End, pos2(4.0, 200.0), Side::Start)] {
let layout = with_floating(docked(side, &[&[&[2]]]), &[(5, [300.0, 300.0, 150.0, 150.0])]);
let mut d = Driver::new(layout);
d.hold(bar(&d, 5), at);
let x = if new == Side::End { 598.0 } else { 2.0 };
assert!(d.painted(vline(x, 0.0..=600.0)), "{new:?}: a vertical line at the window edge");
let out = d.step(vec![button(at, false)]);
assert_eq!(moved(&out.actions), Some((5, &Target::NewDock { side: new })));
assert_eq!(columns(&d.layout, new), vec![vec![vec![5]]]);
assert_eq!(columns(&d.layout, side), vec![vec![vec![2]]]);
// The new dock is drawn on its edge, its »/« button at the window edge.
d.settle();
let button = d.rect("column-collapse", 5);
assert!(if new == Side::End { button.right() == 600.0 } else { button.left() == 0.0 }, "{button:?}");
}
}
#[test]
fn tab_dragged_to_the_window_edge_without_a_dock_makes_a_dock_of_that_panel() {
let mut d = testing::DockDriver::new(docked(Side::Start, &[&[&[2, 3]]]), TestContent::default());
let tab = d.hit(&Hit::Tab(3)).unwrap_or_else(|| panic!("{:?}", d.hits()));
let out = d.drag(tab.center(), pos2(795.0, 300.0));
assert!(out.actions.contains(&Action::MovePanel { panel: 3, to: Target::NewDock { side: Side::End } }), "{:?}", out.actions);
assert_eq!(columns(&d.layout, Side::End), vec![vec![vec![3]]]);
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2]]]);
}
#[test]
fn a_column_takes_groups_anywhere_in_it_and_as_tabs() {
let windows = [(5, [400.0, 40.0, 150.0, 120.0]), (6, [400.0, 220.0, 150.0, 120.0]), (7, [400.0, 400.0, 150.0, 120.0])];
let mut d = Driver::new(with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &windows));
// Onto group 3's tab strip: joins as a tab.
d.drag(bar(&d, 5), pos2(d.tab(3).right() + 20.0, d.tab(3).center().y));
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3, 5]]]);
// Onto the middle of group 2's body: joins as the last tab.
d.drag(bar(&d, 6), pos2(120.0, 160.0));
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2, 6], vec![3, 5]]]);
// Into the room under the groups, every group minimized: below the last group.
d.step(vec![Event::PointerMoved(pos2(700.0, 10.0))]);
let mut minimized = d.layout.clone();
for panel in [2, 3] {
minimized.apply(Action::SetMinimized { panel, minimized: true }).unwrap();
}
d.layout = minimized;
d.step(vec![]);
d.drag(bar(&d, 7), pos2(120.0, 500.0));
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2, 6], vec![3, 5], vec![7]]]);
}
#[test]
fn a_column_too_tall_for_the_window_scrolls() {
let groups: [&[u32]; 5] = [&[2], &[3], &[4], &[5], &[6]];
let mut d = Driver::with(docked(Side::Start, &[&groups]), TestContent { min_height: 160.0 }, false);
let last = d.tab(6);
assert!(last.top() > 600.0, "the last group starts below the window: {last:?}");
let over = d.tab(2).center();
for _ in 0..6 {
d.step(vec![
Event::PointerMoved(over),
Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -200.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
},
]);
}
d.wait();
let moved = d.tab(6);
assert!(moved.top() < last.top() - 100.0, "scrolled up: {moved:?} from {last:?}");
}
#[test]
fn column_bar_drag_moves_the_column_and_away_from_a_dock_floats_its_groups() {
let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[3]], &[&[5]]]));
// The second column to the window edge: it becomes the first.
let out = d.drag(d.rect("column-bar", 5).center(), pos2(3.0, 200.0));
assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Column { after: false } })));
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![5]], vec![vec![2], vec![3]]]);
d.settle();
// Away from the dock: a floating window holding the column's groups.
let source = Rect::from_min_max(pos2(240.0, 0.0), pos2(480.0, 600.0));
assert_eq!(d.rect("column-bar", 2).left(), 240.0 + 18.0);
let grab = d.rect("column-bar", 2).center();
d.hold(grab, pos2(560.0, 300.0));
// The column's width; a docked column floats at most 320 pt tall.
let size = vec2(source.width(), 320.0);
let ghost = egui::Rect::from_min_size(pos2(560.0, 300.0) - (grab - source.min), size);
let ghost = egui::Rect::from_min_size(ghost.min.min(pos2(800.0, 600.0) - size).max(Pos2::ZERO), size);
assert!(
d.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == ghost && r.fill != egui::Color32::TRANSPARENT)),
"a ghost of the column follows the pointer"
);
let out = d.step(vec![button(pos2(560.0, 300.0), false)]);
assert!(matches!(moved(&out.actions), Some((2, Target::Float { .. }))), "{:?}", out.actions);
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![5]]]);
assert_eq!(d.layout.floating[0].groups(), vec![group(&[2]), group(&[3])]);
}
#[test]
fn column_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_unchanged() {
let layout = docked(Side::Start, &[&[&[2], &[3]], &[&[5]]]);
for case in ["escape", "focus", "exit", "cancel"] {
let mut d = Driver::new(layout.clone());
let target = pos2(3.0, 200.0);
let held = d.hold(d.rect("column-bar", 5).center(), target);
assert_eq!(held.group_drag, Some(5), "{case}");
let out = match case {
"escape" => d.step(vec![key(egui::Key::Escape)]),
"focus" => {
d.focused = false;
let out = d.step(vec![]);
d.focused = true;
out
}
"exit" => d.step(vec![Event::PointerGone]),
_ => {
cancel_drag::<u32>(&d.ctx, Id::unique(AREA));
d.step(vec![Event::PointerMoved(target + vec2(1.0, 0.0))])
}
};
assert!(out.actions.is_empty(), "{case}: {out:?}");
let out = d.step(vec![Event::PointerMoved(target), button(target, false)]);
assert!(out.actions.is_empty() && out.group_drag.is_none(), "{case}: {out:?}");
assert_eq!(d.layout, layout, "{case}");
assert!(!is_dragging::<u32>(&d.ctx, Id::unique(AREA)), "{case}");
}
}
#[test]
fn column_and_group_dividers_resize_and_minimized_groups_keep_their_height() {
let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[4]]]));
// The dock's inner edge widens its column; the canvas gives way.
d.drag(pos2(240.0, 300.0), pos2(280.0, 300.0));
assert_eq!(column(&d.layout, 0).width, 280.0);
d.settle();
assert_eq!(d.rect("column-bar", 2).right(), 280.0);
// The divider between the groups saves both heights.
d.wait();
d.drag(pos2(120.0, 307.0), pos2(120.0, 357.0));
let groups = column(&d.layout, 0).groups;
assert_eq!((groups[0].height, groups[1].height), (Some(343.0), Some(243.0)));
d.settle();
assert_eq!(d.tab(4).top(), 357.0);
// Minimized, the lower group keeps its saved height and the upper one takes the room.
d.layout.apply(Action::SetMinimized { panel: 4, minimized: true }).unwrap();
d.settle();
assert_eq!(column(&d.layout, 0).groups[1].height, Some(243.0));
assert_eq!(d.tab(4).top(), 600.0 - 26.0);
}
#[test]
fn a_group_divider_dragged_past_the_lower_groups_minimum_shrinks_the_next_group() {
let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[3], &[4]]]));
let divider = d.rect("group-resize", 2).center();
d.drag(divider, divider + vec2(0.0, 500.0));
// Groups 3 and 4 are at their minimum: a tab strip and a 40 pt body.
let heights = column(&d.layout, 0).groups.iter().map(|g| g.height).collect::<Vec<_>>();
assert_eq!(heights[1..], [Some(66.0), Some(66.0)], "{heights:?}");
}
#[test]
fn double_click_on_a_docked_tab_minimizes_and_clicking_another_tab_expands() {
let mut d = Driver::new(docked(Side::Start, &[&[&[2, 3], &[4]]]));
let minimized = |d: &Driver| column(&d.layout, 0).groups[0].minimized;
let tab = d.tab(2).center();
d.click(tab);
d.click(tab);
assert!(minimized(&d));
d.settle();
assert_eq!(d.tab(4).top(), 14.0 + 26.0, "the minimized group takes its strip");
d.wait();
d.click(d.tab(3).center());
assert!(!minimized(&d));
assert_eq!(column(&d.layout, 0).groups[0].active_panel(), Some(&3));
}
#[test]
fn collapse_button_and_column_bar_double_click_emit_set_iconic() {
let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[3]]]));
let iconic = |d: &Driver| column(&d.layout, 0).iconic;
let button = d.rect("column-collapse", 2).center();
let out = d.click(button);
assert_eq!(out.actions, vec![Action::SetIconic { panel: 2, iconic: true }]);
assert!(iconic(&d));
d.wait();
d.click(d.rect("column-collapse", 2).center());
assert!(!iconic(&d));
d.wait();
let bar = d.rect("column-bar", 2).center();
d.click(bar);
d.click(bar);
assert!(iconic(&d));
}
#[test]
fn dock_targets_respect_occlusion_by_floating_windows() {
// Window 5 covers the column's seam at x 150..240; window 6 is dragged.
let windows = [(5, [150.0, 250.0, 200.0, 150.0]), (6, [400.0, 450.0, 150.0, 120.0])];
let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &windows);
let mut d = Driver::new(layout.clone());
let out = d.drag(bar(&d, 6), pos2(200.0, 309.0));
assert!(matches!(moved(&out.actions), Some((6, Target::At { anchor: 5, .. }))), "joins the window in front: {:?}", out.actions);
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3]]]);
// Left of the window, the seam takes it.
let mut d = Driver::new(layout);
d.drag(bar(&d, 6), pos2(100.0, 309.0));
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![6], vec![3]]]);
}
#[test]
fn right_to_left_mirrors_docks_their_edges_and_new_docks() {
let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [100.0, 300.0, 150.0, 150.0])]);
let mut d = Driver::with(layout.clone(), TestContent::default(), true);
// The start dock is on the right, its »/« button at the window edge.
let column_bar = d.rect("column-bar", 2);
assert_eq!((column_bar.left(), column_bar.right()), (360.0, 600.0 - 18.0));
// Its inner edge is on the left: dragging it left widens the column.
d.drag(pos2(360.0, 300.0), pos2(320.0, 300.0));
assert_eq!(column(&d.layout, 0).width, 280.0);
// The left window edge has no dock: a new end dock.
d.wait();
let out = d.drag(bar(&d, 5), pos2(4.0, 200.0));
assert_eq!(moved(&out.actions), Some((5, &Target::NewDock { side: Side::End })));
d.settle();
assert_eq!(d.rect("column-collapse", 5).left(), 0.0);
}
#[test]
fn a_column_s_first_tab_dropped_on_its_own_column_edge_anchors_on_another_panel() {
// Two columns, 0..240 and 240..480; the dragged tab is the first panel of the last one.
let wide = |columns: &[&[&[u32]]]| {
let mut layout = docked(Side::Start, columns);
if let Some(Node::Split { size, .. }) = &mut layout.root {
*size = SplitSize::FixedFirst(480.0);
}
layout
};
let mut d = Driver::new(wide(&[&[&[2]], &[&[3, 4]]]));
let out = d.drag(d.tab(3).center(), pos2(484.0, 300.0));
assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 4, placement: Placement::Column { after: true } }]);
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2]], vec![vec![4]], vec![vec![3]]]);
// Alone in its column, the panel stays where it is.
let layout = wide(&[&[&[2]], &[&[3]]]);
let mut d = Driver::new(layout.clone());
let out = d.drag(d.tab(3).center(), pos2(484.0, 300.0));
assert_eq!(out.actions, vec![]);
assert_eq!(d.layout, layout);
}
#[test]
fn a_window_that_never_reported_focus_drags_tabs_and_losing_focus_cancels_a_drag() {
let mut layout = docked(Side::Start, &[&[&[2]], &[&[3, 4]]]);
if let Some(Node::Split { size, .. }) = &mut layout.root {
*size = SplitSize::FixedFirst(480.0);
}
let to = pos2(484.0, 300.0);
let mut d = Driver::never_focused(layout.clone());
let out = d.drag(d.tab(3).center(), to);
assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 4, placement: Placement::Column { after: true } }]);
// Focus that arrives during the drag and goes again ends it.
let mut d = Driver::never_focused(layout.clone());
d.hold(d.tab(3).center(), to);
d.focused = true;
d.step(vec![Event::WindowFocused(true)]);
assert!(is_dragging::<u32>(&d.ctx, Id::unique(AREA)));
d.focused = false;
d.step(vec![Event::WindowFocused(false)]);
let out = d.step(vec![button(to, false)]);
assert!(out.actions.is_empty(), "{out:?}");
assert_eq!(d.layout, layout);
assert!(!is_dragging::<u32>(&d.ctx, Id::unique(AREA)));
}
#[test]
fn a_dock_wider_than_the_window_shrinks_to_fit_and_resizes() {
let widths = render::fit_widths(&[(600.0, Some(40.0)), (600.0, Some(40.0)), (48.0, None)], 800.0);
assert!((widths.iter().sum::<f32>() - 800.0).abs() < 0.01 && widths[2] == 48.0, "{widths:?}");
assert_eq!(render::fit_widths(&[(600.0, Some(300.0)), (600.0, Some(300.0))], 400.0), vec![300.0, 300.0]);
// Three 600-wide columns saved on a larger screen, in an 800-wide window.
let mut layout = docked(Side::Start, &[&[&[1]], &[&[2]], &[&[3]]]);
if let Some(Node::Split { first, .. }) = &mut layout.root
&& let Node::Dock { columns, .. } = &mut **first
{
for column in columns {
column.width = 600.0;
}
}
let mut d = testing::DockDriver::new(layout, TestContent::default());
for panel in [1, 2, 3] {
let divider = d.hit(&Hit::Divider(Divider::Column(panel))).unwrap();
assert!(d.hit(&Hit::Tab(panel)).is_some_and(|tab| tab.width() > 0.0), "{panel}");
assert!(divider.right() < 800.0, "{panel}: {divider:?}");
}
let divider = d.hit(&Hit::Divider(Divider::Column(1))).unwrap().center();
d.press(divider);
d.move_to(divider + vec2(-8.0, 0.0));
let out = d.move_to(divider + vec2(-30.0, 0.0));
assert!(out.error.is_none() && !out.actions.is_empty(), "{out:?}");
for action in &out.actions {
assert!(matches!(action, Action::ResizeColumn { width, .. } if *width > 0.0), "{action:?}");
}
d.release(divider + vec2(-30.0, 0.0));
let total: f32 = (0..3).map(|i| column(&d.layout, i).width).sum();
assert!(total < 800.0, "{total}");
assert!((d.hit(&Hit::Divider(Divider::Column(1))).unwrap().center().x - (divider.x - 30.0)).abs() < 1.0);
}
#[test]
fn screen_reader_values_follow_the_column_width_on_either_edge() {
use egui::accesskit::{Action as A, ActionData, ActionRequest, TreeId};
for (side, right_to_left) in [(Side::End, false), (Side::Start, true), (Side::Start, false)] {
let mut d = testing::DockDriver::new(docked(side, &[&[&[2]]]), TestContent::default()).with_style(move |s| s.right_to_left = right_to_left);
d.ctx.enable_accesskit();
d.settle();
let id = DockArea::<u32>::hit_id(d.id, &Hit::Divider(Divider::Column(2)));
let request =
|action, data| Event::AccessKitActionRequest(ActionRequest { action, target_tree: TreeId::ROOT, target_node: id.accesskit_id(), data });
let case = format!("{side:?}, right to left {right_to_left}");
let out = d.frame(vec![request(A::SetValue, Some(ActionData::NumericValue(300.0)))]);
assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width: 300.0 }], "{case}");
let out = d.frame(vec![request(A::Increment, None)]);
assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width: 248.0 }], "{case}");
// Arrow keys stay physical: right moves the divider right.
d.ctx.memory_mut(|m| m.request_focus(id));
d.frame(vec![]);
let out = d.frame(vec![key(egui::Key::ArrowRight)]);
let wider = d.hit(&Hit::Divider(Divider::Column(2))).unwrap().center().x < 400.0;
let width = if wider { 248.0 } else { 232.0 };
assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width }], "{case}");
}
}
#[test]
fn an_unanchored_open_prefers_the_end_dock_and_an_edge_frame_takes_a_quarter() {
let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![group(&[1])], 240.0)] };
let end = docked(Side::End, &[&[&[2]]]).root.unwrap();
let mut layout = Layout {
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(start), second: Box::new(end) }),
floating: Vec::new(),
};
layout.apply(Action::Open { panel: 9, anchor: None }).unwrap();
assert_eq!(columns(&layout, Side::End), vec![vec![vec![2, 9]]]);
assert_eq!(columns(&layout, Side::Start), vec![vec![vec![1]]]);
// A minimized group and an iconic column are passed over: the panel would not show there.
let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![group(&[1])], 240.0)] };
let mut end = docked(Side::End, &[&[&[2]]]).root.unwrap();
if let Node::Split { second, .. } = &mut end
&& let Node::Dock { columns, .. } = second.as_mut()
{
columns[0].groups[0].minimized = true;
}
let mut layout = Layout {
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(start), second: Box::new(end) }),
floating: Vec::new(),
};
let mut iconic = layout.clone();
layout.apply(Action::Open { panel: 9, anchor: None }).unwrap();
assert_eq!(columns(&layout, Side::Start), vec![vec![vec![1, 9]]]);
if let Some(Node::Split { first, .. }) = &mut iconic.root
&& let Node::Dock { columns, .. } = first.as_mut()
{
columns[0].iconic = true;
}
iconic.apply(Action::Open { panel: 9, anchor: None }).unwrap();
assert!(matches!(iconic.place(&9), Some(dock_model::Place::Node(_))), "{iconic:?}");
// No dock: a frame at the right edge, a quarter of the width.
let mut layout = Layout { root: Some(Node::Tabs { panels: vec![0], active: 0 }), floating: Vec::new() };
layout.apply(Action::Open { panel: 9, anchor: None }).unwrap();
let Some(Node::Split { axis: SplitAxis::Horizontal, size, second, .. }) = &layout.root else { panic!("{layout:?}") };
assert_eq!(*size, SplitSize::Ratio(0.75));
assert_eq!(**second, Node::Tabs { panels: vec![9], active: 0 });
}
/// Panels 1-3 as a creative app's Color, Properties and Layers: Layers fills the column and asks
/// for room for about ten rows.
struct Creative;
impl DockContent<u32> for Creative {
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
ui.label("Body");
}
fn permissions(&self, panel: &u32) -> Permissions {
if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() }
}
fn limits(&self, panel: &u32) -> PanelLimits {
PanelLimits { min: vec2(0.0, if *panel == 3 { 120.0 } else { 60.0 }), ..PanelLimits::default() }
}
fn dock_heights(&self, panel: &u32) -> Option<DockHeights> {
match panel {
1 => Some(DockHeights::new(160.0).compact(100.0)),
2 => Some(DockHeights::new(220.0).compact(130.0)),
3 => Some(DockHeights::new(300.0).fill(480.0)),
_ => None,
}
}
}
/// Color, Properties and Layers docked in a window `height` tall, with saved `heights`.
fn sized_column(height: f32, heights: [Option<f32>; 3]) -> testing::DockDriver<u32, Creative> {
let mut layout = docked(Side::Start, &[&[&[1], &[2], &[3]]]);
if let Some(Node::Split { first, .. }) = &mut layout.root
&& let Node::Dock { columns, .. } = first.as_mut()
{
columns[0].groups.iter_mut().zip(heights).for_each(|(g, h)| g.height = h);
}
let mut d = testing::DockDriver::new(layout, Creative);
d.screen = Rect::from_min_size(Pos2::ZERO, vec2(1440.0, height));
d.area = d.screen;
d.settle();
d
}
/// Heights of the column's groups as drawn, from their tabs' tops.
fn drawn_heights(d: &testing::DockDriver<u32, Creative>) -> [f32; 3] {
let top = |p| d.hit(&Hit::Tab(p)).unwrap().top();
[top(2) - top(1), top(3) - top(2), d.area.bottom() - top(3)]
}
#[test]
fn a_default_column_gives_the_filler_its_fill_height() {
let strip = DockStyle::from_visuals(&egui::Visuals::dark()).tab_height;
// A 900 pt window: Properties, nearest Layers, gives way towards its compact height so
// Layers gets the height it asks for; Color keeps its default.
let d = sized_column(900.0, [None; 3]);
let [color, properties, layers] = drawn_heights(&d);
assert!((layers - (480.0 + strip)).abs() < 0.5, "{:?}", drawn_heights(&d));
assert_eq!(color, 160.0 + strip, "{:?}", drawn_heights(&d));
assert!(properties < 220.0 + strip && properties >= 130.0 + strip, "{:?}", drawn_heights(&d));
// A tall window: both at their defaults, Layers takes the rest.
let d = sized_column(1200.0, [None; 3]);
let [color, properties, layers] = drawn_heights(&d);
assert_eq!((color, properties), (160.0 + strip, 220.0 + strip));
assert!(layers > 480.0 + strip);
// Nothing is saved until the user resizes a group.
assert!(column(&d.layout, 0).groups.iter().all(|g| g.height.is_none()));
}
#[test]
fn shared_groups_with_different_minimums_fit_an_exact_room() {
let open = |min: f32, content: Option<DockHeights>| render::GroupSize { minimized: false, saved: None, min, content };
let column = [open(50.0, Some(DockHeights::new(186.0))), open(86.0, None), open(146.0, None)];
assert_eq!(render::group_heights(&column, 26.0, 418.0), vec![186.0, 86.0, 146.0]);
assert_eq!(render::group_heights(&column, 26.0, 518.0), vec![186.0, 166.0, 166.0]);
}
#[test]
fn a_group_asking_for_a_fill_fills_the_column_beside_groups_without_dock_heights() {
let open = |min: f32, content: Option<DockHeights>| render::GroupSize { minimized: false, saved: None, min, content };
let column = [open(86.0, Some(DockHeights::new(186.0))), open(86.0, None), open(146.0, Some(DockHeights::new(326.0).fill(506.0)))];
assert_eq!(render::group_heights(&column, 26.0, 1000.0), vec![186.0, 86.0, 728.0]);
}
#[test]
fn defaults_give_way_to_the_filler_but_user_heights_stay() {
let open = |saved: Option<f32>, min: f32, content: Option<DockHeights>| render::GroupSize { minimized: false, saved, min, content };
let color = Some(DockHeights::new(186.0).compact(126.0));
let properties = Some(DockHeights::new(246.0).compact(156.0));
let layers = Some(DockHeights::new(326.0).fill(506.0));
let column = [open(None, 86.0, color), open(None, 86.0, properties), open(None, 146.0, layers)];
// Tall: defaults, the filler takes the rest.
assert_eq!(render::group_heights(&column, 26.0, 1200.0), vec![186.0, 246.0, 768.0]);
// Short: the default nearest the filler gives way first; Color keeps its default.
assert_eq!(render::group_heights(&column, 26.0, 863.0), vec![186.0, 171.0, 506.0]);
// Shorter: Properties is at its compact height before Color gives way.
assert_eq!(render::group_heights(&column, 26.0, 818.0), vec![156.0, 156.0, 506.0]);
// Shorter than the compact heights and the fill: every group gives way towards its minimum.
let hs = render::group_heights(&column, 26.0, 400.0);
assert!(hs.iter().zip(&column).all(|(h, g)| *h >= g.min - 0.01), "{hs:?}");
assert!((hs.iter().sum::<f32>() - 400.0).abs() < 0.01 && hs[0] < 126.0 && hs[1] < 156.0, "{hs:?}");
// Heights the user set stay; only the defaults give way.
let mine = [open(Some(200.0), 86.0, color), open(None, 86.0, properties), open(None, 146.0, layers)];
assert_eq!(render::group_heights(&mine, 26.0, 838.0), vec![200.0, 156.0, 482.0]);
// In a drawn column too: Layers gets less than it asks for rather than the user's heights.
let d = sized_column(900.0, [Some(200.0), Some(340.0), None]);
assert_eq!(drawn_heights(&d), [200.0, 340.0, 900.0 - 14.0 - 540.0]);
}
+718
View File
@@ -0,0 +1,718 @@
//! Floating windows (title bar, stacks, close), the split-tree settings (outer edge, Maximize,
//! Ctrl/Cmd undock, frame menu items), hidden panels, native windows and the public chrome.
use super::*;
use crate::layout::{SplitAxis, SplitSize};
use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, Vec2, pos2, vec2};
const AREA: &str = "floating-area";
fn tabs(panels: &[u32], active: usize) -> Node<u32> {
Node::Tabs { panels: panels.to_vec(), active }
}
/// `[1] | [2, 3]` with panel 3 selected.
fn frames() -> Layout<u32> {
Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedFirst(300.0),
first: Box::new(tabs(&[1], 0)),
second: Box::new(tabs(&[2, 3], 1)),
}),
floating: Vec::new(),
}
}
/// A start dock (`[2, 3]` above `[4]`) beside protected canvas 0; window W1 stacks `[5]` above
/// `[6, 7]`; window W2 holds `[8]`.
fn workspace() -> Layout<u32> {
Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedFirst(240.0),
first: Box::new(Node::Dock {
side: Side::Start,
columns: vec![Column::new(vec![Group::new(vec![2, 3], 1), Group::new(vec![4], 0)], 240.0)],
}),
second: Box::new(tabs(&[0], 0)),
}),
floating: vec![
Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], [300.0, 80.0, 240.0, 300.0]).unwrap(),
Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0]),
],
}
}
#[derive(Default)]
struct Content {
native: bool,
keep: Vec<u32>,
/// Canvas 0 accepts side splits, as an app that opens panels beside it does.
beside: bool,
drawn: Vec<u32>,
}
impl DockContent<u32> for Content {
fn body(&mut self, ui: &mut egui::Ui, panel: &u32) {
self.drawn.push(*panel);
ui.label("Body");
}
fn logical_label(&self, panel: &u32) -> String {
format!("Panel {panel}")
}
fn permissions(&self, panel: &u32) -> Permissions {
match *panel {
0 if self.beside => Permissions { accepts_splits: true, ..Permissions::PROTECTED },
0 => Permissions::PROTECTED,
_ => Permissions::default(),
}
}
fn allow_native(&self, _: &u32) -> bool {
self.native
}
}
#[derive(Clone, Copy, PartialEq)]
enum Hide {
None,
All,
Keep,
}
/// The style of an editor whose layout is one split tree of tab groups, as FilmCraft sets it.
pub(super) fn frame_style(style: &mut DockStyle) {
style.side_edge_zones = true;
style.new_dock_zones = false;
style.close_others_item = true;
style.maximize_item = true;
style.command_press_floats = true;
style.float_on_empty_drop = false;
style.title_bar_docks = false;
style.float_hint = true;
style.wheel_selects_tabs = true;
style.middle_click_closes_tabs = true;
style.float_label = "Undock Panel".into();
style.float_group_label = "Undock Panel Group".into();
style.close_group_label = "Close Panel Group".into();
}
struct Driver {
ctx: Context,
layout: Layout<u32>,
content: Content,
split_tree: bool,
maximized: Option<u32>,
hide: Hide,
modifiers: Modifiers,
time: f64,
}
impl Driver {
fn new(layout: Layout<u32>, split_tree: bool) -> Self {
let mut driver = Self {
ctx: Context::default(),
layout,
content: Content::default(),
split_tree,
maximized: None,
hide: Hide::None,
modifiers: Modifiers::NONE,
time: 0.0,
};
driver.step(vec![]);
driver.step(vec![]);
driver
}
fn frame(&mut self, mut events: Vec<Event>) -> DockOutput<u32> {
let mut output = DockOutput::default();
events.insert(0, Event::ModifiersChanged(self.modifiers));
self.time += 1.0 / 60.0;
let input = RawInput {
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
events,
focused: true,
time: Some(self.time),
..Default::default()
};
let (layout, content, split_tree, maximized, hide) = (&self.layout, &mut self.content, self.split_tree, self.maximized, self.hide);
let keep: Vec<u32> = content.keep.clone();
let keep = move |p: &u32| keep.contains(p);
let full = self.ctx.run_ui(input, |ui| {
let mut style = DockStyle::from_ui(ui);
if split_tree {
frame_style(&mut style);
}
let hide = match hide {
Hide::None => HidePanels::None,
Hide::All => HidePanels::All,
Hide::Keep => HidePanels::Except(&keep),
};
output =
DockArea::new(Id::unique(AREA)).native_windows(true).maximized(maximized).hide_panels(hide).show_content(ui, layout, &style, content);
});
let mut full = full;
full.textures_delta.clear();
output
}
fn step(&mut self, events: Vec<Event>) -> DockOutput<u32> {
let output = self.frame(events);
assert!(output.error.is_none(), "{output:?}");
for action in &output.actions {
self.layout.apply_with_permissions(action.clone(), |p| self.content.permissions(p)).unwrap();
}
output
}
fn response(&self, id: Id) -> Option<Rect> {
self.ctx.read_response(id).map(|r| r.rect)
}
fn title(&self, first: u32) -> Rect {
self.response(Id::unique(AREA).with(("move-group", Some(&first)))).unwrap()
}
fn tab(&self, panel: u32) -> Option<Rect> {
self.response(Id::unique(AREA).with(("tab", &panel)))
}
fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
self.step(vec![Event::PointerMoved(from), button(from, true)]);
self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]);
self.step(vec![Event::PointerMoved(to)])
}
fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
self.hold(from, to);
self.step(vec![button(to, false)])
}
fn click(&mut self, at: egui::Pos2) -> DockOutput<u32> {
self.step(vec![Event::PointerMoved(at), button(at, true)]);
self.step(vec![button(at, false)])
}
fn wait(&mut self) {
for _ in 0..45 {
self.step(vec![]);
}
}
}
fn done(mut full: egui::FullOutput) {
full.textures_delta.clear();
}
fn button(pos: egui::Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
fn window_of(layout: &Layout<u32>, panel: u32) -> Option<&Floating<u32>> {
layout.floating.iter().find(|w| w.contains(&panel))
}
fn groups_of(window: &Floating<u32>) -> Vec<Vec<u32>> {
window.groups().iter().map(|g| g.panels.clone()).collect()
}
#[test]
fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_groups() {
// The window follows the pointer with every group; Escape puts it back.
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
d.hold(title, title + vec2(40.0, 30.0));
assert_eq!(d.layout.floating[0].rect, [340.0, 110.0, 240.0, 300.0]);
assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![5], vec![6, 7]]);
d.step(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
d.step(vec![button(title + vec2(40.0, 30.0), false)]);
assert_eq!(d.layout, workspace());
// Released over another window's lower band: its tree goes below that window's group,
// unless that leaves a group without room; then its panels join that group.
let out = d.drag(title, pos2(660.0, 480.0));
assert!(
out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Split(Zone::Center) } }),
"{:?}",
out.actions
);
assert_eq!(d.layout.floating.len(), 1);
assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![8, 5, 6, 7]]);
// Released over a docked group's strip: every panel joins it as tabs.
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
let strip = d.tab(4).unwrap().center();
let out = d.drag(title, strip);
assert!(out.actions.iter().any(|a| matches!(a, Action::MoveWindow { panel: 5, to: Target::At { anchor: 4, .. } })), "{:?}", out.actions);
assert!(window_of(&d.layout, 5).is_none() && window_of(&d.layout, 7).is_none());
assert_eq!(d.layout.place(&4), d.layout.place(&6));
// Released over the canvas: the window stays where it was moved and hosts get the release.
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
let out = d.drag(title, pos2(500.0, 300.0));
assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveWindow { .. })), "{:?}", out.actions);
assert_eq!(out.group_drop, Some((5, pos2(500.0, 300.0))));
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![6, 7]]);
}
#[test]
fn title_bar_drag_onto_dock_targets_docks_the_window() {
// Released on the seam between two docked groups: the window's group stacks between them.
let mut d = Driver::new(workspace(), false);
let title = d.title(8).center();
let seam = pos2(120.0, d.tab(4).unwrap().top());
let out = d.drag(title, seam);
assert!(
out.actions
.contains(&Action::MoveWindow { panel: 8, to: Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } } }),
"{:?}",
out.actions
);
assert!(window_of(&d.layout, 8).is_none());
let Some(Node::Split { first, .. }) = &d.layout.root else { panic!("{:?}", d.layout.root) };
let Node::Dock { columns, .. } = first.as_ref() else { panic!("{first:?}") };
let stacked: Vec<_> = columns[0].groups.iter().map(|g| g.panels.clone()).collect();
assert_eq!(stacked, vec![vec![2, 3], vec![8], vec![4]]);
// Released on the new-dock band along the right edge: the window becomes an end dock.
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
let out = d.drag(title, pos2(796.0, 300.0));
assert!(out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::NewDock { side: Side::End } }), "{:?}", out.actions);
assert!(window_of(&d.layout, 5).is_none() && window_of(&d.layout, 6).is_none());
}
#[test]
fn a_floating_window_is_a_tree_whose_groups_split_and_resize_like_frames() {
// A group dragged by its bar to another window's top band splits that window's group.
let mut d = Driver::new(workspace(), false);
let bar = d.response(Id::unique(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center();
let out = d.drag(bar, pos2(660.0, 360.0));
assert!(
out.actions.contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Split(Zone::Top) } }),
"{:?}",
out.actions
);
assert_eq!(groups_of(window_of(&d.layout, 8).unwrap()), vec![vec![6, 7], vec![8]]);
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5]]);
// The gap between two groups of a window is no target.
let mut d = Driver::new(workspace(), false);
let tab = d.tab(8).unwrap().center();
let gap = pos2(420.0, d.tab(6).unwrap().top() - 2.0);
d.drag(tab, gap);
assert_eq!(d.layout, workspace());
// The divider between them resizes both.
let mut d = Driver::new(workspace(), false);
let divider = d.response(DockArea::hit_id(Id::unique(AREA), &Hit::Divider(Divider::WindowSplit(5u32, Vec::new())))).unwrap().center();
d.drag(divider, divider + vec2(0.0, 40.0));
let Node::Split { size: SplitSize::Ratio(ratio), .. } = window_of(&d.layout, 5).unwrap().root else { panic!("{:?}", d.layout) };
assert!(ratio > 0.6, "{ratio}");
}
#[test]
fn title_bar_double_click_minimizes_every_group_and_close_box_closes_the_window() {
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
d.click(title);
d.click(title);
assert!(window_of(&d.layout, 5).unwrap().minimized);
d.wait();
d.click(title);
d.click(title);
assert!(!window_of(&d.layout, 5).unwrap().minimized);
let close = d.response(Id::unique(AREA).with(("move-group", Some(&5u32))).with("close")).unwrap();
assert_eq!(close.center().x, 530.0, "× sits 10 pt in from the right edge");
let before = d.layout.clone();
d.wait();
let out = d.click(close.center());
assert_eq!(out.actions, vec![Action::CloseWindow { panel: 5 }]);
assert_eq!(before.closed_by(&Action::CloseWindow { panel: 5 }), Ok(vec![5, 6, 7]));
assert!(window_of(&d.layout, 6).is_none() && d.layout.contains(&8));
}
#[test]
fn closed_floating_window_reopens_each_panel_at_its_last_rect() {
let mut layout = workspace();
let locations: Vec<_> = [5, 6, 7].map(|p| (p, layout.location(&p).unwrap())).into();
layout.apply(Action::CloseWindow { panel: 6 }).unwrap();
for (panel, location) in locations {
layout.restore(panel, &location).unwrap();
assert_eq!(window_of(&layout, panel).unwrap().rect, [300.0, 80.0, 240.0, 300.0], "{panel}");
}
}
#[test]
fn ctrl_drag_from_a_frame_strip_undocks_to_a_floating_window() {
let mut d = Driver::new(frames(), true);
let tab = d.tab(3).unwrap().center();
let other = d.tab(1).unwrap().center();
d.modifiers = Modifiers::COMMAND;
d.hold(tab, other);
d.modifiers = Modifiers::NONE;
let out = d.step(vec![button(other, false)]);
assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions);
assert!(window_of(&d.layout, 3).is_some());
// Without the modifier the same drag joins the other frame.
let mut d = Driver::new(frames(), true);
let out = d.drag(tab, other);
assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 3, anchor: 1, .. }]), "{:?}", out.actions);
}
#[test]
fn outer_edge_band_splits_at_the_root() {
// With side edge zones: all four edges.
let mut d = Driver::new(frames(), true);
let tab = d.tab(3).unwrap().center();
let out = d.drag(tab, pos2(3.0, 300.0));
assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }), "{:?}", out.actions);
let Some(Node::Split { axis: SplitAxis::Horizontal, first, .. }) = &d.layout.root else { panic!("{:?}", d.layout) };
assert_eq!(**first, tabs(&[3], 0));
// The defaults: the bottom edge of the part between the docks, for a Timeline.
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, false);
let tab = d.tab(2).unwrap().center();
let out = d.drag(tab, pos2(500.0, 597.0));
assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 2, placement: Placement::Edge(Zone::Bottom), .. })), "{:?}", out.actions);
let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() };
assert!(matches!(**first, Node::Dock { .. }));
assert!(matches!(&**second, Node::Split { axis: SplitAxis::Vertical, second, .. } if **second == tabs(&[2], 0)));
// Its left and right edges are the docks' (new columns and docks), not root splits.
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, false);
let tab = d.tab(4).unwrap().center();
let out = d.drag(tab, pos2(246.0, 300.0));
assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { placement: Placement::Edge(_), .. })), "{:?}", out.actions);
}
#[test]
fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() {
let mut layout = frames();
layout.floating.push(Floating::single(vec![5], 0, [400.0, 300.0, 200.0, 150.0]));
let mut d = Driver::new(layout.clone(), true);
let window_title = d.title(5).center();
d.maximized = Some(2);
d.step(vec![]);
d.step(vec![]);
assert_eq!(d.tab(2).unwrap().left(), 0.0, "the maximized frame starts at the area's edge");
assert!(d.tab(1).is_none());
assert!(d.response(Id::unique(AREA).with(("move-group", Some(&5u32)))).is_none());
let out = d.drag(window_title, window_title + vec2(30.0, 30.0));
assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveFloating { .. } | Action::MoveWindow { .. })), "{:?}", out.actions);
// A floating panel does not maximize.
let mut d = Driver::new(layout, true);
d.maximized = Some(5);
d.step(vec![]);
d.step(vec![]);
assert!(d.tab(1).is_some());
}
#[test]
fn close_other_panels_keeps_only_the_chosen_tab() {
use egui_kittest::{Harness, kittest::Queryable};
type State = (Content, Vec<Action<u32>>, Vec<DockEvent<u32>>);
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|ui, (content, actions, events): &mut State| {
let mut style = DockStyle::from_ui(ui);
frame_style(&mut style);
let out = DockArea::new(Id::unique(AREA)).native_windows(true).show_content(ui, &frames(), &style, content);
actions.extend(out.actions);
events.extend(out.events);
},
(Content::default(), Vec::new(), Vec::new()),
);
h.run_steps(2);
let at = h.ctx.read_response(Id::unique(AREA).with(("tab", &3u32))).unwrap().rect.center();
let open_menu = |h: &mut Harness<'_, State>| {
h.input_mut().events.extend([
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
]);
h.run_steps(1);
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
};
open_menu(&mut h);
for label in ["Undock Panel", "Close Panel Group", "Undock Panel Group", "Close Other Panels in Group", "Maximize Frame"] {
assert_eq!(h.query_all_by_label(label).count(), 1, "{label}");
}
assert_eq!(h.query_all_by_label("Minimize").count(), 0);
h.get_by_label("Close Other Panels in Group").click();
h.run_steps(2);
assert_eq!(h.state().1, vec![Action::CloseOthers { panel: 3 }]);
let mut layout = frames();
layout.apply(Action::CloseOthers { panel: 3 }).unwrap();
assert!(layout.contains(&3) && !layout.contains(&2));
open_menu(&mut h);
h.get_by_label("Maximize Frame").click();
h.run_steps(2);
assert_eq!(h.state().2, vec![DockEvent::ToggleMaximize { panel: 3 }]);
}
#[test]
fn hide_panels_keeps_protected_panels_and_the_except_set() {
let mut d = Driver::new(workspace(), false);
d.hide = Hide::All;
d.step(vec![]);
d.step(vec![]);
for panel in [2, 3, 4, 5, 6, 8] {
assert!(d.tab(panel).is_none(), "{panel}");
}
assert!(d.layout == workspace(), "hiding leaves the layout unchanged");
d.hide = Hide::Keep;
d.content.keep = vec![4, 6];
d.step(vec![]);
d.step(vec![]);
assert_eq!(d.tab(4).map(|r| r.left()), Some(0.0));
assert!(d.tab(6).is_some());
for panel in [2, 3, 5, 7, 8] {
assert!(d.tab(panel).is_none(), "{panel}");
}
// A window keeps its rect with its hidden groups left out.
d.step(vec![]);
assert_eq!(d.title(6).min, pos2(300.0, 84.0), "below the resize band");
}
#[test]
fn a_canvas_that_accepts_splits_is_still_protected() {
let mut d = Driver::new(workspace(), false);
d.content.beside = true;
d.hide = Hide::All;
d.step(vec![]);
d.content.drawn.clear();
d.step(vec![]);
assert_eq!(d.content.drawn, vec![0], "hiding every panel keeps the canvas");
assert!(d.tab(0).is_none());
assert!(d.response(DockArea::<u32>::hit_id(Id::unique(AREA), &Hit::DragBar(0))).is_none());
}
#[test]
fn native_window_falls_back_in_window_when_viewports_are_embedded() {
let mut layout = workspace();
layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]);
let mut d = Driver::new(layout.clone(), false);
d.content.native = true;
assert!(d.ctx.embed_viewports());
let out = d.step(vec![]);
assert!(out.actions.is_empty(), "{out:?}");
assert_eq!(d.title(5).min, pos2(300.0, 84.0));
assert_eq!(d.layout, layout);
}
#[cfg(feature = "native-windows")]
#[test]
fn native_window_close_closes_its_panels() {
use std::{cell::Cell, rc::Rc};
let close = Rc::new(Cell::new(false));
let requested = close.clone();
// A backend with real viewports: the child reports its place and, when asked, a close.
Context::set_immediate_viewport_renderer(move |ctx, mut viewport| {
let mut info = egui::ViewportInfo {
outer_rect: Some(Rect::from_min_size(pos2(1210.0, 905.0), vec2(250.0, 330.0))),
inner_rect: Some(Rect::from_min_size(pos2(1210.0, 935.0), vec2(250.0, 300.0))),
..Default::default()
};
if requested.get() {
info.events.push(egui::ViewportEvent::Close);
}
let input = RawInput {
viewport_id: viewport.ids.this,
viewports: std::iter::once((viewport.ids.this, info)).collect(),
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(250.0, 300.0))),
..Default::default()
};
done(ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)));
});
let mut layout = workspace();
layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]);
let mut d = Driver::new(Layout::default(), false);
d.ctx.set_embed_viewports(false);
d.content.native = true;
d.layout = layout;
d.step(vec![]);
assert!(d.response(Id::unique(AREA).with(("move-group", Some(&5u32)))).is_none(), "drawn in its own OS window");
// Where the window system put its content, once a report that late is no longer the
// opening move landing.
let reported = (0..60).any(|_| d.step(vec![]).actions.contains(&Action::SetNative { panel: 5, rect: Some([1210.0, 935.0, 250.0, 300.0]) }));
assert!(reported, "{:?}", d.layout.floating[0].native);
close.set(true);
let out = d.step(vec![]);
assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions);
assert!(window_of(&d.layout, 5).is_none() && !d.layout.contains(&6) && !d.layout.contains(&7));
}
#[test]
fn chrome_frame_and_title_bar_work_without_a_dock_area() {
use chrome::{ChromeStyle, FloatingFrame, TitleBar};
let ctx = Context::default();
let id = Id::unique("task-pane");
let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0));
let mut time = 0.0;
let mut frame = |events: Vec<Event>| {
time += 1.0 / 60.0;
let input =
RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, time: Some(time), ..Default::default() };
let mut result = None;
done(ctx.run_ui(input, |ui| {
let style = ChromeStyle::from_ui(ui);
let shown = FloatingFrame::new(id, rect).show(ui.ctx(), &style, |ui, inner| {
let title = Rect::from_min_size(inner.min, vec2(inner.width(), 22.0));
let bar = TitleBar::new(id.with("title"), "Move pane").title("Styles").menu("Pane menu").close("Close pane").show(ui, title, &style);
(bar.bar.dragged(), bar.bar.drag_delta(), bar.menu.is_some_and(|r| r.clicked()), bar.close.is_some_and(|r| r.clicked()))
});
result = Some((shown.inner, shown.resized));
}));
result.unwrap()
};
frame(vec![]);
frame(vec![]);
type Shown = ((bool, Vec2, bool, bool), Option<Rect>);
let click = |frame: &mut dyn FnMut(Vec<Event>) -> Shown, at| {
frame(vec![Event::PointerMoved(at), button(at, true)]);
frame(vec![button(at, false)])
};
let ((_, _, _, closed), _) = click(&mut frame, pos2(390.0, 111.0));
assert!(closed, "× 10 pt in from the right");
let ((_, _, menu, _), _) = click(&mut frame, pos2(374.0, 111.0));
assert!(menu, "▾ left of ×");
frame(vec![Event::PointerMoved(pos2(200.0, 110.0)), button(pos2(200.0, 110.0), true)]);
frame(vec![Event::PointerMoved(pos2(210.0, 115.0))]);
let ((dragged, delta, ..), _) = frame(vec![Event::PointerMoved(pos2(230.0, 125.0))]);
assert!(dragged && delta == vec2(20.0, 10.0), "{delta:?}");
frame(vec![button(pos2(230.0, 125.0), false)]);
let edge = pos2(398.0, 200.0);
frame(vec![Event::PointerMoved(edge), button(edge, true)]);
frame(vec![Event::PointerMoved(edge + vec2(10.0, 0.0))]);
let (_, resized) = frame(vec![Event::PointerMoved(edge + vec2(50.0, 0.0))]);
assert_eq!(resized, Some(Rect::from_min_size(rect.min, vec2(350.0, 200.0))));
}
#[test]
fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() {
let layout = Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedFirst(200.0),
first: Box::new(tabs(&[9], 0)),
second: Box::new(Node::Split {
axis: SplitAxis::Vertical,
size: SplitSize::Ratio(0.5),
first: Box::new(tabs(&[1], 0)),
second: Box::new(tabs(&[2], 0)),
}),
}),
floating: Vec::new(),
};
let mut d = Driver::new(layout, true);
d.hide = Hide::Keep;
d.content.keep = vec![1, 2];
d.step(vec![]);
d.step(vec![]);
assert_eq!(d.tab(1).map(|r| r.left()), Some(0.0), "the hidden frame's space goes to the others");
// Hits and widget ids name the split by its layout path, as `ResizeSplit` does.
let hit = Hit::Divider(Divider::Split(vec![true]));
assert!(DockArea::<u32>::hit_rects(&d.ctx, Id::unique(AREA)).iter().any(|(h, _)| *h == hit), "the hits name the layout path");
let divider = d.response(DockArea::<u32>::hit_id(Id::unique(AREA), &hit)).unwrap().center();
let out = d.hold(divider, divider + vec2(0.0, 60.0));
assert!(out.actions.iter().any(|a| matches!(a, Action::ResizeSplit { path, .. } if path == &vec![true])), "{:?}", out.actions);
let Some(Node::Split { size: SplitSize::FixedFirst(200.0), second, .. }) = &d.layout.root else { panic!("{:?}", d.layout) };
assert!(matches!(**second, Node::Split { size, .. } if size != SplitSize::Ratio(0.5)));
}
#[test]
fn a_hidden_floating_window_does_not_cover_dock_targets() {
// Window 5 covers the column's seam at x 150..240 until it is hidden.
let mut layout = workspace();
layout.floating = vec![Floating::single(vec![5], 0, [150.0, 250.0, 200.0, 150.0]), Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0])];
let mut d = Driver::new(layout, false);
d.hide = Hide::Keep;
d.content.keep = vec![2, 3, 4, 8];
d.step(vec![]);
d.step(vec![]);
d.drag(d.title(8).center(), pos2(200.0, 309.0));
let Some(Node::Split { first, .. }) = &d.layout.root else { panic!("{:?}", d.layout.root) };
let Node::Dock { columns, .. } = &**first else { panic!("{first:?}") };
let groups: Vec<Vec<u32>> = columns[0].groups.iter().map(|g| g.panels.clone()).collect();
assert_eq!(groups, vec![vec![2, 3], vec![8], vec![4]]);
}
#[test]
fn top_edges_resize_from_the_band_above_the_title_bar() {
// W2 holds [8] at [560, 300, 200, 200]; each top handle keeps the opposite edges.
let area = Id::unique(AREA);
for (edge, by, expected) in [
(ResizeEdge::Top, vec2(0.0, -40.0), [560.0, 260.0, 200.0, 240.0]),
(ResizeEdge::TopLeft, vec2(-30.0, 20.0), [530.0, 320.0, 230.0, 180.0]),
(ResizeEdge::TopRight, vec2(20.0, -10.0), [560.0, 290.0, 220.0, 210.0]),
] {
let mut d = Driver::new(workspace(), false);
let handle = d.response(DockArea::hit_id(area, &Hit::Resize(8u32, edge))).unwrap();
assert_eq!(handle.bottom(), d.title(8).top(), "{edge:?} lies above the title bar");
d.drag(handle.center(), handle.center() + by);
assert_eq!(window_of(&d.layout, 8).unwrap().rect, expected, "{edge:?}");
}
// The title bar still moves the window, and arrow keys move the focused top edge.
let mut d = Driver::new(workspace(), false);
let title = d.title(8);
d.drag(pos2(title.center().x, title.top() + 1.0), pos2(title.center().x - 50.0, title.top() + 1.0));
assert_eq!(window_of(&d.layout, 8).unwrap().rect, [510.0, 300.0, 200.0, 200.0]);
d.ctx.memory_mut(|m| m.request_focus(DockArea::hit_id(area, &Hit::Resize(8u32, ResizeEdge::Top))));
d.step(vec![Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
assert_eq!(window_of(&d.layout, 8).unwrap().rect, [510.0, 292.0, 200.0, 208.0]);
}
#[test]
fn undock_panel_group_floats_one_group_of_a_floating_window() {
use egui_kittest::{Harness, kittest::Queryable};
let stacked = || Layout {
root: Some(tabs(&[1], 0)),
floating: vec![Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 0)], [300.0, 80.0, 300.0, 400.0]).unwrap()],
};
type State = (Content, Layout<u32>, Vec<Action<u32>>);
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|ui, (content, layout, actions): &mut State| {
let mut style = DockStyle::from_ui(ui);
frame_style(&mut style);
let out = DockArea::new(Id::unique(AREA)).native_windows(true).show_content(ui, layout, &style, content);
actions.extend(out.actions);
},
(Content::default(), stacked(), Vec::new()),
);
h.run_steps(2);
let menu = |h: &mut Harness<'_, State>, panel: u32| {
let at = h.ctx.read_response(Id::unique(AREA).with(("tab", &panel))).unwrap().rect.center();
h.input_mut().events.extend([
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
]);
h.run_steps(1);
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
};
menu(&mut h, 6);
h.get_by_label("Undock Panel Group").click();
h.run_steps(2);
let actions = std::mem::take(&mut h.state_mut().2);
assert!(matches!(&actions[..], [Action::MoveGroup { panel: 6, to: Target::Float { rect: Some(_) } }]), "{actions:?}");
let mut layout = stacked();
layout.apply_all(actions, |_| Permissions::default()).unwrap();
assert_eq!(layout.floating.iter().map(groups_of).collect::<Vec<_>>(), vec![vec![vec![5]], vec![vec![6, 7]]]);
// A window's only group has nothing to undock from.
h.state_mut().1 = layout;
h.run_steps(2);
menu(&mut h, 5);
assert_eq!(h.query_all_by_label("Undock Panel Group").count(), 0);
}
+762
View File
@@ -0,0 +1,762 @@
//! Whole-group gestures: drag bar, top border, drop resolution, minimize and the floating frame.
use super::*;
use crate::layout::{SplitAxis, SplitSize};
use egui::{Color32, Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, UiBuilder, Vec2, pos2, vec2};
const AREA: &str = "group-area";
#[path = "trial_tests.rs"]
mod trial;
fn tabs(panels: &[u32], active: usize) -> Node<u32> {
Node::Tabs { panels: panels.to_vec(), active }
}
/// `[1] | [2, 3]` with panel 3 selected.
fn frames() -> Layout<u32> {
Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedFirst(300.0),
first: Box::new(tabs(&[1], 0)),
second: Box::new(tabs(&[2, 3], 1)),
}),
floating: Vec::new(),
}
}
/// A start dock with one column holding `[2, 3]` (3 selected) above `[4]`, beside canvas 0.
fn docked() -> Layout<u32> {
Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedFirst(240.0),
first: Box::new(Node::Dock {
side: Side::Start,
columns: vec![Column::new(vec![Group::new(vec![2, 3], 1), Group::new(vec![4], 0)], 240.0)],
}),
second: Box::new(tabs(&[0], 0)),
}),
floating: Vec::new(),
}
}
#[derive(Default)]
struct TestContent {
protected: Vec<u32>,
header: f32,
header_clicks: usize,
/// The strip width the last header was sized for.
strip_width: std::cell::Cell<f32>,
floating_size: Option<Vec2>,
host_owned: bool,
whole_group: bool,
/// Height every body takes; `None` draws a one-line label.
body_height: Option<f32>,
/// Every body takes its whole rect.
fill: bool,
}
impl DockContent<u32> for TestContent {
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
if self.fill {
ui.allocate_rect(ui.max_rect(), egui::Sense::hover());
return;
}
match self.body_height {
Some(height) => _ = ui.allocate_exact_size(vec2(10.0, height), egui::Sense::hover()),
None => _ = ui.label("Body"),
}
}
fn logical_label(&self, panel: &u32) -> String {
format!("Panel {panel}")
}
fn permissions(&self, panel: &u32) -> Permissions {
if self.protected.contains(panel) { Permissions::PROTECTED } else { Permissions::default() }
}
fn floating_size(&self, _: &u32) -> Option<Vec2> {
self.floating_size
}
fn resolve_drag(&mut self, _: &Context, _: &DockStyle, request: &DropRequest<'_, u32>) -> Option<DropFeedback> {
self.whole_group |= request.whole_group;
self.host_owned.then(DropFeedback::default)
}
fn group_header_width(&self, _: &[u32], _: &u32, _: bool, strip_width: f32) -> f32 {
self.strip_width.set(strip_width);
self.header
}
fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) {
if ui.button("≡").clicked() {
self.header_clicks += 1;
}
}
}
/// Renders a layout through `show_content` in `area` of an 800x600 window and applies what
/// it emits, the way an application's command handler would.
struct Driver {
ctx: Context,
layout: Layout<u32>,
content: TestContent,
area: Rect,
modifiers: Modifiers,
focused: bool,
transparent: bool,
/// Applied to the style every frame.
tweak: fn(&mut DockStyle),
shapes: Vec<egui::epaint::ClippedShape>,
time: f64,
}
impl Driver {
fn new(layout: Layout<u32>) -> Self {
Self::with(layout, TestContent::default(), Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)))
}
fn with(layout: Layout<u32>, content: TestContent, area: Rect) -> Self {
let mut driver = Self {
ctx: Context::default(),
layout,
content,
area,
modifiers: Modifiers::NONE,
focused: true,
transparent: false,
tweak: |_| {},
shapes: Vec::new(),
time: 0.0,
};
driver.step(vec![]);
driver.step(vec![]);
driver
}
fn frame(&mut self, mut events: Vec<Event>) -> DockOutput<u32> {
let mut output = DockOutput::default();
events.insert(0, Event::ModifiersChanged(self.modifiers));
self.time += 1.0 / 60.0;
let input = RawInput {
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
events,
focused: self.focused,
time: Some(self.time),
..Default::default()
};
let (layout, content, area, transparent, tweak) = (&self.layout, &mut self.content, self.area, self.transparent, self.tweak);
let mut full = self.ctx.run_ui(input, |ui| {
let mut style = DockStyle::from_ui(ui);
if transparent {
style.background = Color32::TRANSPARENT;
style.tab_background = Color32::TRANSPARENT;
}
tweak(&mut style);
ui.scope_builder(UiBuilder::new().max_rect(area), |ui| {
output = DockArea::new(Id::unique(AREA)).show_content(ui, layout, &style, content);
});
});
full.textures_delta.clear();
self.shapes = full.shapes;
output
}
fn step(&mut self, events: Vec<Event>) -> DockOutput<u32> {
let output = self.frame(events);
assert!(output.error.is_none(), "{output:?}");
let permissions = |p: &u32| self.content.permissions(p);
for action in &output.actions {
self.layout.apply_with_permissions(action.clone(), permissions).unwrap();
}
output
}
fn rect(&self, name: &str, panel: u32) -> Rect {
self.ctx.read_response(Id::unique(AREA).with((name, Some(&panel)))).unwrap().rect
}
fn tab(&self, panel: u32) -> Rect {
self.ctx.read_response(Id::unique(AREA).with(("tab", &panel))).unwrap().rect
}
/// Press at `from`, pass the drag threshold, move to `to`; returns the last move's output.
fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
self.step(vec![Event::PointerMoved(from), button(from, true)]);
self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]);
self.step(vec![Event::PointerMoved(to)])
}
/// A full drag; returns the release frame's output.
fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
self.hold(from, to);
self.step(vec![button(to, false)])
}
/// Lets egui's double- and triple-click windows pass.
fn wait(&mut self) {
for _ in 0..45 {
self.step(vec![]);
}
}
fn click(&mut self, at: egui::Pos2) -> DockOutput<u32> {
self.step(vec![Event::PointerMoved(at), button(at, true)]);
self.step(vec![button(at, false)])
}
}
fn button(pos: egui::Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
fn key(key: egui::Key) -> Event {
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }
}
fn moved_group(actions: &[Action<u32>]) -> Option<&Target<u32>> {
actions.iter().find_map(|a| match a {
Action::MoveGroup { to, .. } => Some(to),
_ => None,
})
}
#[test]
fn docked_two_tab_group_drags_by_its_bar_and_keeps_order_and_active_tab() {
// Onto another group's strip: one MoveGroup, both panels in order, 3 still selected.
let mut d = Driver::new(frames());
let bar = d.rect("drag-bar", 2);
assert!(bar.width() >= 40.0);
let out = d.drag(bar.center(), d.tab(1).center());
assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Tab { .. } })), "{:?}", out.actions);
assert_eq!(out.actions.len(), 1);
assert!(out.group_drop.is_none(), "a resolved target leaves group_drop empty");
assert_eq!(d.layout.root, Some(tabs(&[1, 2, 3], 2)));
// Onto empty space: a floating window at the group's docked size.
let mut d = Driver::new(frames());
let border = d.rect("drag-border", 2);
let bar = d.rect("drag-bar", 2);
let out = d.drag(bar.center(), pos2(700.0, 200.0));
let Some(Target::Float { rect: Some([_, _, width, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) };
assert_eq!((*width, *height), (border.width(), 600.0));
assert_eq!(out.group_drop, Some((2, pos2(700.0, 200.0))));
assert_eq!(d.layout.root, Some(tabs(&[1], 0)));
assert_eq!(d.layout.floating[0].groups(), vec![Group::new(vec![2, 3], 1)]);
// The same group in a dock column joins the group below it.
let mut d = Driver::new(docked());
let bar = d.rect("drag-bar", 2);
let out = d.drag(bar.center(), d.tab(4).center());
assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 4, .. })), "{:?}", out.actions);
let Some(Node::Split { first, .. }) = &d.layout.root else { panic!() };
let Node::Dock { columns, .. } = first.as_ref() else { panic!() };
assert_eq!(columns[0].groups.len(), 1);
assert_eq!(columns[0].groups[0].panels, vec![4, 2, 3]);
assert_eq!(columns[0].groups[0].active_panel(), Some(&3));
}
#[test]
fn drag_bar_survives_a_strip_full_of_tabs() {
let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6, 7, 8], 0)), floating: Vec::new() };
let content = TestContent { header: 24.0, ..Default::default() };
let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 400.0)));
let bar = d.rect("drag-bar", 1);
assert!(bar.width() >= 40.0, "{bar:?}");
assert_eq!(bar.right(), 300.0 - 24.0, "the bar ends at the header");
assert_eq!(d.content.strip_width.get(), 300.0, "the header is sized for the whole strip");
assert!(d.ctx.read_response(Id::unique(AREA).with(("overflow", &1u32))).is_some(), "tabs shrink, then overflow, before the bar narrows");
let out = d.drag(bar.center(), pos2(500.0, 300.0));
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions);
assert_eq!(d.layout.floating[0].groups()[0].panels.len(), 8);
}
/// Panels with ordinary names.
struct Named;
impl DockContent<u32> for Named {
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
ui.label("Body");
}
fn logical_label(&self, panel: &u32) -> String {
if *panel == 1 { "Properties" } else { "Layers" }.into()
}
}
#[test]
fn narrow_frames_shrink_their_tabs_before_the_rest_overflow() {
let texts = |d: &testing::DockDriver<u32, Named>| {
let mut out = Vec::new();
fn walk(shape: &egui::Shape, out: &mut Vec<egui::epaint::TextShape>) {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)),
egui::Shape::Text(text) => out.push(text.clone()),
_ => {}
}
}
d.shapes().iter().for_each(|clipped| walk(&clipped.shape, &mut out));
out
};
let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: Vec::new() };
let area = |width: f32| Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 300.0));
// Both tabs show, cut to fit, with their labels inside them.
let d = testing::DockDriver::new(layout.clone(), Named).with_area(area(168.0));
assert!(d.hit(&Hit::Overflow(1)).is_none(), "no overflow while the tabs fit at their minimum width");
let min = DockStyle::default().min_tab_width;
for (panel, name) in [(1, "Properties"), (2, "Layers")] {
let tab = d.hit(&Hit::Tab(panel)).unwrap_or_else(|| panic!("{name} is hidden"));
assert!(tab.width() >= min - 0.5, "{name}: {tab:?}");
let label = texts(&d).into_iter().find(|t| t.galley.text() == name && tab.contains(t.pos)).unwrap_or_else(|| panic!("no {name} label"));
assert!(tab.contains_rect(label.visual_bounding_rect()), "{name}: {:?} outside {tab:?}", label.visual_bounding_rect());
}
let properties = texts(&d).into_iter().find(|t| t.galley.text() == "Properties").unwrap();
assert!(properties.galley.elided, "the wider tab shrinks to make room");
// Too narrow for both at their minimum width: the active tab stays, the other is listed.
let mut d = testing::DockDriver::new(layout, Named).with_area(area(120.0));
assert!(d.hit(&Hit::Tab(1)).is_some() && d.hit(&Hit::Tab(2)).is_none());
let overflow = d.hit(&Hit::Overflow(1)).expect("an overflow button");
d.click(overflow.center());
d.settle();
let listed = texts(&d).into_iter().find(|t| t.galley.text() == "Layers").expect("the menu lists Layers");
assert!(listed.pos.y >= overflow.bottom(), "{listed:?}");
let out = d.click(listed.visual_bounding_rect().center());
assert!(out.actions.contains(&Action::Activate { panel: 2 }), "{:?}", out.actions);
}
#[test]
fn top_border_drags_the_group_and_the_menu_button_only_clicks() {
let content = TestContent { header: 30.0, ..Default::default() };
let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
let border = d.rect("drag-border", 2);
assert!(border.height() <= 4.0);
// The band lies over the tabs and wins there.
let over_tab = pos2(d.tab(2).center().x, border.center().y);
let menu = pos2(576.0, d.tab(2).center().y);
// Pressing ≡ and dragging moves nothing; clicking it reaches the host.
let held = d.hold(menu, menu + vec2(0.0, 200.0));
assert!(held.group_drag.is_none());
let out = d.step(vec![button(menu + vec2(0.0, 200.0), false)]);
assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveGroup { .. } | Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions);
// A press in the header focuses the group's shown panel, like a press on its tab.
let pressed = d.step(vec![Event::PointerMoved(menu), button(menu, true)]);
assert_eq!(pressed.focused, Some(3));
d.step(vec![button(menu, false)]);
assert_eq!(d.content.header_clicks, 1);
let out = d.drag(over_tab, d.tab(1).center());
assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, .. })), "{:?}", out.actions);
assert_eq!(d.layout.root, Some(tabs(&[1, 2, 3], 2)));
}
#[test]
fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_floats() {
let canvas = || TestContent { protected: vec![1], ..Default::default() };
let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0));
let mut d = Driver::with(frames(), TestContent::default(), area);
let out = d.drag(d.rect("drag-bar", 2).center(), pos2(20.0, 300.0));
assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Split(Zone::Left) })), "{:?}", out.actions);
let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() };
assert_eq!(**first, tabs(&[2, 3], 1));
assert_eq!(**second, tabs(&[1], 0));
// A protected canvas takes nothing, at its center or its edge: no join, no split, no
// preview; the group floats there.
for at in [pos2(150.0, 300.0), pos2(20.0, 300.0)] {
let mut d = Driver::with(frames(), canvas(), area);
let out = d.drag(d.rect("drag-bar", 2).center(), at);
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions);
assert_eq!(d.layout.root, Some(tabs(&[1], 0)));
}
}
#[test]
fn ctrl_or_cmd_held_at_release_always_floats() {
for modifiers in [Modifiers::CTRL, Modifiers::MAC_CMD, Modifiers::COMMAND] {
let mut d = Driver::new(frames());
let target = d.tab(1).center();
d.hold(d.rect("drag-bar", 2).center(), target);
d.modifiers = modifiers;
let out = d.step(vec![button(target, false)]);
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{modifiers:?}: {:?}", out.actions);
assert_eq!(d.layout.root, Some(tabs(&[1], 0)));
// A tab drag floats the same way.
let mut d = Driver::new(frames());
let target = d.tab(1).center();
d.hold(d.tab(3).center(), target);
d.modifiers = modifiers;
let out = d.step(vec![button(target, false)]);
assert!(out.actions.iter().any(|a| matches!(a, Action::Float { panel: 3, .. })), "{modifiers:?}: {:?}", out.actions);
}
}
#[test]
fn group_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_unchanged() {
for case in ["escape", "focus", "exit", "cancel"] {
let mut d = Driver::new(frames());
let target = d.tab(1).center();
let held = d.hold(d.rect("drag-bar", 2).center(), target);
assert_eq!(held.group_drag, Some(2));
let out = match case {
"escape" => d.step(vec![key(egui::Key::Escape)]),
"focus" => {
d.focused = false;
let out = d.step(vec![]);
d.focused = true;
out
}
"exit" => d.step(vec![Event::PointerGone]),
_ => {
cancel_drag::<u32>(&d.ctx, Id::unique(AREA));
d.step(vec![Event::PointerMoved(target + vec2(1.0, 0.0))])
}
};
assert!(out.actions.is_empty() && out.group_drop.is_none(), "{case}: {out:?}");
let out = d.step(vec![Event::PointerMoved(target), button(target, false)]);
assert!(out.actions.is_empty() && out.group_drop.is_none() && out.group_drag.is_none(), "{case}: {out:?}");
assert_eq!(d.layout, frames(), "{case}");
assert!(!is_dragging::<u32>(&d.ctx, Id::unique(AREA)), "{case}");
}
}
#[test]
fn floated_panel_fits_its_content_up_to_floating_size_not_the_column_height() {
for size in [Some(vec2(200.0, 150.0)), None] {
let mut layout = docked();
let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() };
**first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![2, 3], 1)], 240.0)] };
let content = TestContent { protected: vec![0], floating_size: size, body_height: Some(200.0), ..Default::default() };
let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
let out = d.drag(d.tab(3).center(), pos2(700.0, 100.0));
let Some([_, _, width, height]) = out.actions.iter().find_map(|a| match a {
Action::Float { panel: 3, rect } => Some(*rect),
_ => None,
}) else {
panic!("{:?}", out.actions)
};
let fitted = crate::docking::chrome::TITLE_HEIGHT + d.rect("drag-bar", 2).height() + 200.0;
match size {
Some(size) => assert_eq!((width, height), (size.x, size.y)),
None => assert_eq!(height, fitted),
}
}
}
#[test]
fn floating_window_is_opaque_with_a_transparent_host_background() {
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
let mut d = Driver::new(layout);
d.transparent = true;
d.step(vec![]);
let window = Rect::from_min_size(pos2(80.0, 70.0), vec2(300.0, 220.0));
let fill = d.ctx.global_style().visuals.window_fill;
assert_ne!(fill, Color32::TRANSPARENT);
fn filled(shape: &egui::Shape, rect: Rect, fill: Color32) -> bool {
match shape {
egui::Shape::Rect(shape) => shape.rect == rect && shape.fill == fill,
egui::Shape::Vec(shapes) => shapes.iter().any(|shape| filled(shape, rect, fill)),
_ => false,
}
}
assert!(d.shapes.iter().any(|clipped| filled(&clipped.shape, window, fill)), "no opaque fill under the floating window");
}
#[test]
fn double_click_on_a_floating_tab_or_bar_minimizes_and_another_tab_expands() {
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
let mut d = Driver::new(layout);
let minimized = |d: &Driver| d.layout.floating[0].minimized;
let tab = d.tab(6).center();
d.click(tab);
let out = d.click(tab);
assert!(out.actions.contains(&Action::SetMinimized { panel: 6, minimized: true }), "{:?}", out.actions);
assert!(minimized(&d));
d.wait();
// Another tab of the minimized group expands it and selects that tab.
let other = d.tab(5).center();
d.click(other);
assert!(!minimized(&d));
assert_eq!(d.layout.floating[0].groups()[0].active_panel(), Some(&5));
d.wait();
let bar = d.rect("drag-bar", 5).center();
d.click(bar);
d.click(bar);
assert!(minimized(&d));
d.wait();
d.click(bar);
d.click(bar);
assert!(!minimized(&d));
}
#[test]
fn release_over_own_position_is_a_noop() {
let mut d = Driver::new(frames());
let bar = d.rect("drag-bar", 2);
let out = d.drag(bar.center(), bar.center() + vec2(0.0, 200.0));
assert!(out.actions.is_empty() && out.group_drop.is_none(), "{out:?}");
assert_eq!(d.layout, frames());
}
#[test]
fn floating_border_and_drag_bar_move_and_cancel_as_a_group() {
for name in ["drag-border", "drag-bar"] {
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
let mut d = Driver::new(layout.clone());
let from = d.rect(name, 5).center();
d.hold(from, from + vec2(50.0, 40.0));
assert_eq!(d.layout, layout, "{name}: a ghost moves, not the window");
d.step(vec![key(egui::Key::Escape)]);
d.step(vec![button(from + vec2(50.0, 40.0), false)]);
assert_eq!(d.layout, layout, "{name}");
let out = d.drag(from, from + vec2(50.0, 40.0));
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{name}: {:?}", out.actions);
let [x, y, w, h] = d.layout.floating[0].rect;
assert_eq!([x, y, w, h], [130.0, 110.0, 300.0, 220.0], "{name}");
assert_eq!(d.layout.floating[0].groups()[0], Group::new(vec![5, 6], 1));
}
}
#[test]
fn floating_border_movement_does_not_accumulate_fractional_step_rounding() {
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
let mut d = Driver::new(layout);
let from = d.rect("drag-border", 5).center();
d.step(vec![Event::PointerMoved(from), button(from, true)]);
for index in 1..=8 {
d.step(vec![Event::PointerMoved(from + vec2(80.3, 60.7) * (index as f32 / 8.0))]);
}
d.step(vec![button(from + vec2(80.3, 60.7), false)]);
let [x, y, ..] = d.layout.floating[0].rect;
assert!((x - 160.3).abs() <= 1.0 && (y - 130.7).abs() <= 1.0, "{x} {y}");
}
#[test]
fn docked_header_drag_reaches_the_host_hook_and_preserves_the_group() {
for name in ["drag-border", "drag-bar"] {
let content = TestContent { host_owned: true, ..Default::default() };
let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
let out = d.drag(d.rect(name, 2).center(), pos2(70.0, 300.0));
assert!(d.content.whole_group, "{name}");
assert!(out.actions.is_empty() && out.group_drop.is_none(), "{name}: the host owns the release");
assert_eq!(d.layout, frames());
}
}
#[test]
fn a_lone_protected_panel_draws_no_strip_and_no_bar() {
struct Bare;
impl DockContent<u32> for Bare {
fn body(&mut self, _: &mut egui::Ui, _: &u32) {}
fn permissions(&self, panel: &u32) -> Permissions {
if *panel == 1 { Permissions::PROTECTED } else { Permissions::default() }
}
}
let ctx = Context::default();
for _ in 0..2 {
ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| {
DockArea::new(Id::unique(AREA)).show_content(ui, &frames(), &DockStyle::from_ui(ui), &mut Bare);
})
.textures_delta
.clear();
}
assert!(ctx.read_response(Id::unique(AREA).with(("drag-bar", Some(&1u32)))).is_none());
assert!(ctx.read_response(Id::unique(AREA).with(("tab", &1u32))).is_none());
assert!(ctx.read_response(Id::unique(AREA).with(("drag-bar", Some(&2u32)))).is_some());
}
#[test]
fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() {
use egui_kittest::{Harness, kittest::Queryable};
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|ui, (content, actions): &mut (TestContent, Vec<Action<u32>>)| {
let out = DockArea::new(Id::unique(AREA)).show_content(ui, &docked(), &DockStyle::from_ui(ui), content);
actions.extend(out.actions);
},
(TestContent { protected: vec![0], ..Default::default() }, Vec::new()),
);
h.run_steps(2);
let bar_id = Id::unique(AREA).with(("drag-bar", Some(&2u32)));
let at = h.ctx.read_response(bar_id).unwrap().rect.center();
h.input_mut().events.extend([
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
]);
h.run_steps(1);
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
for label in ["Close Tab Group", "Minimize", "Float Tab Group", "Move Group To"] {
assert_eq!(h.query_all_by_label(label).count(), 1, "{label}");
}
h.get_by_label("Close Tab Group").click();
h.run_steps(2);
assert!(h.state().1.contains(&Action::CloseGroup { panel: 3 }), "{:?}", h.state().1);
h.ctx.memory_mut(|memory| memory.request_focus(bar_id));
h.run_steps(1);
assert!(DockArea::<u32>::new(Id::unique(AREA)).chrome_has_focus(&h.ctx, &docked()));
h.input_mut().events.push(key(egui::Key::Enter));
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx));
}
#[test]
fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() {
let stack = Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], [80.0, 70.0, 300.0, 400.0]).unwrap();
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![stack] };
let mut d = Driver::new(layout);
let from = d.rect("drag-bar", 6).center();
let out = d.drag(from, pos2(700.0, 300.0));
assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 189.0]) })), "{:?}", out.actions);
assert_eq!(d.layout.floating.len(), 2);
assert_eq!(d.layout.floating[0].groups(), vec![Group::new(vec![5], 0)]);
assert_eq!(d.layout.floating[1].groups(), vec![Group::new(vec![6, 7], 1)]);
}
#[test]
fn a_short_docked_panel_floats_at_its_content_height_up_to_its_floating_size() {
let title = crate::docking::chrome::TITLE_HEIGHT;
let float = |body: f32, floating_size: Option<Vec2>| {
let content = TestContent { body_height: Some(body), floating_size, ..Default::default() };
let mut d = Driver::with(docked(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
let strip = d.rect("drag-bar", 4).height();
let recorded = crate::docking::float_space::<u32>(&d.ctx, Id::unique(AREA)).unwrap().panel(&4).unwrap();
let target = pos2(400.0, 300.0);
d.hold(d.tab(4).center(), target);
d.modifiers = Modifiers::COMMAND;
let out = d.step(vec![button(target, false)]);
let Some([_, _, width, height]) = out.actions.iter().find_map(|a| match a {
Action::Float { panel: 4, rect } => Some(*rect),
_ => None,
}) else {
panic!("{:?}", out.actions)
};
assert_eq!(recorded, vec2(width, height), "a command floats at the size a drag does");
(strip, vec2(width, height))
};
let (strip, size) = float(150.0, None);
assert_eq!(size.y, title + strip + 150.0);
assert_eq!(float(0.0, None).1.y, 120.0, "an empty panel floats at the least fitted height");
assert_eq!(float(600.0, None).1.y, 320.0, "a tall docked panel keeps the docked cap");
// The host's floating size gives the width and the most height.
assert_eq!(float(150.0, Some(vec2(300.0, 360.0))).1, vec2(300.0, title + strip + 150.0));
assert_eq!(float(150.0, Some(vec2(300.0, 160.0))).1, vec2(300.0, 160.0));
// A group takes its active panel's content height.
let content = TestContent { body_height: Some(150.0), ..Default::default() };
let mut d = Driver::with(docked(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
let target = pos2(400.0, 300.0);
d.hold(d.rect("drag-bar", 2).center(), target);
d.modifiers = Modifiers::COMMAND;
let out = d.step(vec![button(target, false)]);
let Some(Target::Float { rect: Some([_, _, _, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) };
assert_eq!(*height, title + strip + 150.0);
}
#[test]
fn a_panel_that_fills_its_frame_floats_at_its_floating_size_or_320() {
let float = |floating_size: Option<Vec2>| {
let content = TestContent { fill: true, floating_size, ..Default::default() };
let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
let target = pos2(400.0, 300.0);
d.hold(d.tab(3).center(), target);
d.modifiers = Modifiers::COMMAND;
let out = d.step(vec![button(target, false)]);
out.actions
.iter()
.find_map(|a| match a {
Action::Float { panel: 3, rect } => Some(rect[3]),
_ => None,
})
.unwrap_or_else(|| panic!("{:?}", out.actions))
};
assert_eq!(float(None), 320.0);
assert_eq!(float(Some(vec2(300.0, 400.0))), 400.0);
}
#[test]
fn tab_focus_follows_only_the_users_input() {
let mut d = Driver::new(docked());
let tab = Id::unique(AREA).with(("tab", &4u32));
// Keyboard focus as Tab gives it.
d.ctx.memory_mut(|m| m.request_focus(tab));
d.step(vec![]);
assert!(d.ctx.memory(|m| m.has_focus(tab)));
// A reset that moves the panel takes its focus away and reports no new focus.
let mut reset = docked();
let Some(Node::Split { first, .. }) = &mut reset.root else { panic!() };
let Node::Dock { columns, .. } = first.as_mut() else { panic!() };
columns[0].groups.reverse();
d.layout = reset;
let out = d.step(vec![]);
assert_eq!(out.focused, None);
d.step(vec![]);
assert_eq!(d.ctx.memory(|m| m.focused()), None, "the moved tab gives up keyboard focus");
// A user drag keeps the dragged tab's focus where the user put it.
let mut d = Driver::new(docked());
d.ctx.memory_mut(|m| m.request_focus(tab));
d.drag(d.tab(4).center(), d.tab(2).center());
d.step(vec![]);
assert!(d.layout.place(&4) != docked().place(&4));
assert!(d.ctx.memory(|m| m.has_focus(tab)), "a tab moved by the user keeps focus");
// A closed panel's tab gives focus up too.
let out = d.step(vec![]);
assert!(out.actions.is_empty());
d.layout.apply(Action::Close { panel: 4 }).unwrap();
d.step(vec![]);
d.step(vec![]);
assert_eq!(d.ctx.memory(|m| m.focused()), None);
}
#[test]
fn frame_tabs_follow_the_wheel_and_close_on_a_middle_click() {
let second = |d: &Driver| match &d.layout.root {
Some(Node::Split { second, .. }) => (**second).clone(),
_ => panic!("{:?}", d.layout.root),
};
let wheel = |unit, y| Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit,
delta: vec2(0.0, y),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
};
let middle = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Middle, pressed, modifiers: Modifiers::NONE };
// Off by default.
let mut d = Driver::new(frames());
let over = d.tab(2).center();
d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]);
d.step(vec![middle(over, true)]);
d.step(vec![middle(over, false)]);
assert_eq!(second(&d), tabs(&[2, 3], 1));
let mut d = Driver::new(frames());
d.tweak = super::floating_tests::frame_style;
d.step(vec![]);
let over = d.tab(2).center();
d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]);
assert_eq!(second(&d), tabs(&[2, 3], 0), "wheel up selects the previous tab");
d.step(vec![wheel(egui::MouseWheelUnit::Line, 1.0)]);
assert_eq!(second(&d), tabs(&[2, 3], 0), "and stops at the first");
d.step(vec![wheel(egui::MouseWheelUnit::Point, -30.0)]);
assert_eq!(second(&d), tabs(&[2, 3], 0), "trackpad scrolling adds up to a notch");
let out = d.step(vec![wheel(egui::MouseWheelUnit::Point, -30.0)]);
assert_eq!(second(&d), tabs(&[2, 3], 1));
assert_eq!(out.focused, Some(3));
d.step(vec![wheel(egui::MouseWheelUnit::Line, -1e30)]);
assert_eq!(second(&d), tabs(&[2, 3], 1), "a huge delta stops at the last tab");
d.step(vec![wheel(egui::MouseWheelUnit::Line, 1e30)]);
assert_eq!(second(&d), tabs(&[2, 3], 0), "and at the first");
d.step(vec![middle(over, true)]);
let out = d.step(vec![middle(over, false)]);
assert_eq!(out.actions, vec![Action::Close { panel: 2 }]);
assert_eq!(second(&d), tabs(&[3], 0));
}
+230
View File
@@ -0,0 +1,230 @@
//! Where each docking gesture starts, for control protocols and tests.
use std::{collections::HashMap, fmt::Debug, hash::Hash};
use egui::{Context, Id, LayerId, Order, Rect};
use super::{DockArea, Floating, Layout, Node, render::RESIZE_HANDLES};
/// A rendered docking control that starts a gesture. Groups, columns and windows are named by
/// their first panel.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Hit<P> {
/// A tab: click activates, drag moves the panel (S1).
Tab(P),
/// A group's drag bar: drag moves the group; a right click, Enter or Space opens the group
/// menu (S2).
DragBar(P),
/// The top border of a group's strip: drag moves the group (S3).
Border(P),
/// The "…" button of a group whose tabs overflow; named by the active panel.
Overflow(P),
/// [D] A column bar: drag moves the column (S6).
ColumnBar(P),
/// [D] The »/« button of a column bar (M2).
Collapse(P),
/// An accordion entry's −/+ button.
StackToggle(P),
/// A floating window's title bar (S5).
Title(P),
/// A floating window's × button.
Close(P),
/// [D] A panel's icon in an iconic column (S7, M5).
Icon(P),
/// [D] The grip above an icon group (S8).
IconGrip(P),
/// A floating window's resize handle.
Resize(P, ResizeEdge),
Divider(Divider<P>),
}
/// The edge or corner a floating window's resize handle moves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum ResizeEdge {
Left,
Right,
Bottom,
BottomLeft,
BottomRight,
Top,
TopLeft,
TopRight,
}
/// A divider that resizes what is on either side of it.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Divider<P> {
/// A split, by its path from the root (see [`super::Action::ResizeSplit`]).
Split(Vec<bool>),
/// Below the accordion entry of this panel.
Stack(P),
/// [D] The inner edge of the column holding this panel first.
Column(P),
/// Below the dock group holding this panel first.
Group(P),
/// Where the divider of a split inside another split ends on the outer split's divider,
/// by the outer and the inner split's paths. Dragging it moves both dividers.
Intersection(Vec<bool>, Vec<bool>),
/// A divider in the floating window holding the panel, by its path from the window's root.
WindowSplit(P, Vec<bool>),
WindowIntersection(P, Vec<bool>, Vec<bool>),
}
fn key(area: Id) -> Id {
area.with("hit-rects")
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'_, P> {
/// Every gesture source the area drew in its last frame, with its rect in points. Hosts
/// return these from a `ui.docking` control method beside the layout and `focused`; tests
/// drag from their centers.
pub fn hit_rects(ctx: &Context, id: Id) -> Vec<(Hit<P>, Rect)> {
let Some((layout, rects)) = ctx.data(|d| d.get_temp::<Recorded<P>>(key(id))) else { return Vec::new() };
let mut names = named(id, &layout);
rects.into_iter().filter_map(|(id, rect)| Some((names.remove(&id)?, rect))).collect()
}
/// The egui widget id of the control `hit` names in the area `id`, for tests and
/// automation that focus a control or read its response.
pub fn hit_id(id: Id, hit: &Hit<P>) -> Id {
match hit {
Hit::Tab(p) => id.with(("tab", p)),
Hit::Icon(p) => id.with(("icon", p)),
Hit::Overflow(p) => id.with(("overflow", p)),
Hit::StackToggle(p) => id.with(("collapse", p)),
Hit::Divider(Divider::Stack(p)) => id.with(("stack-resize", p)),
Hit::DragBar(p) => id.with(("drag-bar", Some(p))),
Hit::Border(p) => id.with(("drag-border", Some(p))),
Hit::IconGrip(p) => id.with(("icon-grip", Some(p))),
Hit::ColumnBar(p) => id.with(("column-bar", Some(p))),
Hit::Collapse(p) => id.with(("column-collapse", Some(p))),
Hit::Divider(Divider::Column(p)) => id.with(("column-resize", Some(p))),
Hit::Divider(Divider::Group(p)) => id.with(("group-resize", Some(p))),
Hit::Divider(Divider::Split(path)) => id.with(("split", path)),
Hit::Divider(Divider::Intersection(outer, inner)) => id.with(("intersection", outer, inner)),
Hit::Divider(Divider::WindowSplit(p, path)) => id.with(("window-split", p, path)),
Hit::Divider(Divider::WindowIntersection(p, outer, inner)) => id.with(("window-intersection", p, outer, inner)),
Hit::Title(p) => id.with(("move-group", Some(p))),
Hit::Close(p) => id.with(("move-group", Some(p))).with("close"),
Hit::Resize(p, edge) => {
let name = match edge {
ResizeEdge::BottomRight => RESIZE_HANDLES[0],
ResizeEdge::Left => RESIZE_HANDLES[1],
ResizeEdge::Right => RESIZE_HANDLES[2],
ResizeEdge::Bottom => RESIZE_HANDLES[3],
ResizeEdge::BottomLeft => RESIZE_HANDLES[4],
ResizeEdge::Top => RESIZE_HANDLES[5],
ResizeEdge::TopLeft => RESIZE_HANDLES[6],
ResizeEdge::TopRight => RESIZE_HANDLES[7],
};
id.with(("floating", p)).with(name)
}
}
}
/// The egui layer of a floating window in the area `id`; `None` for an empty window. Of two
/// layers, the later in `ctx.memory(|m| m.layer_ids())` is drawn on top, and
/// `ctx.memory(|m| m.area_rect(layer.id))` is the window's rect. A window the area does not
/// draw (all its panels hidden, or a frame maximized) keeps its last rect there: check
/// `ctx.memory(|m| m.areas().is_visible(&layer))` before treating it as covering anything.
pub fn floating_layer(id: Id, window: &Floating<P>) -> Option<LayerId> {
window.panels().next().map(|first| LayerId::new(Order::Middle, id.with(("floating", first))))
}
}
/// Every control the area `area` can draw for `layout`, by widget id.
pub(super) fn named<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(area: Id, layout: &Layout<P>) -> HashMap<Id, Hit<P>> {
let mut names: HashMap<Id, Hit<P>> = HashMap::new();
let mut name = |hit: Hit<P>| {
names.insert(DockArea::<P>::hit_id(area, &hit), hit);
};
for panel in layout.panels() {
let p = || panel.clone();
for hit in [
Hit::Tab(p()),
Hit::Icon(p()),
Hit::Overflow(p()),
Hit::StackToggle(p()),
Hit::Divider(Divider::Stack(p())),
Hit::DragBar(p()),
Hit::Border(p()),
Hit::IconGrip(p()),
Hit::ColumnBar(p()),
Hit::Collapse(p()),
Hit::Divider(Divider::Column(p())),
Hit::Divider(Divider::Group(p())),
Hit::Title(p()),
Hit::Close(p()),
] {
name(hit);
}
for edge in [
ResizeEdge::BottomRight,
ResizeEdge::Left,
ResizeEdge::Right,
ResizeEdge::Bottom,
ResizeEdge::BottomLeft,
ResizeEdge::Top,
ResizeEdge::TopLeft,
ResizeEdge::TopRight,
] {
name(Hit::Resize(p(), edge));
}
}
if let Some(root) = &layout.root {
name_splits(root, &mut |path, outer| match outer {
None => name(Hit::Divider(Divider::Split(path))),
Some(outer) => name(Hit::Divider(Divider::Intersection(outer, path))),
});
}
for window in &layout.floating {
let Some(key) = window.panels().next() else { continue };
name_splits(&window.root, &mut |path, outer| match outer {
None => name(Hit::Divider(Divider::WindowSplit(key.clone(), path))),
Some(outer) => name(Hit::Divider(Divider::WindowIntersection(key.clone(), outer, path))),
});
}
names
}
/// Every split of a tree by its path (`None`), and every pair of a split and a split inside it
/// (`Some(outer)`), whose dividers may meet.
fn name_splits<P>(root: &Node<P>, name: &mut dyn FnMut(Vec<bool>, Option<Vec<bool>>)) {
let mut nodes = vec![(root, Vec::<bool>::new())];
let mut splits: Vec<Vec<bool>> = Vec::new();
while let Some((node, path)) = nodes.pop() {
if splits.len() > super::model::MAX_NODES {
break;
}
if let Node::Split { first, second, .. } = node {
name(path.clone(), None);
for outer in splits.iter().filter(|outer| path.starts_with(outer)) {
name(path.clone(), Some(outer.clone()));
}
splits.push(path.clone());
let mut second_path = path.clone();
second_path.push(true);
let mut first_path = path;
first_path.push(false);
nodes.push((second, second_path));
nodes.push((first, first_path));
}
}
}
/// The layout of the last frame and the rects of the controls it registered, by widget id.
type Recorded<P> = (Layout<P>, Vec<(Id, Rect)>);
/// Store the rects of the controls the renderer registered this frame. They are named only
/// when [`DockArea::hit_rects`] asks, which tests and automation do, not every frame.
pub(super) fn record<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(ctx: &Context, area: Id, layout: &Layout<P>, owned: &[Id]) {
let rects: Vec<(Id, Rect)> =
owned.iter().filter_map(|id| Some((*id, ctx.read_response(*id)?.rect))).filter(|(_, rect)| rect.is_positive()).collect();
ctx.data_mut(|d| d.insert_temp::<Recorded<P>>(key(area), (layout.clone(), rects)));
}
+409
View File
@@ -0,0 +1,409 @@
//! [D] Iconic columns, flyouts and hit rects, driven through `testing::DockDriver`.
use super::testing::DockDriver;
use super::*;
use crate::layout::{SplitAxis, SplitSize};
use egui::{Key, Pos2, Rect, pos2, vec2};
struct Content;
impl DockContent<u32> for Content {
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
ui.label("Body");
}
fn logical_label(&self, panel: &u32) -> String {
format!("Panel {panel}")
}
fn permissions(&self, panel: &u32) -> Permissions {
if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() }
}
}
type Driver = DockDriver<u32, Content>;
/// A name and a test for one kind of [`Hit`].
type Check<'a> = (&'a str, &'a dyn Fn(&Hit<u32>) -> bool);
/// A left dock with one column of `groups`, iconic when `iconic`, beside canvas 0.
fn docked(groups: &[&[u32]], iconic: bool) -> Layout<u32> {
let mut column = Column::new(groups.iter().map(|g| Group::new(g.to_vec(), 0)).collect(), 240.0);
column.iconic = iconic;
let dock = Node::Dock { side: Side::Start, columns: vec![column] };
let canvas = Node::Tabs { panels: vec![0], active: 0 };
Layout {
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(canvas) }),
floating: Vec::new(),
}
}
fn first_column(layout: &Layout<u32>) -> Column<u32> {
match &layout.root {
Some(Node::Split { first, .. }) => match first.as_ref() {
Node::Dock { columns, .. } => columns.first().cloned().unwrap(),
other => panic!("no dock: {other:?}"),
},
other => panic!("no split: {other:?}"),
}
}
fn groups(layout: &Layout<u32>) -> Vec<Vec<u32>> {
first_column(layout).groups.iter().map(|g| g.panels.clone()).collect()
}
fn hit(d: &Driver, hit: Hit<u32>) -> Rect {
d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits()))
}
fn open(d: &Driver) -> Option<u32> {
flyout::<u32>(&d.ctx, d.id)
}
fn texts(d: &Driver) -> Vec<String> {
d.shapes()
.iter()
.filter_map(|clipped| match &clipped.shape {
egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
_ => None,
})
.collect()
}
const CANVAS: Pos2 = pos2(600.0, 400.0);
#[test]
fn collapsed_column_shows_one_icon_per_panel_with_group_separators() {
let d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
let (one, two, three) = (hit(&d, Hit::Icon(1)), hit(&d, Hit::Icon(2)), hit(&d, Hit::Icon(3)));
let (grip1, grip3) = (hit(&d, Hit::IconGrip(1)), hit(&d, Hit::IconGrip(3)));
assert_eq!(one.width(), 36.0, "the column takes the style's iconic width");
assert_eq!(one.bottom(), two.top(), "a group's icons follow each other");
assert_eq!(three.top() - two.bottom(), 7.0, "groups are apart by the separator, with no room for a grip");
assert_eq!((grip1.top(), grip1.bottom()), (one.top(), one.top() + 4.0), "the first group's band is its top edge");
assert_eq!((grip3.top(), grip3.bottom()), (two.bottom(), three.top() + 4.0), "a later group's band covers the separator");
assert!(d.hit(&Hit::Tab(1)).is_none(), "an iconic column draws no tabs");
}
#[test]
fn widening_an_iconic_column_shows_labels() {
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
assert!(!texts(&d).iter().any(|t| t == "Panel 1"));
let edge = hit(&d, Hit::Divider(Divider::Column(1)));
let out = d.drag(edge.center(), pos2(150.0, edge.center().y));
assert!(out.error.is_none());
let width = first_column(&d.layout).iconic_width.unwrap();
assert!((100.0..=160.0).contains(&width), "{width}");
d.settle();
let labels = texts(&d);
assert!(["Panel 1", "Panel 2", "Panel 3"].iter().all(|l| labels.iter().any(|t| t == l)), "{labels:?}");
let edge = hit(&d, Hit::Divider(Divider::Column(1)));
d.drag(edge.center(), pos2(20.0, edge.center().y));
d.settle();
assert_eq!(first_column(&d.layout).iconic_width, Some(36.0));
assert!(!texts(&d).iter().any(|t| t == "Panel 1"));
}
#[test]
fn icon_click_opens_a_flyout_on_the_canvas_side_and_auto_collapse_closes_it() {
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
let icon = hit(&d, Hit::Icon(2)).center();
let out = d.click(icon);
assert!(out.actions.contains(&Action::Activate { panel: 2 }));
d.settle();
assert_eq!(open(&d), Some(2));
let column = hit(&d, Hit::Icon(1));
let tab = hit(&d, Hit::Tab(2));
assert!(tab.left() >= column.right(), "the flyout opens on the canvas side: {tab:?}");
assert!(tab.top() <= hit(&d, Hit::IconGrip(1)).bottom(), "beside its group");
assert_eq!(first_column(&d.layout).groups.first().map(|g| g.active), Some(1));
// Another icon switches flyouts, the same icon closes.
d.click(hit(&d, Hit::Icon(3)).center());
d.settle();
assert_eq!(open(&d), Some(3));
assert!(d.hit(&Hit::Tab(2)).is_none());
d.click(hit(&d, Hit::Icon(3)).center());
d.settle();
assert_eq!(open(&d), None);
// Escape and a press outside close it.
d.click(icon);
d.settle();
d.key(Key::Escape);
d.settle();
assert_eq!(open(&d), None);
d.click(icon);
d.settle();
let inside = hit(&d, Hit::Tab(2)).center();
d.click(inside);
d.settle();
assert_eq!(open(&d), Some(2), "a press inside keeps it");
d.click(CANVAS);
d.settle();
assert_eq!(open(&d), None);
// Auto-Collapse off: it stays until an icon closes it.
d.auto_collapse = false;
d.click(icon);
d.settle();
d.key(Key::Escape);
d.click(CANVAS);
d.settle();
assert_eq!(open(&d), Some(2));
d.click(icon);
d.settle();
assert_eq!(open(&d), None);
// U3: raising an iconic panel, as the Window menu does, opens its flyout.
d.raise = Some(3);
d.settle();
assert_eq!(open(&d), Some(3));
}
#[test]
fn auto_collapse_toggle_is_a_column_menu_event() {
use egui_kittest::{Harness, kittest::Queryable};
let layout = docked(&[&[1, 2]], true);
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|ui, events: &mut Vec<DockEvent<u32>>| {
let out = DockArea::new(egui::Id::unique("menu")).show_content(ui, &layout, &DockStyle::from_ui(ui), &mut Content);
events.extend(out.events);
},
Vec::new(),
);
h.run_steps(2);
let bar = h.ctx.read_response(egui::Id::unique("menu").with(("column-bar", Some(&1u32)))).unwrap().rect.center();
h.input_mut().events.extend([
egui::Event::PointerMoved(bar),
egui::Event::PointerButton { pos: bar, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE },
]);
h.run_steps(1);
h.input_mut().events.push(egui::Event::PointerButton {
pos: bar,
button: egui::PointerButton::Secondary,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(2);
h.get_by_label("Auto-Collapse Iconic Panels").click();
h.run_steps(2);
assert_eq!(h.state(), &vec![DockEvent::ToggleAutoCollapse]);
}
#[test]
fn dragging_a_flyout_strip_floats_its_group() {
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
d.click(hit(&d, Hit::Icon(1)).center());
d.settle();
// A one-line body: the flyout and the floated group take the least fitted height.
let flyout = d.ctx.memory(|m| m.area_rect(d.id.with("flyout"))).unwrap();
assert_eq!(flyout.height(), 120.0);
let bar = hit(&d, Hit::DragBar(1));
let out = d.drag(bar.center(), CANVAS);
let [Action::MoveGroup { panel: 1, to: Target::Float { rect: Some(rect) } }] = out.actions.as_slice() else { panic!("{:?}", out.actions) };
assert_eq!(rect[3], 120.0);
assert_eq!(groups(&d.layout), vec![vec![3]]);
assert_eq!(d.layout.floating.len(), 1);
assert_eq!(d.layout.floating.first().map(|w| w.panels().copied().collect::<Vec<_>>()), Some(vec![1, 2]));
d.settle();
assert_eq!(open(&d), None, "the flyout closes with its group gone");
// A tab dragged out of a flyout floats that panel alone.
d.click(hit(&d, Hit::Icon(3)).center());
d.settle();
let tab = hit(&d, Hit::Tab(3));
let out = d.drag(tab.center(), pos2(450.0, 200.0));
assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions);
}
#[test]
fn icon_and_icon_group_grip_drags_move_a_panel_and_a_group() {
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
let before = d.layout.clone();
// Escape, focus loss and cancel_drag end an icon drag with no change.
let icon = hit(&d, Hit::Icon(2)).center();
d.hold(icon, CANVAS);
d.key(Key::Escape);
d.release(CANVAS);
d.hold(icon, CANVAS);
d.focus(false);
d.focus(true);
d.release(CANVAS);
d.hold(icon, CANVAS);
cancel_drag::<u32>(&d.ctx, d.id);
d.release(CANVAS);
assert_eq!(d.layout, before);
let out = d.drag(icon, CANVAS);
let Some(Action::Float { panel: 2, rect }) = out.actions.first() else { panic!("{:?}", out.actions) };
assert_eq!((rect[2], rect[3]), (240.0, 320.0), "a panel floated from its icon takes the column's expanded width");
assert_eq!(groups(&d.layout), vec![vec![1], vec![3]]);
let grip = hit(&d, Hit::IconGrip(3)).center();
let out = d.drag(grip, pos2(450.0, 250.0));
assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 3, to: Target::Float { .. } }]), "{:?}", out.actions);
assert_eq!(groups(&d.layout), vec![vec![1]]);
}
#[test]
fn group_dropped_on_an_iconic_column_joins_as_an_icon_group() {
let mut layout = docked(&[&[1], &[2]], true);
layout.floating.push(Floating::single(vec![5, 6], 0, [300.0, 200.0, 240.0, 200.0]));
let mut d = DockDriver::new(layout, Content);
let bar = hit(&d, Hit::DragBar(5)).center();
let one = hit(&d, Hit::Icon(1));
let target = pos2(one.center().x, one.bottom() - 2.0);
d.hold(bar, target);
let column = Rect::from_min_max(pos2(0.0, 14.0), pos2(36.0, 600.0));
assert!(d.outlined(column.shrink(1.0)), "the column is outlined");
let line = Rect::from_x_y_ranges(column.x_range(), (one.bottom() - 2.0)..=(one.bottom() + 2.0));
assert!(d.filled(line), "with the insertion line under group 1");
let out = d.release(target);
assert!(
out.actions
.contains(&Action::MoveGroup { panel: 5, to: Target::At { anchor: 1, placement: Placement::Group { below: true, height: None } } }),
"{:?}",
out.actions
);
assert_eq!(groups(&d.layout), vec![vec![1], vec![5, 6], vec![2]]);
assert!(first_column(&d.layout).iconic);
}
/// Every kind of gesture source has a rect, and dragging from it does something.
#[test]
fn hit_rects_cover_every_gesture_source() {
let mut expanded = Column::new(vec![Group::new(vec![1, 2, 3, 4, 5, 6, 7], 0), Group::new(vec![8], 0)], 150.0);
expanded.width = 150.0;
let mut icons = Column::new(vec![Group::new(vec![9], 0)], 240.0);
icons.iconic = true;
let dock = Node::Dock { side: Side::Start, columns: vec![icons, expanded] };
let stack = Node::Stack { entries: vec![StackEntry { panel: 10, open: true, height: None }, StackEntry { panel: 11, open: true, height: None }] };
let frames = Node::Split {
axis: SplitAxis::Vertical,
size: SplitSize::Ratio(0.5),
first: Box::new(Node::Tabs { panels: vec![0], active: 0 }),
second: Box::new(stack),
};
let window = Floating::stack(vec![Group::new(vec![12], 0), Group::new(vec![13], 0)], [500.0, 60.0, 220.0, 300.0]).unwrap();
let layout = Layout {
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(200.0), first: Box::new(dock), second: Box::new(frames) }),
floating: vec![window],
};
let d = DockDriver::new(layout.clone(), Content);
let hits = d.hits();
let kinds = |f: &dyn Fn(&Hit<u32>) -> bool| hits.iter().filter(|(h, _)| f(h)).count();
let expected: [Check<'_>; 15] = [
("tab", &|h| matches!(h, Hit::Tab(_))),
("drag bar", &|h| matches!(h, Hit::DragBar(_))),
("border", &|h| matches!(h, Hit::Border(_))),
("overflow", &|h| matches!(h, Hit::Overflow(_))),
("column bar", &|h| matches!(h, Hit::ColumnBar(_))),
("collapse", &|h| matches!(h, Hit::Collapse(_))),
("stack toggle", &|h| matches!(h, Hit::StackToggle(_))),
("title", &|h| matches!(h, Hit::Title(12))),
("close", &|h| matches!(h, Hit::Close(12))),
("icon", &|h| matches!(h, Hit::Icon(9))),
("icon grip", &|h| matches!(h, Hit::IconGrip(9))),
("resize", &|h| matches!(h, Hit::Resize(12, _))),
("split", &|h| matches!(h, Hit::Divider(Divider::Split(_)))),
("column divider", &|h| matches!(h, Hit::Divider(Divider::Column(_)))),
("group divider", &|h| matches!(h, Hit::Divider(Divider::Group(_)))),
];
for (name, kind) in expected {
assert!(kinds(kind) > 0, "{name}: {hits:?}");
}
assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Divider(Divider::Stack(10)))));
assert_eq!(hits.iter().filter(|(h, _)| matches!(h, Hit::Resize(..))).count(), 8);
for (hit, rect) in hits.iter().filter(|(h, _)| !matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) {
let mut d = DockDriver::new(layout.clone(), Content);
let from = rect.center();
let moved = d.hold(from, from + vec2(-30.0, 40.0));
let dragging = is_dragging::<u32>(&d.ctx, d.id);
let released = d.release(from + vec2(-30.0, 40.0));
assert!(dragging || !moved.actions.is_empty() || !released.actions.is_empty(), "{hit:?} at {rect:?} does nothing");
}
for (hit, rect) in hits.iter().filter(|(h, _)| matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) {
let mut d = DockDriver::new(layout.clone(), Content);
let out = d.click(rect.center());
let opened = egui::Popup::is_any_open(&d.ctx);
assert!(opened || !out.actions.is_empty(), "{hit:?} at {rect:?} does nothing");
}
}
/// Paints panel 1's icon as a dot in code; panel 2 has neither a painted icon nor an image.
struct Painted;
impl DockContent<u32> for Painted {
fn body(&mut self, _: &mut egui::Ui, _: &u32) {}
fn logical_label(&self, panel: &u32) -> String {
format!("Panel {panel}")
}
fn paint_icon(&mut self, painter: &egui::Painter, rect: Rect, panel: &u32, color: egui::Color32) -> bool {
if *panel != 1 {
return false;
}
painter.circle_filled(rect.center(), 3.0, color);
true
}
}
#[test]
fn an_icon_painted_in_code_replaces_the_initial_and_others_fall_back() {
let d = DockDriver::new(docked(&[&[1, 2]], true), Painted);
let (one, two) = (d.hit(&Hit::Icon(1)).unwrap(), d.hit(&Hit::Icon(2)).unwrap());
let dots: Vec<Pos2> = d
.shapes()
.iter()
.filter_map(|clipped| match &clipped.shape {
egui::Shape::Circle(circle) if circle.radius == 3.0 => Some(circle.center),
_ => None,
})
.collect();
assert_eq!(dots.len(), 1, "{dots:?}");
assert!(one.contains(dots[0]) && !two.contains(dots[0]), "the dot sits in panel 1's cell: {dots:?}");
let initials: Vec<Pos2> = d
.shapes()
.iter()
.filter_map(|clipped| match &clipped.shape {
egui::Shape::Text(text) if text.galley.text() == "P" => Some(text.pos),
_ => None,
})
.collect();
assert_eq!(initials.len(), 1, "only panel 2 draws its initial: {initials:?}");
assert!(two.contains(initials[0]));
}
#[test]
fn chrome_has_focus_covers_every_drawn_control() {
let mut layout = docked(&[&[1, 2], &[3]], true);
layout.floating.push(Floating::single(vec![5, 6], 0, [400.0, 100.0, 240.0, 200.0]));
let mut d = Driver::new(layout, Content);
d.settle();
let hits = d.hits();
assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Icon(_))), "{hits:?}");
assert!(hits.iter().any(|(h, _)| matches!(h, Hit::IconGrip(_))), "{hits:?}");
assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Resize(_, ResizeEdge::Left))), "{hits:?}");
for (hit, _) in hits {
d.ctx.memory_mut(|m| m.request_focus(DockArea::<u32>::hit_id(d.id, &hit)));
assert!(DockArea::<u32>::new(d.id).chrome_has_focus(&d.ctx, &d.layout), "{hit:?}");
}
}
#[test]
fn floating_windows_stay_inside_the_float_bounds() {
// The app's title bar strip (top 30 pt) is outside the bounds: a window saved there is
// drawn below it, and a title bar drag towards it stops at its edge. Command keeps the
// drag from docking.
let mut layout = docked(&[&[1]], false);
layout.floating.push(Floating::single(vec![5], 0, [400.0, 0.0, 240.0, 200.0]));
let mut d = DockDriver::new(layout, Content);
d.float_bounds = Some(Rect::from_min_max(pos2(0.0, 30.0), pos2(800.0, 600.0)));
d.settle();
let title = hit(&d, Hit::Title(5));
assert!(title.top() >= 30.0, "{title:?}");
d.modifiers = egui::Modifiers::COMMAND;
d.drag(title.center(), pos2(title.center().x, 2.0));
d.modifiers = egui::Modifiers::NONE;
d.settle();
let title = hit(&d, Hit::Title(5));
assert!(title.top() >= 30.0, "{title:?}");
}
+84
View File
@@ -0,0 +1,84 @@
//! One keyed hook for every label in [`DockStyle`].
use super::DockStyle;
impl DockStyle {
/// Replace every `*_label` field with `translate(key)`, keeping the English default where it
/// returns `None`. The defaults of menu items are in title case; those of controls, which
/// only screen readers announce, in sentence case. An application translates the docking
/// chrome in one place:
///
/// ```
/// fn dock_style(ui: &egui::Ui) -> craft_ui::docking::DockStyle {
/// craft_ui::docking::DockStyle::from_ui(ui).localized(|key| match key {
/// "dock.close" => Some("Fermer le panneau".to_owned()),
/// _ => None,
/// })
/// }
/// ```
///
/// | Key | Field | English default |
/// |---|---|---|
/// | `dock.float` | `float_label` | Float Panel |
/// | `dock.close` | `close_label` | Close Panel |
/// | `dock.movePanelTo` | `move_panel_to_label` | Move Panel to Group |
/// | `dock.panels` | `panels_label` | Panels |
/// | `dock.resize` | `resize_label` | Resize panels |
/// | `dock.resizeWindow` | `resize_window_label` | Resize panel window |
/// | `dock.moveGroup` | `move_group_label` | Move tab group |
/// | `dock.closeGroup` | `close_group_label` | Close Tab Group |
/// | `dock.minimize` | `minimize_label` | Minimize |
/// | `dock.expand` | `expand_label` | Expand |
/// | `dock.floatGroup` | `float_group_label` | Float Tab Group |
/// | `dock.moveGroupTo` | `move_group_to_label` | Move Group To |
/// | `dock.moveColumn` | `move_column_label` | Move panel column |
/// | `dock.collapseToIcons` | `collapse_to_icons_label` | Collapse to Icons |
/// | `dock.expandPanels` | `expand_panels_label` | Expand Panels |
/// | `dock.collapseColumn` | `collapse_column_label` | Collapse column to icons |
/// | `dock.expandColumn` | `expand_column_label` | Expand column |
/// | `dock.moveWindow` | `move_window_label` | Move panel window |
/// | `dock.closeWindow` | `close_window_label` | Close panel window |
/// | `dock.closeOthers` | `close_others_label` | Close Other Panels in Group |
/// | `dock.maximize` | `maximize_label` | Maximize Frame |
/// | `dock.restoreFrame` | `restore_frame_label` | Restore Frame Size |
/// | `dock.autoCollapse` | `auto_collapse_label` | Auto-Collapse Iconic Panels |
/// | `dock.floatHint` | `float_hint_label` | Hold {key} to undock |
#[must_use]
pub fn localized(mut self, translate: impl Fn(&str) -> Option<String>) -> Self {
for (key, label) in self.labels_mut() {
if let Some(text) = translate(key) {
*label = text;
}
}
self
}
fn labels_mut(&mut self) -> [(&'static str, &mut String); 24] {
[
("dock.float", &mut self.float_label),
("dock.close", &mut self.close_label),
("dock.movePanelTo", &mut self.move_panel_to_label),
("dock.panels", &mut self.panels_label),
("dock.resize", &mut self.resize_label),
("dock.resizeWindow", &mut self.resize_window_label),
("dock.moveGroup", &mut self.move_group_label),
("dock.closeGroup", &mut self.close_group_label),
("dock.minimize", &mut self.minimize_label),
("dock.expand", &mut self.expand_label),
("dock.floatGroup", &mut self.float_group_label),
("dock.moveGroupTo", &mut self.move_group_to_label),
("dock.moveColumn", &mut self.move_column_label),
("dock.collapseToIcons", &mut self.collapse_to_icons_label),
("dock.expandPanels", &mut self.expand_panels_label),
("dock.collapseColumn", &mut self.collapse_column_label),
("dock.expandColumn", &mut self.expand_column_label),
("dock.moveWindow", &mut self.move_window_label),
("dock.closeWindow", &mut self.close_window_label),
("dock.closeOthers", &mut self.close_others_label),
("dock.maximize", &mut self.maximize_label),
("dock.restoreFrame", &mut self.restore_frame_label),
("dock.autoCollapse", &mut self.auto_collapse_label),
("dock.floatHint", &mut self.float_hint_label),
]
}
}
+45 -3
View File
@@ -5,13 +5,55 @@
//! Validate deserialized layouts before rendering them. Every transaction checks its complete
//! input and commits only a valid result; a rejected move cannot lose the source panel.
pub mod chrome;
#[cfg(feature = "serde")]
mod command;
#[cfg(feature = "native-windows")]
mod desktop;
mod dock_model;
mod hits;
mod labels;
mod model;
#[cfg(feature = "native-windows")]
mod placement;
mod render;
mod resize;
mod surface;
mod targets;
#[cfg(any(test, feature = "testing"))]
pub mod testing;
mod workspace;
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone};
pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag};
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect};
#[cfg(feature = "serde")]
pub use command::{DockCommand, DockOperation, UiDock, ZoneName};
pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target};
pub use hits::{Divider, Hit, ResizeEdge};
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Monitor, Node, Permissions, Placement, StackEntry, Zone};
pub use render::{close_flyout, flyout, open_flyout};
pub use surface::{
DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits,
WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging,
};
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect, zone_shape};
pub use workspace::Workspace;
use surface::{Drag, GroupDrag, GroupGesture};
#[cfg(test)]
mod command_tests;
#[cfg(test)]
mod dock_tests;
#[cfg(test)]
mod floating_tests;
#[cfg(test)]
mod group_tests;
#[cfg(test)]
mod iconic_tests;
#[cfg(test)]
mod model_tests;
#[cfg(all(test, feature = "native-windows"))]
mod native_tests;
#[cfg(test)]
mod split_tests;
#[cfg(test)]
mod tests;
File diff suppressed because it is too large. Load diff
+756
View File
@@ -0,0 +1,756 @@
use super::dock_model::{Place, node_at};
use super::*;
use crate::layout::{SplitAxis, SplitSize};
pub(super) fn g(panels: &[u32], active: usize) -> Group<u32> {
Group::new(panels.to_vec(), active)
}
fn tabs(panels: &[u32], active: usize) -> Node<u32> {
Node::Tabs { panels: panels.to_vec(), active }
}
fn hsplit(size: SplitSize, first: Node<u32>, second: Node<u32>) -> Node<u32> {
Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) }
}
/// Canvas 1 above frame tabs [11, 12]; an end dock with column A ([2, 3], [4]) and column B
/// ([5]); a floating window with groups [6, 7] and [8].
pub(super) fn docked() -> Layout<u32> {
let middle =
Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(0.7), first: Box::new(tabs(&[1], 0)), second: Box::new(tabs(&[11, 12], 1)) };
let dock =
Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[2, 3], 1), g(&[4], 0)], 180.0), Column::new(vec![g(&[5], 0)], 120.0)] };
Layout {
root: Some(hsplit(SplitSize::FixedSecond(300.0), middle, dock)),
floating: vec![Floating::stack(vec![g(&[6, 7], 1), g(&[8], 0)], [100.0, 100.0, 300.0, 400.0]).unwrap()],
}
}
pub(super) const ALL: [u32; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 11, 12];
fn sorted(layout: &Layout<u32>) -> Vec<u32> {
let mut panels: Vec<u32> = layout.panels().into_iter().copied().collect();
panels.sort_unstable();
panels
}
/// Members, active panel and minimized state of the group holding `panel`.
fn group_state(layout: &Layout<u32>, panel: u32) -> (Vec<u32>, u32, bool) {
match layout.place(&panel).unwrap() {
place @ (Place::Node(_) | Place::Floating { .. }) => match layout.node_of(&place).unwrap() {
Node::Tabs { panels, active } => (panels.clone(), panels[*active], false),
other => panic!("{other:?}"),
},
place => {
let group = layout.group(&place).unwrap();
(group.panels.clone(), group.panels[group.active], group.minimized)
}
}
}
fn dock(layout: &Layout<u32>, side: Side) -> &Vec<Column<u32>> {
let path = layout.dock_path(side).unwrap();
let Some(Node::Dock { columns, .. }) = node_at(layout.root.as_ref().unwrap(), &path) else { panic!() };
columns
}
fn panels_of(groups: &[Group<u32>]) -> Vec<Vec<u32>> {
groups.iter().map(|g| g.panels.clone()).collect()
}
#[test]
fn group_moves_keep_order_active_tab_and_minimized_state() {
let targets = [
Target::At { anchor: 5, placement: Placement::Group { below: true, height: None } },
Target::At { anchor: 5, placement: Placement::Column { after: true } },
Target::At { anchor: 8, placement: Placement::Group { below: false, height: None } },
Target::NewDock { side: Side::Start },
Target::Float { rect: Some([20.0, 20.0, 200.0, 300.0]) },
Target::Float { rect: None },
];
// A frame Tabs node, a column group and a floating group.
for source in [11, 2, 6] {
for to in &targets {
let mut layout = docked();
if source != 11 {
layout.apply(Action::SetMinimized { panel: source, minimized: true }).unwrap();
}
let before = group_state(&layout, source);
layout.apply(Action::MoveGroup { panel: source, to: to.clone() }).unwrap_or_else(|e| panic!("{source} {to:?}: {e}"));
let after = group_state(&layout, source);
assert_eq!((&after.0, after.1), (&before.0, before.1), "{source} {to:?}");
// A group keeps its minimized state from dock to dock; a floating window's tree
// has no minimized groups.
if matches!((source, layout.place(&source)), (2, Some(Place::Dock { .. }))) {
assert_eq!(after.2, before.2, "{source} {to:?}");
}
assert_eq!(sorted(&layout), ALL);
}
}
// Into a frame split the group becomes a Tabs node with the same order and selection.
let mut layout = docked();
layout.apply(Action::MoveGroup { panel: 6, to: Target::At { anchor: 1, placement: Placement::Split(Zone::Left) } }).unwrap();
assert_eq!(group_state(&layout, 6), (vec![6, 7], 7, false));
assert_eq!(layout.floating[0].groups().len(), 1);
}
#[test]
fn group_merge_into_tabs_inserts_every_member_before_the_anchor_tab() {
let mut layout = docked();
layout.apply(Action::MoveGroup { panel: 6, to: Target::At { anchor: 2, placement: Placement::Tab { before: Some(3) } } }).unwrap();
assert_eq!(group_state(&layout, 2), (vec![2, 6, 7, 3], 7, false));
assert_eq!(panels_of(&layout.floating[0].groups()), [vec![8]]);
let mut layout = docked();
layout.apply(Action::MoveGroup { panel: 2, to: Target::At { anchor: 11, placement: Placement::Tab { before: Some(12) } } }).unwrap();
assert_eq!(group_state(&layout, 11), (vec![11, 2, 3, 12], 3, false));
assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![4]]);
// A target inside the moving group is a no-op; a protected anchor refuses tabs.
let before = docked();
let mut layout = before.clone();
layout.apply(Action::MoveGroup { panel: 2, to: Target::At { anchor: 3, placement: Placement::Tab { before: None } } }).unwrap();
assert_eq!(layout, before);
let protect = |p: &u32| if *p == 1 { Permissions::PROTECTED } else { Permissions::default() };
let to = Target::At { anchor: 1, placement: Placement::Tab { before: None } };
assert_eq!(layout.apply_with_permissions(Action::MoveGroup { panel: 2, to }, protect), Err(DockError::NotAllowed));
assert_eq!(layout, before);
}
#[test]
fn column_moves_and_floats_keep_every_group() {
let mut layout = docked();
layout.apply(Action::SetIconic { panel: 4, iconic: true }).unwrap();
layout.apply(Action::MoveColumn { panel: 2, to: Target::NewDock { side: Side::Start } }).unwrap();
let Some(Node::Split { size, first, .. }) = &layout.root else { panic!() };
assert_eq!(*size, SplitSize::FixedFirst(180.0));
assert!(matches!(**first, Node::Dock { side: Side::Start, .. }));
let start = dock(&layout, Side::Start);
assert_eq!(start.len(), 1);
assert_eq!(panels_of(&start[0].groups), [vec![2, 3], vec![4]]);
assert_eq!((start[0].width, start[0].iconic, start[0].groups[0].active), (180.0, true, 1));
assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![5]]);
// The last column leaves the end dock, which collapses.
layout.apply(Action::MoveColumn { panel: 5, to: Target::At { anchor: 2, placement: Placement::Column { after: false } } }).unwrap();
assert!(layout.dock_path(Side::End).is_none());
let start = dock(&layout, Side::Start);
assert_eq!(start.iter().map(|c| c.width).collect::<Vec<_>>(), [120.0, 180.0]);
layout.apply(Action::MoveColumn { panel: 3, to: Target::Float { rect: None } }).unwrap();
let window = layout.floating.last().unwrap();
assert_eq!(panels_of(&window.groups()), [vec![2, 3], vec![4]]);
assert_eq!(window.rect[2], 180.0);
assert_eq!(sorted(&layout), ALL);
assert_eq!(layout.apply(Action::MoveColumn { panel: 11, to: Target::Float { rect: None } }), Err(DockError::InvalidPlacement));
}
#[test]
fn window_docks_as_stacked_groups_or_merges_as_tabs() {
let mut layout = docked();
layout.apply(Action::MoveWindow { panel: 8, to: Target::At { anchor: 4, placement: Placement::Group { below: true, height: None } } }).unwrap();
assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![2, 3], vec![4], vec![6, 7], vec![8]]);
assert!(layout.floating.is_empty());
let mut layout = docked();
layout.apply(Action::MoveWindow { panel: 8, to: Target::At { anchor: 11, placement: Placement::Tab { before: None } } }).unwrap();
assert_eq!(group_state(&layout, 11), (vec![11, 12, 6, 7, 8], 7, false));
let mut layout = docked();
layout.apply(Action::MoveWindow { panel: 6, to: Target::NewDock { side: Side::Start } }).unwrap();
let start = dock(&layout, Side::Start);
assert_eq!((panels_of(&start[0].groups), start[0].width), (vec![vec![6, 7], vec![8]], 300.0));
let before = docked();
let mut layout = before.clone();
layout.apply(Action::MoveWindow { panel: 7, to: Target::At { anchor: 8, placement: Placement::Tab { before: None } } }).unwrap();
assert_eq!(layout, before);
assert_eq!(layout.apply(Action::MoveWindow { panel: 2, to: Target::Float { rect: None } }), Err(DockError::InvalidPlacement));
}
#[test]
fn close_group_and_close_window_record_a_restorable_location_per_member() {
let mut workspace = Workspace::new(docked());
let allow = |_: &u32| Permissions::default();
assert_eq!(workspace.apply(Action::CloseGroup { panel: 3 }, allow).unwrap(), [2, 3]);
assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [2, 3]);
assert_eq!(panels_of(&dock(&workspace.layout, Side::End)[0].groups), [vec![4]]);
workspace.set_visible(2, true, None, |_| Permissions::default()).unwrap();
workspace.set_visible(3, true, None, |_| Permissions::default()).unwrap();
assert_eq!(panels_of(&dock(&workspace.layout, Side::End)[0].groups), [vec![2, 3], vec![4]]);
assert_eq!(workspace.layout.closed_by(&Action::CloseWindow { panel: 7 }).unwrap(), [6, 7, 8]);
assert_eq!(workspace.apply(Action::CloseWindow { panel: 7 }, allow).unwrap(), [6, 7, 8]);
assert!(workspace.layout.floating.is_empty());
assert_eq!(workspace.hidden.len(), 3);
for panel in [6, 7, 8] {
workspace.set_visible(panel, true, None, |_| Permissions::default()).unwrap();
}
assert_eq!(workspace.layout.floating.len(), 1);
assert_eq!(panels_of(&workspace.layout.floating[0].groups()), [vec![6, 7], vec![8]]);
assert_eq!(workspace.layout.floating[0].rect, [100.0, 100.0, 300.0, 400.0]);
workspace.apply(Action::CloseOthers { panel: 12 }, allow).unwrap();
assert_eq!(group_state(&workspace.layout, 12).0, [12]);
workspace.set_visible(11, true, None, |_| Permissions::default()).unwrap();
assert_eq!(group_state(&workspace.layout, 12).0, [11, 12]);
assert!(workspace.hidden.is_empty());
assert_eq!(sorted(&workspace.layout), ALL);
// A member that may not close keeps the whole group open.
let keep = |p: &u32| if *p == 4 { Permissions { closable: false, ..Permissions::default() } } else { Permissions::default() };
let before = workspace.clone();
assert_eq!(workspace.apply(Action::CloseGroup { panel: 4 }, keep), Err(DockError::NotAllowed));
assert_eq!(workspace, before);
}
#[test]
fn panels_closed_one_by_one_reopen_into_one_group() {
let allow = |_: &u32| Permissions::default();
let rect = [100.0, 100.0, 300.0, 400.0];
let cycle = |workspace: &mut Workspace<u32>, close: &[u32], reopen: &[u32]| {
for &panel in close {
workspace.set_visible(panel, false, None, allow).unwrap();
}
for &panel in reopen {
workspace.set_visible(panel, true, None, allow).unwrap_or_else(|e| panic!("{close:?} {reopen:?} {panel}: {e}"));
}
assert!(workspace.hidden.is_empty());
};
let orders2 = [[2, 3], [3, 2]];
for close in orders2 {
for reopen in orders2 {
let mut workspace = Workspace::new(docked());
cycle(&mut workspace, &close, &reopen);
let columns = dock(&workspace.layout, Side::End);
assert_eq!(
columns.iter().map(|c| panels_of(&c.groups)).collect::<Vec<_>>(),
[vec![vec![2, 3], vec![4]], vec![vec![5]]],
"{close:?} {reopen:?}"
);
}
}
let orders2 = [[10, 20], [20, 10]];
for close in orders2 {
for reopen in orders2 {
let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![10, 20], 0, rect)] });
cycle(&mut workspace, &close, &reopen);
let [window] = &workspace.layout.floating[..] else { panic!("{close:?} {reopen:?}: {:?}", workspace.layout.floating) };
assert_eq!((window.panels().copied().collect::<Vec<_>>(), window.rect), (vec![10, 20], rect), "{close:?} {reopen:?}");
}
}
// Three tabs: the locations do not always record the order, only the window.
let orders3 = [[10, 20, 30], [10, 30, 20], [20, 10, 30], [20, 30, 10], [30, 10, 20], [30, 20, 10]];
for close in orders3 {
for reopen in orders3 {
let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![10, 20, 30], 0, rect)] });
cycle(&mut workspace, &close, &reopen);
let [window] = &workspace.layout.floating[..] else { panic!("{close:?} {reopen:?}: {:?}", workspace.layout.floating) };
let mut panels: Vec<u32> = window.panels().copied().collect();
panels.sort_unstable();
assert_eq!(panels, [10, 20, 30], "{close:?} {reopen:?}");
}
}
}
#[test]
fn a_panel_reopened_through_a_hidden_neighbour_keeps_its_own_group_state() {
let allow = |_: &u32| Permissions::default();
let dock_of = |columns: Vec<Column<u32>>| Layout {
root: Some(hsplit(SplitSize::FixedSecond(480.0), tabs(&[1], 0), Node::Dock { side: Side::End, columns })),
floating: Vec::new(),
};
// Groups [2] and [3] stacked with their own heights: 2 comes back at its height, not 3's.
let stacked = dock_of(vec![
Column::new(vec![Group { height: Some(120.0), ..g(&[2], 0) }, Group { height: Some(300.0), ..g(&[3], 0) }], 240.0),
Column::new(vec![g(&[5], 0)], 240.0),
]);
let mut workspace = Workspace::new(stacked);
workspace.set_visible(2, false, None, allow).unwrap();
workspace.set_visible(3, false, None, allow).unwrap();
workspace.set_visible(2, true, None, allow).unwrap();
let group = |w: &Workspace<u32>, panel: u32| {
dock(&w.layout, Side::End).iter().flat_map(|c| c.groups.iter()).find(|g| g.panels.contains(&panel)).cloned().unwrap()
};
assert_eq!(group(&workspace, 2).height, Some(120.0), "{:?}", workspace.layout);
workspace.set_visible(3, true, None, allow).unwrap();
assert_eq!(group(&workspace, 3).height, Some(300.0), "{:?}", workspace.layout);
// [2] beside an icon-only column holding [3]: 2 comes back in a column of its own width,
// not as an icon.
let iconic = Column { iconic: true, ..Column::new(vec![g(&[3], 0)], 200.0) };
let mut workspace = Workspace::new(dock_of(vec![Column::new(vec![g(&[2], 0)], 260.0), iconic]));
workspace.set_visible(2, false, None, allow).unwrap();
workspace.set_visible(3, false, None, allow).unwrap();
workspace.set_visible(2, true, None, allow).unwrap();
let column = dock(&workspace.layout, Side::End).iter().find(|c| c.groups.iter().any(|g| g.panels.contains(&2))).cloned().unwrap();
assert!(!column.iconic, "{:?}", workspace.layout);
assert_eq!(column.width, 260.0);
}
#[test]
fn panels_closed_one_by_one_reopen_on_their_own_sides() {
let allow = |_: &u32| Permissions::default();
let orders = [[2, 3], [3, 2]];
let frames = Layout {
root: Some(hsplit(SplitSize::Ratio(0.3), tabs(&[1], 0), hsplit(SplitSize::Ratio(0.5), tabs(&[2], 0), tabs(&[3], 0)))),
floating: Vec::new(),
};
let columns = Layout {
root: Some(hsplit(
SplitSize::FixedSecond(480.0),
tabs(&[1], 0),
Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[2], 0)], 240.0), Column::new(vec![g(&[3], 0)], 240.0)] },
)),
floating: Vec::new(),
};
for close in orders {
for reopen in orders {
let mut workspace = Workspace::new(frames.clone());
for (panel, visible) in close.iter().map(|p| (*p, false)).chain(reopen.iter().map(|p| (*p, true))) {
workspace.set_visible(panel, visible, None, allow).unwrap();
}
let order: Vec<u32> = workspace.layout.panels().into_iter().copied().collect();
assert_eq!(order, [1, 2, 3], "{close:?} {reopen:?}: {:?}", workspace.layout);
assert!(order.iter().all(|p| matches!(workspace.layout.place(p), Some(Place::Node(_)))));
assert_ne!(workspace.layout.place(&2), workspace.layout.place(&3), "{close:?} {reopen:?}");
let mut workspace = Workspace::new(columns.clone());
for (panel, visible) in close.iter().map(|p| (*p, false)).chain(reopen.iter().map(|p| (*p, true))) {
workspace.set_visible(panel, visible, None, allow).unwrap();
}
let groups: Vec<_> = dock(&workspace.layout, Side::End).iter().map(|c| panels_of(&c.groups)).collect();
assert_eq!(groups, [vec![vec![2]], vec![vec![3]]], "{close:?} {reopen:?}");
}
}
// A panel that sat at the edge beside a hidden one keeps that one's own location: 3 opens
// right of 1, not as a tab beside 2.
let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() });
let at = |anchor: u32, placement: Placement<u32>| Location {
floating: None,
anchor: Some(anchor),
placement,
size: None,
stack: None,
sibling: Vec::new(),
group: None,
};
workspace.hidden = vec![(2, at(3, Placement::Edge(Zone::Right))), (3, at(1, Placement::Split(Zone::Right)))];
workspace.set_visible(2, true, None, allow).unwrap();
workspace.set_visible(3, true, None, allow).unwrap();
assert!(matches!(workspace.layout.place(&3), Some(Place::Node(_))));
assert_ne!(workspace.layout.place(&2), workspace.layout.place(&3), "{:?}", workspace.layout);
// Past the edge frame the chain keeps its forward anchors: 4 does not anchor on 3 while 3
// anchors on 4, and both still reopen.
let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() });
workspace.hidden =
vec![(2, at(3, Placement::Edge(Zone::Right))), (3, at(4, Placement::Split(Zone::Right))), (4, at(1, Placement::Split(Zone::Right)))];
workspace.set_visible(2, true, None, allow).unwrap();
assert_eq!(workspace.hidden.iter().find(|(p, _)| *p == 4).and_then(|(_, l)| l.anchor), Some(1), "{:?}", workspace.hidden);
workspace.set_visible(3, true, None, allow).unwrap();
workspace.set_visible(4, true, None, allow).unwrap();
assert!(workspace.hidden.is_empty() && [1, 2, 3, 4].iter().all(|p| workspace.layout.contains(p)), "{:?}", workspace.layout);
}
#[test]
fn restore_into_a_column_group_seam_and_new_column_round_trips() {
let column_a = Column::new(vec![g(&[2, 3], 1), Group { minimized: true, height: Some(150.0), ..g(&[4], 0) }, g(&[9], 0)], 180.0);
let column_b = Column { iconic: true, iconic_width: Some(48.0), ..Column::new(vec![g(&[5], 0)], 120.0) };
let end = Node::Dock { side: Side::End, columns: vec![column_a, column_b] };
let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![g(&[10], 0)], 200.0)] };
let original = Layout {
root: Some(hsplit(SplitSize::FixedFirst(200.0), start, hsplit(SplitSize::FixedSecond(300.0), tabs(&[1], 0), end))),
floating: vec![Floating::stack(vec![g(&[6], 0), Group { height: Some(90.0), ..g(&[7], 0) }], [10.0, 10.0, 200.0, 300.0]).unwrap()],
};
original.validate().unwrap();
// 4: the seam between [2, 3] and [9]; 9: below [4]; 5: a new column after A; 10: alone in
// its dock; 3: a tab; 7: below a floating group.
for panel in [4, 9, 5, 10, 3, 7] {
let mut layout = original.clone();
let location = layout.location(&panel).unwrap();
#[cfg(feature = "serde")]
let location: Location<u32> = serde_json::from_str(&serde_json::to_string(&location).unwrap()).unwrap();
layout.apply(Action::Close { panel }).unwrap();
layout.restore(panel, &location).unwrap_or_else(|e| panic!("{panel}: {e} {location:?}"));
assert_eq!(layout, original, "{panel}: {location:?}");
}
}
#[test]
fn dock_validation_rejects_two_docks_per_side_nested_docks_empty_columns_and_bad_widths() {
docked().validate().unwrap();
let empty_dock = |side| Node::Dock { side, columns: Vec::new() };
Layout { root: Some(hsplit(SplitSize::FixedFirst(10.0), empty_dock(Side::Start), tabs(&[1], 0))), floating: Vec::new() }.validate().unwrap();
let two = hsplit(SplitSize::Ratio(0.5), empty_dock(Side::Start), empty_dock(Side::Start));
let nested = Node::Split {
axis: SplitAxis::Vertical,
size: SplitSize::Ratio(0.5),
first: Box::new(empty_dock(Side::Start)),
second: Box::new(tabs(&[1], 0)),
};
let wrong_side = hsplit(SplitSize::Ratio(0.5), empty_dock(Side::End), tabs(&[1], 0));
for root in [two, nested, wrong_side] {
assert_eq!(Layout { root: Some(root), floating: Vec::new() }.validate(), Err(DockError::InvalidPlacement));
}
let with_column = |column: Column<u32>| Layout { root: Some(Node::Dock { side: Side::End, columns: vec![column] }), floating: Vec::new() };
let bad = [
Column::new(Vec::new(), 100.0),
Column::new(vec![g(&[], 0)], 100.0),
Column::new(vec![g(&[2], 1)], 100.0),
Column::new(vec![Group { height: Some(f32::NAN), ..g(&[2], 0) }], 100.0),
Column::new(vec![g(&[2], 0)], 0.0),
Column::new(vec![g(&[2], 0)], -5.0),
Column::new(vec![g(&[2], 0)], f32::INFINITY),
Column::new(vec![g(&[2], 0)], 2.0e6),
Column { iconic_width: Some(f32::NAN), ..Column::new(vec![g(&[2], 0)], 100.0) },
];
for column in bad {
assert!(with_column(column.clone()).validate().is_err(), "{column:?}");
}
let mut duplicate = docked();
duplicate.floating[0].root = Node::Tabs { panels: vec![6, 7, 2], active: 0 };
assert_eq!(duplicate.validate(), Err(DockError::DuplicatePanel));
let mut empty_window = docked();
empty_window.floating[0].root = Node::Tabs { panels: Vec::new(), active: 0 };
assert_eq!(empty_window.validate(), Err(DockError::InvalidGeometry));
let mut docked_window = docked();
docked_window.floating[0].root = Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[6, 7], 0)], 100.0)] };
assert_eq!(docked_window.validate(), Err(DockError::InvalidPlacement));
let mut native = docked();
native.floating[0].native = Some([0.0, 0.0, -1.0, 10.0]);
assert_eq!(native.validate(), Err(DockError::InvalidGeometry));
let mut monitor = docked();
monitor.floating[0].monitor = Some(super::Monitor { name: None, rect: [0.0, 0.0, 1920.0, 1080.0], scale: f32::NAN });
assert_eq!(monitor.validate(), Err(DockError::InvalidGeometry));
let mut monitor_size = docked();
monitor_size.floating[0].monitor_size = Some([f32::NAN, 1080.0]);
assert_eq!(monitor_size.validate(), Err(DockError::InvalidGeometry));
let mut layout = docked();
assert_eq!(layout.apply(Action::ResizeColumn { panel: 2, width: f32::NAN }), Err(DockError::InvalidGeometry));
assert_eq!(layout.apply(Action::ResizeColumn { panel: 11, width: 50.0 }), Err(DockError::InvalidPlacement));
assert_eq!(layout, docked());
}
#[test]
fn a_locked_workspace_records_its_native_windows() {
let mut workspace = Workspace::new(docked());
workspace.locked = true;
for rect in [[0.0, 0.0, 300.0, 400.0], [40.0, 30.0, 300.0, 400.0]] {
workspace.apply(Action::SetNative { panel: 6, rect: Some(rect) }, |_| Permissions::default()).unwrap();
assert_eq!(workspace.layout.floating[0].native, Some(rect));
}
}
#[test]
fn locked_workspace_rejects_structure_but_allows_activate_open_minimize_and_resize() {
let mut workspace = Workspace::new(docked());
let allow = |_: &u32| Permissions::default();
workspace.apply(Action::Close { panel: 12 }, allow).unwrap();
workspace.locked = true;
let before = workspace.clone();
let refused = [
Action::Move { panel: 2, anchor: 5, placement: Placement::Tab { before: None } },
Action::MoveGroup { panel: 2, to: Target::Float { rect: None } },
Action::MoveColumn { panel: 5, to: Target::NewDock { side: Side::Start } },
Action::MoveWindow { panel: 6, to: Target::At { anchor: 4, placement: Placement::Group { below: true, height: None } } },
Action::Float { panel: 4, rect: [0.0, 0.0, 100.0, 100.0] },
Action::MoveFloating { panel: 6, rect: [0.0, 0.0, 100.0, 100.0] },
Action::Close { panel: 4 },
Action::CloseGroup { panel: 4 },
Action::CloseWindow { panel: 6 },
];
for action in refused {
assert_eq!(workspace.apply(action.clone(), allow), Err(DockError::NotAllowed), "{action:?}");
assert_eq!(workspace, before);
}
assert_eq!(workspace.set_visible(4, false, None, |_| Permissions::default()), Err(DockError::NotAllowed));
let allowed = [
Action::Activate { panel: 2 },
Action::Open { panel: 12, anchor: Some(11) },
Action::SetMinimized { panel: 4, minimized: true },
Action::SetIconic { panel: 5, iconic: true },
Action::ResizeColumn { panel: 5, width: 160.0 },
Action::ResizeGroup { panel: 4, height: Some(120.0) },
Action::ResizeIconic { panel: 5, width: Some(60.0) },
Action::ResizeSplit { path: Vec::new(), size: SplitSize::FixedSecond(320.0) },
];
workspace.apply_all(allowed, allow).unwrap();
assert!(workspace.hidden.is_empty());
assert_eq!(workspace.layout.panel_state(&12), PanelState::Front);
assert_eq!(workspace.layout.panel_state(&4), PanelState::Open);
assert_eq!(workspace.layout.panel_state(&5), PanelState::Open);
assert_eq!(workspace.layout.panel_state(&2), PanelState::Front);
assert_eq!(workspace.layout.panel_state(&3), PanelState::Open);
// Opening at a chosen place works too; moving a shown panel there does not.
workspace.apply(Action::OpenAt { panel: 13, anchor: 11, placement: Placement::Split(Zone::Bottom) }, allow).unwrap();
assert!(workspace.layout.contains(&13));
assert_eq!(
workspace.apply(Action::OpenAt { panel: 2, anchor: 11, placement: Placement::Split(Zone::Bottom) }, allow),
Err(DockError::NotAllowed)
);
}
#[test]
fn hiding_a_panel_that_may_not_close_is_refused() {
let mut workspace = Workspace::new(docked());
let canvas = |p: &u32| if *p == 1 { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() };
assert_eq!(workspace.set_visible(1, false, None, canvas), Err(DockError::NotAllowed));
assert!(workspace.layout.contains(&1) && workspace.hidden.is_empty());
workspace.set_visible(2, false, None, canvas).unwrap();
assert!(!workspace.layout.contains(&2));
}
#[test]
fn reconcile_drops_unknown_panels_and_places_missing_registered_ones() {
let default = Workspace::new(docked());
let mut workspace = default.clone();
workspace.layout.apply(Action::Close { panel: 5 }).unwrap();
workspace.layout.apply(Action::Close { panel: 12 }).unwrap();
workspace.layout.apply(Action::OpenAt { panel: 99, anchor: 2, placement: Placement::Tab { before: None } }).unwrap();
let stray = workspace.layout.location(&99).unwrap();
workspace.hidden.push((98, stray));
let registered = |p: &u32| *p < 90;
workspace.reconcile(registered, |_| Permissions::default(), &default);
workspace.validate().unwrap();
// Unregistered panels wait in `hidden` and come back once registered.
assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [98, 99]);
assert_eq!(sorted(&workspace.layout), ALL);
workspace.set_visible(99, true, None, |_| Permissions::default()).unwrap();
assert_eq!(group_state(&workspace.layout, 2).0, [2, 3, 99]);
assert_eq!(dock(&workspace.layout, Side::End).len(), 2);
assert_eq!(group_state(&workspace.layout, 11).0, [11, 12]);
let mut broken = default.clone();
broken.layout.floating[0].rect[2] = -1.0;
broken.reconcile(registered, |_| Permissions::default(), &default);
assert_eq!(broken.layout, default.layout);
}
#[test]
fn reset_returns_to_saved_or_default() {
let default = docked();
let mut workspace = Workspace::new(default.clone());
workspace.apply(Action::MoveGroup { panel: 6, to: Target::NewDock { side: Side::Start } }, |_| Permissions::default()).unwrap();
workspace.reset(&default);
assert_eq!(workspace.layout, default);
let mut saved = default.clone();
saved.apply(Action::Close { panel: 8 }).unwrap();
saved.apply(Action::MoveGroup { panel: 11, to: Target::Float { rect: None } }).unwrap();
workspace.saved = Some(saved.clone());
workspace.reset(&default);
assert_eq!(workspace.layout, saved);
assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [8]);
workspace.validate().unwrap();
}
#[cfg(feature = "serde")]
mod serde_tests {
use super::*;
use serde_json::{Value, json};
#[test]
fn a_saved_stack_of_groups_loads_as_a_vertical_split_by_their_heights() {
let saved = json!({
"groups": [{"panels": [4], "active": 0, "height": 100.0}, {"panels": [5, 6], "active": 1, "height": 300.0}],
"rect": [1.0, 2.0, 300.0, 400.0],
});
let window: Floating<u32> = serde_json::from_value(saved).unwrap();
let expected = Node::Split {
axis: SplitAxis::Vertical,
size: SplitSize::Ratio(0.25),
first: Box::new(Node::Tabs { panels: vec![4], active: 0 }),
second: Box::new(Node::Tabs { panels: vec![5, 6], active: 1 }),
};
assert_eq!((window.root, window.minimized), (expected, false));
let all_minimized = json!({"groups": [{"panels": [4], "active": 0, "minimized": true}], "rect": [1.0, 2.0, 300.0, 400.0]});
let window: Floating<u32> = serde_json::from_value(all_minimized).unwrap();
assert_eq!((window.root, window.minimized), (Node::Tabs { panels: vec![4], active: 0 }, true));
}
#[test]
fn single_plain_floating_window_writes_the_v1_shape() {
let rect = [1.0, 2.0, 300.0, 200.0];
let plain = Floating::single(vec![4u32, 5], 1, rect);
assert_eq!(serde_json::to_value(&plain).unwrap(), json!({"panels": [4, 5], "active": 1, "rect": rect}));
let minimized = Floating { minimized: true, ..Floating::single(vec![4u32], 0, rect) };
let stacked = Floating::stack(vec![g(&[4], 0), g(&[5], 0)], rect).unwrap();
let native = Floating { native: Some(rect), ..Floating::single(vec![4u32], 0, rect) };
for window in [minimized, stacked, native] {
let value = serde_json::to_value(&window).unwrap();
assert!(value.get("root").is_some() && value.get("panels").is_none(), "{value}");
assert_eq!(serde_json::from_value::<Floating<u32>>(value).unwrap(), window);
}
assert!(serde_json::from_value::<Floating<u32>>(json!({"rect": rect})).is_err());
}
#[test]
fn v2_round_trips() {
let mut workspace = Workspace::new(docked());
workspace.apply(Action::CloseGroup { panel: 4 }, |_| Permissions::default()).unwrap();
workspace.apply(Action::SetNative { panel: 8, rect: Some([5.0, 5.0, 400.0, 300.0]) }, |_| Permissions::default()).unwrap();
workspace.saved = Some(docked());
workspace.locked = true;
let text = serde_json::to_string(&workspace).unwrap();
let parsed: Workspace<u32> = serde_json::from_str(&text).unwrap();
assert_eq!(parsed, workspace);
assert_eq!(serde_json::from_str::<Value>(&text).unwrap()["version"], json!(2));
let mut old = parsed.clone();
old.version = 1;
let mut value = serde_json::to_value(&old).unwrap();
value.as_object_mut().unwrap().remove("version");
assert_eq!(serde_json::from_value::<Workspace<u32>>(value).unwrap().version, 1);
old.version = 3;
assert_eq!(old.validate(), Err(DockError::UnsupportedVersion));
}
#[derive(Clone, Copy, Debug)]
enum Kind {
Layout,
Workspace,
Hidden,
}
/// App, file contents, and the JSON pointers to its docking values.
type Fixture = (&'static str, &'static str, &'static [(&'static str, Kind)]);
#[test]
fn v1_layout_json_parses_unchanged() {
use Kind::*;
let fixtures: [Fixture; 7] = [
(
"designcraft",
include_str!("../../tests/fixtures/v1/designcraft.json"),
&[
("/docking", Layout),
("/dockingHidden", Hidden),
("/customWorkspaces/0/docking", Layout),
("/customWorkspaces/0/dockingHidden", Hidden),
],
),
("vectorcraft", include_str!("../../tests/fixtures/v1/vectorcraft.json"), &[("/docking", Layout), ("/docking_hidden", Hidden)]),
("lightcraft", include_str!("../../tests/fixtures/v1/lightcraft.json"), &[("/docking", Workspace)]),
("pdfcraft", include_str!("../../tests/fixtures/v1/pdfcraft.json"), &[("/docking", Workspace)]),
("cadcraft", include_str!("../../tests/fixtures/v1/cadcraft.json"), &[("/workspace/docking", Workspace)]),
("soundcraft", include_str!("../../tests/fixtures/v1/soundcraft.json"), &[("/docking", Workspace), ("/mix_docking", Workspace)]),
(
"photocraft",
include_str!("../../tests/fixtures/v1/photocraft.json"),
&[("/docking", Layout), ("/docking_hidden", Hidden), ("/panel_layout/docking", Layout), ("/panel_layout/dockingHidden", Hidden)],
),
];
for (app, text, fields) in fixtures {
let file: Value = serde_json::from_str(text).unwrap();
for (pointer, kind) in fields {
let value = file.pointer(pointer).unwrap_or_else(|| panic!("{app}{pointer}")).clone();
let layout_value = match kind {
Layout => value.clone(),
Workspace => value["layout"].clone(),
Hidden => {
let hidden: std::collections::BTreeMap<String, Location<String>> = serde_json::from_value(value).unwrap();
assert!(!hidden.is_empty(), "{app}{pointer}");
continue;
}
};
if let Workspace = kind {
let workspace: super::Workspace<String> = serde_json::from_value(value).unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
workspace.validate().unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
assert_eq!(workspace.version, 1);
for (panel, location) in &workspace.hidden {
let mut layout = workspace.layout.clone();
layout.restore(panel.clone(), location).unwrap_or_else(|e| panic!("{app}{pointer} {panel}: {e}"));
}
}
let layout: super::Layout<String> = serde_json::from_value(layout_value.clone()).unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
layout.validate().unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
// Nothing new is in use, so the layout writes back exactly as read.
assert_eq!(serde_json::to_value(&layout).unwrap(), layout_value, "{app}{pointer}");
}
}
}
#[test]
fn every_app_fixture_reconciles_with_its_canvas_shown() {
let s = |p: &str| p.to_owned();
let permissions = |p: &String| if p == "canvas" { Permissions::PROTECTED } else { Permissions::default() };
// The canvas between a start dock and the properties, which no fixture matches exactly.
let beside = super::Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedFirst(240.0),
first: Box::new(Node::Dock {
side: Side::Start,
columns: vec![Column::new(vec![Group::new(vec![s("layers"), s("pages")], 0)], 240.0)],
}),
second: Box::new(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::Ratio(0.75),
first: Box::new(Node::Tabs { panels: vec![s("canvas")], active: 0 }),
second: Box::new(Node::Tabs { panels: vec![s("properties"), s("stroke")], active: 0 }),
}),
}),
floating: Vec::new(),
};
// The canvas with no anchor to go back beside.
let alone = super::Layout { root: Some(Node::Tabs { panels: vec![s("canvas")], active: 0 }), floating: Vec::new() };
let fixtures = [
("designcraft", include_str!("../../tests/fixtures/v1/designcraft.json"), "/docking"),
("designcraft", include_str!("../../tests/fixtures/v1/designcraft.json"), "/customWorkspaces/0/docking"),
("vectorcraft", include_str!("../../tests/fixtures/v1/vectorcraft.json"), "/docking"),
("lightcraft", include_str!("../../tests/fixtures/v1/lightcraft.json"), "/docking/layout"),
("pdfcraft", include_str!("../../tests/fixtures/v1/pdfcraft.json"), "/docking/layout"),
("cadcraft", include_str!("../../tests/fixtures/v1/cadcraft.json"), "/workspace/docking/layout"),
("soundcraft", include_str!("../../tests/fixtures/v1/soundcraft.json"), "/docking/layout"),
("soundcraft", include_str!("../../tests/fixtures/v1/soundcraft.json"), "/mix_docking/layout"),
("photocraft", include_str!("../../tests/fixtures/v1/photocraft.json"), "/docking"),
("photocraft", include_str!("../../tests/fixtures/v1/photocraft.json"), "/panel_layout/docking"),
];
for (app, text, pointer) in fixtures {
let file: Value = serde_json::from_str(text).unwrap();
let mut saved: super::Layout<String> = serde_json::from_value(file.pointer(pointer).unwrap().clone()).unwrap();
let own = saved.contains(&s("canvas")).then(|| saved.clone());
saved.take(&s("canvas"));
for default in own.iter().chain([&beside, &alone]) {
let mut workspace = super::Workspace::new(saved.clone());
// A file written while the canvas could still be lost.
if let Ok(location) = default.location(&s("canvas")) {
workspace.hidden.push((s("canvas"), location));
}
for mut workspace in [super::Workspace::new(saved.clone()), workspace] {
workspace.reconcile(|_| true, permissions, &super::Workspace::new(default.clone()));
workspace.validate().unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
assert!(workspace.layout.contains(&s("canvas")), "{app}{pointer}: {:?}", workspace.layout);
assert!(workspace.hidden.iter().all(|(p, _)| p != "canvas"), "{app}{pointer}");
}
}
}
}
}
#[test]
fn edge_placement_splits_the_part_between_the_docks_or_the_root() {
// Dock layout: a floating window lands under the frames, beside the end dock.
let mut layout = docked();
layout.apply(Action::MoveWindow { panel: 6, to: Target::At { anchor: 1, placement: Placement::Edge(Zone::Bottom) } }).unwrap();
let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("{layout:?}") };
assert!(matches!(&**second, Node::Dock { .. }));
let Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(size), second: bottom, .. } = &**first else { panic!("{first:?}") };
assert_eq!(*size, 0.75);
assert_eq!(**bottom, tabs(&[6, 7, 8], 1));
assert!(layout.floating.is_empty());
// Frame layout: one panel takes the whole left edge.
let mut frames = Layout { root: Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), tabs(&[2, 3], 0))), floating: Vec::new() };
frames.apply(Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }).unwrap();
let Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(size), first, .. }) = &frames.root else { panic!() };
assert_eq!((*size, &**first), (0.25, &tabs(&[3], 0)));
let before = frames.clone();
assert_eq!(frames.apply(Action::Move { panel: 2, anchor: 1, placement: Placement::Edge(Zone::Center) }), Err(DockError::InvalidPlacement));
assert_eq!(frames, before);
}
File diff suppressed because it is too large. Load diff
+248
View File
@@ -0,0 +1,248 @@
//! Where native floating windows go on the desktop (feature `native-windows`). Rects are
//! `[x, y, width, height]` in desktop logical points.
use egui::{Pos2, Rect, Vec2, pos2};
use super::Monitor;
/// Largest native window, and the farthest it may sit from the desktop origin.
const MAX_SIZE: f32 = 16_384.0;
const MAX_POSITION: f32 = 100_000.0;
const MIN_SIZE: [f32; 2] = [160.0, 100.0];
/// Height of the bar at the top of a native window.
pub(super) const BAR_HEIGHT: f32 = 18.0;
/// How much of a window's bar must lie on one monitor for the window to be reachable: wide
/// enough to grab, and half its height.
const REACHABLE: [f32; 2] = [40.0, BAR_HEIGHT / 2.0];
/// A rect safe to hand to the windowing system: finite, its size and position bounded.
/// Hostile workspace files and bad monitor reports included.
pub(super) fn sanitized(rect: [f32; 4]) -> [f32; 4] {
let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
let [x, y, w, h] = rect;
[
finite(x, 0.0).clamp(-MAX_POSITION, MAX_POSITION),
finite(y, 0.0).clamp(-MAX_POSITION, MAX_POSITION),
finite(w, 420.0).clamp(MIN_SIZE[0], MAX_SIZE),
finite(h, 320.0).clamp(MIN_SIZE[1], MAX_SIZE),
]
}
fn usable(monitor: &Monitor) -> bool {
let [x, y, w, h] = monitor.rect;
[x, y, w, h].iter().all(|v| v.is_finite() && v.abs() <= MAX_POSITION) && w > 0.0 && h > 0.0
}
fn to_rect([x, y, w, h]: [f32; 4]) -> Rect {
Rect::from_min_size(pos2(x, y), Vec2::new(w, h))
}
/// The monitor holding the center of `rect`, else the one nearest to it. None for an empty
/// list.
pub(super) fn monitor_of(monitors: &[Monitor], rect: [f32; 4]) -> Option<&Monitor> {
let center = to_rect(rect).center();
let monitors = || monitors.iter().filter(|m| usable(m));
monitors()
.find(|m| to_rect(m.rect).contains(center))
.or_else(|| monitors().min_by(|a, b| to_rect(a.rect).distance_sq_to_pos(center).total_cmp(&to_rect(b.rect).distance_sq_to_pos(center))))
}
/// Whether enough of the bar along the top of `rect` lies on one of `monitors` to grab it.
fn reachable(rect: [f32; 4], monitors: &[Monitor]) -> bool {
let [x, y, w, _] = rect;
let bar = to_rect([x, y, w, BAR_HEIGHT]);
monitors.iter().filter(|m| usable(m)).any(|m| {
let on = bar.intersect(to_rect(m.rect));
on.width() >= REACHABLE[0].min(w) && on.height() >= REACHABLE[1]
})
}
/// `rect` made safe, and moved wholly onto the nearest of `monitors` when too little of its
/// bar lies on any to grab it. Without a monitor list, only made safe.
pub(super) fn kept_reachable(rect: [f32; 4], monitors: &[Monitor]) -> [f32; 4] {
let rect = sanitized(rect);
if !monitors.iter().any(usable) || reachable(rect, monitors) {
return rect;
}
monitor_of(monitors, rect).map_or(rect, |m| clamp_onto(rect, m.rect))
}
/// `rect` moved, and shrunk if it must be, to lie wholly on `area`.
fn clamp_onto(rect: [f32; 4], area: [f32; 4]) -> [f32; 4] {
let [x, y, w, h] = rect;
let [ax, ay, aw, ah] = area;
let (w, h) = (w.min(aw), h.min(ah));
// `ax + aw - w` can round below `ax` when `w == aw`, so no `clamp`, which panics then.
sanitized([x.min(ax + aw - w).max(ax), y.min(ay + ah - h).max(ay), w, h])
}
/// Where a native window from a saved layout opens.
///
/// - Its saved monitor (`monitor`), found in `monitors` by name or else by rect, is connected:
/// the saved rect, moved in proportion if that monitor's rect changed, and onto it if too
/// little of its bar lies on a monitor to grab it.
/// - It is not: the main window's monitor, at the same offset from its origin.
/// - No monitor list: the main window's monitor as egui reports it, by its size alone. Its
/// origin is taken as the multiple of that size nearest below the main window's center,
/// which holds for a row or grid of monitors of one size.
///
/// A window that moves to another monitor ends up wholly on it.
pub(super) fn restore(saved: [f32; 4], monitor: Option<&Monitor>, monitors: &[Monitor], main: Option<Rect>, monitor_size: Option<Vec2>) -> [f32; 4] {
let saved = sanitized(saved);
let main = main.filter(|r| r.is_finite()).map(|r| [r.left(), r.top(), r.width().max(1.0), r.height().max(1.0)]);
if !monitors.iter().any(usable) {
let (Some(main), Some(size)) = (main, monitor_size.filter(|s| s.is_finite() && s.x >= 1.0 && s.y >= 1.0)) else { return saved };
let center = to_rect(main).center();
let origin = Pos2::new((center.x / size.x).floor() * size.x, (center.y / size.y).floor() * size.y);
return clamp_onto(saved, [origin.x, origin.y, size.x, size.y]);
}
let main_monitor = main.and_then(|main| monitor_of(monitors, main)).or_else(|| monitors.iter().find(|m| usable(m)));
let connected = monitor.and_then(|old| {
let by_name = old.name.as_ref().and_then(|name| monitors.iter().filter(|m| usable(m)).find(|m| m.name.as_ref() == Some(name)));
by_name.or_else(|| monitors.iter().filter(|m| usable(m)).find(|m| m.rect == old.rect)).map(|now| (old, now))
});
let [x, y, w, h] = saved;
match (connected, monitor) {
(Some((old, now)), _) if old.rect == now.rect => {
if reachable(saved, monitors) {
saved
} else {
clamp_onto(saved, now.rect)
}
}
(Some((old, now)), _) => {
let [ox, oy, ow, oh] = old.rect;
let [nx, ny, nw, nh] = now.rect;
let (sx, sy) = if usable(old) { (nw / ow, nh / oh) } else { (1.0, 1.0) };
clamp_onto([nx + (x - ox) * sx, ny + (y - oy) * sy, w, h], now.rect)
}
(None, Some(old)) => {
let Some(target) = main_monitor else { return saved };
let [ox, oy, ..] = old.rect;
let [tx, ty, ..] = target.rect;
let (dx, dy) = if usable(old) { (x - ox, y - oy) } else { (0.0, 0.0) };
clamp_onto([tx + dx, ty + dy, w, h], target.rect)
}
// No saved monitor: the window stays where its bar can be grabbed.
(None, None) => {
if reachable(saved, monitors) {
return saved;
}
main_monitor.map_or(saved, |target| clamp_onto(saved, target.rect))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::vec2;
fn monitor(name: &str, rect: [f32; 4], scale: f32) -> Monitor {
Monitor { name: Some(name.into()), rect, scale }
}
fn main_at(x: f32, y: f32) -> Option<Rect> {
Some(Rect::from_min_size(pos2(x, y), vec2(1200.0, 800.0)))
}
const LEFT: [f32; 4] = [0.0, 0.0, 1920.0, 1080.0];
const RIGHT: [f32; 4] = [1920.0, 0.0, 2560.0, 1440.0];
#[test]
fn a_connected_monitor_keeps_the_saved_rect() {
let monitors = [monitor("left", LEFT, 1.0), monitor("right", RIGHT, 2.0)];
let saved = [2400.0, 300.0, 400.0, 500.0];
assert_eq!(restore(saved, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), saved);
// Renamed, but the same rect.
let renamed = Monitor { name: Some("DP-2".into()), ..monitors[1].clone() };
assert_eq!(restore(saved, Some(&renamed), &monitors, main_at(100.0, 100.0), None), saved);
// Partly off its monitor, as the user left it.
let edge = [4300.0, 300.0, 400.0, 500.0];
assert_eq!(restore(edge, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), edge);
// Wholly off it, or with only its body on it: back onto it.
let off = [5000.0, 300.0, 400.0, 500.0];
assert_eq!(restore(off, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), [4080.0, 300.0, 400.0, 500.0]);
let above = [2400.0, -10.0, 400.0, 500.0];
assert_eq!(restore(above, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), [2400.0, 0.0, 400.0, 500.0]);
}
#[test]
fn a_window_is_kept_where_its_bar_can_be_grabbed() {
let monitors = [monitor("left", LEFT, 1.0), monitor("right", RIGHT, 2.0)];
// Across the seam, or mostly off screen with enough of its bar on one.
for rect in [[1800.0, 100.0, 400.0, 300.0], [-360.0, 1071.0, 400.0, 300.0]] {
assert_eq!(kept_reachable(rect, &monitors), rect);
}
assert_eq!(kept_reachable([-370.0, 100.0, 400.0, 300.0], &monitors), [0.0, 100.0, 400.0, 300.0]);
assert_eq!(kept_reachable([500.0, 1075.0, 400.0, 300.0], &monitors), [500.0, 780.0, 400.0, 300.0]);
assert_eq!(kept_reachable([9000.0, 100.0, 400.0, 300.0], &monitors), [4080.0, 100.0, 400.0, 300.0]);
// No monitor list: only made safe.
assert_eq!(kept_reachable([9000.0, f32::NAN, 400.0, 300.0], &[]), [9000.0, 0.0, 400.0, 300.0]);
}
#[test]
fn a_missing_monitor_moves_the_window_to_the_main_window_monitor() {
let saved_on = monitor("right", RIGHT, 2.0);
let monitors = [monitor("left", LEFT, 1.0)];
// Same offset from the monitor's origin.
assert_eq!(restore([2020.0, 50.0, 400.0, 500.0], Some(&saved_on), &monitors, main_at(100.0, 100.0), None), [100.0, 50.0, 400.0, 500.0]);
// Clamped wholly on screen, shrunk if larger than the monitor.
assert_eq!(restore([4000.0, 1200.0, 400.0, 500.0], Some(&saved_on), &monitors, main_at(100.0, 100.0), None), [1520.0, 580.0, 400.0, 500.0]);
assert_eq!(restore([1920.0, 0.0, 3000.0, 1300.0], Some(&saved_on), &monitors, main_at(100.0, 100.0), None), [0.0, 0.0, 1920.0, 1080.0]);
}
#[test]
fn a_resized_monitor_moves_the_window_in_proportion() {
let old = monitor("left", LEFT, 1.0);
let monitors = [monitor("left", [0.0, 0.0, 960.0, 540.0], 2.0)];
assert_eq!(restore([960.0, 540.0, 200.0, 200.0], Some(&old), &monitors, main_at(0.0, 0.0), None), [480.0, 270.0, 200.0, 200.0]);
assert_eq!(restore([1800.0, 1000.0, 200.0, 200.0], Some(&old), &monitors, main_at(0.0, 0.0), None), [760.0, 340.0, 200.0, 200.0]);
}
#[test]
fn negative_coordinates_and_mixed_scales() {
let monitors = [monitor("left", [-2560.0, -200.0, 2560.0, 1440.0], 2.0), monitor("main", LEFT, 1.0)];
assert_eq!(monitor_of(&monitors, [-500.0, 0.0, 300.0, 300.0]).and_then(|m| m.name.as_deref()), Some("left"));
assert_eq!(monitor_of(&monitors, [100.0, 0.0, 300.0, 300.0]).and_then(|m| m.name.as_deref()), Some("main"));
// Off every monitor: the nearest.
assert_eq!(monitor_of(&monitors, [-3000.0, 2000.0, 100.0, 100.0]).and_then(|m| m.name.as_deref()), Some("left"));
let saved = [-1000.0, -100.0, 300.0, 300.0];
assert_eq!(restore(saved, Some(&monitors[0]), &monitors, main_at(100.0, 100.0), None), saved);
// Unplugged: the same offset on the main window's monitor, at its own scale.
let rest = &monitors[1..];
assert_eq!(restore(saved, Some(&monitors[0]), rest, main_at(100.0, 100.0), None), [1560.0, 100.0, 300.0, 300.0]);
// A main window on the left monitor takes the window there.
let main = monitor("main", LEFT, 1.0);
assert_eq!(restore([100.0, 100.0, 400.0, 300.0], Some(&main), &monitors[..1], main_at(-2000.0, 0.0), None), [-2460.0, -100.0, 400.0, 300.0]);
}
#[test]
fn without_a_monitor_list_the_window_goes_on_the_main_window_monitor_by_size() {
let size = Some(vec2(1920.0, 1080.0));
let saved = [2500.0, 300.0, 400.0, 500.0];
assert_eq!(restore(saved, None, &[], main_at(100.0, 100.0), size), [1520.0, 300.0, 400.0, 500.0]);
assert_eq!(restore(saved, None, &[], main_at(2000.0, 100.0), size), saved);
assert_eq!(restore(saved, None, &[], main_at(-1500.0, 100.0), size), [-400.0, 300.0, 400.0, 500.0]);
// Nothing known: the saved rect, made safe.
assert_eq!(restore([f32::NAN, 1e9, 10.0, 400.0], None, &[], None, None), [0.0, 100_000.0, 160.0, 400.0]);
}
#[test]
fn clamping_onto_a_fractional_origin_does_not_panic() {
let area = [910.6667, 0.333_33, 1280.0, 720.0];
let [x, y, w, h] = clamp_onto([0.0, 2000.0, 1280.0, 900.0], area);
assert_eq!([w, h], [1280.0, 720.0]);
assert!((x - area[0]).abs() < 0.01 && (y - area[1]).abs() < 0.01, "{x} {y}");
let [x, ..] = clamp_onto([5000.0, 0.0, 2000.0, 100.0], area);
assert!((x - area[0]).abs() < 0.01, "{x}");
}
#[test]
fn a_window_without_a_saved_monitor_stays_on_screen() {
let monitors = [monitor("left", LEFT, 1.0), monitor("right", RIGHT, 2.0)];
let on = [2000.0, 100.0, 400.0, 300.0];
assert_eq!(restore(on, None, &monitors, main_at(100.0, 100.0), None), on);
assert_eq!(restore([5000.0, 100.0, 400.0, 300.0], None, &monitors, main_at(100.0, 100.0), None), [1520.0, 100.0, 400.0, 300.0]);
}
}
+434
View File
@@ -0,0 +1,434 @@
//! Drags across OS windows (feature `native-windows`, where viewports are native).
//!
//! The window where a drag started owns it: the window system sends it the pointer's moves
//! and the release while the button is held, inside the window or not. Each frame the owner
//! writes the pointer, with its own desktop origin, under the area's `"drag-pointer"`. The
//! context's data is shared by every viewport. The window under the desktop point (floating
//! windows first, front first, then the main window) shows the drop preview in its own
//! frame, with the targets it drew. The release is resolved at the end of the main window's
//! frame, after every native window has drawn.
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Key, Pos2, Rect, Ui, Vec2, vec2};
use super::floating::WindowHit;
use super::native::{BAR_HEIGHT, WindowDrag};
use super::preview::DragInput;
use super::{Renderer, clamp_float, dock::group_action};
use crate::docking::desktop::{Over, window_at};
use crate::docking::targets::DockDrop;
use crate::docking::{Action, Drag, DropTarget, Floating, GroupDrag, GroupGesture, Target, cancel_drag};
/// Key of [`DragPointer`] in the context's data, under the area's id.
pub(super) const POINTER: &str = "drag-pointer";
/// Key of an Escape that reached one of the application's windows other than the drag's
/// owner. Keys go to the window with keyboard focus, which need not be the one dragging.
const ESCAPE: &str = "drag-escape";
/// The pointer of a drag, as the window that owns the drag last saw it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(in crate::docking) struct DragPointer {
/// [`Floating::id`] of the owning native window; `None` for the main window.
pub owner: Option<u64>,
/// Desktop position of the owner's (0, 0). `None` where the window system reports no
/// positions (Wayland).
pub origin: Option<Pos2>,
/// In the owner's coordinates. Kept while egui reports no position.
pub local: Option<Pos2>,
pub down: bool,
pub released: bool,
pub escape: bool,
/// Ctrl or Cmd held: the drop floats.
pub command: bool,
/// The window a bar drag moves. It follows the pointer and is never its own target.
pub window: Option<u64>,
/// That drag began on the window's only tab or group: it docks whatever
/// [`crate::docking::DockStyle::title_bar_docks`] says.
pub docks: bool,
}
impl DragPointer {
fn desktop(&self) -> Option<Pos2> {
Some(self.origin? + self.local?.to_vec2())
}
}
/// A native window's drop targets from its frame, in its own coordinates.
pub(in crate::docking) struct Surface<P> {
pub window: u64,
/// Desktop position of the window's (0, 0), where known.
pub origin: Option<Pos2>,
/// The window's content, in its coordinates.
pub bounds: Rect,
pub targets: Vec<DropTarget<P>>,
pub dock_targets: Vec<DockDrop<P>>,
pub windows: Vec<WindowHit<P>>,
}
/// Where a drag's pointer is.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Dest {
Main(Pos2),
Window(u64, Pos2),
/// Over no window of the application: the desktop point, where known.
Desktop(Option<Pos2>),
}
/// What one frame of a dock area knows of its OS windows.
pub(in crate::docking) struct Cross<P> {
/// Viewports are native: drags cross windows.
pub on: bool,
/// The application's windows lost focus this pass: one had it on the last pass, none now.
pub focus_lost: bool,
/// The main window's content on the desktop, where known.
pub main: Option<Rect>,
/// Shown native windows' desktop rects, front first.
pub windows: Vec<(u64, Rect)>,
/// How many of `windows` lie in front of the main window.
pub front: usize,
/// Targets of the native windows drawn this frame.
pub surfaces: Vec<Surface<P>>,
/// The drag's pointer, as its owner wrote it this frame.
pub pointer: Option<DragPointer>,
/// A bar drag released this frame.
pub release: Option<(WindowDrag, DragPointer)>,
/// Resolving a native window's bar drag over the main window: frame edge zones count.
pub bar: bool,
/// Where the native window drawing now was told to move its content this frame.
pub moved: Option<Pos2>,
/// Resolving a window move that docks whatever `title_bar_docks` says.
pub docks: bool,
/// A native window's dragged widget is one of the area's controls: egui keeps a drag per
/// viewport.
pub held: bool,
/// The panel whose body is under the pointer in the native window drawing now.
pub pointed: Option<P>,
}
impl<P> Default for Cross<P> {
fn default() -> Self {
Self {
on: false,
focus_lost: false,
main: None,
windows: Vec::new(),
front: 0,
surfaces: Vec::new(),
pointer: None,
release: None,
bar: false,
moved: None,
docks: false,
held: false,
pointed: None,
}
}
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// Write the pointer of the drag this window owns. `origin` is the window's desktop
/// origin for this frame's input; `window` the bar drag it is.
pub(super) fn record_pointer(&mut self, ctx: &Context, owner: Option<u64>, origin: Option<Pos2>, window: Option<&WindowDrag>) -> DragPointer {
let key = self.id.with(POINTER);
let kept = ctx.data(|d| d.get_temp::<DragPointer>(key)).filter(|p| p.owner == owner).and_then(|p| p.local);
let elsewhere = ctx.data_mut(|d| d.remove_temp::<bool>(self.id.with(ESCAPE))).unwrap_or(false);
let pointer = ctx.input(|i| DragPointer {
owner,
origin,
local: i.pointer.latest_pos().or(i.pointer.interact_pos()).filter(|p| p.is_finite()).or(kept),
down: i.pointer.primary_down(),
released: i.pointer.primary_released(),
escape: elsewhere || i.key_pressed(Key::Escape),
command: i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd,
window: window.map(|w| w.window),
docks: window.is_some_and(|w| w.docks),
});
ctx.data_mut(|d| d.insert_temp(key, pointer));
self.cross.pointer = Some(pointer);
pointer
}
/// In a window that does not own the drag (`window`, `None` for the main window): an Escape
/// pressed here cancels the drag, and is no shortcut.
pub(super) fn escape_elsewhere(&self, ctx: &Context, window: Option<u64>) {
let owner = ctx.data(|d| d.get_temp::<DragPointer>(self.id.with(POINTER))).filter(|p| p.down).map(|p| p.owner);
if owner.is_some_and(|owner| owner != window) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) {
ctx.data_mut(|d| d.insert_temp(self.id.with(ESCAPE), true));
}
}
/// Where the drag's pointer is. Without desktop positions only the owner's own window is
/// known, and a point outside it is on the desktop. `current` is a window drawing now.
fn destination(&self, pointer: &DragPointer, current: Option<&Surface<P>>) -> Option<Dest> {
if let (Some(point), Some(main)) = (pointer.desktop(), self.cross.main) {
return Some(match window_at(point, &self.cross.windows, self.cross.front, Some(main), pointer.window) {
Over::Main(local) => Dest::Main(local),
Over::Window(id, local) => Dest::Window(id, local),
Over::Desktop => Dest::Desktop(Some(point)),
});
}
let local = pointer.local?;
let Some(owner) = pointer.owner else { return Some(Dest::Main(local)) };
let bounds = current.into_iter().chain(&self.cross.surfaces).find(|s| s.window == owner).map(|s| s.bounds);
Some(if bounds.is_some_and(|b| b.contains(local)) { Dest::Window(owner, local) } else { Dest::Desktop(None) })
}
/// Run `f` with a native window's targets and bounds in place of the main window's.
fn within<R>(&mut self, surface: &mut Surface<P>, f: impl FnOnce(&mut Self) -> R) -> R {
std::mem::swap(&mut self.targets, &mut surface.targets);
std::mem::swap(&mut self.dock_targets, &mut surface.dock_targets);
std::mem::swap(&mut self.frame.windows, &mut surface.windows);
let bounds = std::mem::replace(&mut self.float_bounds, surface.bounds);
let blockers = std::mem::take(&mut self.blockers);
let result = f(self);
std::mem::swap(&mut self.targets, &mut surface.targets);
std::mem::swap(&mut self.dock_targets, &mut surface.dock_targets);
std::mem::swap(&mut self.frame.windows, &mut surface.windows);
(self.float_bounds, self.blockers) = (bounds, blockers);
result
}
/// [`Self::within`] the native window `window` drawn this frame.
fn within_window<R>(&mut self, window: u64, f: impl FnOnce(&mut Self) -> R) -> Option<R> {
let index = self.cross.surfaces.iter().position(|s| s.window == window)?;
let mut surface = self.cross.surfaces.swap_remove(index);
let result = self.within(&mut surface, f);
self.cross.surfaces.push(surface);
Some(result)
}
/// The offset from the owner's coordinates to those of a window at `to`.
fn shift(&self, pointer: &DragPointer, to: Option<Pos2>) -> Vec2 {
match (pointer.origin, to) {
(Some(from), Some(to)) => from - to,
_ => Vec2::ZERO,
}
}
fn origin_of(&self, window: u64) -> Option<Pos2> {
self.cross.surfaces.iter().find(|s| s.window == window).and_then(|s| s.origin)
}
/// A tab drag at the end of the main window's frame. Over the main window it returns the
/// drag in the main window's coordinates and the input to finish it with; elsewhere it
/// docks in another window or floats a new one on release.
pub(super) fn cross_tab(&mut self, ctx: &Context, drag: &Drag<P>, input: DragInput) -> Option<(Drag<P>, DragInput)> {
let Some(pointer) = self.cross.pointer.filter(|p| self.cross.on && p.window.is_none() && p.local.is_some()) else {
return Some((drag.clone(), input));
};
let input = DragInput { escape: input.escape || pointer.escape, ..self.input_of(&pointer, pointer.local) };
if input.escape || !input.focused || (!input.down && !input.released) {
return Some((drag.clone(), input));
}
let float_only = pointer.command || drag.undock;
let floats_out = float_only && pointer.released && pointer.desktop().is_some();
match self.destination(&pointer, None)? {
Dest::Main(local) if !floats_out => {
let shift = self.shift(&pointer, self.cross.main.map(|m| m.min));
let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() };
return Some((drag, DragInput { pointer: Some(local), ..input }));
}
_ if !pointer.released => {}
Dest::Window(window, local) if !float_only => {
let shift = self.shift(&pointer, self.origin_of(window));
let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() };
let action = self.within_window(window, |r| {
let proposal = r.tab_proposal(&drag.panel, local).filter(|p| !p.noop && local.distance(drag.origin) >= 2.0)?;
Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement })
});
self.push_checked(action.flatten(), &drag.panel);
}
_ if !self.permissions(&drag.panel).floatable => {}
_ => {
let size = self.float_size(&drag.panel, drag.source.size());
let grab = drag.grab.min(vec2(size.x, self.style.tab_height)) + vec2(0.0, BAR_HEIGHT);
let rect = self.desktop_float(&pointer, size, grab);
let action = Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] };
self.push_checked(Some(action), &drag.panel);
}
}
if pointer.released {
cancel_drag::<P>(ctx, self.id);
}
None
}
/// A group or column drag at the end of the main window's frame, like [`Self::cross_tab`].
pub(super) fn cross_group(&mut self, ui: &Ui, drag: &GroupDrag<P>, input: DragInput) -> Option<(GroupDrag<P>, DragInput)> {
let Some(pointer) = self.cross.pointer.filter(|p| self.cross.on && p.window.is_none() && p.local.is_some()) else {
return Some((drag.clone(), input));
};
if drag.gesture == GroupGesture::Window {
return Some((drag.clone(), input));
}
let input = self.input_of(&pointer, pointer.local);
if input.escape || !input.focused || (!input.down && !input.released) {
return Some((drag.clone(), input));
}
let floats_out = pointer.command && pointer.released && pointer.desktop().is_some();
let ctx = ui.ctx();
match self.destination(&pointer, None)? {
Dest::Main(local) if !floats_out => {
let shift = self.shift(&pointer, self.cross.main.map(|m| m.min));
let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() };
return Some((drag, DragInput { pointer: Some(local), ..input }));
}
_ if !pointer.released => {}
Dest::Window(window, local) if !pointer.command => {
let shift = self.shift(&pointer, self.origin_of(window));
let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() };
let action = self.within_window(window, |r| {
let proposal = r.group_proposal(&drag, local).filter(|p| !p.noop && local.distance(drag.origin) >= 2.0)?;
Some(group_action(&drag, Target::At { anchor: proposal.anchor, placement: proposal.placement }))
});
self.push_checked(action.flatten(), &drag.panel);
}
_ => {
let size = self.group_float_size(&drag.members, drag.source.size());
let grab = (drag.origin - drag.source.min).clamp(Vec2::ZERO, size) + vec2(0.0, BAR_HEIGHT);
let rect = self.desktop_float(&pointer, size, grab);
let action = group_action(drag, Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) });
self.push_checked(Some(action), &drag.panel);
}
}
if pointer.released {
cancel_drag::<P>(ctx, self.id);
}
None
}
/// A bar drag of a native window, at the end of the main window's frame: the preview
/// over the main window, and the release.
pub(in crate::docking) fn finish_window_drag(&mut self, ui: &Ui) {
if let Some((drag, pointer)) = self.cross.release.take() {
self.drop_native_window(drag.window, &pointer);
return;
}
let Some(pointer) = self.cross.pointer.filter(|p| p.window.is_some() && !p.released) else { return };
let Some(drag) = pointer.window.and_then(|w| self.window_group_drag(w)) else { return };
if let Some(Dest::Main(local)) = self.destination(&pointer, None) {
(self.cross.bar, self.cross.docks) = (true, pointer.docks);
self.paint_group_drag(ui, &drag, local, pointer.command);
(self.cross.bar, self.cross.docks) = (false, false);
}
}
/// Release of a bar drag: over a target in another window the whole window docks or
/// joins there. Over the desktop, or with Ctrl/Cmd held, it stays where it was moved.
fn drop_native_window(&mut self, window: u64, pointer: &DragPointer) {
let Some(drag) = self.window_group_drag(window).filter(|_| !pointer.command && (pointer.docks || self.style.title_bar_docks)) else { return };
match self.destination(pointer, None) {
Some(Dest::Main(local)) => {
(self.cross.bar, self.cross.docks) = (true, pointer.docks);
self.drop_window(&drag, local, false);
(self.cross.bar, self.cross.docks) = (false, false);
}
Some(Dest::Window(target, local)) => {
let action = self.within_window(target, |r| {
let proposal = r.group_proposal(&drag, local).filter(|p| !p.noop)?;
Some(group_action(&drag, Target::At { anchor: proposal.anchor, placement: proposal.placement }))
});
if let Some(action) = action.flatten() {
self.output.actions.push(action);
}
}
_ => {}
}
}
/// A native window's bar drag as a whole-window group drag.
fn window_group_drag(&self, window: u64) -> Option<GroupDrag<P>> {
let window: &Floating<P> = self.layout.floating.iter().find(|w| w.id == window)?;
let members: Vec<P> = window.panels().cloned().collect();
let panel = members.first()?.clone();
Some(GroupDrag { panel, original: window.rect, gesture: GroupGesture::Window, members, origin: Pos2::ZERO, source: Rect::NOTHING })
}
/// At the end of a native window's frame: the drop preview, and its undock hint, when the
/// drag's pointer is over this window.
pub(super) fn paint_cross(&mut self, ui: &Ui, surface: &mut Surface<P>) {
let ctx = ui.ctx();
let Some(pointer) = ctx.data(|d| d.get_temp::<DragPointer>(self.id.with(POINTER))).filter(|p| !p.released && p.down) else { return };
if let Some(Dest::Window(window, local)) = self.destination(&pointer, Some(&*surface))
&& window == surface.window
{
let shift = self.shift(&pointer, surface.origin);
self.within(surface, |r| r.paint_drop(ui, &pointer, local, shift));
}
}
/// The preview of the drag at `local`, in the window drawing now. `shift` takes the
/// owner's coordinates to this window's.
fn paint_drop(&mut self, ui: &Ui, pointer: &DragPointer, local: Pos2, shift: Vec2) {
let ctx = ui.ctx();
if let Some(window) = pointer.window {
if let Some(drag) = self.window_group_drag(window) {
self.cross.docks = pointer.docks;
self.paint_group_drag(ui, &drag, local, pointer.command);
self.cross.docks = false;
}
return;
}
if let Some(drag) = ctx.data(|d| d.get_temp::<Drag<P>>(self.id.with("drag"))) {
if pointer.command || drag.undock {
return;
}
let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag };
let proposal = self.tab_proposal(&drag.panel, local);
let painter = self.preview_painter(ctx);
self.paint_tab_proposal(ctx, &painter, &drag, proposal.as_ref(), local);
} else if let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag"))).filter(|d| d.gesture != GroupGesture::Window)
{
let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag };
self.paint_group_drag(ui, &drag, local, pointer.command);
}
}
/// End of the main window's frame: forget the pointer of a finished drag.
pub(in crate::docking) fn end_cross(&mut self, ctx: &Context) {
let id = self.id;
let active = ctx.data(|d| {
d.get_temp::<Drag<P>>(id.with("drag")).is_some()
|| d.get_temp::<GroupDrag<P>>(id.with("group-drag")).is_some()
|| d.get_temp::<WindowDrag>(id.with("window-drag")).is_some()
});
if !active {
ctx.data_mut(|d| d.remove::<DragPointer>(id.with(POINTER)));
}
}
/// The drag's input as its owner saw it, at `pointer` in the coordinates of the window
/// finishing it. Leaving a window does not end the drag.
fn input_of(&self, pointer: &DragPointer, at: Option<Pos2>) -> DragInput {
DragInput {
pointer: at,
down: pointer.down,
released: pointer.released,
focused: !self.cross.focus_lost,
gone: false,
escape: pointer.escape,
command: pointer.command,
touch_ended: false,
}
}
/// The in-window rect of a window floated out at the desktop pointer with its top-left
/// `grab` from it. The new window opens at the main window's origin plus this rect. Where
/// positions are unknown the window system places it.
fn desktop_float(&self, pointer: &DragPointer, size: Vec2, grab: Vec2) -> Rect {
match (pointer.desktop(), self.cross.main) {
(Some(point), Some(main)) => Rect::from_min_size(point - grab - main.min.to_vec2(), size),
_ => clamp_float(Rect::from_min_size(self.float_bounds.min, size), self.float_bounds),
}
}
/// Push `action` when the model accepts it, focusing `panel`.
fn push_checked(&mut self, action: Option<Action<P>>, panel: &P) {
let Some(action) = action else { return };
if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() {
self.output.actions.push(action);
self.output.focused = Some(panel.clone());
}
}
}
+659
View File
@@ -0,0 +1,659 @@
//! [D] Docks: side docks of columns, each a column bar above stacked groups.
//!
//! Columns run from the window edge inwards. Every column's inner edge resizes it; the last
//! column's inner edge is the dock's inner edge. Behavior follows VectorCraft `floating.rs`
//! (column layout, drop geometry) and PhotoCraft `dock.rs` (group heights).
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
use super::{GroupKind, Renderer, RowItem, row_drag, row_range};
use crate::docking::{
Action, Column, DockEvent, DockHeights, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target,
dock_model::Unit,
targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line},
};
use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis};
/// Width of the »/« button at the outer end of a column bar.
const COLLAPSE_WIDTH: f32 = 18.0;
/// The physical window edge a dock sits on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::docking) enum Edge {
Left,
Right,
}
/// `Start` is the left edge in a left-to-right layout and the right edge in a right-to-left one.
pub(in crate::docking) fn edge_of(side: Side, right_to_left: bool) -> Edge {
match (side, right_to_left) {
(Side::Start, false) | (Side::End, true) => Edge::Left,
(Side::Start, true) | (Side::End, false) => Edge::Right,
}
}
fn side_at(edge: Edge, right_to_left: bool) -> Side {
match (edge, right_to_left) {
(Edge::Left, false) | (Edge::Right, true) => Side::Start,
(Edge::Left, true) | (Edge::Right, false) => Side::End,
}
}
/// The rects of a dock beside `other` in `rect`, and the widest the dock may get.
pub(in crate::docking) fn dock_rects<P>(
side: Side,
columns: &[Column<P>],
other: &Node<P>,
rect: Rect,
style: &DockStyle,
content: &dyn crate::docking::DockContent<P>,
) -> (Rect, Rect, f32) {
let gap = style.gap;
let room = (rect.width() - gap - super::node_limits(other, style, content).min.x).clamp(0.0, rect.width());
let width = columns.iter().map(|c| column_width(c, style)).sum::<f32>().clamp(0.0, room);
let (dock, rest) = match edge_of(side, style.right_to_left) {
Edge::Left => (
Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())),
Rect::from_min_max(pos2((rect.left() + width + gap).min(rect.right()), rect.top()), rect.max),
),
Edge::Right => (
Rect::from_min_max(pos2(rect.right() - width, rect.top()), rect.max),
Rect::from_min_max(rect.min, pos2((rect.right() - width - gap).max(rect.left()), rect.bottom())),
),
};
(dock, rest, room)
}
pub(in crate::docking) fn column_width<P>(column: &Column<P>, style: &DockStyle) -> f32 {
if column.iconic { column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) } else { column.width }
}
/// A group of a column, as [`group_heights`] sizes it. Heights include the tab strip.
#[derive(Clone, Copy, Debug, Default)]
pub(in crate::docking) struct GroupSize {
pub(in crate::docking) minimized: bool,
/// `Group::height`: set by the user; `None` or not finite when never resized.
pub(in crate::docking) saved: Option<f32>,
pub(in crate::docking) min: f32,
/// The content's [`DockHeights`](crate::docking::DockHeights), the strip added (to a
/// `fill` above 0 only, so 0 still means the group does not ask to fill).
pub(in crate::docking) content: Option<DockHeights>,
}
/// Heights of stacked groups in a column body `room` high. Minimized groups take `strip`,
/// groups with a saved height keep it, and groups the user never resized whose content asks
/// for a fill height share what is left. Without those, the groups without content heights
/// share it, and when every open group has a height, the last one takes the rest. Other
/// groups take their content's default height, or their minimum. Default heights shrink towards their compact heights until the
/// filling groups get their content's fill height, then every height shrinks towards its
/// minimum before the column overflows and scrolls. Default heights give way nearest the
/// filling groups first; saved and default heights then shrink in proportion to their room.
pub(in crate::docking) fn group_heights(groups: &[GroupSize], strip: f32, room: f32) -> Vec<f32> {
let saved = |g: &GroupSize| g.saved.filter(|h| h.is_finite());
let open: Vec<usize> = (0..groups.len()).filter(|i| groups.get(*i).is_some_and(|g| !g.minimized)).collect();
let unsaved = |i: &usize, test: fn(Option<DockHeights>) -> bool| groups.get(*i).is_some_and(|g| saved(g).is_none() && test(g.content));
let mut shared: Vec<usize> = open.iter().copied().filter(|i| unsaved(i, |c| c.is_some_and(|c| c.fill > 0.0))).collect();
if shared.is_empty() {
shared = open.iter().copied().filter(|i| unsaved(i, |c| c.is_none())).collect();
}
if shared.is_empty() {
shared.extend(open.last());
}
let sized: Vec<usize> = open.iter().copied().filter(|i| !shared.contains(i)).collect();
let mut defaulted: Vec<usize> = sized.iter().copied().filter(|i| groups.get(*i).is_some_and(|g| saved(g).is_none())).collect();
let mut heights: Vec<f32> = groups
.iter()
.enumerate()
.map(|(i, g)| {
if g.minimized {
strip
} else if shared.contains(&i) {
0.0
} else {
saved(g).or(g.content.map(|c| c.default)).unwrap_or(g.min).max(g.min)
}
})
.collect();
let mins: Vec<f32> = groups.iter().map(|g| if g.minimized { strip } else { g.min }).collect();
let compacts: Vec<f32> = groups.iter().zip(&mins).map(|(g, min)| g.content.map_or(*min, |c| c.compact).max(*min)).collect();
let fill: f32 = shared.iter().filter_map(|i| groups.get(*i)).map(|g| g.content.map_or(g.min, |c| c.fill).max(g.min)).sum();
// Nearest the filling groups first: one group gives way to its compact height before the next.
defaulted.sort_by_key(|i| shared.iter().map(|s| s.abs_diff(*i)).min());
let mut deficit = heights.iter().sum::<f32>() + fill - room;
for i in defaulted {
if deficit <= 0.0 {
break;
}
if let (Some(height), Some(floor)) = (heights.get_mut(i), compacts.get(i)) {
let give = (*height - floor).clamp(0.0, deficit);
*height -= give;
deficit -= give;
}
}
let shared_min: f32 = shared.iter().filter_map(|i| mins.get(*i)).sum();
give_way(&mut heights, &sized, &mins, shared_min - room);
let free = (room - heights.iter().sum::<f32>()).max(0.0);
// The shared groups split what is left evenly, each at least its minimum: groups whose
// minimum is above the even share take their minimum and the rest split the remainder.
let mut floors: Vec<f32> = shared.iter().filter_map(|i| mins.get(*i).copied()).collect();
floors.sort_by(|a, b| b.total_cmp(a));
let (mut rest, mut count) = (free, floors.len());
for floor in floors {
if count > 1 && floor > rest / count as f32 {
rest -= floor;
count -= 1;
} else {
break;
}
}
let share = rest / count.max(1) as f32;
for i in &shared {
if let (Some(height), Some(min)) = (heights.get_mut(*i), mins.get(*i)) {
*height = share.max(*min);
}
}
heights
}
/// Shrink the `which` heights in proportion towards their `floors` until the column's heights
/// plus `extra` fit, or every one is at its floor.
fn give_way(heights: &mut [f32], which: &[usize], floors: &[f32], extra: f32) {
let deficit = heights.iter().sum::<f32>() + extra;
let slack: f32 = which.iter().filter_map(|i| Some((heights.get(*i)? - floors.get(*i)?).max(0.0))).sum();
if deficit > 0.0 && slack > 0.0 {
let take = deficit.min(slack) / slack;
for i in which {
if let (Some(height), Some(floor)) = (heights.get_mut(*i), floors.get(*i)) {
*height -= (*height - floor).max(0.0) * take;
}
}
}
}
/// Column widths that fit `room`. Each entry is (width, floor); a column with a floor shrinks
/// in proportion, never below its floor, and one without keeps its width. A dock saved on a
/// larger screen still fits, as far as the floors allow.
pub(in crate::docking) fn fit_widths(columns: &[(f32, Option<f32>)], room: f32) -> Vec<f32> {
let mut widths: Vec<f32> = columns.iter().map(|(w, _)| *w).collect();
for _ in 0..columns.len() {
let total: f32 = widths.iter().sum();
if total <= room {
break;
}
let flex: f32 = widths.iter().zip(columns).filter(|(w, (_, floor))| floor.is_some_and(|f| **w > f)).map(|(w, _)| *w).sum();
if flex <= 0.0 {
break;
}
let scale = ((room - (total - flex)) / flex).max(0.0);
for (width, (_, floor)) in widths.iter_mut().zip(columns) {
if let Some(floor) = *floor
&& *width > floor
{
*width = (*width * scale).max(floor);
}
}
}
widths
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// A horizontal split with a dock child: the dock takes its columns' width on its physical
/// edge, the sibling the rest. False when neither child is a dock.
pub(super) fn dock_split(
&mut self,
ui: &mut Ui,
first: &Node<P>,
second: &Node<P>,
rect: Rect,
path: &mut Vec<bool>,
root: (&Node<P>, Rect),
) -> bool {
let (dock_first, side, columns, other) = match (first, second) {
(Node::Dock { side, columns }, other) => (true, *side, columns, other),
(other, Node::Dock { side, columns }) => (false, *side, columns, other),
_ => return false,
};
let (dock, rest, room) = dock_rects(side, columns, other, rect, self.style, &*self.content);
self.dock(ui, side, columns, dock, room);
path.push(dock_first);
self.node(ui, other, rest, path, root);
path.pop();
true
}
/// One dock in `rect`, columns from its outer edge. `room` is the widest the dock may get.
pub(super) fn dock(&mut self, ui: &mut Ui, side: Side, columns: &[Column<P>], rect: Rect, room: f32) {
let edge = edge_of(side, self.style.right_to_left);
let (outer, inward) = match edge {
Edge::Left => (rect.left(), 1.0),
Edge::Right => (rect.right(), -1.0),
};
if columns.is_empty() {
let (band, line) = column_edge(outer, inward, rect.y_range());
let band = band.union(Rect::from_x_y_ranges(
outer.min(outer + inward * self.style.new_dock_band)..=outer.max(outer + inward * self.style.new_dock_band),
rect.y_range(),
));
self.dock_targets.push(DockDrop { target: DockTarget::NewDock { side }, band, line });
return;
}
if usable(rect) {
ui.painter().rect_filled(rect, 0.0, self.style.background);
}
let bounds: Vec<(f32, f32)> =
columns.iter().map(|c| if c.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(c) }).collect();
let natural: Vec<(f32, Option<f32>)> =
columns.iter().zip(&bounds).map(|(c, (lo, _))| (column_width(c, self.style), (!c.iconic).then_some(lo.max(1.0)))).collect();
let widths = fit_widths(&natural, rect.width());
let scaled = widths.iter().zip(&natural).any(|(w, (n, _))| w != n);
let total: f32 = widths.iter().sum();
let mut x = outer;
let mut drawn = Vec::new();
for (index, column) in columns.iter().enumerate() {
let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue };
let fitted = widths.get(index).copied().unwrap_or_default();
let width = fitted.min((rect.width() - (x - outer).abs()).max(0.0));
let inner = x + inward * width;
let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range());
self.column(ui, column, first, column_rect, edge);
if index == 0 {
let (band, line) = column_edge(x, inward, rect.y_range());
self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: false }, band, line });
}
let (band, line) = column_edge(inner, -inward, rect.y_range());
self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: true }, band, line });
drawn.push((index, column, first, width, inner));
x = inner;
}
// Registered after the columns, so the narrow hit areas win at the edges.
for (index, column, first, width, inner) in drawn {
if width < 1.0 {
continue;
}
let others = total - widths.get(index).copied().unwrap_or_default();
let (lo, hi) = bounds.get(index).copied().unwrap_or_default();
let hi = hi.min(room - others).max(lo.min(room - others).max(0.0));
let lo = lo.min(hi);
let divider = Rect::from_x_y_ranges((inner - 1.0)..=(inner + 1.0), rect.y_range());
let response = ResizeHandle::new(
self.id.with(("column-resize", Some(first))),
SplitAxis::Horizontal,
divider,
&self.style.resize_label,
self.handle_style(),
)
.value(width, lo..=hi)
.reversed(inward < 0.0)
.show(ui);
self.register_interaction(ui.ctx(), response.response.id);
let next = (width + inward * response.delta).clamp(lo, hi);
if response.delta != 0.0 && next != width && next > 0.0 {
self.output.actions.push(if column.iconic {
Action::ResizeIconic { panel: first.clone(), width: Some(next) }
} else {
Action::ResizeColumn { panel: first.clone(), width: next }
});
// A dock shrunk to fit keeps the widths it was drawn at, so the resized column
// follows the pointer instead of being scaled again.
if scaled {
for (other, (column, width)) in columns.iter().zip(&widths).enumerate() {
let first = column.groups.first().and_then(|g| g.panels.first());
if let Some(first) = first.filter(|_| other != index && !column.iconic && *width > 0.0) {
self.output.actions.push(Action::ResizeColumn { panel: first.clone(), width: *width });
}
}
}
}
}
}
fn handle_style(&self) -> ResizeHandleStyle {
ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }
}
/// Width bounds of an expanded column from its panels' limits.
fn column_bounds(&self, column: &Column<P>) -> (f32, f32) {
let mut lo = self.style.min_pane;
let mut hi = f32::INFINITY;
for panel in column.groups.iter().flat_map(|g| g.panels.iter()) {
let limits = self.limits(panel);
lo = lo.max(limits.min.x);
hi = hi.min(limits.max.x);
}
(lo, hi.max(lo))
}
/// Least height of an open group: its strip and the tallest minimum of its panels.
fn group_min(&self, group: &Group<P>) -> f32 {
let body = group.panels.iter().map(|p| self.limits(p).min.y).fold(self.style.min_pane, f32::max);
self.style.tab_height + body
}
/// The largest of each of the group's panels' [`DockHeights`], the tab strip added.
fn dock_heights(&self, group: &Group<P>) -> Option<DockHeights> {
let strip = self.style.tab_height;
group
.panels
.iter()
.filter_map(|p| self.content.dock_heights(p).filter(|h| h.valid()))
.reduce(|a, b| DockHeights { default: a.default.max(b.default), compact: a.compact.max(b.compact), fill: a.fill.max(b.fill) })
.map(|h| DockHeights {
default: h.default + strip,
compact: h.compact.min(h.default) + strip,
fill: if h.fill > 0.0 { h.fill + strip } else { 0.0 },
})
}
fn column(&mut self, ui: &mut Ui, column: &Column<P>, first: &P, rect: Rect, edge: Edge) {
if !usable(rect) {
return;
}
self.record_column_float(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(), rect);
let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom())));
self.column_bar(ui, column, first, bar, rect, edge);
let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max);
if column.iconic && usable(body) {
self.iconic_column(ui, column, body, edge);
}
if column.iconic || !usable(body) {
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
return;
}
let sizes: Vec<_> = column
.groups
.iter()
.map(|g| GroupSize { minimized: g.minimized, saved: g.height, min: self.group_min(g), content: self.dock_heights(g) })
.collect();
let heights = group_heights(&sizes, self.style.tab_height, body.height());
let total: f32 = heights.iter().sum();
let start = self.targets.len();
if total > body.height() + 0.5 {
let mut child =
ui.new_child(UiBuilder::new().id_salt(("dock-column", first)).max_rect(body).layout(egui::Layout::top_down(egui::Align::Min)));
child.set_clip_rect(body.intersect(ui.clip_rect()));
egui::ScrollArea::vertical()
.id_salt(self.id.with(("column-scroll", first)))
.auto_shrink([false, false])
.scroll_source(egui::scroll_area::ScrollSource::SCROLL_BAR | egui::scroll_area::ScrollSource::MOUSE_WHEEL)
.show(&mut child, |ui| {
let (content, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover());
self.column_groups(ui, column, &heights, content, body);
});
} else {
self.column_groups(ui, column, &heights, body, body);
}
// A group scrolled out of view takes nothing.
for target in self.targets.iter_mut().skip(start) {
target.rect = target.rect.intersect(body);
target.tab_strip = target.tab_strip.map(|strip| strip.intersect(body));
for (_, tab) in &mut target.tabs {
*tab = tab.intersect(body);
}
}
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
}
/// Groups top to bottom in `area` (the scrolled content when the column scrolls), their
/// seams and bands clipped to the visible `view`, and the height dividers.
fn column_groups(&mut self, ui: &mut Ui, column: &Column<P>, heights: &[f32], area: Rect, view: Rect) {
let mut top = area.top();
let mut rects = Vec::new();
for (group, height) in column.groups.iter().zip(heights) {
let rect = Rect::from_min_max(pos2(area.left(), top), pos2(area.right(), top + height));
self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Dock);
rects.push(rect);
top += height;
}
let xs = view.x_range();
let half = self.style.seam_band * 0.5;
let push = |targets: &mut Vec<DockDrop<P>>, target: DockTarget<P>, band: Rect, line: Rect| {
let band = band.intersect(view);
if usable(band) {
targets.push(DockDrop { target, band, line: line.intersect(view.expand(2.0)) });
}
};
let mut previous: Option<&P> = None;
for (group, rect) in column.groups.iter().zip(&rects) {
let Some(anchor) = group.panels.first() else { continue };
if let Some(above) = previous {
let band = Rect::from_x_y_ranges(xs, (rect.top() - half)..=(rect.top() + half));
push(&mut self.dock_targets, DockTarget::Seam { anchor: above.clone() }, band, horizontal_line(rect.top(), xs));
}
if !group.minimized {
let body = Rect::from_min_max(pos2(rect.left(), (rect.top() + self.style.tab_height).min(rect.bottom())), rect.max);
let quarter = body.height() * 0.25;
let upper = Rect::from_x_y_ranges(xs, body.top()..=(body.top() + quarter));
let lower = Rect::from_x_y_ranges(xs, (body.bottom() - quarter)..=body.bottom());
push(&mut self.dock_targets, DockTarget::GroupBand { anchor: anchor.clone(), below: false }, upper, horizontal_line(rect.top(), xs));
push(
&mut self.dock_targets,
DockTarget::GroupBand { anchor: anchor.clone(), below: true },
lower,
horizontal_line(rect.bottom(), xs),
);
}
previous = Some(anchor);
}
// The room under the last group, when every group is minimized, stacks below it.
if let Some(last) = previous
&& top < view.bottom()
{
let band = Rect::from_x_y_ranges(xs, top..=view.bottom());
push(&mut self.dock_targets, DockTarget::GroupBand { anchor: last.clone(), below: true }, band, horizontal_line(top, xs));
}
// Height dividers between an open group and the next open one, after the groups so
// their hit areas win at the boundary. The heights of the groups a drag changes are
// saved: the upper group, and the lower groups that give way in turn, or the reverse.
let open: Vec<usize> = (0..column.groups.len()).filter(|i| column.groups.get(*i).is_some_and(|g| !g.minimized)).collect();
let row: Vec<RowItem> = open
.iter()
.filter_map(|i| Some(RowItem { size: *heights.get(*i)?, min: self.group_min(column.groups.get(*i)?), max: f32::INFINITY }))
.collect();
for (index, pair) in open.windows(2).enumerate() {
let &[i, _] = pair else { continue };
let (Some(upper), Some(rect), Some(a)) = (column.groups.get(i), rects.get(i), heights.get(i)) else { continue };
let Some(first) = upper.panels.first() else { continue };
let (lo, hi) = row_range(&row, index);
let divider = Rect::from_x_y_ranges(rect.x_range(), (rect.bottom() - 1.0)..=(rect.bottom() + 1.0));
let response = ResizeHandle::new(
self.id.with(("group-resize", Some(first))),
SplitAxis::Vertical,
divider,
&self.style.resize_label,
self.handle_style(),
)
.value(*a, (a + lo)..=(a + hi))
.show(ui);
self.register_interaction(ui.ctx(), response.response.id);
if response.delta == 0.0 {
continue;
}
for ((group, item), next) in open.iter().filter_map(|g| column.groups.get(*g)).zip(&row).zip(row_drag(&row, index, response.delta)) {
if let Some(panel) = group.panels.first().filter(|_| next != item.size) {
self.output.actions.push(Action::ResizeGroup { panel: panel.clone(), height: Some(next) });
}
}
}
}
/// The column bar: the »/« button at its outer end, the rest drags the column (S6),
/// toggles iconic on double-click (M2) and opens the column menu (U1).
fn column_bar(&mut self, ui: &mut Ui, column: &Column<P>, first: &P, bar: Rect, rect: Rect, edge: Edge) {
ui.painter().rect_filled(bar, 0.0, self.style.tab_background);
let width = COLLAPSE_WIDTH.min(bar.width());
let (button, grip) = match edge {
Edge::Left => (
Rect::from_min_max(bar.min, pos2(bar.left() + width, bar.bottom())),
Rect::from_min_max(pos2(bar.left() + width, bar.top()), bar.max),
),
Edge::Right => (
Rect::from_min_max(pos2(bar.right() - width, bar.top()), bar.max),
Rect::from_min_max(bar.min, pos2(bar.right() - width, bar.bottom())),
),
};
let label = if column.iconic { self.style.expand_panels_label.clone() } else { self.style.collapse_to_icons_label.clone() };
if usable(button) {
let response = ui.interact(button, self.id.with(("column-collapse", Some(first))), Sense::click());
self.register_interaction(ui.ctx(), response.id);
let button_label = if column.iconic { &self.style.expand_column_label } else { &self.style.collapse_column_label };
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), button_label));
// The arrows point where the column goes: towards the window edge when it collapses.
let glyph = if (edge == Edge::Left) != column.iconic { "«" } else { "»" };
let color = if response.hovered() || response.has_focus() { self.style.text } else { self.style.inactive_text };
ui.painter().with_clip_rect(button).text(button.center(), egui::Align2::CENTER_CENTER, glyph, self.style.font.clone(), color);
if response.clicked() {
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
}
}
if !usable(grip) {
return;
}
let movable = column.groups.iter().flat_map(|g| g.panels.iter()).all(|p| self.permissions(p).movable);
let response = ui.interact(grip, self.id.with(("column-bar", Some(first))), Sense::click_and_drag());
let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response };
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_column_label));
if movable && response.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center());
let drag = GroupDrag {
panel: first.clone(),
original: [rect.left(), rect.top(), rect.width(), rect.height()],
gesture: GroupGesture::Column,
members: column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(),
origin,
source: rect,
};
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
}
if response.double_clicked() {
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
}
if response.has_focus() || response.hovered() {
ui.painter().with_clip_rect(grip).rect_stroke(
grip.shrink(1.0),
0.0,
Stroke::new(1.0, self.style.accent.gamma_multiply(0.5)),
StrokeKind::Inside,
);
}
let keyboard = response.has_focus() && ui.input(|i| i.key_pressed(Key::Enter) || i.key_pressed(Key::Space));
let command = if response.secondary_clicked() || keyboard {
Some(egui::SetOpenCommand::Bool(true))
} else if response.clicked() {
Some(egui::SetOpenCommand::Bool(false))
} else {
None
};
egui::Popup::menu(&response).open_memory(command).show(|ui| {
if self.menu_action(ui, &label) {
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
}
if self.auto_collapse_item(ui) {
self.output.events.push(DockEvent::ToggleAutoCollapse);
}
});
}
/// Whether a floating window drawn this frame (other than the one following the pointer)
/// or a host overlay covers `pointer`. Covered dock targets take nothing.
fn dock_covered(&self, pointer: Pos2, moving: Option<&P>) -> bool {
self.blockers.iter().any(|r| r.contains(pointer))
|| self.iconic.covers(pointer)
|| self.frame.windows.iter().any(|window| window.window.contains(pointer) && !moving.is_some_and(|p| window.members.contains(p)))
}
/// Dock targets, with the new-dock bands along the docked area's left and right edges that
/// have no dock yet.
fn dock_candidates(&self) -> Vec<DockDrop<P>> {
let mut candidates = self.dock_targets.clone();
if !self.style.new_dock_zones || !usable(self.workspace) {
return candidates;
}
let rtl = self.style.right_to_left;
let ys = self.workspace.y_range();
for edge in [Edge::Left, Edge::Right] {
let side = side_at(edge, rtl);
if self.layout.dock_path(side).is_some() {
continue;
}
let (x, inward) = match edge {
Edge::Left => (self.workspace.left(), self.style.new_dock_band),
Edge::Right => (self.workspace.right(), -self.style.new_dock_band),
};
let band = Rect::from_x_y_ranges(x.min(x + inward)..=x.max(x + inward), ys);
let line = vertical_line(x + inward.signum() * 2.0, ys);
candidates.push(DockDrop { target: DockTarget::NewDock { side }, band, line });
}
candidates
}
/// The dock target a whole group or column drag would take at `pointer`, with the action
/// and its line. A target that leaves the layout unchanged is a no-op: `None` action.
pub(super) fn dock_group_proposal(&self, drag: &GroupDrag<P>, pointer: Pos2) -> Option<(Option<Action<P>>, Rect)> {
let moving = (drag.gesture == GroupGesture::Window).then_some(&drag.panel);
if self.dock_covered(pointer, moving) {
return None;
}
let candidates = self.dock_candidates();
let drop = resolve_dock(pointer, &candidates)?;
let action = group_action(drag, drop.target.to());
self.checked(action).map(|action| (action, drop.line))
}
/// The dock target a single tab drag would take: a new group, column or dock of that panel.
/// A panel alone in its group takes the group's state to a new dock; one that shares its
/// group leaves the other tabs behind (P6).
pub(super) fn dock_tab_proposal(&self, panel: &P, pointer: Pos2) -> Option<(Option<Action<P>>, Rect)> {
if self.dock_covered(pointer, None) {
return None;
}
let candidates = self.dock_candidates();
let drop = resolve_dock(pointer, &candidates)?;
let action = match drop.target.to() {
// A target named by the dragged panel is its own group or column: another panel
// there names the same one. With none, the drop leaves the panel where it is.
Target::At { anchor, placement } if &anchor == panel => {
let unit = if matches!(placement, Placement::Column { .. }) { Unit::Column } else { Unit::Group };
match self.layout.members(panel, unit).ok()?.into_iter().find(|p| p != panel) {
Some(anchor) => Action::Move { panel: panel.clone(), anchor, placement },
None => return Some((None, drop.line)),
}
}
Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement },
to @ Target::NewDock { .. } if self.place(panel).and_then(|place| self.layout.group(place)).is_some_and(|g| g.panels.len() == 1) => {
Action::MoveGroup { panel: panel.clone(), to }
}
to @ Target::NewDock { .. } => Action::MovePanel { panel: panel.clone(), to },
Target::Float { .. } => return None,
};
self.checked(action).map(|action| (action, drop.line))
}
/// `Some(None)` when the action is valid but changes nothing, `None` when it is invalid.
fn checked(&self, action: Action<P>) -> Option<Option<Action<P>>> {
let mut candidate = self.layout.clone();
candidate.apply_with_permissions(action.clone(), |p| self.permissions(p)).ok()?;
Some((candidate != *self.layout).then_some(action))
}
/// The insertion line of a dock target.
pub(super) fn paint_dock_line(&self, ctx: &Context, line: Rect) {
if usable(line) {
let painter = self.preview_painter(ctx);
self.iconic.outline(&painter, line, self.style);
painter.rect_filled(line, 1.0, self.style.accent);
}
}
}
/// The move a group gesture makes: the column for a column bar, every group for a window's
/// title bar, else the group.
pub(super) fn group_action<P: Clone>(drag: &GroupDrag<P>, to: Target<P>) -> Action<P> {
match drag.gesture {
GroupGesture::Column => Action::MoveColumn { panel: drag.panel.clone(), to },
GroupGesture::Window => Action::MoveWindow { panel: drag.panel.clone(), to },
GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to },
}
}
+252
View File
@@ -0,0 +1,252 @@
use std::{fmt::Debug, hash::Hash};
use egui::{Context, Id, Key, Pos2, Rect, Ui, pos2, vec2};
use super::{GroupKind, Renderer, clamp_float, node_limits};
use crate::docking::chrome::{ChromeStyle, FloatingFrame, RESIZE_BAND, TitleBar};
use crate::docking::{Action, DropProposal, Floating, GroupDrag, GroupGesture, Node, PanelLimits, Placement, Target};
pub(in crate::docking) use crate::docking::chrome::RESIZE_HANDLES;
/// A floating window as drawn: what covers the layout behind it.
pub(in crate::docking) struct WindowHit<P> {
pub(super) window: Rect,
pub(super) members: Vec<P>,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(in crate::docking) fn floating(&mut self, ctx: &Context, windows: &[Floating<P>]) {
let screen = self.float_bounds;
if !crate::docking::targets::usable(screen) {
return;
}
let layers = ctx.memory(|memory| memory.layer_ids().collect::<Vec<_>>());
let mut windows: Vec<_> = windows.iter().collect();
windows.sort_by_key(|window| {
let id = window.panels().next().map(|panel| self.id.with(("floating", panel)));
layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX)
});
#[cfg(feature = "native-windows")]
let windows = self.native_windows(ctx, windows);
for window in windows {
self.floating_window(ctx, window, screen);
}
}
/// Size limits of a floating window: its title bar and, unless it is minimized, its tree.
pub(super) fn window_limits(&self, window: &Floating<P>) -> PanelLimits {
let chrome = self.window_chrome(window);
let tree = node_limits(&window.root, self.style, &*self.content);
let body = match (&window.root, window.minimized) {
(Node::Tabs { .. }, true) => (self.style.tab_height, self.style.tab_height),
(_, true) => (0.0, 0.0),
(_, false) => (tree.min.y, tree.max.y),
};
let min = vec2(tree.min.x, chrome + body.0).min(vec2(1_000_000.0, 1_000_000.0));
PanelLimits { min, max: vec2(tree.max.x, chrome + body.1).max(min).min(vec2(1_000_000.0, 1_000_000.0)) }
}
/// The title bar and, for a movable (resizable) window, the resize band above it.
fn window_chrome(&self, window: &Floating<P>) -> f32 {
let band = if window.panels().all(|panel| self.permissions(panel).movable) { RESIZE_BAND } else { 0.0 };
band + self.style.title_height
}
/// Where a floating window draws its tree, as [`Self::floating_window`] places it.
pub(super) fn window_body(&self, window: &Floating<P>) -> Rect {
let [x, y, width, height] = window.rect;
let limits = self.window_limits(window);
let size = vec2(width, height).max(limits.min).min(limits.max);
let rect = crate::docking::chrome::clamp(Rect::from_min_size(pos2(x, y), size), self.float_bounds);
Rect::from_min_max(pos2(rect.left(), (rect.top() + self.window_chrome(window)).min(rect.bottom())), rect.max)
}
fn floating_window(&mut self, ctx: &Context, window: &Floating<P>, screen: Rect) {
let Some(first) = window.panels().next() else { return };
let [x, y, width, height] = window.rect;
let limits = self.window_limits(window);
let size = vec2(width, height).max(limits.min).min(limits.max);
let rect = crate::docking::chrome::clamp(Rect::from_min_size(pos2(x, y), size), screen);
let id = self.id.with(("floating", first));
if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) {
ctx.move_to_top(FloatingFrame::layer(id));
}
let movable = window.panels().all(|panel| self.permissions(panel).movable);
let style = ChromeStyle::from_dock(self.style);
let resize_label = self.style.resize_window_label.clone();
let response = FloatingFrame::new(id, rect).bounds(screen).limits(limits).resizable(movable).resize_label(&resize_label).show(
ctx,
&style,
|ui, contents| self.window_contents(ui, window, contents, rect, &style),
);
for handle in &response.handles {
self.register_interaction(ctx, handle.id);
}
let down = ctx.input(|i| i.pointer.primary_down());
ctx.data_mut(|data| {
let key = self.id.with("pointer-resize");
if let Some(started) = response.resize_started {
data.insert_temp(key, started);
} else if !down && data.get_temp::<Id>(key).is_some_and(|id| response.handles.iter().any(|h| h.id == id)) {
data.remove::<Id>(key);
}
});
if let Some(resized) = response.resized {
self.output
.actions
.push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] });
}
}
/// The title bar, then the window's tree below it, in `contents`. Moves carry the whole
/// `frame`, resize band included.
fn window_contents(&mut self, ui: &mut Ui, window: &Floating<P>, contents: Rect, frame: Rect, style: &ChromeStyle) {
let title = Rect::from_min_max(contents.min, pos2(contents.right(), (contents.top() + self.style.title_height).min(contents.bottom())));
self.title_bar(ui, window, title, frame, style);
let body = Rect::from_min_max(pos2(contents.left(), title.bottom()), contents.max);
self.floating_body(ui, window, body, frame);
}
/// A floating window's tree in `body`, like the docked layout's: splits, groups, dividers
/// and intersections. A minimized window shows the tab strip of its only group, or nothing.
pub(super) fn floating_body(&mut self, ui: &mut Ui, window: &Floating<P>, body: Rect, frame: Rect) {
let Some(key) = window.panels().next().cloned() else { return };
match (&window.root, window.minimized) {
(Node::Tabs { panels, active }, true) => self.group(ui, panels, *active, true, body, GroupKind::Floating { window: frame }),
(_, true) => {}
(root, false) => {
let outer = self.tree.replace((key, frame));
self.node(ui, root, body, &mut Vec::new(), (root, body));
self.tree = outer;
}
}
self.frame.windows.push(WindowHit { window: frame, members: window.panels().cloned().collect() });
}
/// Drag moves the whole window live, double-click minimizes or expands it, × closes every
/// panel in it.
fn title_bar(&mut self, ui: &mut Ui, window: &Floating<P>, title: Rect, rect: Rect, style: &ChromeStyle) {
let Some(first) = window.panels().next() else { return };
let movable = window.panels().all(|p| self.permissions(p).movable);
let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable);
let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone());
let mut bar = TitleBar::new(self.id.with(("move-group", Some(first))), &move_label).movable(movable);
if closable {
bar = bar.close(&close_label);
}
let response = bar.show(ui, title, style);
self.register_interaction(ui.ctx(), response.bar.id);
if movable && response.bar.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(title.center());
let drag = GroupDrag {
panel: first.clone(),
original: [rect.left(), rect.top(), rect.width(), rect.height()],
gesture: GroupGesture::Window,
members: window.panels().cloned().collect(),
origin,
source: rect,
};
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
}
if movable
&& response.bar.dragged()
&& let Some(drag) = ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag")))
&& drag.gesture == GroupGesture::Window
&& let Some(pointer) = ui.ctx().pointer_interact_pos()
&& !ui.input(|i| i.key_pressed(Key::Escape))
&& !crate::focus::lost(ui.ctx())
{
// From the press origin, so fractional pointer steps do not accumulate rounding.
let moved = clamp_float(drag.source.translate(pointer - drag.origin), self.float_bounds);
self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] });
}
if response.bar.double_clicked() {
self.output.actions.push(Action::SetMinimized { panel: first.clone(), minimized: !window.minimized });
}
if let Some(close) = response.close {
self.register_interaction(ui.ctx(), close.id);
if close.clicked() {
self.output.actions.push(Action::CloseWindow { panel: first.clone() });
}
}
}
/// The title bar of a floating window occludes what lies behind it: a no-op proposal. Then
/// the layout's outer edge (P8). None leaves the pointer to the group targets. `skip_own`
/// looks through the window holding `members` (a window moving live).
pub(super) fn extra_proposal(&self, members: &[P], pointer: Pos2, skip_own: bool) -> Option<DropProposal<P>> {
let own = |panels: &[P]| panels.iter().any(|p| members.contains(p));
let window = self.frame.windows.iter().rev().find(|w| w.window.contains(pointer) && !(skip_own && own(&w.members)));
if let Some(window) = window {
let in_group = self.targets.iter().any(|t| t.rect.contains(pointer) && t.tabs.iter().any(|(p, _)| window.members.contains(p)));
if in_group {
return None;
}
let anchor = members.first()?.clone();
return Some(DropProposal { anchor, placement: Placement::Tab { before: None }, preview: Rect::NOTHING, insertion: None, noop: true });
}
let (anchor, zone, preview) = self.edge_proposal(members, pointer)?;
let lead = members.first()?.clone();
let action = match members {
[_] => Action::Move { panel: lead, anchor: anchor.clone(), placement: Placement::Edge(zone) },
_ => Action::MoveGroup { panel: lead, to: Target::At { anchor: anchor.clone(), placement: Placement::Edge(zone) } },
};
if !self.leaves_room(action) {
return None;
}
Some(DropProposal { anchor, placement: Placement::Edge(zone), preview, insertion: None, noop: false })
}
/// What a group drag carries: the whole window rect for the only group of a floating
/// window, so floating it again keeps the window's size; else the group's rect.
pub(super) fn drag_source(&self, owner: &super::group::TabGroup<'_, P>) -> Rect {
match owner.kind {
GroupKind::Floating { window } if owner.panels.first().is_some_and(|p| self.whole_window(p)) => window,
_ => owner.rect,
}
}
/// Whether a double-click or the group menu minimizes `owner`: a dock group, or the only
/// group of a floating window, which minimizes the window to that group's strip.
pub(super) fn minimizes(&self, owner: &super::group::TabGroup<'_, P>) -> bool {
match owner.kind {
GroupKind::Dock => true,
GroupKind::Floating { .. } => self.whole_window(owner.selected),
GroupKind::Frame => false,
}
}
/// Whether `panel`'s group is the whole tree of a floating window.
pub(super) fn whole_window(&self, panel: &P) -> bool {
matches!(self.place(panel), Some(crate::docking::dock_model::Place::Floating { path, .. }) if path.is_empty())
}
/// Whether moving a whole window docks it: [`crate::docking::DockStyle::title_bar_docks`],
/// or a native window moved by its only tab or group.
pub(super) fn title_bar_docks(&self) -> bool {
#[cfg(feature = "native-windows")]
if self.cross.docks {
return true;
}
self.style.title_bar_docks
}
/// Release of a title bar drag. The window already sits where it was dragged; a dock or
/// group target docks or merges every group in it, else hosts get the release in
/// `group_drop`.
pub(super) fn drop_window(&mut self, drag: &GroupDrag<P>, pointer: Pos2, float_only: bool) {
let float_only = float_only || !self.title_bar_docks();
if !float_only && let Some((action, _)) = self.dock_group_proposal(drag, pointer) {
self.output.actions.extend(action);
return;
}
let proposal = if float_only { None } else { self.group_proposal(drag, pointer) };
match proposal.filter(|p| !p.noop) {
Some(proposal) => {
let to = Target::At { anchor: proposal.anchor, placement: proposal.placement };
self.output.actions.push(super::dock::group_action(drag, to));
}
None => self.output.group_drop = Some((drag.panel.clone(), pointer)),
}
}
}
+243
View File
@@ -0,0 +1,243 @@
//! What the split tree adds around the groups: Maximize Frame, the outer edge band, hidden
//! panels, the float hint and the group menu tail.
use std::{borrow::Cow, collections::HashMap, fmt::Debug, hash::Hash};
use egui::{Context, Pos2, Rect, pos2, vec2};
use super::{GroupKind, Renderer, floating::WindowHit, group::TabGroup};
use crate::docking::{
Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Zone,
dock_model::{Place, node_at},
};
/// How far below the pointer the float hint sits, clear of the cursor.
const HINT_OFFSET: f32 = 28.0;
/// What the renderer draws when it is not the layout itself, and the targets that depend on it.
pub(in crate::docking) struct FrameState<P> {
/// View path to layout path of each split, when the view is not the layout.
paths: Option<HashMap<Vec<bool>, Vec<bool>>>,
/// The same for each floating window's splits, by the window's first shown panel.
window_paths: HashMap<P, HashMap<Vec<bool>, Vec<bool>>>,
/// The view node whose rect is the outer edge area; none while a frame is maximized.
edge_path: Option<Vec<bool>>,
pub(super) edge: Option<Rect>,
pub(super) maximized: Option<P>,
/// Floating windows as drawn, back to front.
pub(super) windows: Vec<WindowHit<P>>,
}
/// The layout as shown: a maximized frame alone, and hidden panels left out.
pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>(
layout: &'a Layout<P>,
maximized: Option<&P>,
hide: HidePanels<'_, P>,
content: &dyn DockContent<P>,
) -> (Cow<'a, Layout<P>>, FrameState<P>) {
let protected = |p: &P| content.permissions(p).is_protected();
let hidden = |p: &P| match hide {
HidePanels::None => false,
HidePanels::All => !protected(p),
HidePanels::Except(keep) => !protected(p) && !keep(p),
};
// Only a docked panel maximizes its frame.
let maximized = maximized.and_then(|panel| match layout.place(panel)? {
Place::Node(path) => Some((panel.clone(), path.clone(), layout.root.as_ref().and_then(|root| node_at(root, &path))?.clone())),
place @ Place::Dock { .. } => {
let group = layout.group(&place)?;
Some((panel.clone(), path_of(&place), Node::Tabs { panels: group.panels.clone(), active: group.active }))
}
Place::Floating { .. } => None,
});
let mut state = FrameState { paths: None, window_paths: HashMap::new(), edge_path: None, edge: None, maximized: None, windows: Vec::new() };
if maximized.is_none() && matches!(hide, HidePanels::None) {
state.edge_path = Some(layout.edge_path());
return (Cow::Borrowed(layout), state);
}
let mut paths = HashMap::new();
let view = match maximized {
Some((panel, path, node)) => {
state.maximized = Some(panel);
Layout { root: prune(&node, path, Vec::new(), &hidden, &mut paths), floating: Vec::new() }
}
None => {
let root = layout.root.as_ref().and_then(|root| prune(root, Vec::new(), Vec::new(), &hidden, &mut paths));
let mut floating = Vec::new();
for window in &layout.floating {
let mut paths = HashMap::new();
let Some(root) = prune(&window.root, Vec::new(), Vec::new(), &hidden, &mut paths) else { continue };
let shown = Floating { root, ..window.clone() };
if let Some(key) = shown.panels().next() {
state.window_paths.insert(key.clone(), paths);
}
floating.push(shown);
}
Layout { root, floating }
}
};
if state.maximized.is_none() {
state.edge_path = Some(view.edge_path());
}
state.paths = Some(paths);
(Cow::Owned(view), state)
}
fn path_of(place: &Place) -> Vec<bool> {
match place {
Place::Node(path) | Place::Dock { path, .. } => path.clone(),
Place::Floating { .. } => Vec::new(),
}
}
fn visible<P>(node: &Node<P>, hidden: &dyn Fn(&P) -> bool) -> bool {
match node {
Node::Split { first, second, .. } => visible(first, hidden) || visible(second, hidden),
Node::Tabs { panels, .. } => panels.iter().any(|p| !hidden(p)),
Node::Stack { entries } => entries.iter().any(|e| !hidden(&e.panel)),
Node::Dock { columns, .. } => columns.iter().flat_map(|c| &c.groups).flat_map(|g| &g.panels).any(|p| !hidden(p)),
}
}
/// `node` without hidden panels; a split with one visible side becomes that side. Records the
/// layout path of every split kept.
fn prune<P: Clone + PartialEq>(
node: &Node<P>,
path: Vec<bool>,
at: Vec<bool>,
hidden: &dyn Fn(&P) -> bool,
paths: &mut HashMap<Vec<bool>, Vec<bool>>,
) -> Option<Node<P>> {
let child = |path: &[bool], side: bool| [path, &[side]].concat();
match node {
Node::Split { axis, size, first, second } => match (visible(first, hidden), visible(second, hidden)) {
(true, true) => {
let first = prune(first, child(&path, false), child(&at, false), hidden, paths)?;
let second = prune(second, child(&path, true), child(&at, true), hidden, paths)?;
paths.insert(at, path);
Some(Node::Split { axis: *axis, size: *size, first: Box::new(first), second: Box::new(second) })
}
(true, false) => prune(first, child(&path, false), at, hidden, paths),
(false, true) => prune(second, child(&path, true), at, hidden, paths),
(false, false) => None,
},
Node::Tabs { panels, active } => {
let group = prune_group(&Group::new(panels.clone(), *active), hidden)?;
Some(Node::Tabs { panels: group.panels, active: group.active })
}
Node::Stack { entries } => {
let entries: Vec<_> = entries.iter().filter(|e| !hidden(&e.panel)).cloned().collect();
(!entries.is_empty()).then_some(Node::Stack { entries })
}
Node::Dock { side, columns } => {
let columns: Vec<_> = columns
.iter()
.filter_map(|c| {
let groups: Vec<_> = c.groups.iter().filter_map(|g| prune_group(g, hidden)).collect();
(!groups.is_empty()).then(|| Column { groups, ..c.clone() })
})
.collect();
(!columns.is_empty()).then_some(Node::Dock { side: *side, columns })
}
}
}
/// A group without hidden panels; the selected panel stays selected when it is shown.
fn prune_group<P: Clone + PartialEq>(group: &Group<P>, hidden: &dyn Fn(&P) -> bool) -> Option<Group<P>> {
let selected = group.active_panel();
let panels: Vec<P> = group.panels.iter().filter(|p| !hidden(p)).cloned().collect();
let active = selected.and_then(|s| panels.iter().position(|p| p == s)).unwrap_or(0);
(!panels.is_empty()).then(|| Group { panels, active, ..group.clone() })
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(super) fn note_edge(&mut self, path: &[bool], rect: Rect) {
if self.tree.is_none() && self.frame.edge_path.as_deref() == Some(path) {
self.frame.edge = Some(rect);
}
}
/// The layout path of a split drawn at view path `path`, in the tree being drawn.
pub(super) fn original_path(&self, path: &[bool]) -> Vec<bool> {
let paths = match &self.tree {
None => self.frame.paths.as_ref(),
Some((key, _)) => self.frame.window_paths.get(key),
};
paths.and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec())
}
/// The outer edge band under the pointer (P8): top and bottom, and left and right with
/// [`crate::docking::DockStyle::side_edge_zones`]. Returns a docked anchor that is not
/// moving, the zone and the preview of the new frame.
pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> {
let area = self.frame.edge.filter(|area| area.contains(pointer))?;
let band = self.style.edge_band.min(area.width() / 4.0).min(area.height() / 4.0);
let mut edges = vec![(pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom)];
if self.style.side_edge_zones {
edges.extend([(pointer.x - area.left(), Zone::Left), (area.right() - pointer.x, Zone::Right)]);
}
let (distance, zone) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?;
if distance > band {
return None;
}
let anchor =
self.layout.panels().into_iter().find(|p| !members.contains(p) && !matches!(self.place(p), Some(Place::Floating { .. }) | None))?.clone();
let size = area.size() * 0.25;
let preview = match zone {
Zone::Left => Rect::from_min_size(area.min, vec2(size.x, area.height())),
Zone::Right => Rect::from_min_max(pos2(area.right() - size.x, area.top()), area.max),
Zone::Top => Rect::from_min_size(area.min, vec2(area.width(), size.y)),
Zone::Bottom | Zone::Center => Rect::from_min_max(pos2(area.left(), area.bottom() - size.y), area.max),
};
Some((anchor, zone, preview))
}
/// Under the pointer while a drag offers a docking target: which modifier floats the drop
/// instead. With native windows, the window under the pointer draws it, beside its preview.
pub(super) fn paint_float_hint(&self, ctx: &Context, pointer: Pos2) {
if !self.style.float_hint || self.style.float_hint_label.is_empty() {
return;
}
let key = if ctx.os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" };
let painter = self.preview_painter(ctx);
let galley = painter.layout_no_wrap(self.style.float_hint_label.replace("{key}", key), self.style.font.clone(), self.style.text);
let rect = Rect::from_center_size(pointer + vec2(0.0, HINT_OFFSET), galley.size() + vec2(12.0, 6.0));
let rect = crate::docking::chrome::clamp(rect, self.float_bounds);
painter.rect(rect, 4.0, self.style.floating_background, egui::Stroke::new(1.0, self.style.accent), egui::StrokeKind::Inside);
painter.galley(rect.center() - galley.size() * 0.5, galley, self.style.text);
}
/// Ctrl/Cmd held when a split-tree tab drag starts: the drop floats (S9).
pub(super) fn latch_undock(&self, ctx: &Context, kind: Option<GroupKind>) {
let command = ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd);
if self.style.command_press_floats && matches!(kind, Some(GroupKind::Frame | GroupKind::Floating { .. })) && command {
ctx.data_mut(|d| {
if let Some(drag) = d.get_temp::<crate::docking::Drag<P>>(self.id.with("drag")) {
d.insert_temp(self.id.with("drag"), crate::docking::Drag { undock: true, ..drag });
}
});
}
}
/// The menu tail: Close Other Panels in Group and Maximize Frame when the style shows
/// them.
pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) {
let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable);
if self.style.close_others_item
&& owner.panels.len() > 1
&& others
&& self.style.close_menu_items
&& self.menu_action(ui, &self.style.close_others_label.clone())
{
self.output.actions.push(Action::CloseOthers { panel: panel.clone() });
}
if self.style.maximize_item && owner.kind == GroupKind::Frame {
let restore = self.frame.maximized.as_ref().is_some_and(|m| owner.panels.contains(m));
let label = if restore { self.style.restore_frame_label.clone() } else { self.style.maximize_label.clone() };
if self.menu_action(ui, &label) {
let panel = if restore { self.frame.maximized.clone().unwrap_or_else(|| panel.clone()) } else { panel.clone() };
self.output.events.push(DockEvent::ToggleMaximize { panel });
}
}
}
}
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more