mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 12:27:38 -06:00
Merge remote-tracking branch 'origin/main' into feat/file-215-2-mezzotint-halftone
This commit is contained in:
@@ -18,16 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
@@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
crates/
|
||||
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
|
||||
psd codecs L0 standalone format crates (no workspace deps; publishable)
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate
|
||||
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
|
||||
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
|
||||
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
|
||||
@@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
|
||||
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
|
||||
|
||||
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
|
||||
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
|
||||
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
|
||||
|
||||
Generated
+34
-5
@@ -1234,7 +1234,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b7610
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"tempfile",
|
||||
"winprint",
|
||||
"winsafe",
|
||||
@@ -2116,7 +2116,7 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2141,9 +2141,17 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"linebender_resource_handle",
|
||||
"memmap2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parlance",
|
||||
"read-fonts",
|
||||
"roxmltree 0.21.1",
|
||||
"smallvec",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"yeslogic-fontconfig-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4824,13 +4832,14 @@ dependencies = [
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"wat",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4912,7 +4921,7 @@ dependencies = [
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
"resvg",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -5960,6 +5969,15 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -7460,7 +7478,7 @@ dependencies = [
|
||||
"kurbo 0.11.3",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"rustybuzz",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
@@ -8706,6 +8724,17 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "yeslogic-fontconfig-sys"
|
||||
version = "6.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
@@ -12,6 +12,14 @@ fn main() -> std::io::Result<()> {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return Ok(());
|
||||
}
|
||||
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
|
||||
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
|
||||
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
|
||||
// the print command checks for it first and reports an error instead.
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
|
||||
println!("cargo:rustc-link-arg-bins=delayimp.lib");
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
|
||||
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
|
||||
|
||||
@@ -163,19 +163,29 @@ fn info_and_convert_report_decode_warnings() {
|
||||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||||
}
|
||||
gif.push(0x3B);
|
||||
// A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it
|
||||
// writes, after the decode warning.
|
||||
let cases = [
|
||||
("whole.jpg", &jpeg[..], None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
|
||||
("whole.jpg", &jpeg[..], None, None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")),
|
||||
];
|
||||
for (name, bytes, warning) in cases {
|
||||
for (name, bytes, warning, export) in cases {
|
||||
let file = d.join(name);
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
|
||||
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
|
||||
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
|
||||
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
|
||||
assert_eq!(
|
||||
err.trim(),
|
||||
expected.join(
|
||||
"
|
||||
"
|
||||
),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ flate2 = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
//! one part, however large the app grows.
|
||||
//!
|
||||
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
|
||||
//! In one that can't, this module inflates them itself, a little slower.
|
||||
//! In one that can't, this module inflates them itself, a little slower. Some servers recognise
|
||||
//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic`
|
||||
//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then
|
||||
//! taken as it is, by its first bytes.
|
||||
//!
|
||||
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
|
||||
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
|
||||
@@ -36,6 +39,34 @@ fn parts(list: &str) -> Option<Vec<String>> {
|
||||
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
|
||||
}
|
||||
|
||||
/// What the joined parts hold, told by their first bytes.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Payload {
|
||||
/// The gzip packaging made, still to be inflated.
|
||||
Gzip,
|
||||
/// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser
|
||||
/// already undid.
|
||||
Wasm,
|
||||
}
|
||||
|
||||
/// Enough leading bytes to tell the payloads apart.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
const HEAD: usize = 4;
|
||||
|
||||
/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or
|
||||
/// `None` when it is neither, such as a web page or nothing at all.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn payload(head: &[u8]) -> Option<Payload> {
|
||||
if head.starts_with(&[0x1f, 0x8b]) {
|
||||
Some(Payload::Gzip)
|
||||
} else if head.starts_with(b"\0asm") {
|
||||
Some(Payload::Wasm)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
|
||||
@@ -69,11 +100,11 @@ mod boot {
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{
|
||||
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
|
||||
TransformStreamDefaultController, Transformer, WritableStream,
|
||||
Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions,
|
||||
TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter,
|
||||
};
|
||||
|
||||
use super::Inflater;
|
||||
use super::{HEAD, Inflater, Payload};
|
||||
|
||||
/// Where packaging puts the app, next to this module's own files.
|
||||
const PARTS: &str = "./app/parts.txt";
|
||||
@@ -172,11 +203,71 @@ mod boot {
|
||||
Ok(compressed.pipe_through(&pair))
|
||||
}
|
||||
|
||||
/// The next chunk `reader` gives, or `None` at the end of the stream.
|
||||
async fn read(reader: &ReadableStreamDefaultReader) -> Result<Option<js_sys::Uint8Array>, JsValue> {
|
||||
let result = JsFuture::from(reader.read()).await?;
|
||||
if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?))
|
||||
}
|
||||
|
||||
/// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up
|
||||
/// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending.
|
||||
async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
|
||||
if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await {
|
||||
let _ = reader.cancel_with_reason(&e);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it.
|
||||
async fn pass_on(held: Vec<js_sys::Uint8Array>, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
|
||||
for chunk in &held {
|
||||
write(chunk, reader, writer).await?;
|
||||
}
|
||||
while let Some(chunk) = read(reader).await? {
|
||||
write(&chunk, reader, writer).await?;
|
||||
}
|
||||
JsFuture::from(writer.close()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the
|
||||
/// browser has already done that.
|
||||
async fn unpacked(joined: &ReadableStream) -> Result<ReadableStream, JsValue> {
|
||||
let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into();
|
||||
let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new());
|
||||
while head.len() < HEAD {
|
||||
let Some(chunk) = read(&reader).await? else { break };
|
||||
let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length());
|
||||
head.extend(chunk.subarray(0, wanted).to_vec());
|
||||
held.push(chunk);
|
||||
}
|
||||
let Some(payload) = super::payload(&head) else {
|
||||
let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly");
|
||||
// Stops the rest of the download, which nothing will read.
|
||||
let _ = reader.cancel_with_reason(&e);
|
||||
return Err(e);
|
||||
};
|
||||
let rest = TransformStream::new()?;
|
||||
let writer = rest.writable().get_writer()?;
|
||||
spawn_local(async move {
|
||||
if let Err(e) = pass_on(held, &reader, &writer).await {
|
||||
let _ = writer.abort_with_reason(&e);
|
||||
}
|
||||
});
|
||||
match payload {
|
||||
Payload::Gzip => inflated(&rest.readable()),
|
||||
Payload::Wasm => Ok(rest.readable()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(fresh: bool) -> Result<(), JsValue> {
|
||||
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
|
||||
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
|
||||
let joined = TransformStream::new()?;
|
||||
let wasm = inflated(&joined.readable())?;
|
||||
// Why the download stopped: the app's loader only reports that its stream broke.
|
||||
let cause = Rc::new(RefCell::new(None));
|
||||
let failed = cause.clone();
|
||||
@@ -187,6 +278,10 @@ mod boot {
|
||||
failed.replace(Some(e));
|
||||
}
|
||||
});
|
||||
let wasm = match unpacked(&joined.readable()).await {
|
||||
Ok(wasm) => wasm,
|
||||
Err(e) => return Err(cause.take().unwrap_or(e)),
|
||||
};
|
||||
let headers = Headers::new()?;
|
||||
headers.set("Content-Type", "application/wasm")?;
|
||||
let init = ResponseInit::new();
|
||||
@@ -258,7 +353,7 @@ fn main() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Inflater, MARKER, parts};
|
||||
use super::{HEAD, Inflater, MARKER, Payload, parts, payload};
|
||||
|
||||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
@@ -317,6 +412,22 @@ mod tests {
|
||||
assert!(run(b"").is_err(), "nothing at all");
|
||||
}
|
||||
|
||||
/// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with
|
||||
/// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts
|
||||
/// are then the app's WebAssembly, which must start as it is, not be inflated a second time.
|
||||
#[test]
|
||||
fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() {
|
||||
let mut wasm = b"\0asm\x01\0\0\0".to_vec();
|
||||
wasm.extend(sample());
|
||||
let packed = gzip(&wasm);
|
||||
assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged");
|
||||
assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser");
|
||||
assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD");
|
||||
assert_eq!(payload(b"<!DOCTYPE html>"), None, "a web page");
|
||||
assert_eq!(payload(b"\0as"), None, "a cut-off wasm header");
|
||||
assert_eq!(payload(b""), None, "nothing at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parts_list_gives_its_names_in_order() {
|
||||
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
|
||||
|
||||
@@ -11,6 +11,14 @@ fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
|
||||
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
|
||||
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
|
||||
// the print command checks for it first and reports an error instead.
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
|
||||
println!("cargo:rustc-link-arg-bins=delayimp.lib");
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon("../../assets/app-icon/photocraft.ico")
|
||||
.set("ProductName", "PhotoCraft")
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
//! - macOS: Metal → CPU
|
||||
//!
|
||||
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
|
||||
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an
|
||||
//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
|
||||
//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
|
||||
//! for one launch.
|
||||
//!
|
||||
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
|
||||
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
|
||||
@@ -204,6 +206,28 @@ pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor
|
||||
/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An
|
||||
/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's
|
||||
/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]'
|
||||
/// choice.
|
||||
pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec<u32>) -> Option<wgpu::Backends> {
|
||||
let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows;
|
||||
(auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL)
|
||||
}
|
||||
|
||||
/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver)
|
||||
/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]).
|
||||
fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec<u32> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::DX12,
|
||||
flags: desc.flags,
|
||||
backend_options: desc.backend_options.clone(),
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect()
|
||||
}
|
||||
|
||||
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
|
||||
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
|
||||
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
|
||||
@@ -228,6 +252,8 @@ pub struct Candidate {
|
||||
pub backend: wgpu::Backend,
|
||||
/// Can present to the window.
|
||||
pub surface_ok: bool,
|
||||
/// Its name matches the adapter the user asked for ([`adapter_matches`]).
|
||||
pub wanted: bool,
|
||||
}
|
||||
|
||||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
@@ -259,19 +285,59 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
(fallback, ty, be)
|
||||
}
|
||||
|
||||
/// The adapter to use among `adapters` (`None` when there is none).
|
||||
/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for
|
||||
/// win over the rest when any of them can present; otherwise the ranking decides (see
|
||||
/// [`wanted_missed`]).
|
||||
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
let usable = |a: &&Candidate| a.surface_ok;
|
||||
let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted);
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive
|
||||
/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds
|
||||
/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing.
|
||||
pub fn adapter_matches(name: &str, wanted: &str) -> bool {
|
||||
photocraft_engine::prefs::gpu_adapter_matches(name, wanted)
|
||||
}
|
||||
|
||||
/// The user asked for an adapter and none of `adapters` that can present is it.
|
||||
pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool {
|
||||
wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted)
|
||||
}
|
||||
|
||||
/// Choosing an adapter is Linux-only for now: Windows has its own Intel/DX12 selection and macOS a
|
||||
/// single Metal path, and neither is tested with a requested adapter.
|
||||
pub const ADAPTER_CHOICE: bool = cfg!(target_os = "linux");
|
||||
|
||||
/// The adapter request that applies to this launch: none off Linux (`linux`), and none in CPU mode,
|
||||
/// which wants a software adapter whatever the preference says.
|
||||
pub fn applicable_adapter(wanted: Option<String>, backend: GpuBackend, linux: bool) -> Option<String> {
|
||||
wanted.filter(|_| linux && backend != GpuBackend::Cpu)
|
||||
}
|
||||
|
||||
/// Whether `PHOTOCRAFT_GPU_ADAPTER` makes this launch its own process: only when startup will act
|
||||
/// on it (a non-empty value, on Linux, outside CPU mode). Otherwise it changes nothing, and the
|
||||
/// launch may still be forwarded to the running window.
|
||||
pub fn adapter_override_is_process_specific(env: Option<&str>, mode: RenderingMode, linux: bool) -> bool {
|
||||
env.is_some_and(|v| !v.trim().is_empty()) && linux && mode != RenderingMode::Cpu
|
||||
}
|
||||
|
||||
/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else
|
||||
/// `performance.gpuAdapter`; `None` is automatic.
|
||||
pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option<String> {
|
||||
[env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
/// same vendor was available).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
|
||||
/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
|
||||
/// the instance).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
|
||||
let Some(c) = adapters.get(chosen) else { return false };
|
||||
os == Os::Windows
|
||||
&& c.vendor == INTEL
|
||||
&& c.backend == wgpu::Backend::Dx12
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
&& (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
|
||||
}
|
||||
|
||||
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
|
||||
@@ -285,7 +351,7 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
|
||||
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
|
||||
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
|
||||
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option<String>, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
|
||||
if let Some(b) = backends(plan, os) {
|
||||
create.instance_descriptor.backends = b;
|
||||
@@ -298,7 +364,13 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
|
||||
let env = std::env::var("WGPU_DX12_COMPILER").ok();
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
|
||||
}
|
||||
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
|
||||
let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor));
|
||||
if let Some(b) = without_vulkan {
|
||||
create.instance_descriptor.backends = b;
|
||||
}
|
||||
let vulkan_left_out = without_vulkan.is_some();
|
||||
let wanted = applicable_adapter(wanted, plan.backend, ADAPTER_CHOICE);
|
||||
let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)));
|
||||
if select {
|
||||
let backend = plan.backend;
|
||||
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
|
||||
@@ -311,15 +383,20 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
|
||||
device_type: i.device_type,
|
||||
backend: i.backend,
|
||||
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
|
||||
wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
|
||||
if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice"));
|
||||
}
|
||||
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
|
||||
}
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
|
||||
}
|
||||
@@ -443,6 +520,13 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window
|
||||
/// opens; empty (automatic) when the file or value is missing.
|
||||
pub fn read_gpu_adapter(path: Option<&Path>) -> String {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
|
||||
/// turned on), read before the window opens: it picks the surface's present mode.
|
||||
pub fn read_low_latency(path: Option<&Path>) -> bool {
|
||||
@@ -604,7 +688,82 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
|
||||
Candidate { vendor, device_type, backend, surface_ok: true }
|
||||
Candidate { vendor, device_type, backend, surface_ok: true, wanted: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_adapter_beats_the_automatic_ranking() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
// The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU.
|
||||
let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc");
|
||||
a[2].wanted = true;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(2));
|
||||
a[2].wanted = false;
|
||||
a[0].wanted = true;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for");
|
||||
// A wanted adapter that can't present is skipped, not an error.
|
||||
a[0].surface_ok = false;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(1));
|
||||
assert!(wanted_missed(&a, Some("iris")));
|
||||
assert!(!wanted_missed(&a, None));
|
||||
a[0].surface_ok = true;
|
||||
assert!(!wanted_missed(&a, Some("iris")));
|
||||
// Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides.
|
||||
let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
gl.iter_mut().for_each(|c| c.wanted = true);
|
||||
assert_eq!(pick(&gl, Auto, Os::Other), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adapter_request_applies_on_linux_outside_cpu_mode() {
|
||||
let w = || Some("nvidia".to_string());
|
||||
assert_eq!(applicable_adapter(w(), Auto, true), w());
|
||||
assert_eq!(applicable_adapter(w(), Vulkan, true), w());
|
||||
assert_eq!(applicable_adapter(w(), Cpu, true), None);
|
||||
assert_eq!(applicable_adapter(w(), Auto, false), None, "Windows and macOS keep their own selection");
|
||||
assert_eq!(applicable_adapter(None, Auto, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adapter_override_keeps_its_own_process_only_when_it_applies() {
|
||||
let on = |env, mode, linux| adapter_override_is_process_specific(env, mode, linux);
|
||||
assert!(on(Some("nvidia"), RenderingMode::Auto, true));
|
||||
assert!(on(Some("nvidia"), RenderingMode::Gpu, true));
|
||||
assert!(!on(Some("nvidia"), RenderingMode::Cpu, true), "ignored in CPU mode");
|
||||
assert!(!on(Some("nvidia"), RenderingMode::Auto, false), "ignored off Linux");
|
||||
assert!(!on(Some(" "), RenderingMode::Auto, true));
|
||||
assert!(!on(None, RenderingMode::Auto, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_names_match_loosely_and_the_environment_wins() {
|
||||
assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia"));
|
||||
assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M "));
|
||||
assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc"));
|
||||
assert!(!adapter_matches("anything", ""));
|
||||
assert!(!adapter_matches("anything", " "));
|
||||
assert_eq!(wanted_adapter(None, ""), None);
|
||||
assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into()));
|
||||
assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into()));
|
||||
assert_eq!(wanted_adapter(Some(&"x".repeat(1000)), "").map(|w| w.len()), Some(256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_adapter_is_read_before_the_window_opens() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("preferences.json");
|
||||
let read = |json: &str| {
|
||||
std::fs::write(&file, json).unwrap();
|
||||
read_gpu_adapter(Some(&file))
|
||||
};
|
||||
assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia");
|
||||
assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), "");
|
||||
assert_eq!(read(r#"{"performance":{}}"#), "");
|
||||
assert_eq!(read("not json"), "");
|
||||
assert_eq!(read_gpu_adapter(None), "");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -613,13 +772,13 @@ mod tests {
|
||||
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&intel, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(intel[i].backend, B::Dx12);
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows));
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
|
||||
// Elsewhere (and for other vendors) Vulkan stays first.
|
||||
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
|
||||
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
let i = pick(&nv, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(nv[i].backend, B::Vulkan);
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
|
||||
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
|
||||
let hybrid = [intel[0], intel[1], nv[1]];
|
||||
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
|
||||
@@ -630,6 +789,45 @@ mod tests {
|
||||
assert_eq!(pick(&[], Auto, Os::Windows), None);
|
||||
}
|
||||
|
||||
/// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
|
||||
/// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
|
||||
/// Windows creates the instance without Vulkan.
|
||||
#[test]
|
||||
fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
const NVIDIA: u32 = 0x10de;
|
||||
const AMD: u32 = 0x1002;
|
||||
const WARP: u32 = 0x1414;
|
||||
let auto = plan(Auto, None, None, false, Os::Windows);
|
||||
let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl);
|
||||
// Intel iGPU + discrete GPU: the discrete GPU is then used on DX12.
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl);
|
||||
// No Intel adapter: egui's default backends, Vulkan included.
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None);
|
||||
// `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms
|
||||
// don't list the DX12 adapters at all.
|
||||
let not_listed = || -> Vec<u32> { panic!("DX12 adapters listed") };
|
||||
for (p, os) in [
|
||||
(plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, None, None, true, Os::Windows), Os::Windows),
|
||||
(plan(Vulkan, None, None, false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, None, None, false, Os::Other), Os::Other),
|
||||
(plan(Auto, None, None, false, Os::Mac), Os::Mac),
|
||||
] {
|
||||
assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}");
|
||||
}
|
||||
// Without Vulkan adapters to compare with, System Info still names the Intel default.
|
||||
let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&listed, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(i, 0);
|
||||
assert!(intel_dx12_applied(&listed, i, Os::Windows, true));
|
||||
assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_prefers_software_adapters() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
@@ -894,7 +1092,7 @@ mod tests {
|
||||
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let plan = plan(Auto, None, None, false, Os::Windows);
|
||||
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
|
||||
@@ -918,13 +1116,13 @@ mod tests {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
|
||||
}
|
||||
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
|
||||
@@ -71,8 +71,7 @@ mod services;
|
||||
// macOS gets one instance from LaunchServices (open-documents Apple events).
|
||||
#[cfg(any(not(target_os = "macos"), test))]
|
||||
mod single_instance;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
|
||||
mod tablet;
|
||||
mod ui_state;
|
||||
mod window_frame;
|
||||
@@ -496,7 +495,7 @@ fn main() -> eframe::Result {
|
||||
unreadable_paths,
|
||||
safe_gpu,
|
||||
new_instance,
|
||||
launch_options,
|
||||
mut launch_options,
|
||||
present_mode_arg,
|
||||
frame_latency_arg,
|
||||
stroke_trace_arg,
|
||||
@@ -516,6 +515,11 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
|
||||
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
|
||||
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
|
||||
if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) {
|
||||
launch_options.push("PHOTOCRAFT_GPU_ADAPTER");
|
||||
}
|
||||
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
|
||||
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
|
||||
// shared between users), and not for automation, `--new-instance` or per-process options.
|
||||
@@ -627,6 +631,11 @@ fn main() -> eframe::Result {
|
||||
let tablet_monitor = std::cell::OnceCell::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
let tablet_monitor = &tablet_monitor;
|
||||
// The Windows pointer message hook; dropping it unhooks when the event loop returns.
|
||||
#[cfg(target_os = "windows")]
|
||||
let tablet_monitor_win = std::cell::OnceCell::new();
|
||||
#[cfg(target_os = "windows")]
|
||||
let tablet_monitor_win = &tablet_monitor_win;
|
||||
|
||||
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
|
||||
// where the platform has no reader).
|
||||
@@ -678,7 +687,9 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
let wanted_adapter =
|
||||
gpu_startup::wanted_adapter(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()));
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone());
|
||||
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
|
||||
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
|
||||
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
|
||||
@@ -774,9 +785,16 @@ fn main() -> eframe::Result {
|
||||
info.canvas = "cpu".into();
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
app.perf.gpu_info.set_available(&rs.available_adapters);
|
||||
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
|
||||
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
|
||||
// Keep an earlier note, such as a requested adapter that couldn't be used.
|
||||
let software = "No compatible hardware graphics adapter; using software graphics.";
|
||||
let fallback = &mut app.perf.gpu_info.fallback;
|
||||
*fallback = Some(match fallback.take() {
|
||||
Some(earlier) => format!("{earlier}. {software}"),
|
||||
None => software.into(),
|
||||
});
|
||||
}
|
||||
if !software_window
|
||||
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
@@ -853,8 +871,13 @@ fn main() -> eframe::Result {
|
||||
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
|
||||
let _ = tablet_monitor.set(monitor);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(monitor) = tablet::install_windows() {
|
||||
let _ = tablet_monitor_win.set(monitor);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
tablet::spawn_x11(&app.stylus.feed, display);
|
||||
app.services.right_button_down = tablet::right_button_service();
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
if !unreadable_paths.is_empty() {
|
||||
|
||||
@@ -274,7 +274,7 @@ fn path_of(file: &rfd::FileHandle) -> String {
|
||||
}
|
||||
|
||||
/// Runs `future` to completion on this thread, sleeping until it is woken.
|
||||
fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
pub(crate) fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
struct Unpark(std::thread::Thread);
|
||||
impl std::task::Wake for Unpark {
|
||||
fn wake(self: Arc<Self>) {
|
||||
@@ -571,7 +571,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
let clip = clip.clone();
|
||||
let unreadable = clip_unreadable.clone();
|
||||
Box::new(move || {
|
||||
let mut slot = clip.try_borrow_mut().ok()?;
|
||||
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
@@ -580,21 +580,33 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
// `--socket=fallback-x11` gives no X server while a compositor runs.
|
||||
let opened = arboard::Clipboard::new();
|
||||
unreadable.set(opened.is_err());
|
||||
slot.insert(opened.ok()?)
|
||||
slot.insert(opened.map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
// Copied files first: Finder also puts the file's icon on the clipboard as an
|
||||
// image, which would otherwise paste instead of the file.
|
||||
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
|
||||
return Some(img);
|
||||
match cb.get().file_list() {
|
||||
Ok(paths) => {
|
||||
if let Some(img) = image_from_files(&paths) {
|
||||
return Ok(Some(img));
|
||||
}
|
||||
}
|
||||
Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()),
|
||||
Err(_) => {}
|
||||
}
|
||||
if let Ok(img) = cb.get_image() {
|
||||
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
|
||||
match cb.get_image() {
|
||||
Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))),
|
||||
Err(error) => {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(image) = wayland_clipboard_image() {
|
||||
return Ok(Some(image));
|
||||
}
|
||||
match error {
|
||||
arboard::Error::ContentNotAvailable => Ok(None),
|
||||
_ => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
return wayland_clipboard_image();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
None
|
||||
})
|
||||
}),
|
||||
clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())),
|
||||
@@ -736,6 +748,57 @@ mod tests {
|
||||
assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd"));
|
||||
}
|
||||
|
||||
/// Regression using the real clipboard on an isolated Windows CI runner.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
#[ignore = "requires exclusive use of the Windows clipboard"]
|
||||
fn busy_native_clipboard_preserves_copied_shape() {
|
||||
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] {
|
||||
let mut native = native(None);
|
||||
let services = Services {
|
||||
clipboard_set_image: native.clipboard_set_image.take(),
|
||||
clipboard_get_image: native.clipboard_get_image.take(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
|
||||
app.run("edit.copy", json!({})).unwrap();
|
||||
assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
|
||||
assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded");
|
||||
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
|
||||
|
||||
// Another thread holds the OS clipboard open beyond arboard's bounded retry window.
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let holder = std::thread::spawn(move || {
|
||||
// A non-null window distinguishes this owner from arboard's null-window opens.
|
||||
let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
drop(guard);
|
||||
});
|
||||
ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
let contention = arboard::Clipboard::new().unwrap().get_image().err();
|
||||
let first = app.run(id, json!({}));
|
||||
release_tx.send(()).unwrap();
|
||||
holder.join().unwrap();
|
||||
assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}");
|
||||
eprintln!("paste while clipboard occupied: {first:?}");
|
||||
assert!(first.is_err(), "report contention rather than a successful no-op");
|
||||
assert!(
|
||||
app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()),
|
||||
"a temporary OS read failure must not discard the copied editable shape"
|
||||
);
|
||||
app.run(id, json!({})).unwrap();
|
||||
let state = app.session.active().unwrap();
|
||||
assert!(
|
||||
matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)),
|
||||
"retry keeps the shape editable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and
|
||||
//! barrel button on Windows (#5905).
|
||||
//!
|
||||
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
|
||||
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
|
||||
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
|
||||
//! `WM_POINTER` pressure as touch force.
|
||||
//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit
|
||||
//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes
|
||||
//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure
|
||||
//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel
|
||||
//! button through a thread-local message hook beside winit.
|
||||
//!
|
||||
//! Wayland compositors give a pen only to clients that bind the tablet protocol
|
||||
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
|
||||
@@ -19,15 +21,36 @@ use photocraft_tablet::Sample;
|
||||
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
|
||||
/// A tablet sample as the UI's pen sample.
|
||||
#[allow(dead_code)]
|
||||
pub fn pen_sample(s: Sample) -> PenSample {
|
||||
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
|
||||
}
|
||||
|
||||
/// Writes tablet samples into `feed` (`None` = a mouse).
|
||||
#[allow(dead_code)]
|
||||
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
|
||||
move |s| feed.set(s.map(pen_sample))
|
||||
}
|
||||
|
||||
/// Install the Windows pointer message hook (main thread; keep the result until the event loop
|
||||
/// returns). On failure this logs why: the pen barrel button is then missed.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn install_windows() -> Option<photocraft_tablet::windows::Monitor> {
|
||||
photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()
|
||||
}
|
||||
|
||||
/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which
|
||||
/// reports the logical secondary button (swapped buttons included) for the app's own window only.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
|
||||
Some(Box::new(photocraft_tablet::windows::is_barrel_button_down))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
|
||||
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
|
||||
/// returns. On failure this logs why: pens then paint like a mouse.
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
|
||||
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
|
||||
//!
|
||||
//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the
|
||||
//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar
|
||||
//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard
|
||||
//! instead stops the window one pixel short of the monitor, as other custom-frame apps do.
|
||||
//!
|
||||
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
|
||||
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
|
||||
|
||||
@@ -29,7 +34,20 @@ pub use platform::service;
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::WindowFrameFn;
|
||||
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
|
||||
use winsafe::prelude::Handle;
|
||||
use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co};
|
||||
|
||||
/// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive.
|
||||
type Rect = (i32, i32, i32, i32);
|
||||
|
||||
/// A monitor edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Edge {
|
||||
Left,
|
||||
Top,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
|
||||
/// system title bar (the window is decorated) or its handle can't be found.
|
||||
@@ -99,8 +117,11 @@ mod platform {
|
||||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
let Some((left, top, right, bottom)) =
|
||||
clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
|
||||
let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom));
|
||||
// The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window
|
||||
// covers the whole monitor, which a trimmed maximize no longer does.
|
||||
let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work)
|
||||
.or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -153,6 +174,69 @@ mod platform {
|
||||
(corrected != rect).then_some(corrected)
|
||||
}
|
||||
|
||||
/// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app.
|
||||
fn covers(rect: Rect, monitor: Rect) -> bool {
|
||||
rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3
|
||||
}
|
||||
|
||||
/// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary
|
||||
/// monitor's `Shell_SecondaryTrayWnd`), shown or hidden.
|
||||
fn taskbar_edges(monitor: Rect) -> Vec<Edge> {
|
||||
let mut edges = Vec::new();
|
||||
for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] {
|
||||
let mut after: Option<HWND> = None;
|
||||
// One taskbar per monitor; the bound only guards against a misbehaving shell.
|
||||
for _ in 0..64 {
|
||||
let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else {
|
||||
break;
|
||||
};
|
||||
if let Ok(r) = tray.GetWindowRect() {
|
||||
edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor));
|
||||
}
|
||||
after = Some(tray);
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
/// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre,
|
||||
/// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its
|
||||
/// edge with a sliver left on screen, so its centre may lie outside the monitor.
|
||||
fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option<Edge> {
|
||||
let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3));
|
||||
let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3));
|
||||
if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb {
|
||||
return None;
|
||||
}
|
||||
// Doubled centre and distances, so nothing rounds.
|
||||
let (cx, cy) = (tl + tr, tt + tb);
|
||||
[(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)]
|
||||
.into_iter()
|
||||
.min_by_key(|&(_, distance)| distance)
|
||||
.map(|(edge, _)| edge)
|
||||
}
|
||||
|
||||
/// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a
|
||||
/// taskbar reserves no work area (it auto-hides), so the shell still reveals it there.
|
||||
/// `None` when there is nothing to trim.
|
||||
fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option<Rect> {
|
||||
if !covers(rect, monitor) {
|
||||
return None;
|
||||
}
|
||||
let (mut l, mut t, mut r, mut b) = rect;
|
||||
for edge in taskbars {
|
||||
match edge {
|
||||
Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?,
|
||||
Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?,
|
||||
Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?,
|
||||
Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let trimmed = (l, t, r, b);
|
||||
(l < r && t < b && trimmed != rect).then_some(trimmed)
|
||||
}
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
|
||||
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
|
||||
/// keep working, as they did with winit 0.27's undecorated windows.
|
||||
@@ -162,7 +246,7 @@ mod platform {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{borderless, clamped_rect};
|
||||
use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
|
||||
use winsafe::co;
|
||||
|
||||
#[test]
|
||||
@@ -221,6 +305,35 @@ mod platform {
|
||||
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
|
||||
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() {
|
||||
// #4845: an auto-hidden taskbar reserves no work area.
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let rect = (-8, -8, 1928, 1088);
|
||||
for (edge, expected) in
|
||||
[(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))]
|
||||
{
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected));
|
||||
assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed");
|
||||
}
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar");
|
||||
// A taskbar that reserves its work area is the clamp's job.
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None);
|
||||
assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown");
|
||||
assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden");
|
||||
assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top));
|
||||
assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left));
|
||||
assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right));
|
||||
assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor");
|
||||
assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,3 +185,9 @@ sync_github_name = true
|
||||
[people.guillehr2]
|
||||
real_name = "Guillem Hermida"
|
||||
sync_github_name = true
|
||||
|
||||
[people.fjamesprice]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ha1des]
|
||||
sync_github_name = true
|
||||
|
||||
+29
-12
@@ -124,12 +124,13 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
|
||||
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
|
||||
let ks: f32 = k.iter().sum();
|
||||
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
|
||||
// Horizontal pass over the window rows extended vertically by r.
|
||||
// Horizontal pass over the window rows extended vertically by r. Rows are independent, so
|
||||
// both passes run them in parallel: a 2500 px Blur dab is ~10⁹ multiply-adds (#5426).
|
||||
let th = oh + 2 * r as usize;
|
||||
let stride = ow * ch;
|
||||
let mut tmp = vec![0.0f32; stride * th];
|
||||
let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
|
||||
for ty in 0..th {
|
||||
let scratch = || vec![0.0f32; (ow + 2 * r as usize) * ch];
|
||||
let horizontal = |row: &mut Vec<f32>, (ty, dst): (usize, &mut [f32])| {
|
||||
let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize;
|
||||
// Clamp and premultiply each source pixel once, rather than once for every kernel tap.
|
||||
for (rx, px) in row.chunks_exact_mut(ch).enumerate() {
|
||||
@@ -145,7 +146,6 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
|
||||
}
|
||||
}
|
||||
}
|
||||
let dst = &mut tmp[ty * stride..(ty + 1) * stride];
|
||||
// Contiguous accumulation can be vectorized for any channel layout. Each output still
|
||||
// sums the same products in the same kernel order as the scalar convolution.
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
@@ -153,26 +153,43 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
|
||||
*d += s * kv;
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
tmp.par_chunks_mut(stride).enumerate().for_each_init(scratch, horizontal);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut row = scratch();
|
||||
tmp.chunks_mut(stride).enumerate().for_each(|item| horizontal(&mut row, item));
|
||||
}
|
||||
let mut out = vec![0.0f32; ow * oh * ch];
|
||||
for oy in 0..oh {
|
||||
let dst = &mut out[oy * stride..(oy + 1) * stride];
|
||||
let vertical = |(oy, dst): (usize, &mut [f32])| {
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) {
|
||||
*d += s * kv;
|
||||
}
|
||||
}
|
||||
for ox in 0..ow {
|
||||
let o = (oy * ow + ox) * ch;
|
||||
if let Some(a) = alpha {
|
||||
let av = out[o + a];
|
||||
for c in 0..ch {
|
||||
if let Some(a) = alpha {
|
||||
for (ox, px) in dst.chunks_exact_mut(ch).enumerate() {
|
||||
let av = px[a];
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
if c != a {
|
||||
out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
|
||||
*v = if av > 1e-6 { *v / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(stride).enumerate().for_each(vertical);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
out.chunks_mut(stride).enumerate().for_each(vertical);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
||||
* `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain.
|
||||
* `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning).
|
||||
* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture.
|
||||
* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for
|
||||
display shaders. Float tables retain near-black detail; unsupported chains return `None`
|
||||
for the 3D LUT fallback. No GPU dependency is required by this crate.
|
||||
* `transform::cached(src, dst, opts)`: process-wide transform cache.
|
||||
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
|
||||
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
|
||||
@@ -36,6 +39,10 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
||||
tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely
|
||||
analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon
|
||||
(native only).
|
||||
* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus
|
||||
offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated
|
||||
separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB
|
||||
33³ table cannot resolve steep inverse gamma curves near black, even with float texels.
|
||||
* Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute
|
||||
colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero,
|
||||
negative or non-finite white is refused with `CmsError::Invalid`); BPC uses
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Separable RGB display transforms without a coarse 3D interpolation near black.
|
||||
|
||||
/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display
|
||||
/// transform. Values are floats independent of the document's pixel depth.
|
||||
///
|
||||
/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply
|
||||
/// [`Self::matrix_rows`] to that result, then sample the output table at
|
||||
/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel
|
||||
/// separately between its two adjacent table entries. These nonuniform output samples
|
||||
/// retain shadow detail where a pure gamma curve's inverse has an infinite slope.
|
||||
///
|
||||
/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels.
|
||||
/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable.
|
||||
/// This representation covers SDR display output; input and output are clamped to `[0, 1]`.
|
||||
/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the
|
||||
/// [`crate::Lut3d`] fallback.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GpuMatrixShaper {
|
||||
matrix: [[f32; 4]; 3],
|
||||
input: Vec<[f32; 4]>,
|
||||
output: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl GpuMatrixShaper {
|
||||
/// The input table has one more entry than this number of equal intervals.
|
||||
pub const INPUT_INTERVALS: usize = 4096;
|
||||
/// Output entries are spaced uniformly in the fourth root of the linear value.
|
||||
pub const OUTPUT_ENTRIES: usize = 4096;
|
||||
|
||||
/// Row-major matrix: each row contains three RGB coefficients followed by its offset.
|
||||
pub fn matrix_rows(&self) -> [[f32; 4]; 3] {
|
||||
self.matrix
|
||||
}
|
||||
|
||||
/// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1.
|
||||
pub fn input_table(&self) -> &[[f32; 4]] {
|
||||
&self.input
|
||||
}
|
||||
|
||||
/// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1.
|
||||
pub fn output_table(&self) -> &[[f32; 4]] {
|
||||
&self.output
|
||||
}
|
||||
|
||||
pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec<f32>], output: Option<&[Vec<f32>]>) -> Option<Self> {
|
||||
if !matrix.iter().flatten().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?;
|
||||
let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?;
|
||||
let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max));
|
||||
for row in matrix {
|
||||
let magnitude =
|
||||
row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::<f64>() + f64::from(row[3]).abs();
|
||||
if magnitude > f64::from(f32::MAX) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self { matrix, input, output })
|
||||
}
|
||||
}
|
||||
|
||||
fn pack_table(tables: Option<&[Vec<f32>]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option<Vec<[f32; 4]>> {
|
||||
if let Some(tables) = tables
|
||||
&& (tables.len() != 3 || tables.iter().any(|t| t.len() != entries))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut packed: Vec<[f32; 4]> = Vec::new();
|
||||
// Both lengths are fixed constants, but a failed allocation still means the caller
|
||||
// should retain its existing fallback rather than abort while opening a document.
|
||||
packed.try_reserve_exact(entries).ok()?;
|
||||
for i in 0..entries {
|
||||
let rgb = match tables {
|
||||
Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?],
|
||||
None => [identity(i as f32 / (entries - 1) as f32); 3],
|
||||
};
|
||||
if !rgb.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(previous) = packed.last()
|
||||
&& rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
packed.push([rgb[0], rgb[1], rgb[2], 1.0]);
|
||||
}
|
||||
Some(packed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_tables_and_nonfinite_values() {
|
||||
let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
|
||||
let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3];
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none());
|
||||
let mut invalid = inputs.clone();
|
||||
invalid[0].pop();
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
|
||||
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
let mut invalid = inputs.clone();
|
||||
invalid[1][123] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
|
||||
let mut invalid = outputs.clone();
|
||||
invalid[2][321] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none());
|
||||
let mut invalid = IDENTITY;
|
||||
invalid[1][3] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_finite_data_that_overflows_shader_arithmetic() {
|
||||
let mut matrix = IDENTITY;
|
||||
matrix[0] = [f32::MAX; 4];
|
||||
assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none());
|
||||
let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
|
||||
inputs[0][100] = -f32::MAX;
|
||||
inputs[0][101] = f32::MAX;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
//! * [`builtin`] ships sRGB, Display P3, Adobe RGB (1998)-compatible, ProPhoto-compatible,
|
||||
//! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50 and a synthetic coated CMYK profile.
|
||||
//! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas.
|
||||
//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms.
|
||||
//!
|
||||
//! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications,
|
||||
//! CIE 15, and Adobe's published black point compensation paper. This crate depends on no
|
||||
@@ -19,6 +20,7 @@ pub mod builtin;
|
||||
pub mod clut;
|
||||
pub mod curve;
|
||||
pub mod gamut;
|
||||
mod gpu_matrix_shaper;
|
||||
pub mod lut3d;
|
||||
pub mod lutfile;
|
||||
pub mod math;
|
||||
@@ -32,6 +34,7 @@ pub use builtin::Builtin;
|
||||
pub use clut::Clut;
|
||||
pub use curve::Curve;
|
||||
pub use gamut::GamutCheck;
|
||||
pub use gpu_matrix_shaper::GpuMatrixShaper;
|
||||
pub use lut3d::Lut3d;
|
||||
pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};
|
||||
pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached};
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::curve::Curve;
|
||||
use crate::math;
|
||||
use crate::pipeline::{Pipeline, Stage};
|
||||
use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass};
|
||||
use crate::{CmsError, Intent};
|
||||
use crate::{CmsError, GpuMatrixShaper, Intent};
|
||||
|
||||
/// Sample storage of a byte buffer for [`Transform::convert_bytes_many`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -39,9 +39,9 @@ impl Default for TransformOptions {
|
||||
/// Grid sizes of the device-link tables built for integer (and fast float) evaluation.
|
||||
pub const GRID_3D: usize = 33;
|
||||
pub const GRID_4D: usize = 17;
|
||||
const TABLE_1D: usize = 4096;
|
||||
const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS;
|
||||
/// Resolution of output shaper tables (indexed by `x^(1/4)`).
|
||||
const OUT_TABLE: usize = 4096;
|
||||
const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES;
|
||||
|
||||
/// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ.
|
||||
const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287];
|
||||
@@ -226,6 +226,31 @@ impl Transform {
|
||||
&self.pipeline
|
||||
}
|
||||
|
||||
/// A separable RGB transform for accurate GPU display conversion, when available.
|
||||
///
|
||||
/// Reuses the input/matrix/output acceleration of the integer conversion path instead
|
||||
/// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other
|
||||
/// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers
|
||||
/// can keep their existing display-LUT fallback. This is an SDR representation; see
|
||||
/// [`GpuMatrixShaper`] for the precise sampling and clamping contract.
|
||||
pub fn gpu_matrix_shaper(&self) -> Option<GpuMatrixShaper> {
|
||||
if self.inputs != 3 || self.outputs != 3 {
|
||||
return None;
|
||||
}
|
||||
let Core::Shaper { curves, matrix, .. } = &self.core else { return None };
|
||||
let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
|
||||
if let Some((nr, nc, coefficients, offsets)) = matrix {
|
||||
if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) {
|
||||
let [r, g, b] = coefficients;
|
||||
*row = [*r, *g, *b, *offset];
|
||||
}
|
||||
}
|
||||
GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice()))
|
||||
}
|
||||
|
||||
/// Exact evaluation of one colour (`inputs` → `outputs` normalised values).
|
||||
#[inline]
|
||||
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Independent ICC matrix/TRC reference for display shadows.
|
||||
//!
|
||||
//! Fixtures are generated here from the published sRGB equation and a synthetic monitor,
|
||||
//! with no photograph or device profile. The expected values implement ICC.1:2010 Annex F.3
|
||||
//! directly in f64, without Transform, Curve evaluation, or photocraft_cms::math:
|
||||
//! https://www.color.org/specification/ICC1v43_2010-12.pdf
|
||||
|
||||
use photocraft_cms::pipeline::{Pipeline, Stage};
|
||||
use photocraft_cms::{Builtin, Clut, Curve, GpuMatrixShaper, Intent, Lut3d, Profile, Transform, TransformOptions};
|
||||
|
||||
const SOURCE_MATRIX: [[f64; 3]; 3] = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]];
|
||||
// Deliberately synthetic, D50-referenced wider-gamut monitor; not a user's measured profile.
|
||||
const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.55, 0.27, 0.1442], [0.25, 0.69, 0.06], [0.01, 0.06, 0.7549]];
|
||||
const GAMMA: f64 = 2.2;
|
||||
const SOURCE_ENTRIES: usize = 1024;
|
||||
const DISPLAY_TOLERANCE: f64 = 1.0 / 255.0;
|
||||
|
||||
fn fixed16(x: f64) -> f64 {
|
||||
(x * 65536.0).round() / 65536.0
|
||||
}
|
||||
|
||||
fn srgb_sample(i: usize) -> f64 {
|
||||
let x = i as f64 / (SOURCE_ENTRIES - 1) as f64;
|
||||
let linear = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) };
|
||||
(linear * 65535.0).round() / 65535.0
|
||||
}
|
||||
|
||||
fn synthetic_profiles() -> (Profile, Profile) {
|
||||
let mut source = Builtin::Srgb.profile().clone();
|
||||
source.description = "Synthetic sampled sRGB for display regression".into();
|
||||
source.matrix = Some(SOURCE_MATRIX);
|
||||
source.trc = Some(std::array::from_fn(|_| Curve::Table((0..SOURCE_ENTRIES).map(|i| srgb_sample(i) as f32).collect())));
|
||||
let source = source.with_encoded_bytes();
|
||||
let mut monitor = Builtin::Srgb.profile().clone();
|
||||
monitor.description = "Synthetic gamma 2.2 display for regression".into();
|
||||
monitor.matrix = Some(MONITOR_MATRIX);
|
||||
monitor.trc = Some(std::array::from_fn(|_| Curve::Parametric { kind: 0, p: [GAMMA, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }));
|
||||
let monitor = monitor.with_encoded_bytes();
|
||||
// Exercise actual ICC serialization/parsing, including fixed-point matrix/TRC values.
|
||||
(Profile::parse(&source.to_bytes()).unwrap(), Profile::parse(&monitor.to_bytes()).unwrap())
|
||||
}
|
||||
|
||||
fn synthetic_transform(bpc: bool) -> Transform {
|
||||
let (source, monitor) = synthetic_profiles();
|
||||
Transform::new(&source, &monitor, Intent::RelativeColorimetric, bpc).unwrap()
|
||||
}
|
||||
|
||||
fn reference(rgb: [f32; 3]) -> [f64; 3] {
|
||||
let linear = rgb.map(|x| {
|
||||
let p = f64::from(x.clamp(0.0, 1.0)) * (SOURCE_ENTRIES - 1) as f64;
|
||||
let i = (p as usize).min(SOURCE_ENTRIES - 2);
|
||||
srgb_sample(i) + (srgb_sample(i + 1) - srgb_sample(i)) * (p - i as f64)
|
||||
});
|
||||
let xyz = SOURCE_MATRIX.map(|row| row.iter().zip(linear).map(|(m, x)| fixed16(*m) * x).sum::<f64>());
|
||||
// Solve M_monitor * linear_monitor = XYZ using elimination, rather than the production
|
||||
// matrix inverse/multiplication implementation.
|
||||
let mut a: [[f64; 4]; 3] = std::array::from_fn(|r| [fixed16(MONITOR_MATRIX[r][0]), fixed16(MONITOR_MATRIX[r][1]), fixed16(MONITOR_MATRIX[r][2]), xyz[r]]);
|
||||
for pivot in 0..3 {
|
||||
let divisor = a[pivot][pivot];
|
||||
for value in &mut a[pivot][pivot..] {
|
||||
*value /= divisor;
|
||||
}
|
||||
let normalized = a[pivot];
|
||||
for (r, row) in a.iter_mut().enumerate() {
|
||||
if r != pivot {
|
||||
let factor = row[pivot];
|
||||
for (value, coefficient) in row.iter_mut().zip(normalized).skip(pivot) {
|
||||
*value -= factor * coefficient;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::array::from_fn(|c| a[c][3].clamp(0.0, 1.0).powf(1.0 / fixed16(GAMMA)))
|
||||
}
|
||||
|
||||
/// CPU interpretation of the public GPU data layout; expected colours come only from
|
||||
/// `reference`, so this tests the sampled representation, not Transform against itself.
|
||||
fn sample_gpu(gpu: &GpuMatrixShaper, rgb: [f32; 3]) -> [f32; 3] {
|
||||
fn interpolate(table: &[[f32; 4]], channel: usize, coordinate: f32) -> f32 {
|
||||
let i = (coordinate as usize).min(table.len() - 2);
|
||||
let f = coordinate - i as f32;
|
||||
table[i][channel] + (table[i + 1][channel] - table[i][channel]) * f
|
||||
}
|
||||
let linear: [f32; 3] = std::array::from_fn(|c| interpolate(gpu.input_table(), c, rgb[c].clamp(0.0, 1.0) * GpuMatrixShaper::INPUT_INTERVALS as f32));
|
||||
let rows = gpu.matrix_rows();
|
||||
std::array::from_fn(|c| {
|
||||
let row = rows[c];
|
||||
let value = row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2] + row[3];
|
||||
let coordinate = value.clamp(0.0, 1.0).sqrt().sqrt() * (GpuMatrixShaper::OUTPUT_ENTRIES - 1) as f32;
|
||||
interpolate(gpu.output_table(), c, coordinate).clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn check_reference(gpu: &GpuMatrixShaper, rgb: [f32; 3]) {
|
||||
let actual = sample_gpu(gpu, rgb);
|
||||
let expected = reference(rgb);
|
||||
for c in 0..3 {
|
||||
assert!((f64::from(actual[c]) - expected[c]).abs() <= DISPLAY_TOLERANCE, "input {rgb:?}, channel {c}: GPU {actual:?}, ICC {expected:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_srgb_to_gamma_display_preserves_neutral_and_colored_shadows() {
|
||||
for bpc in [false, true] {
|
||||
let transform = synthetic_transform(bpc);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
assert_eq!(gpu.input_table().len(), GpuMatrixShaper::INPUT_INTERVALS + 1);
|
||||
assert_eq!(gpu.output_table().len(), GpuMatrixShaper::OUTPUT_ENTRIES);
|
||||
assert!(gpu.input_table().iter().chain(gpu.output_table()).all(|v| v[3] == 1.0));
|
||||
for v in 0..=255 {
|
||||
check_reference(&gpu, [v as f32 / 255.0; 3]);
|
||||
}
|
||||
// Includes coloured shadows within the old LUT's very first 0..8/255 cell.
|
||||
for r in 0..=16 {
|
||||
for g in 0..=16 {
|
||||
for b in 0..=16 {
|
||||
check_reference(&gpu, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for rgb in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.9, 0.6, 0.3]] {
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coarse_final_rgb_lut_fails_the_same_icc_shadow_reference() {
|
||||
let transform = synthetic_transform(false);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
let coarse = Lut3d::from_transform(&transform, 33);
|
||||
let rgb = [2.0 / 255.0; 3];
|
||||
let expected = reference(rgb);
|
||||
let old = coarse.sample(rgb);
|
||||
// Even float LUT nodes fail, before the additional rounding of RGBA8 upload. This
|
||||
// guards the cause of the original bug rather than a particular observed screenshot.
|
||||
assert!(old.iter().zip(expected).all(|(a, e)| e - f64::from(*a) > 2.0 / 255.0), "old {old:?}, reference {expected:?}");
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_display_representation_accepts_u8_u16_and_float_samples() {
|
||||
let transform = synthetic_transform(false);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
for v in [0_u8, 1, 2, 7, 8, 16, 32, 128, 255] {
|
||||
check_reference(&gpu, [v as f32 / 255.0; 3]);
|
||||
}
|
||||
for v in [0_u16, 1, 2, 15, 63, 257, 514, 1028, 4096, 32768, 65535] {
|
||||
check_reference(&gpu, [v as f32 / 65535.0; 3]);
|
||||
}
|
||||
for rgb in [[0.000003, 0.00001, 0.00002], [0.00123, 0.00456, 0.00789], [0.2, 0.45, 0.8], [-0.5, 0.0, 2.0]] {
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
assert_eq!(sample_gpu(&gpu, [0.0; 3]), [0.0; 3]);
|
||||
// White differs very slightly from 1 because the synthetic matrices use ICC s15Fixed16.
|
||||
check_reference(&gpu, [1.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_curves_and_matrix_use_identity_without_losing_offsets() {
|
||||
let identity = Transform::from_pipeline(Pipeline::new(3, Vec::new()), TransformOptions::default()).gpu_matrix_shaper().unwrap();
|
||||
for rgb in [[0.0; 3], [1.0; 3], [0.001, 0.2, 0.8], [-2.0, 0.5, 2.0]] {
|
||||
for (actual, input) in sample_gpu(&identity, rgb).into_iter().zip(rgb) {
|
||||
assert!((actual - input.clamp(0.0, 1.0)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
let matrix = Stage::Matrix { rows: 3, cols: 3, m: vec![0.8, 0.0, 0.0, 0.0, 0.7, 0.0, 0.0, 0.0, 0.6], offset: vec![0.01, 0.02, 0.03] };
|
||||
let transform = Transform::from_pipeline(Pipeline::new(3, vec![matrix]), TransformOptions::default()).gpu_matrix_shaper().unwrap();
|
||||
let input = [0.1, 0.2, 0.3];
|
||||
let output = sample_gpu(&transform, input);
|
||||
for (actual, expected) in output.into_iter().zip([0.09, 0.16, 0.21]) {
|
||||
assert!((actual - expected).abs() < 1e-6, "{output:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_clut_proof_and_non_rgb_transforms_retain_fallback() {
|
||||
let identity_clut = Clut::sample(vec![2; 3], 3, |input, output| output.copy_from_slice(input));
|
||||
let clut = Transform::from_pipeline(Pipeline::new(3, vec![Stage::Clut(identity_clut)]), TransformOptions::default());
|
||||
assert!(clut.gpu_matrix_shaper().is_none());
|
||||
let srgb = Builtin::Srgb.profile();
|
||||
let lab = Transform::new(Builtin::LabD50.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
|
||||
assert!(lab.gpu_matrix_shaper().is_none());
|
||||
let gray = Transform::new(Builtin::GrayGamma22.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
|
||||
assert!(gray.gpu_matrix_shaper().is_none());
|
||||
let proof = Transform::proof(srgb, Builtin::DisplayP3.profile(), srgb, Intent::RelativeColorimetric, false, false).unwrap();
|
||||
assert!(proof.gpu_matrix_shaper().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_matrix_storage_is_not_exposed_to_the_gpu() {
|
||||
for (coefficients, offsets) in [(vec![2.0; 8], vec![0.0; 3]), (vec![2.0; 9], vec![0.0; 2]), (vec![f64::INFINITY; 9], vec![0.0; 3])] {
|
||||
let transform =
|
||||
Transform::from_pipeline(Pipeline::new(3, vec![Stage::Matrix { rows: 3, cols: 3, m: coefficients, offset: offsets }]), TransformOptions::default());
|
||||
assert!(transform.gpu_matrix_shaper().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! The colour table of a GIF, read from its blocks without decoding pixels (GIF89a
|
||||
//! specification: Logical Screen Descriptor, Global and Local Color Tables, Graphic Control
|
||||
//! Extension).
|
||||
//!
|
||||
//! A GIF is always palette-based, so it opens in Indexed Color with the table of the frame that
|
||||
//! [`crate::decode`] keeps, the first: its Local Color Table when it has one, else the Global
|
||||
//! one, and the transparent entry that frame's Graphic Control Extension names.
|
||||
|
||||
/// Colours of the table the first frame uses, and its transparent entry. `None` for anything
|
||||
/// that isn't a well-formed GIF up to its first image, or a first frame without a table.
|
||||
pub(crate) fn palette(b: &[u8]) -> Option<(Vec<[u8; 3]>, Option<u8>)> {
|
||||
if !(b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a")) {
|
||||
return None;
|
||||
}
|
||||
// Logical Screen Descriptor: width, height, packed fields, background, aspect.
|
||||
let packed = *b.get(10)?;
|
||||
let mut at = 13;
|
||||
let table = |at: usize, packed: u8| -> Option<Vec<[u8; 3]>> {
|
||||
let n = 2usize << (packed & 0x07);
|
||||
let bytes = b.get(at..at.checked_add(n.checked_mul(3)?)?)?;
|
||||
Some(bytes.as_chunks::<3>().0.to_vec())
|
||||
};
|
||||
let global = if packed & 0x80 != 0 {
|
||||
let t = table(at, packed)?;
|
||||
at += t.len() * 3;
|
||||
Some(t)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut transparent = None;
|
||||
loop {
|
||||
match *b.get(at)? {
|
||||
// Extension: label, then data sub-blocks up to a zero-length one.
|
||||
0x21 => {
|
||||
let label = *b.get(at + 1)?;
|
||||
at += 2;
|
||||
// Graphic Control Extension: block size 4, packed (bit 0: transparent colour
|
||||
// given), delay (2 bytes), transparent colour index.
|
||||
if label == 0xF9 && *b.get(at)? == 4 {
|
||||
let flags = *b.get(at + 1)?;
|
||||
transparent = (flags & 0x01 != 0).then_some(*b.get(at + 4)?);
|
||||
}
|
||||
loop {
|
||||
let size = usize::from(*b.get(at)?);
|
||||
at = at.checked_add(1 + size)?;
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Image Descriptor: position and size (8 bytes), packed fields, then the Local Color
|
||||
// Table if bit 7 is set. This is the first frame: its table is the one used.
|
||||
0x2C => {
|
||||
let packed = *b.get(at + 9)?;
|
||||
let colors = if packed & 0x80 != 0 { table(at + 10, packed)? } else { global? };
|
||||
let transparent = transparent.filter(|i| usize::from(*i) < colors.len());
|
||||
return Some((colors, transparent));
|
||||
}
|
||||
// Trailer, or anything else: no image.
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::palette;
|
||||
|
||||
/// A GIF up to its first image: `global` table entries, optional GCE transparency, optional
|
||||
/// local table.
|
||||
fn gif(global: &[[u8; 3]], transparent: Option<u8>, local: Option<&[[u8; 3]]>) -> Vec<u8> {
|
||||
let size_bits = |n: usize| (n.max(2).next_power_of_two().trailing_zeros() - 1) as u8;
|
||||
let mut b = b"GIF89a".to_vec();
|
||||
b.extend([4, 0, 4, 0]);
|
||||
b.push(if global.is_empty() { 0 } else { 0x80 | size_bits(global.len()) });
|
||||
b.extend([0, 0]);
|
||||
let pad = |b: &mut Vec<u8>, t: &[[u8; 3]]| {
|
||||
let n = 2usize << size_bits(t.len());
|
||||
for k in 0..n {
|
||||
b.extend(t.get(k).copied().unwrap_or([0, 0, 0]));
|
||||
}
|
||||
};
|
||||
if !global.is_empty() {
|
||||
pad(&mut b, global);
|
||||
}
|
||||
// A comment extension first, to skip.
|
||||
b.extend([0x21, 0xFE, 3, b'h', b'i', b'!', 0]);
|
||||
if let Some(i) = transparent {
|
||||
b.extend([0x21, 0xF9, 4, 0x01, 0, 0, i, 0]);
|
||||
}
|
||||
b.extend([0x2C, 0, 0, 0, 0, 4, 0, 4, 0]);
|
||||
match local {
|
||||
Some(t) => {
|
||||
b.push(0x80 | size_bits(t.len()));
|
||||
pad(&mut b, t);
|
||||
}
|
||||
None => b.push(0),
|
||||
}
|
||||
b.extend([2, 2, 0x4C, 0x01, 0, 0x3B]);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_frames_table_and_transparent_entry() {
|
||||
let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]];
|
||||
assert_eq!(palette(&gif(&g, None, None)), Some((g.to_vec(), None)));
|
||||
assert_eq!(palette(&gif(&g, Some(2), None)), Some((g.to_vec(), Some(2))));
|
||||
// A local table wins over the global one.
|
||||
let l = [[1, 2, 3], [4, 5, 6]];
|
||||
assert_eq!(palette(&gif(&g, Some(1), Some(&l))), Some((l.to_vec(), Some(1))));
|
||||
// A transparent index past the table is no transparent entry.
|
||||
assert_eq!(palette(&gif(&l, Some(9), None)), Some((l.to_vec(), None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_table_or_not_a_gif_is_none() {
|
||||
assert_eq!(palette(&gif(&[], None, None)), None, "no table at all");
|
||||
assert_eq!(palette(b"\x89PNG\r\n\x1a\n"), None);
|
||||
assert_eq!(palette(b""), None);
|
||||
let whole = gif(&[[9, 9, 9], [8, 8, 8]], Some(0), None);
|
||||
for cut in 0..whole.len() - 6 {
|
||||
// Cut short anywhere before the image descriptor's fields: never a panic.
|
||||
let _ = palette(&whole[..cut]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub(crate) mod exr;
|
||||
pub(crate) mod exr_cryptomatte;
|
||||
pub(crate) mod fits;
|
||||
pub(crate) mod gif_frames;
|
||||
pub(crate) mod gif_palette;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod jpeg;
|
||||
pub(crate) mod pcx;
|
||||
|
||||
@@ -128,6 +128,12 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> {
|
||||
Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) })
|
||||
}
|
||||
|
||||
/// The colour table of a GIF's first frame (its Local Color Table, else the Global one) and the
|
||||
/// entry its Graphic Control Extension makes transparent, read without decoding pixels.
|
||||
pub fn gif_palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option<u8>)> {
|
||||
crate::codecs::gif_palette::palette(bytes)
|
||||
}
|
||||
|
||||
/// Returns the embedded colour table for an indexed BMP, if it has one.
|
||||
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
via_image::bmp_palette(bytes)
|
||||
|
||||
@@ -138,12 +138,12 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
||||
}
|
||||
}
|
||||
Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => {
|
||||
let luts = tone_luts_depth(adj, depth);
|
||||
let identity = float_tone_identity(adj, depth);
|
||||
match space {
|
||||
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { lut(&luts[i], c[i]) })),
|
||||
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(*space, &luts, c)),
|
||||
if identity == [true; 3] {
|
||||
return;
|
||||
}
|
||||
let luts = tone_luts_depth(adj, depth);
|
||||
apply_tone(buf, *space, &luts, identity);
|
||||
}
|
||||
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
|
||||
let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges));
|
||||
@@ -572,6 +572,13 @@ pub fn float_tone_identity(adj: &Adjustment, depth: Option<SampleType>) -> [bool
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_tone(buf: &mut Buffer, space: ToneSpace, luts: &[Vec<f32>; 4], identity: [bool; 3]) {
|
||||
match space {
|
||||
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { lut(&luts[i], c[i]) })),
|
||||
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(space, luts, c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel LUTs of a Levels or Curves adjustment: each channel's record first, then the
|
||||
/// master (composite) record, as Photoshop does (psd-tools levels_rgb.psd and curves_rgb.psd,
|
||||
/// #975). Four rows: the three `per_channel` channels then black
|
||||
|
||||
+326
-51
@@ -31,6 +31,7 @@ pub mod pattern;
|
||||
pub mod proxy;
|
||||
pub mod psblend;
|
||||
pub mod shape_split;
|
||||
mod uniform;
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern};
|
||||
@@ -96,6 +97,59 @@ impl Buffer {
|
||||
/// Tile size for parallel rendering (tile results are independent).
|
||||
pub const RENDER_TILE: i32 = 256;
|
||||
|
||||
// One tile of scratch per worker; nested rendering takes its own buffer.
|
||||
thread_local! {
|
||||
static SCRATCH: std::cell::RefCell<Vec<[f32; 4]>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn scratch_buffer(rect: Rect) -> Buffer {
|
||||
if rect.width() as usize * rect.height() as usize > (RENDER_TILE * RENDER_TILE) as usize {
|
||||
return Buffer::transparent(rect);
|
||||
}
|
||||
let mut px = SCRATCH.with(|slot| std::mem::take(&mut *slot.borrow_mut()));
|
||||
px.resize(rect.width() as usize * rect.height() as usize, [0.0; 4]);
|
||||
Buffer { rect, px }
|
||||
}
|
||||
|
||||
fn recycle_buffer(buf: Buffer) {
|
||||
if buf.px.capacity() <= (RENDER_TILE * RENDER_TILE) as usize {
|
||||
SCRATCH.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
if slot.capacity() < buf.px.capacity() {
|
||||
*slot = buf.px;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn scratch_reuse_preserves_pixels_and_bounds_retention() {
|
||||
SCRATCH.with(|slot| slot.borrow_mut().clear());
|
||||
let rect = Rect::new(-2, -3, 18, 11);
|
||||
for fmt in [photocraft_color::PixelFormat::RGBA8, photocraft_color::PixelFormat::RGBA16, photocraft_color::PixelFormat::RGBA32F] {
|
||||
let s = Surface::with_default(fmt, &[0.2, 0.4, 0.6, 0.8]);
|
||||
let buf = surface_to_scratch(&s, rect);
|
||||
assert_eq!(buf, surface_to_buffer(&s, rect));
|
||||
let ptr = buf.px.as_ptr();
|
||||
recycle_buffer(buf);
|
||||
let reused = surface_to_scratch(&Surface::new(fmt), rect);
|
||||
assert_eq!(reused.px.as_ptr(), ptr);
|
||||
assert!(reused.px.iter().all(|p| p == &[0.0; 4]));
|
||||
let nested = scratch_buffer(rect);
|
||||
assert_ne!(nested.px.as_ptr(), reused.px.as_ptr());
|
||||
recycle_buffer(nested);
|
||||
recycle_buffer(reused);
|
||||
}
|
||||
let cached = SCRATCH.with(|slot| slot.borrow().as_ptr());
|
||||
let huge = scratch_buffer(Rect::new(0, 0, RENDER_TILE + 1, RENDER_TILE));
|
||||
recycle_buffer(huge);
|
||||
SCRATCH.with(|slot| {
|
||||
assert_eq!(slot.borrow().as_ptr(), cached);
|
||||
assert!(slot.borrow().capacity() <= (RENDER_TILE * RENDER_TILE) as usize);
|
||||
});
|
||||
}
|
||||
|
||||
/// Composite the whole document over `rect`, in parallel 256² tiles on
|
||||
/// native targets (single-threaded on wasm).
|
||||
pub fn render(doc: &Document, rect: Rect) -> Buffer {
|
||||
@@ -193,14 +247,18 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Few, wide bands: the tiles of each band in parallel.
|
||||
for (i, band) in out.px.chunks_mut(band_len).enumerate() {
|
||||
// Few, wide bands: the tiles of each band in parallel, and the bands too, so a band's
|
||||
// slowest tile doesn't leave the other cores idle until the next band starts. Each
|
||||
// tile is written as soon as it is done, so only the tiles in flight are held.
|
||||
out.px.par_chunks_mut(band_len).enumerate().for_each(|(i, band)| {
|
||||
let y0 = rect.y0 + i as i32 * tile;
|
||||
let parts: Vec<Buffer> = band_tiles(y0).into_par_iter().map(run).collect();
|
||||
for part in &parts {
|
||||
put(band, y0, part);
|
||||
}
|
||||
}
|
||||
let band = std::sync::Mutex::new(band);
|
||||
band_tiles(y0).into_par_iter().for_each(|t| {
|
||||
let part = run(t);
|
||||
let mut band = band.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
put(&mut band, y0, &part);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -341,6 +399,7 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
fx_maps: Default::default(),
|
||||
fx_clip: Default::default(),
|
||||
tone_luts: prepare_tones(std::slice::from_ref(layer), 0),
|
||||
doc: None,
|
||||
layer_keys: Default::default(),
|
||||
},
|
||||
@@ -431,6 +490,38 @@ pub fn render_reduced_damage(doc: &Document, w: u32, h: u32, damage: Rect) -> Bu
|
||||
/// [`render_reduced_rect`] (or, with `damage`, [`render_reduced_damage`]) of area `b` with an
|
||||
/// explicit band height (see [`render_bands`]).
|
||||
fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Option<Rect>, band_rows: i32) -> Buffer {
|
||||
render_reduced_with(doc, b, w, h, damage, band_rows, true, &mut ReducedStats::default())
|
||||
}
|
||||
|
||||
/// What a reduced render composited (tests check that one-colour areas are not composited).
|
||||
#[derive(Debug, Default)]
|
||||
struct ReducedStats {
|
||||
/// Whether one-colour areas were looked for ([`uniform::UniformAreas`]).
|
||||
uniform: bool,
|
||||
/// Document pixels composited one by one.
|
||||
composited: u64,
|
||||
}
|
||||
|
||||
/// A tile of a reduced render: one colour (premultiplied), or its composited pixels.
|
||||
enum Part {
|
||||
Solid([f32; 4]),
|
||||
Px(Buffer),
|
||||
}
|
||||
|
||||
/// A column of document tiles in a reduced render (`b`-relative source columns).
|
||||
struct TileColumn {
|
||||
/// The columns rendered: the tile's, within the source area.
|
||||
x0: usize,
|
||||
x1: usize,
|
||||
/// The whole tile's columns within `b`, whose values decide whether it is one colour.
|
||||
whole: (i64, i64),
|
||||
/// Runs of (output column, how many of the rendered columns it averages).
|
||||
runs: Vec<(usize, f32)>,
|
||||
}
|
||||
|
||||
/// [`render_reduced_in_bands`]; `uniform` off composites every pixel (the reference in tests).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_reduced_with(doc: &Document, b: Rect, w: u32, h: u32, damage: Option<Rect>, band_rows: i32, uniform: bool, stats: &mut ReducedStats) -> Buffer {
|
||||
let (fw, fh) = (b.width() as usize, b.height() as usize);
|
||||
let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize);
|
||||
let full = Rect::from_xywh(0, 0, w as u32, h as u32);
|
||||
@@ -474,22 +565,14 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti
|
||||
let (cols, rows) = (map(fw, w), map(fh, h));
|
||||
let cols = &cols[sx0..sx1];
|
||||
let mut acc = vec![[0.0f32; 4]; ow * oh];
|
||||
let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| {
|
||||
let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize);
|
||||
// Each output row sums its source rows in a band (in order, top to bottom), each row's pixels
|
||||
// left to right: `row(y, out)` adds source row `y` (band-relative) to output row `out`.
|
||||
let sum_band = |acc: &mut [[f32; 4]], by0: usize, by1: usize, row: &(dyn Fn(usize, &mut [[f32; 4]]) + Sync)| {
|
||||
let (t0, t1) = (rows[by0] as usize, rows[by1 - 1] as usize + 1);
|
||||
let sw = sx1 - sx0;
|
||||
// Each output row sums its source rows in this band (in order, top to bottom).
|
||||
let sum_row = |t: usize, out: &mut [[f32; 4]]| {
|
||||
let (a, z) = span(t, fh, h);
|
||||
for y in a.max(by0)..z.min(by1) {
|
||||
let src = &band.px[(y - by0) * sw..(y - by0 + 1) * sw];
|
||||
for (p, &c) in src.iter().zip(cols) {
|
||||
let o = &mut out[c as usize - ox0];
|
||||
for i in 0..3 {
|
||||
o[i] += p[i] * p[3];
|
||||
}
|
||||
o[3] += p[3];
|
||||
}
|
||||
row(y - by0, out);
|
||||
}
|
||||
};
|
||||
let part = &mut acc[(t0 - oy0) * ow..(t1 - oy0) * ow];
|
||||
@@ -502,9 +585,110 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti
|
||||
for (i, out) in part.chunks_mut(ow).enumerate() {
|
||||
sum_row(t0 + i, out);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
debug_assert!(ok.is_ok());
|
||||
};
|
||||
let add = |o: &mut [f32; 4], p: &[f32; 4]| {
|
||||
for i in 0..3 {
|
||||
o[i] += p[i] * p[3];
|
||||
}
|
||||
o[3] += p[3];
|
||||
};
|
||||
if let Some(uniform) = uniform.then(|| uniform::UniformAreas::new(doc)).flatten() {
|
||||
stats.uniform = true;
|
||||
// One-colour tiles (a new document's background, the empty parts of a sparse one) are
|
||||
// composited once per colour and summed per run of source columns, not per pixel
|
||||
// (#4017). Tiles lie on the document's tile grid whatever `damage` is, so a damage
|
||||
// render sums exactly what the whole reduction does.
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode);
|
||||
let cx = Ctx::for_doc(doc, &patterns);
|
||||
let t = RENDER_TILE as usize;
|
||||
// The document tile grid (the surfaces' own), in `b`-relative coordinates: the start of
|
||||
// the tile holding `v` (negative when `b` starts inside a tile).
|
||||
let grid = |v: usize, o: i32| (i64::from(o) + v as i64).div_euclid(t as i64) * t as i64 - i64::from(o);
|
||||
let tile_cols: Vec<TileColumn> = (grid(sx0, b.x0)..sx1 as i64)
|
||||
.step_by(t)
|
||||
.map(|gx| {
|
||||
let (x0, x1) = ((gx.max(0) as usize).max(sx0), ((gx + t as i64) as usize).min(sx1));
|
||||
let mut runs: Vec<(usize, f32)> = Vec::new();
|
||||
for &c in &cols[x0 - sx0..x1 - sx0] {
|
||||
match runs.last_mut() {
|
||||
Some((last, n)) if *last == c as usize - ox0 => *n += 1.0,
|
||||
_ => runs.push((c as usize - ox0, 1.0)),
|
||||
}
|
||||
}
|
||||
TileColumn { x0, x1, whole: (gx.max(0), (gx + t as i64).min(fw as i64)), runs }
|
||||
})
|
||||
.collect();
|
||||
let band_h = band_rows_for(src.width(), band_rows).max(RENDER_TILE) as usize;
|
||||
let mut y = grid(sy0, b.y0);
|
||||
while y < sy1 as i64 {
|
||||
let y1 = (y + band_h as i64).min(sy1 as i64);
|
||||
let tiles: Vec<(Rect, Rect)> = (y..y1)
|
||||
.step_by(t)
|
||||
.flat_map(|gy| {
|
||||
tile_cols.iter().map(move |&TileColumn { x0, x1, whole: (gx0, gx1), .. }| {
|
||||
let at = |x0: i64, y0: i64, x1: i64, y1: i64| Rect::new(b.x0 + x0 as i32, b.y0 + y0 as i32, b.x0 + x1 as i32, b.y0 + y1 as i32);
|
||||
let (gy0, gy1) = (gy.max(0), (gy + t as i64).min(fh as i64));
|
||||
(at(gx0, gy0, gx1, gy1), at(x0 as i64, gy0.max(sy0 as i64), x1 as i64, gy1.min(sy1 as i64)))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let one = |(whole, part): &(Rect, Rect)| -> Part {
|
||||
let render = |r: Rect| render_tiled_with(doc, r, RENDER_TILE, &cx);
|
||||
match uniform.color(*whole, |r| render(r).px.first().copied()) {
|
||||
Some(p) => Part::Solid([p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]),
|
||||
None => Part::Px(render(*part)),
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let parts: Vec<Part> = {
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter().map(one).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let parts: Vec<Part> = tiles.iter().map(one).collect();
|
||||
stats.composited += parts.iter().map(|p| if let Part::Px(b) = p { b.px.len() as u64 } else { 0 }).sum::<u64>();
|
||||
let (by0, by1) = (y.max(sy0 as i64) as usize, y1 as usize);
|
||||
let n = tile_cols.len();
|
||||
let band_y = y;
|
||||
sum_band(&mut acc, by0, by1, &|row: usize, out: &mut [[f32; 4]]| {
|
||||
let y = (by0 + row) as i64;
|
||||
let j = (y - band_y) as usize / t;
|
||||
let ty = (y - (band_y + (j * t) as i64).max(sy0 as i64)) as usize;
|
||||
for (TileColumn { x0, x1, runs, .. }, part) in tile_cols.iter().zip(parts.iter().skip(j * n).take(n)) {
|
||||
match part {
|
||||
Part::Solid(pa) => {
|
||||
for &(c, k) in runs {
|
||||
let o = &mut out[c];
|
||||
for i in 0..4 {
|
||||
o[i] += pa[i] * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
Part::Px(buf) => {
|
||||
let tw = x1 - x0;
|
||||
for (p, &c) in buf.px.iter().skip(ty * tw).take(tw).zip(&cols[x0 - sx0..x1 - sx0]) {
|
||||
add(&mut out[c as usize - ox0], p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
y = y1;
|
||||
}
|
||||
} else {
|
||||
let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| {
|
||||
stats.composited += band.px.len() as u64;
|
||||
let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize);
|
||||
let sw = sx1 - sx0;
|
||||
sum_band(&mut acc, by0, by1, &|y: usize, out: &mut [[f32; 4]]| {
|
||||
for (p, &c) in band.px[y * sw..(y + 1) * sw].iter().zip(cols) {
|
||||
add(&mut out[c as usize - ox0], p);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
debug_assert!(ok.is_ok());
|
||||
}
|
||||
for (i, p) in acc.iter_mut().enumerate() {
|
||||
let ((x0, x1), (y0, y1)) = (span(ox0 + i % ow, fw, w), span(oy0 + i / ow, fh, h));
|
||||
let n = ((y1 - y0) * (x1 - x0)).max(1) as f32;
|
||||
@@ -556,6 +740,8 @@ struct Ctx<'a> {
|
||||
/// The document's colour mode (channel restrictions name its channels).
|
||||
mode: photocraft_color::ColorMode,
|
||||
depth: photocraft_color::SampleType,
|
||||
/// At most 1 MiB of tone tables, initialized once and shared by this render's tiles and bands.
|
||||
tone_luts: Vec<(&'a photocraft_doc::Adjustment, std::sync::OnceLock<[Vec<f32>; 4]>)>,
|
||||
vector_masks: RenderVectorMasks,
|
||||
/// Effect maps used by this render, by cache key: built before the parallel tiles (see
|
||||
/// [`prepare_effects`]) and held for the whole render (or band, for maps clipped to it: the
|
||||
@@ -635,6 +821,7 @@ impl<'a> Ctx<'a> {
|
||||
fx_maps: Default::default(),
|
||||
fx_clip: Default::default(),
|
||||
layer_keys: Default::default(),
|
||||
tone_luts: prepare_tones(&doc.layers, 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,9 +833,41 @@ impl<'a> Ctx<'a> {
|
||||
self.fx_maps.lock().unwrap_or_else(|e| e.into_inner()).retain(|_, (_, clipped)| !*clipped);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_adjustment(&self, adj: &photocraft_doc::Adjustment, buf: &mut Buffer) {
|
||||
if let photocraft_doc::Adjustment::Levels { space, .. } | photocraft_doc::Adjustment::Curves { space, .. } = adj
|
||||
&& !matches!(adj, photocraft_doc::Adjustment::Levels { space: photocraft_doc::adjust::ToneSpace::Rgb, .. } if self.depth == photocraft_color::SampleType::F32)
|
||||
&& let Some((_, luts)) = self.tone_luts.iter().find(|(a, _)| std::ptr::eq(*a, adj))
|
||||
{
|
||||
let identity = adjust::float_tone_identity(adj, Some(self.depth));
|
||||
if identity == [true; 3] {
|
||||
return;
|
||||
}
|
||||
adjust::apply_tone(buf, *space, luts.get_or_init(|| adjust::tone_luts_depth(adj, Some(self.depth))), identity);
|
||||
} else {
|
||||
adjust::apply_in(adj, buf, self.transfer, Some(self.depth), self.mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deepest group nesting [`prepare_effects`] walks (deeper layers build their maps on demand).
|
||||
fn prepare_tones(layers: &[Layer], depth: u32) -> Vec<(&photocraft_doc::Adjustment, std::sync::OnceLock<[Vec<f32>; 4]>)> {
|
||||
let mut out = Vec::new();
|
||||
for layer in layers.iter().filter(|l| l.visible) {
|
||||
if out.len() == 16 {
|
||||
break;
|
||||
}
|
||||
match &layer.content {
|
||||
LayerContent::Adjustment(a @ (photocraft_doc::Adjustment::Levels { .. } | photocraft_doc::Adjustment::Curves { .. })) => {
|
||||
out.push((a, Default::default()))
|
||||
}
|
||||
LayerContent::Group(g) if depth < PREPARE_DEPTH => out.extend(prepare_tones(&g.children, depth + 1).into_iter().take(16 - out.len())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Deepest group nesting preparation walks (deeper layers prepare on demand).
|
||||
const PREPARE_DEPTH: u32 = 64;
|
||||
|
||||
/// Build the effect maps of every visible layer in `layers` (groups included) that can reach
|
||||
@@ -972,7 +1191,7 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
|
||||
}
|
||||
LayerContent::Fill(f) => match &layer.fill_cache {
|
||||
// Photoshop's own rendering, valid while the fill is unchanged.
|
||||
Some(c) if c.fill == *f => surface_to_buffer(&c.surface, rect),
|
||||
Some(c) if c.fill == *f => surface_to_scratch(&c.surface, rect),
|
||||
_ if cx.mode == photocraft_color::ColorMode::Cmyk => {
|
||||
let frame = fill_frame(layer, cx.canvas);
|
||||
let native_rect = if matches!(f, Fill::Solid(_)) { Rect::new(0, 0, 1, 1) } else { rect };
|
||||
@@ -986,7 +1205,7 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
|
||||
},
|
||||
LayerContent::Adjustment(_) => return None,
|
||||
_ => match layer.surface() {
|
||||
Some(s) => surface_to_buffer(s, rect),
|
||||
Some(s) => surface_to_scratch(s, rect),
|
||||
None => Buffer::transparent(rect),
|
||||
},
|
||||
};
|
||||
@@ -1027,6 +1246,12 @@ pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer {
|
||||
Buffer { rect, px }
|
||||
}
|
||||
|
||||
fn surface_to_scratch(s: &Surface, rect: Rect) -> Buffer {
|
||||
let mut buf = scratch_buffer(rect);
|
||||
s.read_rgba_into(rect, &mut buf.px);
|
||||
buf
|
||||
}
|
||||
|
||||
/// The frame a fill layer's gradient is laid out in ("Align with layer", Photoshop's default):
|
||||
/// the layer's bounds, i.e. the area its masks reveal: a hide-all pixel mask's painted area, or
|
||||
/// the vector mask's path; otherwise the canvas.
|
||||
@@ -1407,6 +1632,38 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface `render_content` reads `layer`'s pixels from, when that is all it draws.
|
||||
fn pixel_surface(layer: &Layer) -> Option<&Surface> {
|
||||
if matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_)) {
|
||||
return None;
|
||||
}
|
||||
layer.surface()
|
||||
}
|
||||
|
||||
/// A pixel layer (nothing clipped to it) onto `backdrop`, read from its tiles a row at a time:
|
||||
/// the same arithmetic as `render_content` + `blend_into_fill`, without a tile-sized copy of the
|
||||
/// layer (a megabyte per 256² render tile).
|
||||
fn composite_pixel_layer(layer: &Layer, surf: &Surface, backdrop: &mut Buffer, cx: &Ctx) {
|
||||
let rect = backdrop.rect;
|
||||
let w = rect.width() as usize;
|
||||
if w == 0 {
|
||||
return;
|
||||
}
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
let gamma = text_gamma(layer);
|
||||
let mut row = vec![[0.0f32; 4]; w];
|
||||
for (ry, dst) in backdrop.px.chunks_exact_mut(w).enumerate() {
|
||||
let y = rect.y0 + ry as i32;
|
||||
surf.read_rgba_into(Rect::new(rect.x0, y, rect.x1, y + 1), &mut row);
|
||||
if let Some(m) = mv.as_ref().and_then(|m| m.get(ry * w..(ry + 1) * w)) {
|
||||
for (p, k) in row.iter_mut().zip(m) {
|
||||
p[3] *= k;
|
||||
}
|
||||
}
|
||||
blend_span_fill(dst, &row, rect.x0, y, layer.blend, layer.opacity, layer.fill_opacity, gamma);
|
||||
}
|
||||
}
|
||||
|
||||
fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
|
||||
let rect = backdrop.rect;
|
||||
// A TSL-off stroke base that is transparent here can still have visible clipped siblings;
|
||||
@@ -1482,11 +1739,15 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
|
||||
// Adjustment layers transform the backdrop, then blend the result back in.
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = backdrop.clone();
|
||||
adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
if mv.as_ref().is_some_and(|values| values.iter().all(|&value| opacity * value <= 0.0)) {
|
||||
return;
|
||||
}
|
||||
let mut adjusted = scratch_buffer(rect);
|
||||
adjusted.px.copy_from_slice(&backdrop.px);
|
||||
cx.apply_adjustment(adj, &mut adjusted);
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
advanced::composite_clipped(clipped, &mut adjusted, cx);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
@@ -1501,6 +1762,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
backdrop.px[i] = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
|
||||
}
|
||||
}
|
||||
recycle_buffer(adjusted);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1525,9 +1787,15 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0);
|
||||
return;
|
||||
}
|
||||
if clipped.is_empty()
|
||||
&& let Some(surf) = pixel_surface(layer)
|
||||
{
|
||||
return composite_pixel_layer(layer, surf, backdrop, cx);
|
||||
}
|
||||
let Some(mut content) = render_content(layer, rect, cx) else { return };
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer));
|
||||
recycle_buffer(content);
|
||||
}
|
||||
|
||||
/// A layer with effects (and its clipping group) onto `backdrop`.
|
||||
@@ -1829,7 +2097,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
let rect = base.rect;
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = base.clone();
|
||||
adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode);
|
||||
cx.apply_adjustment(adj, &mut adjusted);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for (i, p) in base.px.iter_mut().enumerate() {
|
||||
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
|
||||
@@ -1894,44 +2162,51 @@ fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32
|
||||
/// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for
|
||||
/// Photoshop's special eight ([`psblend::composite_fill`]), plain coverage otherwise.
|
||||
fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) {
|
||||
if fill >= 1.0 || !psblend::fill_is_special(mode) {
|
||||
return blend_into_g(backdrop, src, mode, opacity * fill, gamma);
|
||||
for_rows(backdrop, src, |dst, s, x0, y| blend_span_fill(dst, s, x0, y, mode, opacity, fill, gamma));
|
||||
}
|
||||
|
||||
/// [`blend_into`] mixing coverage in a `gamma` space (type layers).
|
||||
fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) {
|
||||
for_rows(backdrop, src, |dst, s, x0, y| blend_span_g(dst, s, x0, y, mode, opacity, gamma));
|
||||
}
|
||||
|
||||
/// Runs `f(backdrop row, src row, x0, y)` over the rows of two buffers covering the same rect.
|
||||
fn for_rows(backdrop: &mut Buffer, src: &Buffer, mut f: impl FnMut(&mut [[f32; 4]], &[[f32; 4]], i32, i32)) {
|
||||
let rect = backdrop.rect;
|
||||
let w = (rect.width() as usize).max(1);
|
||||
for (ry, (dst, s)) in backdrop.px.chunks_mut(w).zip(src.px.chunks(w)).enumerate() {
|
||||
f(dst, s, rect.x0, rect.y0 + ry as i32);
|
||||
}
|
||||
for (b, s) in backdrop.px.iter_mut().zip(&src.px) {
|
||||
}
|
||||
|
||||
/// [`blend_into_fill`] of one row: `dst` and `src` start at pixel (`x0`, `y`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend_span_fill(dst: &mut [[f32; 4]], src: &[[f32; 4]], x0: i32, y: i32, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) {
|
||||
if fill >= 1.0 || !psblend::fill_is_special(mode) {
|
||||
return blend_span_g(dst, src, x0, y, mode, opacity * fill, gamma);
|
||||
}
|
||||
for (b, s) in dst.iter_mut().zip(src) {
|
||||
if s[3] > 0.0 {
|
||||
*b = psblend::composite_fill(mode, *b, *s, opacity, fill, gamma);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`blend_into`] mixing coverage in a `gamma` space (type layers).
|
||||
fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) {
|
||||
/// [`blend_into_g`] of one row: `dst` and `src` start at pixel (`x0`, `y`).
|
||||
fn blend_span_g(dst: &mut [[f32; 4]], src: &[[f32; 4]], x0: i32, y: i32, mode: BlendMode, opacity: f32, gamma: f32) {
|
||||
// The blend space is constant in this loop. Some TLS implementations synchronise even a
|
||||
// boolean read, so select the common Normal kernel once instead of once per pixel.
|
||||
if mode == BlendMode::Normal && gamma == 1.0 && !psblend::LAB_MIX.with(|l| l.get()) {
|
||||
for (i, b) in backdrop.px.iter_mut().enumerate() {
|
||||
let s = src.px[i];
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
*b = psblend::composite_normal_rgb(*b, s, opacity);
|
||||
}
|
||||
return;
|
||||
return psblend::composite_normal_rgb_span(dst, src, opacity);
|
||||
}
|
||||
let rect = backdrop.rect;
|
||||
let w = rect.width() as i32;
|
||||
for (i, b) in backdrop.px.iter_mut().enumerate() {
|
||||
let mut s = src.px[i];
|
||||
for (x, (b, s)) in (x0..).zip(dst.iter_mut().zip(src)) {
|
||||
let mut s = *s;
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut mode = mode;
|
||||
if mode == BlendMode::Dissolve {
|
||||
let x = rect.x0 + (i as i32 % w);
|
||||
let y = rect.y0 + (i as i32 / w);
|
||||
s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 };
|
||||
mode = BlendMode::Normal;
|
||||
*b = blend::composite(mode, *b, s, 1.0);
|
||||
*b = blend::composite(BlendMode::Normal, *b, s, 1.0);
|
||||
continue;
|
||||
}
|
||||
*b = blend::composite_gamma(mode, *b, s, opacity, gamma);
|
||||
|
||||
@@ -193,6 +193,7 @@ pub fn composite_fill(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opa
|
||||
|
||||
/// Straight-alpha source over straight-alpha backdrop (W3C/PDF general
|
||||
/// formula) using [`blend_rgb`].
|
||||
#[inline]
|
||||
pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] {
|
||||
let ab = backdrop[3];
|
||||
let as_ = source[3] * opacity;
|
||||
@@ -238,6 +239,64 @@ pub(crate) fn composite_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity
|
||||
normal_rgb(backdrop, source, as_, ao)
|
||||
}
|
||||
|
||||
/// [`composite_normal_rgb`] over a row, with the same bits, in blocks the compiler vectorizes (see
|
||||
/// [`normal_lanes`]); the tail that doesn't fill a block goes through the scalar kernel.
|
||||
pub(crate) fn composite_normal_rgb_span(dst: &mut [[f32; 4]], src: &[[f32; 4]], opacity: f32) {
|
||||
let n = dst.len().min(src.len());
|
||||
let (dc, dt) = dst[..n].as_chunks_mut::<LANES>();
|
||||
let (sc, st) = src[..n].as_chunks::<LANES>();
|
||||
for (b, s) in dc.iter_mut().zip(sc) {
|
||||
normal_lanes(b, s, opacity);
|
||||
}
|
||||
for (b, s) in dt.iter_mut().zip(st) {
|
||||
// Not `s[3] > 0`: a NaN alpha is blended (into NaN), as the scalar loop does.
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
*b = composite_normal_rgb(*b, *s, opacity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels per block of [`normal_lanes`]: two 128-bit vectors (or one 256-bit) per channel.
|
||||
const LANES: usize = 8;
|
||||
|
||||
/// [`composite_normal_rgb`] (after the caller's `source alpha > 0` test) on one block of pixels,
|
||||
/// without branches: one array per channel, fixed-length loops the compiler vectorizes, and a
|
||||
/// select per pixel between the backdrop, zero and the mix. Same operations in the same order
|
||||
/// (Rust never fuses them into multiply-adds) and the same tests: the same bits, except that a
|
||||
/// NaN's sign and payload may differ (IEEE 754 doesn't fix which operand's NaN is passed on).
|
||||
#[inline]
|
||||
#[allow(clippy::needless_range_loop)] // lanes index several arrays in lockstep
|
||||
fn normal_lanes(b: &mut [[f32; 4]; LANES], s: &[[f32; 4]; LANES], opacity: f32) {
|
||||
let lane = |p: &[[f32; 4]; LANES], c: usize| -> [f32; LANES] { std::array::from_fn(|i| p[i][c]) };
|
||||
let (b0, b1, b2, ab) = (lane(b, 0), lane(b, 1), lane(b, 2), lane(b, 3));
|
||||
let (s0, s1, s2, s3) = (lane(s, 0), lane(s, 1), lane(s, 2), lane(s, 3));
|
||||
let mut as_ = [0.0f32; LANES];
|
||||
let mut ao = [0.0f32; LANES];
|
||||
let mut kb = [0.0f32; LANES];
|
||||
let mut inv = [0.0f32; LANES];
|
||||
for i in 0..LANES {
|
||||
as_[i] = s3[i] * opacity;
|
||||
ao[i] = as_[i] + ab[i] * (1.0 - as_[i]);
|
||||
kb[i] = ab[i] * (1.0 - as_[i]);
|
||||
inv[i] = 1.0 / ao[i];
|
||||
}
|
||||
for i in 0..LANES {
|
||||
let mix = [(kb[i] * b0[i] + as_[i] * s0[i]) * inv[i], (kb[i] * b1[i] + as_[i] * s1[i]) * inv[i], (kb[i] * b2[i] + as_[i] * s2[i]) * inv[i], ao[i]];
|
||||
// The scalar path's tests: skipped where the source is empty (`s[3] <= 0`) or adds
|
||||
// nothing (`as_ <= 0`), zero where nothing is left (`ao <= 0`).
|
||||
let keep = (s3[i] <= 0.0) | (as_[i] <= 0.0);
|
||||
let gone = ao[i] <= 0.0;
|
||||
b[i] = if keep {
|
||||
b[i]
|
||||
} else if gone {
|
||||
[0.0; 4]
|
||||
} else {
|
||||
mix
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn normal_rgb(backdrop: [f32; 4], source: [f32; 4], as_: f32, ao: f32) -> [f32; 4] {
|
||||
// B(Cb, Cs) = Cs. Keep the scalar composite's operation order and straight alpha.
|
||||
@@ -293,6 +352,7 @@ pub fn text_decode(v: f32, gamma: f32) -> f32 {
|
||||
|
||||
/// [`composite`] with the coverage mix done in the text blending space of `gamma`
|
||||
/// ([`text_encode`]; `gamma == 1` is [`composite`], the setting switched off).
|
||||
#[inline]
|
||||
pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32, gamma: f32) -> [f32; 4] {
|
||||
if gamma == 1.0 {
|
||||
return composite(mode, backdrop, source, opacity);
|
||||
|
||||
@@ -14,6 +14,93 @@ fn doc_white(w: u32, h: u32) -> Document {
|
||||
Document::with_background("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_tones_match_fresh_tables_and_stay_bounded() {
|
||||
use photocraft_doc::adjust::ToneSpace;
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for space in [ToneSpace::Rgb, ToneSpace::Cmyk, ToneSpace::Lab] {
|
||||
for (mut adj, edited) in [
|
||||
(Adjustment::identity_levels(), false),
|
||||
(Adjustment::identity_levels(), true),
|
||||
(Adjustment::identity_curves(), false),
|
||||
(Adjustment::identity_curves(), true),
|
||||
] {
|
||||
match &mut adj {
|
||||
Adjustment::Levels { master, space: s, .. } => {
|
||||
if edited {
|
||||
master.gamma = 1.4;
|
||||
}
|
||||
*s = space;
|
||||
}
|
||||
Adjustment::Curves { master, space: s, .. } => {
|
||||
if edited {
|
||||
master[1].output = 0.8;
|
||||
}
|
||||
*s = space;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut doc = Document::new("tones", Size::new(3, 1), ColorMode::Rgb, depth);
|
||||
doc.layers.push(Layer::group("group", vec![Layer::new("tone", LayerContent::Adjustment(adj.clone()))]));
|
||||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(&doc, &patterns);
|
||||
let cached_adj = cx.tone_luts[0].0;
|
||||
let input = Buffer { rect: doc.bounds(), px: vec![[0.2, 0.4, 0.7, 0.6], [2.0, -0.2, 0.5, 0.5], [2.0, -0.2, 0.5, 0.0]] };
|
||||
let mut expected = input.clone();
|
||||
adjust::apply_depth(&adj, &mut expected, cx.transfer, Some(depth));
|
||||
for a in [cached_adj, cached_adj, &adj] {
|
||||
let mut actual = input.clone();
|
||||
cx.apply_adjustment(a, &mut actual);
|
||||
assert_eq!(actual.px, expected.px);
|
||||
}
|
||||
let direct = depth == SampleType::F32 && matches!(adj, Adjustment::Levels { space: ToneSpace::Rgb, .. });
|
||||
let identity = adjust::float_tone_identity(&adj, Some(depth)) == [true; 3];
|
||||
assert_eq!(cx.tone_luts[0].1.get().is_none(), direct || identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut layers: Vec<_> = (0..20).map(|_| Layer::new("tone", LayerContent::Adjustment(Adjustment::identity_levels()))).collect();
|
||||
layers[0].visible = false;
|
||||
let tables = prepare_tones(&layers, 0);
|
||||
assert_eq!(tables.len(), 16);
|
||||
assert!(tables.iter().all(|(_, cell)| cell.get().is_none()));
|
||||
assert!(matches!(&layers[1].content, LayerContent::Adjustment(a) if std::ptr::eq(tables[0].0, a)));
|
||||
assert!(prepare_tones(&[Layer::group("deep", layers)], PREPARE_DEPTH).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_adjustments_keep_document_channel_semantics() {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let doc = Document::new("channels", Size::new(1, 1), mode, depth);
|
||||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(&doc, &patterns);
|
||||
for adj in [Adjustment::Invert, Adjustment::BrightnessContrast { brightness: 20.0, contrast: 30.0, legacy: false }] {
|
||||
let mut actual = Buffer::filled(doc.bounds(), [0.2, 0.4, 0.7, 0.6]);
|
||||
let mut expected = actual.clone();
|
||||
adjust::apply_in(&adj, &mut expected, cx.transfer, Some(depth), mode);
|
||||
cx.apply_adjustment(&adj, &mut actual);
|
||||
assert_eq!(actual.px, expected.px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_float_curves_match_previous_mapping_bits() {
|
||||
let adj = Adjustment::identity_curves();
|
||||
let input = Buffer {
|
||||
rect: Rect::new(0, 0, 4, 1),
|
||||
px: vec![[2.0, -0.0, -0.2, 1.0], [f32::from_bits(0x7fc01234), f32::INFINITY, f32::NEG_INFINITY, 0.6], [0.7, -0.0, 1.5, 0.0], [0.1; 4]],
|
||||
};
|
||||
let mut previous = input.clone();
|
||||
adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3]);
|
||||
let mut actual = input.clone();
|
||||
adjust::apply_depth(&adj, &mut actual, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
|
||||
assert_buffer_bits(&actual, &previous);
|
||||
assert_buffer_bits(&actual, &input);
|
||||
}
|
||||
|
||||
fn solid_layer(name: &str, rect: Rect, rgba: [f32; 4]) -> Layer {
|
||||
let mut l = Layer::raster(name, PixelFormat::RGBA8);
|
||||
l.surface_mut().unwrap().fill_rect(rect, &rgba);
|
||||
@@ -80,6 +167,108 @@ fn normal_rgb_kernel_preserves_scalar_bits() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The vectorized row kernel against the scalar loop it replaced, over row lengths that cover
|
||||
/// whole blocks, tails and both, with samples full of NaN, signed zeros, infinities, subnormals
|
||||
/// and values outside 0..1: the same bits, except that a NaN only has to stay NaN.
|
||||
#[test]
|
||||
fn normal_rgb_span_preserves_scalar_bits() {
|
||||
let specials = [0.0, -0.0, 1.0, 0.5, 0.03, -0.5, 2.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::from_bits(1), f32::MIN_POSITIVE, 1e-30];
|
||||
let mut seed = 0x9e37_79b9u32;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed
|
||||
};
|
||||
let mut value = || {
|
||||
let r = next();
|
||||
if r % 3 == 0 { specials[(r / 3) as usize % specials.len()] } else { (r % 1000) as f32 / 999.0 }
|
||||
};
|
||||
for len in 0..41 {
|
||||
for _ in 0..40 {
|
||||
let backdrop: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect();
|
||||
let source: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect();
|
||||
let opacity = value();
|
||||
let mut want = backdrop.clone();
|
||||
for (b, s) in want.iter_mut().zip(&source) {
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
*b = original_normal_rgb(*b, *s, opacity);
|
||||
}
|
||||
let mut got = backdrop;
|
||||
psblend::composite_normal_rgb_span(&mut got, &source, opacity);
|
||||
// IEEE 754 leaves open which operand's NaN an operation passes on (x86 and the
|
||||
// vector units differ in its sign), so any NaN counts as the same NaN.
|
||||
let bits = |p: &[f32; 4]| p.map(|v| if v.is_nan() { u32::MAX } else { v.to_bits() });
|
||||
for (i, (g, w)) in got.iter().zip(&want).enumerate() {
|
||||
assert_eq!(bits(g), bits(w), "len {len}, pixel {i}, opacity {opacity}: {g:?} vs {w:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pixel layer read and blended a row at a time gives exactly what blending a tile-sized copy
|
||||
/// of it gave, across depths, modes (Dissolve's per-pixel noise and the fill-special modes
|
||||
/// included), fill, masks (feathered too) and a rect that is not tile-aligned and runs off the
|
||||
/// layer's pixels.
|
||||
#[test]
|
||||
fn pixel_layer_rows_match_tile_copy() {
|
||||
let rect = Rect::new(-3, 2, 37, 21);
|
||||
let modes = [
|
||||
BlendMode::Normal,
|
||||
BlendMode::Multiply,
|
||||
BlendMode::Dissolve,
|
||||
BlendMode::Overlay,
|
||||
BlendMode::ColorBurn,
|
||||
BlendMode::LinearBurn,
|
||||
BlendMode::VividLight,
|
||||
BlendMode::Difference,
|
||||
BlendMode::Hue,
|
||||
];
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
let mut l = Layer::raster("p", fmt);
|
||||
for y in 0..20 {
|
||||
for x in 0..30 {
|
||||
let t = (x * 7 + y * 13) as f32 / 400.0;
|
||||
let a = [0.0, 0.03, 0.5, 1.0][(x + y) as usize % 4];
|
||||
l.surface_mut().unwrap().write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, [t.fract(), 1.0 - t.fract(), 0.3, a]));
|
||||
}
|
||||
}
|
||||
let mut sharp = LayerMask::reveal_all();
|
||||
sharp.surface.fill_rect(Rect::new(5, 0, 12, 30), &[0.25]);
|
||||
let mut feathered = sharp.clone();
|
||||
feathered.feather = 2.5;
|
||||
for mask in [None, Some(sharp), Some(feathered)] {
|
||||
l.mask = mask;
|
||||
for mode in modes {
|
||||
for fill in [1.0, 0.4] {
|
||||
l.blend = mode;
|
||||
l.opacity = 0.8;
|
||||
l.fill_opacity = fill;
|
||||
let mut doc = doc_white(40, 30);
|
||||
doc.layers.push(l.clone());
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode);
|
||||
let cx = Ctx::for_doc(&doc, &patterns);
|
||||
let layer = &doc.layers[1];
|
||||
let backdrop = Buffer {
|
||||
rect,
|
||||
px: (0..rect.width() * rect.height())
|
||||
.map(|i| [(i % 11) as f32 / 10.0, (i % 5) as f32 / 4.0, 0.6, [1.0, 0.5, 0.0][i as usize % 3]])
|
||||
.collect(),
|
||||
};
|
||||
let mut want = backdrop.clone();
|
||||
let content = render_content(layer, rect, &cx).unwrap();
|
||||
blend_into_fill(&mut want, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer));
|
||||
let mut got = backdrop;
|
||||
composite_pixel_layer(layer, pixel_surface(layer).unwrap(), &mut got, &cx);
|
||||
assert_buffer_bits(&got, &want);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normal_rgb_test_buffers(fmt: PixelFormat) -> (Buffer, Buffer) {
|
||||
let rect = Rect::new(-3, -2, 5, 0);
|
||||
let mut bottom = Surface::new(fmt);
|
||||
@@ -3116,6 +3305,108 @@ fn effect_cache_follows_a_group_childs_blend_if() {
|
||||
assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed");
|
||||
}
|
||||
|
||||
/// A document whose one-colour areas are found: shared background tiles, a painted patch over
|
||||
/// tile edges, a uniform mask tile, an adjustment, a group, a 16-bit layer and a hidden one.
|
||||
fn sparse_doc(w: u32, h: u32) -> Document {
|
||||
let mut d = doc_white(w, h);
|
||||
let mut paint = Layer::raster("paint", PixelFormat::RGBA8);
|
||||
let s = paint.surface_mut().unwrap();
|
||||
for y in 200..330 {
|
||||
s.fill_rect(Rect::new(240, y, 520, y + 1), &[(y - 200) as f32 / 130.0, 0.4, 0.7, 0.8]);
|
||||
}
|
||||
s.fill_rect(Rect::new(0, 600, w as i32 / 2, 900), &[0.2, 0.9, 0.1, 0.5]);
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface.fill_rect(Rect::new(0, 512, 256, 768), &[0.25]);
|
||||
m.surface.fill_rect(Rect::new(300, 700, 310, 710), &[0.0]);
|
||||
paint.mask = Some(m);
|
||||
paint.blend = BlendMode::Multiply;
|
||||
let mut deep = solid_layer("deep", Rect::new(100, 100, 700, 400), [0.3, 0.3, 0.9, 0.6]);
|
||||
deep.content = LayerContent::Raster(deep.surface().unwrap().convert(PixelFormat::new(ColorMode::Rgb, SampleType::U16, true)));
|
||||
let mut hidden = solid_layer("hidden", Rect::new(0, 0, 50, 50), [1.0, 0.0, 0.0, 1.0]);
|
||||
hidden.visible = false;
|
||||
d.layers.push(Layer::group("g", vec![paint, deep, hidden]));
|
||||
d.layers.push(Layer::new("levels", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.0, gamma: 1.1 })));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_colour_areas_reduce_like_composited_ones() {
|
||||
// #4017: one-colour tiles are composited once and summed per run of columns: the same
|
||||
// averages as compositing every pixel (to float rounding), and a damage render gives exactly
|
||||
// the whole reduction's pixels, so a reduced canvas updated in parts shows no seams.
|
||||
let d = sparse_doc(2000, 2100);
|
||||
for (b, w, h, band) in [
|
||||
(d.bounds(), 37, 41, 0),
|
||||
(d.bounds(), 250, 2100, 256),
|
||||
(d.bounds(), 1999, 3, 512),
|
||||
(Rect::new(-300, -77, 2200, 2300), 61, 29, 0),
|
||||
(Rect::new(13, 5, 2000, 2100), 1, 1, 256),
|
||||
] {
|
||||
let reference = render_reduced_with(&d, b, w, h, None, band, false, &mut ReducedStats::default());
|
||||
let mut stats = ReducedStats::default();
|
||||
let all = render_reduced_with(&d, b, w, h, None, band, true, &mut stats);
|
||||
assert!(stats.uniform);
|
||||
assert!(stats.composited < u64::from(b.width()) * u64::from(b.height()) / 2, "{b:?}: {stats:?}");
|
||||
assert_eq!(all.rect, reference.rect);
|
||||
for (p, q) in all.px.iter().zip(&reference.px) {
|
||||
assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 1e-4), "{b:?} {w}x{h} band {band}: {p:?} vs {q:?}");
|
||||
}
|
||||
for dmg in [Rect::new(250, 210, 260, 220), Rect::new(0, 0, 2000, 2100), Rect::new(-50, 640, 3000, 641), Rect::new(1500, 1500, 1600, 1900)] {
|
||||
let part = render_reduced_with(&d, b, w, h, Some(dmg), band, true, &mut ReducedStats::default());
|
||||
for y in part.rect.y0..part.rect.y1 {
|
||||
for x in part.rect.x0..part.rect.x1 {
|
||||
assert_eq!(part.get(x, y), all.get(x, y), "{b:?} {w}x{h} band {band} {dmg:?} at {x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_that_read_other_pixels_are_composited_pixel_by_pixel() {
|
||||
let base = sparse_doc(300, 300);
|
||||
let edits: [fn(&mut Layer); 4] = [
|
||||
|l| l.blend = BlendMode::Dissolve,
|
||||
|l| l.mask.get_or_insert_with(LayerMask::reveal_all).feather = 3.0,
|
||||
|l| l.effects.items.push(stroke(2.0, StrokePosition::Outside)),
|
||||
|l| l.content = LayerContent::Fill(Fill::Solid(Color::BLACK)),
|
||||
];
|
||||
for edit in edits {
|
||||
let mut d = base.clone();
|
||||
edit(&mut d.layers[0]);
|
||||
let mut stats = ReducedStats::default();
|
||||
render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats);
|
||||
assert!(!stats.uniform);
|
||||
assert_eq!(stats.composited, 300 * 300);
|
||||
}
|
||||
let mut d = base.clone();
|
||||
d.layers.push(Layer::new("map", LayerContent::Adjustment(Adjustment::GradientMap { stops: vec![], reverse: false, dither: true })));
|
||||
let mut stats = ReducedStats::default();
|
||||
render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats);
|
||||
assert!(!stats.uniform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gigapixel_new_document_reduces_without_compositing_its_canvas() {
|
||||
// #4017: the reduced canvas of a 10 x 5 m, 200 ppi document (3.1 GP) composited every pixel
|
||||
// (25 s on 32 threads, all cores busy). Only the partial tiles at its right and bottom edges
|
||||
// hold two values; the rest is one shared white tile.
|
||||
let (w, h) = (78_740u32, 39_370u32);
|
||||
for fill in [Some(Color::WHITE), Some(Color::rgba(0.2, 0.4, 0.6, 1.0)), None] {
|
||||
let mut d = match fill {
|
||||
Some(c) => Document::with_background("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8, c),
|
||||
None => Document::new("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8),
|
||||
};
|
||||
d.layers.push(Layer::raster("Layer 1", PixelFormat::RGBA8));
|
||||
let mut stats = ReducedStats::default();
|
||||
let r = render_reduced_with(&d, d.bounds(), 3937, 1969, None, 0, true, &mut stats);
|
||||
let edge_tiles = u64::from(w.div_ceil(256) + h.div_ceil(256) - 1);
|
||||
assert!(stats.composited <= edge_tiles * 256 * 256, "{fill:?}: {stats:?}");
|
||||
let want = fill.map_or([0.0; 4], |c| [c.c[0], c.c[1], c.c[2], 1.0]);
|
||||
assert!(r.px.iter().all(|p| close4(*p, want)), "{fill:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_layer_bounds_includes_vector_bounds_clipped_to_canvas_and_preserves_effects() {
|
||||
let canvas = Rect::new(0, 0, 100, 100);
|
||||
@@ -3242,3 +3533,94 @@ fn concurrent_effect_identity_misses_compute_once() {
|
||||
});
|
||||
assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_render_matches_previous_allocating_stack() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut doc = Document::new("equivalence", Size::new(513, 7), ColorMode::Rgb, depth);
|
||||
let rect = doc.bounds();
|
||||
for (name, color) in [("bottom", [0.2, 0.4, 0.8, 1.0]), ("top", [1.5, -0.25, 0.6, 0.5])] {
|
||||
let mut layer = Layer::raster(name, doc.pixel_format());
|
||||
layer.surface_mut().unwrap().fill_rect(rect, &color);
|
||||
doc.layers.push(layer);
|
||||
}
|
||||
let mut levels = Adjustment::identity_levels();
|
||||
if let Adjustment::Levels { master, .. } = &mut levels {
|
||||
master.gamma = 1.4;
|
||||
}
|
||||
for adj in [levels, Adjustment::identity_curves()] {
|
||||
let mut layer = Layer::new("tone", LayerContent::Adjustment(adj));
|
||||
layer.opacity = 0.6;
|
||||
doc.layers.push(layer);
|
||||
}
|
||||
let mut previous = Buffer::transparent(rect);
|
||||
in_blend_space(false, depth == SampleType::F32, || {
|
||||
for layer in &doc.layers {
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = previous.clone();
|
||||
adjust::apply_in(adj, &mut adjusted, adjust::Transfer::for_document(doc.mode, depth), Some(depth), doc.mode);
|
||||
for (b, a) in previous.px.iter_mut().zip(&adjusted.px) {
|
||||
let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]);
|
||||
*b = [
|
||||
b[0] + (blended[0] - b[0]) * layer.opacity,
|
||||
b[1] + (blended[1] - b[1]) * layer.opacity,
|
||||
b[2] + (blended[2] - b[2]) * layer.opacity,
|
||||
b[3],
|
||||
];
|
||||
}
|
||||
if let Some(q) = adjustment_quantum(depth) {
|
||||
quantize(&mut previous, q);
|
||||
}
|
||||
} else {
|
||||
let content = surface_to_buffer(layer.surface().unwrap(), rect);
|
||||
blend_into_fill(&mut previous, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0);
|
||||
}
|
||||
}
|
||||
});
|
||||
for tile in [1, 127, 256, 1024] {
|
||||
assert_buffer_bits(&render_tiled(&doc, rect, tile), &previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_masked_adjustment_tiles_preserve_output_and_skip_tone_work() {
|
||||
let rect = Rect::new(-1, -1, 2, 1);
|
||||
for depth in SampleType::ALL {
|
||||
for partial in [false, true] {
|
||||
let mut doc = Document::new("masked", Size::new(3, 2), ColorMode::Rgb, depth);
|
||||
let mut adj = Adjustment::identity_curves();
|
||||
if let Adjustment::Curves { master, .. } = &mut adj {
|
||||
master[1].output = 0.8;
|
||||
}
|
||||
let mut layer = Layer::new("curves", LayerContent::Adjustment(adj.clone()));
|
||||
layer.opacity = 0.6;
|
||||
let mut mask = LayerMask::hide_all();
|
||||
if partial {
|
||||
mask.surface.write_pixel(0, 0, &[0.75]);
|
||||
}
|
||||
layer.mask = Some(mask);
|
||||
doc.layers.push(layer);
|
||||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(&doc, &patterns);
|
||||
let before = Buffer::filled(rect, [0.2, -0.0, 0.7, 0.6]);
|
||||
let mut adjusted = before.clone();
|
||||
adjust::apply_in(&adj, &mut adjusted, cx.transfer, Some(depth), doc.mode);
|
||||
let mv = mask_vals(&doc.layers[0], rect, &cx);
|
||||
let mut expected = before.clone();
|
||||
for (i, (b, a)) in expected.px.iter_mut().zip(&adjusted.px).enumerate() {
|
||||
let k = 0.6 * mask_k(&mv, i);
|
||||
if k > 0.0 {
|
||||
for c in 0..3 {
|
||||
b[c] += (a[c] - b[c]) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
let clipped = if partial { vec![] } else { vec![solid_layer("clipped", rect, [0.9, 0.1, 0.2, 1.0])] };
|
||||
let mut actual = before;
|
||||
composite_layer_plain(&doc.layers[0], &clipped, &mut actual, &cx, advanced::Scope::ROOT);
|
||||
assert_buffer_bits(&actual, &expected);
|
||||
assert_eq!(cx.tone_luts[0].1.get().is_some(), partial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! One-colour areas of a composite, known without compositing them pixel by pixel.
|
||||
//!
|
||||
//! A new document is a canvas of one shared white tile (or no tiles at all), and a sparse one is
|
||||
//! mostly that. When every layer composites pixel by pixel from its own pixels (no effects,
|
||||
//! feathered masks, dissolve, dithering or layer kinds laid out on the canvas), an area over which
|
||||
//! every surface holds one value composites to one colour: one pixel of it is composited and
|
||||
//! stands for the rest. The reduced canvas of a gigapixel document (#4017) then costs what its
|
||||
//! painted tiles cost, not a composite of every document pixel.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_doc::{Adjustment, Document, LayerContent};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// The surfaces a document composites from, when its composite at each pixel depends only on
|
||||
/// their values at that pixel.
|
||||
pub(crate) struct UniformAreas<'a> {
|
||||
surfaces: Vec<&'a Surface>,
|
||||
/// One value per tile (by address), or `None` when its pixels differ: each tile is scanned once.
|
||||
tile_values: Mutex<HashMap<usize, Option<Box<[u8]>>>>,
|
||||
/// The composite colour of each combination of surface values met so far.
|
||||
colors: Mutex<HashMap<Vec<u8>, [f32; 4]>>,
|
||||
}
|
||||
|
||||
impl<'a> UniformAreas<'a> {
|
||||
/// `None` when some layer's composite depends on more than its pixels at the same place.
|
||||
pub(crate) fn new(doc: &'a Document) -> Option<Self> {
|
||||
if doc.mode == photocraft_color::ColorMode::Multichannel {
|
||||
return None;
|
||||
}
|
||||
let mut surfaces = Vec::new();
|
||||
for (_, _, l) in doc.walk() {
|
||||
let effects = l.effects.enabled && !l.effects.items.is_empty();
|
||||
if effects || l.vector_mask.is_some() || l.video.is_some() || l.blend == BlendMode::Dissolve {
|
||||
return None;
|
||||
}
|
||||
if let Some(m) = &l.mask {
|
||||
if m.feather != 0.0 {
|
||||
return None;
|
||||
}
|
||||
surfaces.push(&m.surface);
|
||||
}
|
||||
match &l.content {
|
||||
LayerContent::Raster(s) => surfaces.push(s),
|
||||
LayerContent::Group(g) if g.artboard.is_none() => {}
|
||||
LayerContent::Adjustment(a) if per_pixel(a) => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(Self { surfaces, tile_values: Mutex::default(), colors: Mutex::default() })
|
||||
}
|
||||
|
||||
/// The colour every pixel of `r` composites to, when every surface holds one value over it;
|
||||
/// `composite` renders one pixel the first time a combination of values is met.
|
||||
pub(crate) fn color(&self, r: Rect, composite: impl FnOnce(Rect) -> Option<[f32; 4]>) -> Option<[f32; 4]> {
|
||||
if r.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut key = Vec::new();
|
||||
for s in &self.surfaces {
|
||||
key.extend_from_slice(&self.value_over(s, r)?);
|
||||
}
|
||||
if let Some(c) = self.colors.lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
|
||||
return Some(*c);
|
||||
}
|
||||
let c = composite(Rect::from_xywh(r.x0, r.y0, 1, 1))?;
|
||||
self.colors.lock().unwrap_or_else(PoisonError::into_inner).insert(key, c);
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// The one encoded value `s` holds over `r`, if it holds one.
|
||||
fn value_over(&self, s: &Surface, r: Rect) -> Option<Box<[u8]>> {
|
||||
let mut found: Option<Box<[u8]>> = None;
|
||||
for c in r.tiles() {
|
||||
let v = match s.tile(c) {
|
||||
None => Some(Box::from(s.default_bytes())),
|
||||
Some(t) => {
|
||||
let id = std::sync::Arc::as_ptr(t) as usize;
|
||||
let cached = self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).get(&id).cloned();
|
||||
match cached {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let bpp = s.default_bytes().len().max(1);
|
||||
let mut px = t.bytes().chunks_exact(bpp);
|
||||
let v = px.next().filter(|p0| px.all(|p| p == *p0)).map(Box::from);
|
||||
self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).insert(id, v.clone());
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
}?;
|
||||
if found.as_ref().is_some_and(|f| *f != v) {
|
||||
return None;
|
||||
}
|
||||
found = Some(v);
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether adjustment `a` maps each pixel on its own (dithered ones vary with the position).
|
||||
fn per_pixel(a: &Adjustment) -> bool {
|
||||
!matches!(a, Adjustment::GradientMap { dither: true, .. } | Adjustment::ColorLookup { dither: true, .. })
|
||||
}
|
||||
@@ -32,6 +32,16 @@ pub enum Caps {
|
||||
AllCaps,
|
||||
}
|
||||
|
||||
/// Superscript / subscript (Photoshop's `FontBaseline`). The run is drawn smaller and raised or
|
||||
/// lowered; `size_pt` and `baseline_shift_pt` keep their own values.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FontBaseline {
|
||||
#[default]
|
||||
Normal,
|
||||
Superscript,
|
||||
Subscript,
|
||||
}
|
||||
|
||||
/// An OpenType feature setting, e.g. `{ tag: "ss01", value: 1 }`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FontFeature {
|
||||
@@ -79,6 +89,8 @@ pub struct CharStyle {
|
||||
/// per character run as `Kerning`). Added to the automatic kerning of the pair.
|
||||
pub kern: f32,
|
||||
pub caps: Caps,
|
||||
/// Superscript or subscript.
|
||||
pub baseline: FontBaseline,
|
||||
/// Standard ligatures (`liga`/`clig`).
|
||||
pub ligatures: bool,
|
||||
/// Discretionary ligatures (`dlig`).
|
||||
@@ -117,6 +129,7 @@ impl Default for CharStyle {
|
||||
kerning: Kerning::Metrics,
|
||||
kern: 0.0,
|
||||
caps: Caps::Normal,
|
||||
baseline: FontBaseline::Normal,
|
||||
ligatures: true,
|
||||
discretionary_ligatures: false,
|
||||
features: Vec::new(),
|
||||
|
||||
@@ -47,6 +47,9 @@ tempfile = "3"
|
||||
# file is that file (std's `MetadataExt::file_index` is unstable).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
same-file = "1"
|
||||
# `file.print` loads Windows.Data.Pdf.dll before calling into it (#5884): the safe binding
|
||||
# ui-egui and craft-print already use, pinned to the same version.
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] }
|
||||
|
||||
# Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print.
|
||||
[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies]
|
||||
|
||||
@@ -644,13 +644,13 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mix: pct(p, "mix", bm.mix * 100.0),
|
||||
flow: pct(p, "flow", bm.flow * 100.0),
|
||||
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
|
||||
load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke),
|
||||
clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke),
|
||||
};
|
||||
let sample_all = m.sample_all_layers;
|
||||
let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true));
|
||||
let paint = color(p.get("color"), s.tools.foreground);
|
||||
let mut state = s.tools.mixer.clone();
|
||||
if load_after || state.reservoir.is_none() {
|
||||
state.load(color(p.get("color"), s.tools.foreground));
|
||||
}
|
||||
state.begin_stroke(paint);
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
|
||||
let stroke = Stroke { brush, points: pts };
|
||||
let dmg = s.edit("Mixer Brush", |doc, _| {
|
||||
@@ -672,13 +672,43 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock))
|
||||
})?;
|
||||
if clean {
|
||||
state.clean();
|
||||
}
|
||||
state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke);
|
||||
s.tools.mixer = state;
|
||||
Ok(damage_json(s, dmg))
|
||||
}
|
||||
|
||||
/// `brush.mixer.load`: Load Brush fills the Mixer Brush's reservoir with the color. The next stroke
|
||||
/// keeps that paint unless a different foreground is chosen first. Returns the paint the brush now
|
||||
/// carries.
|
||||
fn mixer_load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.mixer.load";
|
||||
let paint = match p.get("color").filter(|v| !v.is_null()) {
|
||||
None => Some(s.tools.foreground),
|
||||
Some(Value::String(h)) => crate::commands::parse_hex(h),
|
||||
Some(Value::Array(a)) => {
|
||||
let c: Option<Vec<f32>> = a.iter().map(|x| x.as_f64().filter(|x| (0.0..=1.0).contains(x)).map(|x| x as f32)).collect();
|
||||
match c.as_deref() {
|
||||
Some(&[r, g, b]) => Some([r, g, b, 1.0]),
|
||||
Some(&[r, g, b, a]) => Some([r, g, b, a]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Some(_) => None,
|
||||
};
|
||||
let paint = paint.ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b, a?] in 0..1"))?;
|
||||
s.tools.mixer.load(paint);
|
||||
s.tools.mixer.color = Some(s.tools.foreground);
|
||||
Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `brush.mixer.clean`: Clean Brush empties the Mixer Brush. The next stroke starts empty unless
|
||||
/// a different color is chosen first. Returns the paint the brush now carries.
|
||||
fn mixer_clean(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.tools.mixer.clean();
|
||||
s.tools.mixer.color = Some(s.tools.foreground);
|
||||
Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour
|
||||
/// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha
|
||||
/// channel or Quick Mask `target` is refused rather than recolouring the layer instead.
|
||||
@@ -1135,11 +1165,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"paint.mixerBrush",
|
||||
"Mixer Brush",
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##,
|
||||
has_paintable,
|
||||
mixer_brush,
|
||||
true
|
||||
),
|
||||
spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"|[r,g,b,a?] 0..1?=foreground}"##, always, mixer_load, true),
|
||||
spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_clean, true),
|
||||
spec!(
|
||||
"paint.colorReplacement",
|
||||
"Color Replacement",
|
||||
|
||||
@@ -814,6 +814,101 @@ fn mixer_brush_uses_the_brush_mixer_settings() {
|
||||
assert!(rgba(&s, 190, 5)[3] > 0.95);
|
||||
}
|
||||
|
||||
/// A red image with an empty layer above it, a blue foreground, and a Mixer Brush that samples all
|
||||
/// layers, keeps its paint along the stroke (Load 100 %) and has the automatic Load off.
|
||||
fn mixer_over_red() -> Session {
|
||||
let mut s = session(200, 30);
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap();
|
||||
let mixer = json!({"load": 1, "sampleAllLayers": true, "loadAfterStroke": false});
|
||||
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": mixer}})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn mixer_row(s: &mut Session, y: i32) -> [f32; 4] {
|
||||
s.execute("paint.mixerBrush", json!({"points": [[5, y], [195, y]], "size": 6})).unwrap();
|
||||
rgba(s, 100, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_brush_keeps_the_foreground_out_of_the_next_stroke() {
|
||||
let mut s = mixer_over_red();
|
||||
s.execute("brush.mixer.load", json!({})).unwrap();
|
||||
let state = s.execute("brush.mixer.clean", json!({})).unwrap();
|
||||
assert_eq!((state["reservoir"].clone(), state["pickup"].clone()), (Value::Null, Value::Null));
|
||||
let c = mixer_row(&mut s, 15);
|
||||
assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the clean brush lays down only the red it picks up: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_automatic_clean_empties_the_reservoir() {
|
||||
let mut s = mixer_over_red();
|
||||
// The brush was never loaded, so the first stroke takes the foreground.
|
||||
assert!(mixer_row(&mut s, 5)[2] > 0.2);
|
||||
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.pickup), (None, None));
|
||||
let c = mixer_row(&mut s, 15);
|
||||
assert!(c[3] > 0.1 && c[2] < 0.01, "the cleaned brush lays down no blue: {c:?}");
|
||||
// With the automatic Load on as well (the default), the brush is cleaned and then refilled
|
||||
// after the stroke, so the next one starts with a full reservoir and no pickup.
|
||||
s.execute("tools.setBrush", json!({"brush": {"mixer": {"loadAfterStroke": true}}})).unwrap();
|
||||
mixer_row(&mut s, 25);
|
||||
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount, s.tools.mixer.pickup), (Some([0.0, 0.0, 1.0, 1.0]), 1.0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_brush_after_a_clean_paints_the_foreground_again() {
|
||||
let mut s = mixer_over_red();
|
||||
s.execute("brush.mixer.clean", json!({})).unwrap();
|
||||
assert!(mixer_row(&mut s, 5)[2] < 0.01);
|
||||
let state = s.execute("brush.mixer.load", json!({})).unwrap();
|
||||
assert_eq!(state["reservoir"], json!([0.0, 0.0, 1.0, 1.0]));
|
||||
assert!(mixer_row(&mut s, 15)[2] > 0.2, "the loaded brush paints blue");
|
||||
// A color param loads that color instead of the foreground, and the next stroke paints it.
|
||||
s.execute("brush.mixer.load", json!({"color": [0.0, 1.0, 0.0]})).unwrap();
|
||||
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount), (Some([0.0, 1.0, 0.0, 1.0]), 1.0));
|
||||
assert_eq!(s.tools.mixer.next_load(s.tools.foreground), Some([0.0, 1.0, 0.0, 1.0]));
|
||||
let c = mixer_row(&mut s, 25);
|
||||
assert!(c[1] > 0.2 && c[2] < 0.01, "the stroke paints the loaded green, not the blue foreground: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_the_automatic_load_off_a_new_foreground_loads_at_the_next_stroke() {
|
||||
let mut s = mixer_over_red();
|
||||
assert!(mixer_row(&mut s, 5)[2] > 0.2);
|
||||
s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap();
|
||||
let c = mixer_row(&mut s, 15);
|
||||
assert!(c[1] > 0.2, "the new foreground is loaded: {c:?}");
|
||||
// The automatic Clean emptied it again, and the foreground did not change.
|
||||
let c = mixer_row(&mut s, 25);
|
||||
assert!(c[3] > 0.1 && c[1] < 0.01 && c[2] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_and_clean_brush_refuse_bad_params_and_keep_the_paint() {
|
||||
let mut s = mixer_over_red();
|
||||
s.execute("brush.mixer.load", json!({"color": "#336699"})).unwrap();
|
||||
let before = s.tools.mixer.clone();
|
||||
for bad in [
|
||||
json!({"color": 5}),
|
||||
json!({"color": "#zz0000"}),
|
||||
json!({"color": "#ff"}),
|
||||
json!({"color": [2, 0, 0]}),
|
||||
json!({"color": [0, 0]}),
|
||||
json!({"color": [0, 0, 0, 0, 0]}),
|
||||
json!({"color": {"r": 1}}),
|
||||
json!({"color": ["a", 0, 0]}),
|
||||
] {
|
||||
assert!(s.execute("brush.mixer.load", bad.clone()).is_err(), "{bad}");
|
||||
assert_eq!(s.tools.mixer, before, "{bad}");
|
||||
}
|
||||
// Clean Brush takes no params, so anything extra is ignored, without a document too.
|
||||
let mut empty = Session::new();
|
||||
empty.execute("brush.mixer.clean", json!({"color": [], "junk": {"x": null}})).unwrap();
|
||||
empty.execute("brush.mixer.load", json!({"color": null})).unwrap();
|
||||
assert_eq!(empty.tools.mixer.reservoir, Some(empty.tools.foreground));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_set_brush_calls_journal_once_per_gesture() {
|
||||
let mut s = session(20, 20);
|
||||
|
||||
@@ -459,6 +459,21 @@ impl ColorState {
|
||||
self.canvas_lut_with(doc, size, false, display)
|
||||
}
|
||||
|
||||
/// Accurate GPU representation of RGB matrix/TRC display transforms. Gamut warnings,
|
||||
/// proof simulations beyond profile conversion, and transforms containing a CLUT retain
|
||||
/// the existing 3D LUT path. The shader applies the 32-bit preview before this transform,
|
||||
/// just as it does before the display LUT.
|
||||
pub fn gpu_canvas_matrix_shaper_for(&self, doc: &Document, display: Option<u32>) -> Result<Option<photocraft_cms::GpuMatrixShaper>> {
|
||||
let proof = self.proof(doc.id);
|
||||
if proof.gamut_warning || (proof.enabled && proof.setup.kind != crate::proof_sim::ProofKind::Profile) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !proof.enabled && self.canvas_display_for(doc, display)?.transform.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(self.display_transform_for(doc, display)?.gpu_matrix_shaper())
|
||||
}
|
||||
|
||||
fn canvas_lut_with(&self, doc: &Document, size: usize, hdr: bool, on: Option<u32>) -> Result<Option<Vec<u8>>> {
|
||||
let size = size.max(2);
|
||||
let pv = self.proof(doc.id);
|
||||
@@ -1338,6 +1353,44 @@ mod tests {
|
||||
assert_eq!(sh.color.mode, ColorMode::Cmyk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_matrix_shaper_keeps_special_proof_simulations_on_the_lut_path() {
|
||||
let mut s = session("rgb", 8);
|
||||
let d = doc(&s).clone();
|
||||
// These valid synthetic RGB profiles make the ordinary profile-proof pipeline
|
||||
// separable. Thus the test cannot pass just because a typical proof happens to
|
||||
// contain unsupported stages; the simulation kind must be excluded explicitly.
|
||||
let mut proof = Builtin::LinearSrgb.profile().clone();
|
||||
proof.description = "Synthetic identity-matrix proof".into();
|
||||
proof.matrix = Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
proof.trc = Some([photocraft_cms::Curve::Identity, photocraft_cms::Curve::Identity, photocraft_cms::Curve::Identity]);
|
||||
let proof = proof.with_encoded_bytes();
|
||||
let mut monitor = proof.clone();
|
||||
monitor.description = "Synthetic identity-matrix gamma monitor".into();
|
||||
monitor.trc = Some(std::array::from_fn(|_| photocraft_cms::Curve::Gamma(2.2)));
|
||||
let monitor = monitor.with_encoded_bytes();
|
||||
s.color.set_displays(Ok(vec![crate::display_color::Display {
|
||||
id: 1,
|
||||
name: "Synthetic monitor".into(),
|
||||
frame: [0.0, 0.0, 800.0, 600.0],
|
||||
profile_name: None,
|
||||
icc: Some(monitor.to_bytes()),
|
||||
}]));
|
||||
let pv = s.color.proof_mut(d.id);
|
||||
pv.setup.profile = Arc::new(Profile::parse(&proof.to_bytes()).unwrap());
|
||||
pv.enabled = true;
|
||||
assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_some(), "ordinary profile conversion is separable");
|
||||
for command in ["view.proofSetup.legacyMacintoshRgb", "view.proofSetup.colorBlindnessProtanopia", "view.proofSetup.colorBlindnessDeuteranopia"] {
|
||||
// These commands retain the previously selected custom profile.
|
||||
s.execute(command, json!({})).unwrap();
|
||||
assert!(s.color.display_transform_for(&d, Some(1)).unwrap().gpu_matrix_shaper().is_some(), "fixture must exercise the shortcut");
|
||||
assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_none(), "{command} must retain its simulation");
|
||||
assert!(s.color.gpu_canvas_lut_for(&d, 9, Some(1)).unwrap().is_some(), "{command} still has its LUT fallback");
|
||||
}
|
||||
s.execute("view.proofColors", json!({"on": false})).unwrap();
|
||||
assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_some(), "disabled simulations do not block ordinary display conversion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proofing_state_and_gamut_warning() {
|
||||
let mut s = session("rgb", 8);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Colour-managed canvas display: the document's composite shown through document profile →
|
||||
//! monitor profile, on the GPU canvas (folded into the display 3D LUT) and the CPU canvas
|
||||
//! monitor profile, on the GPU canvas (matrix/shaper or display 3D LUT) and the CPU canvas
|
||||
//! (an 8-bit transform of the composite).
|
||||
//!
|
||||
//! * The composite is in [`composite_profile`] (the document profile for RGB, its gray curve as
|
||||
|
||||
@@ -1138,13 +1138,37 @@ pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save:
|
||||
/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide.
|
||||
pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
|
||||
let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| {
|
||||
let ext =
|
||||
if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
(path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path))))
|
||||
});
|
||||
process_outputs(outputs, save, f)
|
||||
}
|
||||
|
||||
/// The extension a batch output takes with Format "Same": the input's own, except for formats
|
||||
/// PhotoCraft opens but can't write, camera raws above all (#5799: a folder of ARWs failed on
|
||||
/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. An input
|
||||
/// whose file name has no extension (a dot in a folder name doesn't count) is saved as PNG.
|
||||
fn same_format(path: &str) -> String {
|
||||
const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm"];
|
||||
let ext = extension(path).unwrap_or_else(|| "png".into());
|
||||
if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) { "psd".into() } else { ext }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn same_format_takes_the_file_names_extension() {
|
||||
assert_eq!(same_format("/in/a.PNG"), "png");
|
||||
assert_eq!(same_format("/in/IMG_0001.ARW"), "psd");
|
||||
assert_eq!(same_format("/in/a.kra"), "psd");
|
||||
assert_eq!(same_format("/in/IMG_0001.HEIC"), "psd");
|
||||
assert_eq!(same_format("/in/logo.svg"), "psd");
|
||||
assert_eq!(same_format("/in/scan.cin"), "psd");
|
||||
// No extension in the file name: PNG, not the path after a dot in a folder name.
|
||||
assert_eq!(same_format("/in/v1.2/scan"), "png");
|
||||
assert_eq!(same_format("C:\\in.d\\scan"), "png");
|
||||
assert_eq!(same_format("/in/.hidden"), "png");
|
||||
}
|
||||
|
||||
fn process_outputs<'a>(outputs: impl IntoIterator<Item = (&'a str, String)>, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
|
||||
let mut files = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
@@ -87,8 +87,7 @@ pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &V
|
||||
if base.is_empty() || base.ends_with(['.', ' ']) {
|
||||
return Err(bad("naming must produce a nonempty filename stem without a trailing dot or space"));
|
||||
}
|
||||
let ext =
|
||||
if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
let ext = if format == "same" { super::same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
let name = format!("{base}.{ext}");
|
||||
if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) {
|
||||
return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters"));
|
||||
|
||||
@@ -65,6 +65,33 @@ fn camera_raw_opens_as_16_bit_with_notes() {
|
||||
assert!(open_bytes_as(&mut s, "bad.dng", &bad, None, None).is_err());
|
||||
}
|
||||
|
||||
/// #5799: Batch with Format "Same" over a folder of camera raws saved nothing (every output was
|
||||
/// a raw PhotoCraft can't write). Raws are saved as PSD; other inputs keep their format.
|
||||
#[test]
|
||||
fn batch_same_format_saves_camera_raws_as_psd() {
|
||||
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
|
||||
let dir = tmp("batch-same-raw");
|
||||
let (w, h) = (24, 16);
|
||||
std::fs::write(join(&dir, "IMG_0001.dng"), DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap();
|
||||
png(&dir, "b.png", 8, 8, "#ff0000");
|
||||
// Also with a file naming template, which picks the output extension separately.
|
||||
for (out, naming) in [("out", None), ("named", Some("{name}_{serial}"))] {
|
||||
let out = join(&dir, out);
|
||||
let mut p = json!({"steps": [["image.adjustments.invert", {}]], "input": dir, "output": out, "format": "same"});
|
||||
if let Some(naming) = naming {
|
||||
p["naming"] = json!(naming);
|
||||
}
|
||||
let r = Session::new().execute("file.automate.batch", p).unwrap();
|
||||
assert!(r["errors"].as_array().unwrap().is_empty(), "{naming:?}: {r}");
|
||||
let mut files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
|
||||
files.sort();
|
||||
assert_eq!(files.len(), 2, "{r}");
|
||||
assert!(files[0].contains("/IMG_0001") && files[0].ends_with(".psd") && files[1].contains("/b") && files[1].ends_with(".png"), "{files:?}");
|
||||
let psd = photocraft_io::import("IMG_0001.psd", &std::fs::read(&files[0]).unwrap()).unwrap().document;
|
||||
assert_eq!((psd.size.width, psd.size.height), (w as u32, h as u32));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_reloads_as_one_undoable_step() {
|
||||
let dir = tmp("revert");
|
||||
|
||||
@@ -530,6 +530,11 @@ pub struct Performance {
|
||||
pub rendering_mode: Option<RenderingMode>,
|
||||
/// Graphics backend (applies at next launch; see [`GpuBackend`]).
|
||||
pub gpu_backend: GpuBackend,
|
||||
/// Which graphics adapter the window and canvas use on a machine with several (applies at
|
||||
/// next launch): part of the adapter's name, matched case-insensitively ("nvidia", "arc"). Empty
|
||||
/// is automatic. `PHOTOCRAFT_GPU_ADAPTER` overrides it for one launch. If nothing matches or the
|
||||
/// match can't present to the window, startup falls back to automatic and says so.
|
||||
pub gpu_adapter: String,
|
||||
/// Live previews of large documents (adjustment and filter dialogs, an adjustment layer's
|
||||
/// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off:
|
||||
/// they render at full resolution.
|
||||
@@ -575,6 +580,7 @@ impl Default for Performance {
|
||||
use_gpu: true,
|
||||
rendering_mode: None,
|
||||
gpu_backend: GpuBackend::Auto,
|
||||
gpu_adapter: String::new(),
|
||||
low_resolution_previews: true,
|
||||
low_latency_drawing: false,
|
||||
linux_display_server: LinuxDisplayServer::Auto,
|
||||
@@ -1064,7 +1070,16 @@ pub fn is_hidden(path: &str) -> bool {
|
||||
|
||||
/// Preferences that only do something on Linux; the dialog doesn't show them elsewhere. They
|
||||
/// still load, save and round-trip on every platform.
|
||||
pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer"];
|
||||
pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer", "performance.gpuAdapter"];
|
||||
|
||||
/// Whether a graphics adapter called `name` is the one `performance.gpuAdapter` (`wanted`) asks
|
||||
/// for: every whitespace-separated word of `wanted` appears in the name, ignoring case ("nvidia",
|
||||
/// "arc a370m"). An empty `wanted` matches nothing. Startup and Preferences share this.
|
||||
pub fn gpu_adapter_matches(name: &str, wanted: &str) -> bool {
|
||||
let name = name.to_lowercase();
|
||||
let mut words = wanted.split_whitespace().peekable();
|
||||
words.peek().is_some() && words.all(|w| name.contains(&w.to_lowercase()))
|
||||
}
|
||||
|
||||
/// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`).
|
||||
pub fn choices(path: &str) -> Option<&'static [&'static str]> {
|
||||
|
||||
@@ -493,6 +493,32 @@ fn right_click_with_painting_tools_pref() {
|
||||
assert_eq!(old.right_click_with_painting_tools, RightClickPaint::BrushPicker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_adapter_round_trips_and_defaults_to_automatic() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.prefs().performance.gpu_adapter, "");
|
||||
s.execute("prefs.set", json!({"path": "performance.gpuAdapter", "value": "NVIDIA"})).unwrap();
|
||||
assert_eq!(s.prefs().performance.gpu_adapter, "NVIDIA");
|
||||
assert!(s.execute("prefs.set", json!({"path": "performance.gpuAdapter", "value": 3})).is_err());
|
||||
assert_eq!(s.prefs().performance.gpu_adapter, "NVIDIA");
|
||||
let mut t = Session::new();
|
||||
t.load_prefs_json(&s.prefs_to_json()).unwrap();
|
||||
assert_eq!(t.prefs().performance.gpu_adapter, "NVIDIA");
|
||||
let mut u = Session::new();
|
||||
u.load_prefs_json(r#"{"performance": {"useGpu": true}}"#).unwrap();
|
||||
assert_eq!(u.prefs().performance.gpu_adapter, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_adapter_names_match_by_word_ignoring_case() {
|
||||
let arc = "Intel(R) Arc(tm) A370M Graphics (DG2)";
|
||||
assert!(gpu_adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia"));
|
||||
assert!(gpu_adapter_matches(arc, " ARC A370M "));
|
||||
assert!(!gpu_adapter_matches(arc, "arc a380"));
|
||||
assert!(!gpu_adapter_matches(arc, ""));
|
||||
assert!(!gpu_adapter_matches(arc, " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_backend_round_trips_and_validates() {
|
||||
let mut s = session();
|
||||
@@ -553,7 +579,9 @@ fn notification_autohide_preferences() {
|
||||
fn linux_only_preferences_show_only_on_linux() {
|
||||
assert_eq!(is_hidden("performance.linuxDisplayServer"), !cfg!(target_os = "linux"));
|
||||
assert!(!is_hidden("performance.gpuBackend"));
|
||||
assert!(LINUX_ONLY.iter().all(|p| choices(p).is_some()), "every Linux-only preference is a real one");
|
||||
assert!(LINUX_ONLY.contains(&"performance.gpuAdapter"));
|
||||
let free_text = ["performance.gpuAdapter"];
|
||||
assert!(LINUX_ONLY.iter().all(|p| free_text.contains(p) || choices(p).is_some()), "every Linux-only preference is a real one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,12 +27,21 @@ fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
/// Largest accepted file (Photoshop's biggest bundled sets are well under this).
|
||||
pub const MAX_FILE_BYTES: u64 = 512 << 20;
|
||||
/// Largest accepted `.abr` file: brush libraries of large sampled tips outgrow [`MAX_FILE_BYTES`].
|
||||
/// Tied to the decoder's sample budget (1 GiB, 512 MiB on 32-bit targets) so it never refuses
|
||||
/// what this accepts.
|
||||
pub const MAX_ABR_FILE_BYTES: u64 = photocraft_psd::abr::MAX_TOTAL_BYTES as u64;
|
||||
|
||||
/// File bytes from `data` (base64) or `path`, plus a default group name.
|
||||
pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec<u8>, String)> {
|
||||
file_bytes_within(p, cmd, what, MAX_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// [`file_bytes`] with a size cap of `max` bytes.
|
||||
fn file_bytes_within(p: &Value, cmd: &str, what: &str, max: u64) -> Result<(Vec<u8>, String)> {
|
||||
if let Some(d) = p.get("data") {
|
||||
let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?;
|
||||
if s.len() as u64 > MAX_FILE_BYTES / 3 * 4 + 4 {
|
||||
if s.len() as u64 > max / 3 * 4 + 4 {
|
||||
return Err(bad(cmd, "`data` is too large"));
|
||||
}
|
||||
let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?;
|
||||
@@ -41,7 +50,7 @@ pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec<u8>, S
|
||||
let path =
|
||||
p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?;
|
||||
let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len();
|
||||
if len > MAX_FILE_BYTES {
|
||||
if len > max {
|
||||
return Err(bad(cmd, format!("{path}: file too large ({len} bytes)")));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
@@ -91,7 +100,7 @@ fn import_abr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move |ctx| {
|
||||
ctx.progress(0.0, "Importing brushes");
|
||||
let p = ¶ms;
|
||||
let (bytes, stem) = file_bytes(p, cmd, ".abr file")?;
|
||||
let (bytes, stem) = file_bytes_within(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?;
|
||||
let group = match p.get("group") {
|
||||
Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(),
|
||||
None if !stem.is_empty() => stem,
|
||||
@@ -588,6 +597,29 @@ mod tests {
|
||||
assert_eq!(r["imported"], json!(["Speckle", "SPECKLE 2", "speckle 4", "Speckle 2 2", "Speckle 5", "hard round 2"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abr_files_get_a_larger_size_cap() {
|
||||
// 1 GiB on 64-bit; 32-bit (wasm) keeps 512 MiB so a big file fails cleanly instead of aborting.
|
||||
const WANT: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 512 << 20 };
|
||||
const _: () = assert!(MAX_ABR_FILE_BYTES == WANT && MAX_ABR_FILE_BYTES >= MAX_FILE_BYTES);
|
||||
let dir = std::env::temp_dir().join(format!("pc-abr-cap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("cap.abr");
|
||||
std::fs::write(&path, [0u8; 10]).unwrap();
|
||||
let p = json!({"path": path.to_string_lossy()});
|
||||
assert!(file_bytes_within(&p, "t", "f", 10).is_ok());
|
||||
assert!(file_bytes_within(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading");
|
||||
// A sparse file between the two caps: refused as a .grd, which is checked before reading.
|
||||
let big = dir.join("big.grd");
|
||||
std::fs::File::create(&big).unwrap().set_len(MAX_FILE_BYTES + 1).unwrap();
|
||||
let err = Session::new().execute("gradient.presets.importGrd", json!({"path": big.to_string_lossy()})).unwrap_err();
|
||||
assert!(err.to_string().contains("too large"), "{err}");
|
||||
let data = json!({"data": "A".repeat(16)});
|
||||
assert!(file_bytes_within(&data, "t", "f", 12).is_ok());
|
||||
assert!(file_bytes_within(&data, "t", "f", 8).is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_names() {
|
||||
let mut taken = UniqueNames::new(["A", "a 2"]);
|
||||
|
||||
@@ -90,6 +90,7 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
|
||||
return app(sp);
|
||||
}
|
||||
if let Some(name) = system_spooler() {
|
||||
require_windows_pdf()?;
|
||||
return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other);
|
||||
}
|
||||
}
|
||||
@@ -645,6 +646,34 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spool
|
||||
)
|
||||
}
|
||||
|
||||
/// Windows prints a PDF through Windows.Data.Pdf.dll, which the apps delay-load (see their
|
||||
/// `build.rs`) so a Windows image without it still starts. Calling into it when it cannot load
|
||||
/// would abort the process, so check that it loads first (#5884).
|
||||
fn require_windows_pdf() -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows"));
|
||||
if !windows_pdf_loads(&windows_pdf_dll(std::path::Path::new(&root))) {
|
||||
return Err(other(
|
||||
"printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is missing or cannot load. Save as PDF and print it from another app, or print an RGB image without page text or labels to print directly.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_pdf_dll(system_root: &std::path::Path) -> std::path::PathBuf {
|
||||
system_root.join("System32").join("Windows.Data.Pdf.dll")
|
||||
}
|
||||
|
||||
/// Loads the DLL rather than checking the file exists: one that is present but cannot load (a
|
||||
/// dependency stripped too) would still abort at the delay-load stub.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_pdf_loads(dll: &std::path::Path) -> bool {
|
||||
dll.to_str().is_some_and(|p| winsafe::HINSTANCE::LoadLibrary(p).is_ok())
|
||||
}
|
||||
|
||||
/// Keep CMYK output profiles and page text on the existing PDF composition path. GDI currently
|
||||
/// draws RGB/gray and vector marks only; it must never silently omit requested page content.
|
||||
fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool {
|
||||
@@ -708,6 +737,61 @@ fn printer_settings(p: &Value) -> Result<Option<craft_print::rgb::DevMode>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `dmFields` and `dmOrientation` in DEVMODEW (wingdi.h: after the 32-wchar device name and four
|
||||
/// header words), and the `dmFields` bit that says `dmOrientation` is set.
|
||||
const DM_FIELDS_AT: usize = 72;
|
||||
const DM_ORIENTATION_AT: usize = 76;
|
||||
const DM_ORIENTATION: u32 = 0x1;
|
||||
/// `DMORIENT_PORTRAIT` and `DMORIENT_LANDSCAPE`.
|
||||
const DMORIENT_PORTRAIT: i16 = 1;
|
||||
const DMORIENT_LANDSCAPE: i16 = 2;
|
||||
|
||||
/// Driver settings turned to portrait or landscape, as Photoshop's Layout buttons turn them:
|
||||
/// `dmOrientation` set and flagged in `dmFields`, every other byte (the driver's private settings
|
||||
/// included) kept. The driver validates the result when it lays out the page, as it does for
|
||||
/// settings from Print Settings….
|
||||
pub fn turned_settings(settings: &craft_print::rgb::DevMode, landscape: bool) -> Result<craft_print::rgb::DevMode> {
|
||||
let mut b = settings.as_bytes().to_vec();
|
||||
let short = || other("printer settings are too short to hold an orientation");
|
||||
let fields: [u8; 4] = b.get(DM_FIELDS_AT..DM_FIELDS_AT + 4).and_then(|s| s.try_into().ok()).ok_or_else(short)?;
|
||||
let fields = u32::from_le_bytes(fields) | DM_ORIENTATION;
|
||||
b.get_mut(DM_FIELDS_AT..DM_FIELDS_AT + 4).ok_or_else(short)?.copy_from_slice(&fields.to_le_bytes());
|
||||
let orientation = if landscape { DMORIENT_LANDSCAPE } else { DMORIENT_PORTRAIT };
|
||||
b.get_mut(DM_ORIENTATION_AT..DM_ORIENTATION_AT + 2).ok_or_else(short)?.copy_from_slice(&orientation.to_le_bytes());
|
||||
craft_print::rgb::DevMode::from_bytes(b).map_err(other)
|
||||
}
|
||||
|
||||
/// Whether a driver page of `driver` points is the layout's `paper` turned the other way round
|
||||
/// (within the 2 pt the driver and layout may differ by): then turning the driver settings, not
|
||||
/// choosing another paper, makes them agree. A square paper is never turned.
|
||||
fn turned_paper(driver: (f64, f64), paper: (f64, f64)) -> bool {
|
||||
let near = |a: f64, b: f64| (a - b).abs() <= 2.0;
|
||||
let same = near(driver.0, paper.0) && near(driver.1, paper.1);
|
||||
!same && near(driver.0, paper.1) && near(driver.1, paper.0)
|
||||
}
|
||||
|
||||
/// The driver settings and page for printing a layout of `paper` points on `printer`: the given
|
||||
/// settings (or the printer's own) as they are, or turned to the layout's orientation when the
|
||||
/// driver has that paper the other way round. A landscape layout so prints landscape with portrait
|
||||
/// Print Settings, as a script or another app asking for landscape expects.
|
||||
fn settings_for_layout(
|
||||
printer: &str,
|
||||
settings: Option<craft_print::rgb::DevMode>,
|
||||
paper: (f64, f64),
|
||||
) -> Result<(Option<craft_print::rgb::DevMode>, craft_print::rgb::Page)> {
|
||||
let page = craft_print::rgb::page(printer, settings.as_ref()).map_err(other)?;
|
||||
if !turned_paper(page.paper, paper) {
|
||||
return Ok((settings, page));
|
||||
}
|
||||
let base = match settings {
|
||||
Some(s) => s,
|
||||
None => craft_print::rgb::default_settings(printer).map_err(other)?,
|
||||
};
|
||||
let turned = turned_settings(&base, paper.0 > paper.1)?;
|
||||
let page = craft_print::rgb::page(printer, Some(&turned)).map_err(other)?;
|
||||
Ok((Some(turned), page))
|
||||
}
|
||||
|
||||
/// The page's image as 8-bit RGB rows (gray expanded), mirrored for Emulsion Down.
|
||||
pub fn page_rgb(page: &PrintPage) -> Result<Vec<u8>> {
|
||||
let img = &page.image;
|
||||
@@ -742,8 +826,7 @@ pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) {
|
||||
/// PDF-only (no font/text layout in the GDI path yet).
|
||||
fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Result<Value> {
|
||||
let printer = printer_name(p)?;
|
||||
let settings = printer_settings(p)?;
|
||||
let device_page = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?;
|
||||
let (settings, device_page) = settings_for_layout(&printer, printer_settings(p)?, page.paper)?;
|
||||
if (device_page.paper.0 - page.paper.0).abs() > 2.0 || (device_page.paper.1 - page.paper.1).abs() > 2.0 {
|
||||
return Err(other("the driver paper or orientation differs from the print layout; choose the matching paper in Print Settings before printing"));
|
||||
}
|
||||
@@ -780,11 +863,31 @@ fn printers(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn printer_page(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Checked before the printer, so a bad value is the error whether or not a printer answers.
|
||||
let landscape = match p.get("orientation").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("portrait") => Some(false),
|
||||
Some("landscape") => Some(true),
|
||||
_ => return Err(bad("file.print.page", "orientation is \"portrait\" or \"landscape\"")),
|
||||
},
|
||||
};
|
||||
let printer = printer_name(p)?;
|
||||
let pg = craft_print::rgb::page(&printer, printer_settings(p)?.as_ref()).map_err(other)?;
|
||||
Ok(
|
||||
json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]}),
|
||||
)
|
||||
let mut settings = printer_settings(p)?;
|
||||
if let Some(landscape) = landscape {
|
||||
let base = match settings {
|
||||
Some(s) => s,
|
||||
None => craft_print::rgb::default_settings(&printer).map_err(other)?,
|
||||
};
|
||||
settings = Some(turned_settings(&base, landscape)?);
|
||||
}
|
||||
let pg = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?;
|
||||
let mut out = json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]});
|
||||
// The turned settings, to pass on as `printerSettings` (the Print dialog's Orientation does).
|
||||
if let (Some(_), Some(s), Some(o)) = (landscape, settings, out.as_object_mut()) {
|
||||
o.insert("printerSettings".into(), json!(s.to_hex()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Set once by test harnesses (the panic hunt) so no command in the process can reach a printer.
|
||||
@@ -1014,7 +1117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Printer Page",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"printer":name? (default printer),"printerSettings":hex?} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y]} (Windows)"##,
|
||||
params: r##"{"printer":name? (default printer),"printerSettings":hex?,"orientation":"portrait|landscape"? (turn the settings, or the printer's own, that way)} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y], printerSettings: hex (only with orientation: the turned settings)} (Windows)"##,
|
||||
enabled: |_| Ok(()),
|
||||
journal: false,
|
||||
run: printer_page,
|
||||
|
||||
@@ -207,6 +207,79 @@ fn printer_commands_answer_without_a_document_and_reject_bad_settings() {
|
||||
assert!(s.execute("file.print", json!({"printer": "Anything", "printerSettings": "0102", "send": true})).is_err());
|
||||
}
|
||||
|
||||
/// Synthetic driver settings: a DEVMODEW of `size` bytes plus `extra` private driver bytes, every
|
||||
/// byte a pattern so a stray write shows, with `dmFields` saying only the paper size is set.
|
||||
fn devmode(size: u16, extra: u16) -> Vec<u8> {
|
||||
let mut b: Vec<u8> = (0..usize::from(size) + usize::from(extra)).map(|i| (i % 251) as u8).collect();
|
||||
b[68..70].copy_from_slice(&size.to_le_bytes());
|
||||
b[70..72].copy_from_slice(&extra.to_le_bytes());
|
||||
b[72..76].copy_from_slice(&0x2u32.to_le_bytes()); // DM_PAPERSIZE
|
||||
b
|
||||
}
|
||||
|
||||
/// Photoshop's Layout buttons: driver settings turned to landscape or portrait set `dmOrientation`
|
||||
/// and its `dmFields` bit, and keep every other byte, the driver's private settings included.
|
||||
#[test]
|
||||
fn turned_settings_set_the_orientation_and_keep_the_rest() {
|
||||
for (size, extra) in [(220u16, 0u16), (220, 48)] {
|
||||
let before = devmode(size, extra);
|
||||
let dm = craft_print::rgb::DevMode::from_bytes(before.clone()).unwrap();
|
||||
for (landscape, orient) in [(true, 2i16), (false, 1i16)] {
|
||||
let after = turned_settings(&dm, landscape).unwrap().as_bytes().to_vec();
|
||||
assert_eq!(after.len(), before.len());
|
||||
assert_eq!(i16::from_le_bytes([after[76], after[77]]), orient, "{size}+{extra} landscape={landscape}");
|
||||
assert_eq!(u32::from_le_bytes([after[72], after[73], after[74], after[75]]), 0x3, "DM_ORIENTATION added, DM_PAPERSIZE kept");
|
||||
for (i, (a, b)) in after.iter().zip(&before).enumerate() {
|
||||
if !(72..76).contains(&i) && !(76..78).contains(&i) {
|
||||
assert_eq!(a, b, "byte {i} changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver's paper is turned only when it is the layout's paper the other way round (within
|
||||
/// the 2 pt a driver's paper may differ by): not for the same paper, another paper or a square one.
|
||||
#[test]
|
||||
fn only_the_same_paper_the_other_way_round_is_turned() {
|
||||
let (a3, a3_wide) = ((842.29, 1191.14), (1190.55, 841.89));
|
||||
assert!(turned_paper(a3, a3_wide), "portrait driver, landscape layout");
|
||||
assert!(turned_paper((1191.14, 842.29), (841.89, 1190.55)), "landscape driver, portrait layout");
|
||||
assert!(!turned_paper(a3, (841.89, 1190.55)), "same orientation");
|
||||
assert!(!turned_paper((595.32, 841.92), a3_wide), "an A4 driver for an A3 layout");
|
||||
assert!(!turned_paper((612.0, 612.0), (612.0, 612.0)), "square");
|
||||
}
|
||||
|
||||
/// `file.print.page` takes an orientation and refuses any other value before asking a printer, so
|
||||
/// the error is the same with or without one.
|
||||
#[test]
|
||||
fn printer_page_refuses_an_unknown_orientation() {
|
||||
let mut s = Session::new();
|
||||
for bad in [json!("sideways"), json!(2), json!(true)] {
|
||||
let e = s.execute("file.print.page", json!({"printer": "Anything", "orientation": bad})).unwrap_err().to_string();
|
||||
assert!(e.contains("orientation"), "{bad}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A real driver turns: Windows' "Microsoft Print to PDF" on its default (portrait) settings, for a
|
||||
/// landscape A4 layout, gets settings turned to landscape and a wide page; a portrait layout keeps
|
||||
/// the driver's own settings. Needs that printer, so it runs by hand:
|
||||
/// `cargo test -p photocraft-engine --lib a_portrait_driver -- --ignored`.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn a_portrait_driver_turns_for_a_landscape_layout() {
|
||||
let printer = "Microsoft Print to PDF";
|
||||
let defaults = craft_print::rgb::default_settings(printer).unwrap();
|
||||
let a4 = craft_print::rgb::page(printer, Some(&defaults)).unwrap().paper;
|
||||
let (settings, page) = settings_for_layout(printer, None, (a4.1, a4.0)).unwrap();
|
||||
assert!(settings.is_some(), "turned settings come back");
|
||||
assert!((page.paper.0 - a4.1).abs() <= 2.0 && (page.paper.1 - a4.0).abs() <= 2.0, "wide: {:?}", page.paper);
|
||||
let (settings, page) = settings_for_layout(printer, None, a4).unwrap();
|
||||
assert!(settings.is_none(), "nothing to turn");
|
||||
assert!((page.paper.0 - a4.0).abs() <= 2.0, "{:?}", page.paper);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printer_profile_can_be_an_installed_icc_file() {
|
||||
let dir = tmp("icc");
|
||||
@@ -772,3 +845,27 @@ mod cups {
|
||||
assert_eq!(jobs, 2, "both prints reached the fake CUPS");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_pdf_component_is_detected_by_loading_its_dll() {
|
||||
// #5884: a Windows image without a loadable Windows.Data.Pdf.dll reports an error instead of
|
||||
// aborting at the delay-load stub.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dll = windows_pdf_dll(dir.path());
|
||||
assert!(!windows_pdf_loads(&dll));
|
||||
// Present but not loadable counts as missing.
|
||||
std::fs::create_dir(dir.path().join("System32")).unwrap();
|
||||
std::fs::write(&dll, b"MZ").unwrap();
|
||||
assert!(!windows_pdf_loads(&dll));
|
||||
let root = std::env::var_os("SystemRoot").unwrap();
|
||||
// The real DLL may be missing or unloadable on a stripped image; the check follows the loader.
|
||||
let system = windows_pdf_dll(std::path::Path::new(&root));
|
||||
assert_eq!(require_windows_pdf().is_ok(), windows_pdf_loads(&system));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn the_windows_pdf_check_is_a_no_op_elsewhere() {
|
||||
assert!(require_windows_pdf().is_ok());
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use photocraft_geom::Rect;
|
||||
use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke};
|
||||
use photocraft_paint::{BrushSettings, Stroke, StrokePoint};
|
||||
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
|
||||
use rayon::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, blend_from_str};
|
||||
@@ -1042,19 +1043,26 @@ impl DabTool {
|
||||
let win = ((r.x0 - work.rect.x0) as usize, (r.y0 - work.rect.y0) as usize, (r.x1 - work.rect.x0) as usize, (r.y1 - work.rect.y0) as usize);
|
||||
let out = if sharpen { local_sharpen(&work.data, w, h, n, a, win, 1.0, protect) } else { local_blur(&work.data, w, h, n, a, win, sigma) };
|
||||
let rw = r.width() as usize;
|
||||
for y in r.y0..r.y1 {
|
||||
// Row-parallel, like the blur itself (#5426).
|
||||
let (wx0, wy0) = (work.rect.x0, work.rect.y0);
|
||||
work.data.par_chunks_mut(w * n).enumerate().for_each(|(row, line)| {
|
||||
let y = wy0 + row as i32;
|
||||
if y < r.y0 || y >= r.y1 {
|
||||
return;
|
||||
}
|
||||
for x in r.x0..r.x1 {
|
||||
let k = per_dab(fp.at(x, y) * strength, sp);
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let o = ((y - r.y0) as usize * rw + (x - r.x0) as usize) * n;
|
||||
let px = work.px_mut(x, y);
|
||||
let i = (x - wx0) as usize * n;
|
||||
let px = &mut line[i..i + n];
|
||||
for c in 0..n {
|
||||
px[c] += (out[o + c] - px[c]) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
Self::Smudge { strength, finger, max_size } => {
|
||||
|
||||
@@ -10,7 +10,8 @@ use std::sync::Arc;
|
||||
|
||||
use photocraft_color::Color;
|
||||
use photocraft_doc::text::{
|
||||
AntiAlias, Caps, CharStyle, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape,
|
||||
AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection,
|
||||
TextRun, TextShape,
|
||||
};
|
||||
use photocraft_doc::{Affine, Document, Layer, LayerContent, LayerId, TextLayer};
|
||||
use serde_json::{Value, json};
|
||||
@@ -363,6 +364,14 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
};
|
||||
hit(true);
|
||||
}
|
||||
if let Some(v) = p.get("baseline").and_then(Value::as_str) {
|
||||
s.baseline = match v {
|
||||
"superscript" => FontBaseline::Superscript,
|
||||
"subscript" => FontBaseline::Subscript,
|
||||
_ => FontBaseline::Normal,
|
||||
};
|
||||
hit(true);
|
||||
}
|
||||
if let Some(Value::Object(m)) = p.get("features") {
|
||||
for (tag, v) in m {
|
||||
let value = v.as_u64().map(|x| x as u16).or_else(|| v.as_bool().map(u16::from)).unwrap_or(1);
|
||||
@@ -826,7 +835,7 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result<TextLa
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
|
||||
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","baseline":"normal|superscript|subscript","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -84,7 +84,7 @@ fn id_param(cmd: &str, p: &Value) -> Result<Option<u32>> {
|
||||
/// A character style with every field changed from the default, to see which fields a set of
|
||||
/// Character-panel keys touches.
|
||||
fn perturbed_char() -> CharStyle {
|
||||
use photocraft_doc::text::{Caps, FontFeature, FontVariation, Kerning};
|
||||
use photocraft_doc::text::{Caps, FontBaseline, FontFeature, FontVariation, Kerning};
|
||||
CharStyle {
|
||||
font_family: "\u{1}".into(),
|
||||
font_style: "\u{1}".into(),
|
||||
@@ -105,6 +105,7 @@ fn perturbed_char() -> CharStyle {
|
||||
kerning: Kerning::Off,
|
||||
kern: -12345.0,
|
||||
caps: Caps::AllCaps,
|
||||
baseline: FontBaseline::Subscript,
|
||||
ligatures: false,
|
||||
discretionary_ligatures: true,
|
||||
features: vec![FontFeature { tag: "\u{1}".into(), value: 9 }],
|
||||
|
||||
@@ -213,6 +213,29 @@ fn exhausted_document_ids_and_maximal_text_runs_roundtrip() {
|
||||
assert_eq!(text.paragraph_runs().iter().map(|r| r.len).sum::<usize>(), text.text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_run_baseline_roundtrips() {
|
||||
use photocraft_doc::text::FontBaseline;
|
||||
let mut doc = Document::new("scripts", photocraft_doc::Size::new(2, 1), ColorMode::Rgb, SampleType::U8);
|
||||
doc.layers.push(Layer::new(
|
||||
"Text",
|
||||
LayerContent::Text(TextLayer {
|
||||
text: "H2O2".into(),
|
||||
runs: vec![
|
||||
TextRun { len: 1, style: CharStyle::default() },
|
||||
TextRun { len: 1, style: CharStyle { baseline: FontBaseline::Subscript, ..Default::default() } },
|
||||
TextRun { len: 1, style: CharStyle::default() },
|
||||
TextRun { len: 1, style: CharStyle { baseline: FontBaseline::Superscript, ..Default::default() } },
|
||||
],
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
let loaded = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let LayerContent::Text(text) = &loaded.layers.last().unwrap().content else { panic!("text layer must roundtrip") };
|
||||
let baselines: Vec<FontBaseline> = text.char_runs().iter().map(|r| r.style.baseline).collect();
|
||||
assert_eq!(baselines, [FontBaseline::Normal, FontBaseline::Subscript, FontBaseline::Normal, FontBaseline::Superscript]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_incremental_only_new_tiles() {
|
||||
let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16);
|
||||
|
||||
+113
-7
@@ -31,6 +31,13 @@ struct Op {
|
||||
map_origin: vec2<i32>,
|
||||
map_size: vec2<i32>,
|
||||
p4: vec4<f32>,
|
||||
// fs_layerbatch's third extra layer and the rest of its second (see `batch_layer`); zero
|
||||
// elsewhere. With them the record fills its 256-byte stride.
|
||||
q0: vec4<f32>,
|
||||
q1: vec4<f32>,
|
||||
q2: vec4<f32>,
|
||||
q3: vec4<f32>,
|
||||
q4: vec4<f32>,
|
||||
};
|
||||
|
||||
const F_MASK: u32 = 1u; // layer has an enabled mask
|
||||
@@ -53,6 +60,7 @@ const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment res
|
||||
const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups)
|
||||
const F_BLEND_IF: u32 = 524288u; // lerp: B back towards A by the Blend If ranges in p0..p3
|
||||
const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w
|
||||
const F_LAYER_SHAPE: u32 = 1048576u; // fx merge: B is the shape; layer = its colour at fill p0.x inside it × its alpha
|
||||
|
||||
@group(0) @binding(0) var<uniform> chunk: Chunk;
|
||||
@group(0) @binding(1) var<uniform> op: Op;
|
||||
@@ -312,6 +320,9 @@ fn fill_is_special(mode: i32) -> bool {
|
||||
// colour (Hard Mix: (cb - f(1 - cs)) / (1 - f)) at `opacity`, Fill staying coverage over
|
||||
// transparency. Lab documents mix Normal in CIELAB; type layers mix in the text gamma space.
|
||||
fn composite_fill(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32, fill: f32, gamma_on: bool) -> vec4<f32> {
|
||||
// The same code as fs_layerbatch_normal, so a layer gives the same bits whichever pass
|
||||
// draws it (a render resumed from the prefix cache splits runs differently).
|
||||
if (mode == M_NORMAL && !gamma_on && (op.flags & F_LAB) == 0u) { return composite_normal_fill(b, s, opacity, fill); }
|
||||
let f = clamp(fill, 0.0, 1.0);
|
||||
let special = f < 1.0 && fill_is_special(mode);
|
||||
let ab = b.a;
|
||||
@@ -340,6 +351,21 @@ fn composite_fill(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32, fill: f32
|
||||
return vec4(text_dec(wb * text_enc(b.rgb, g) + wbs * text_enc(bl, g) + ws * text_enc(s.rgb, g), g), ao);
|
||||
}
|
||||
|
||||
// composite_fill for Normal outside Lab documents and without text gamma: Fill is plain coverage
|
||||
// and the blend is the source. Kept apart so fs_layerbatch_normal doesn't carry every other
|
||||
// mode's code, which costs registers and so pixels in flight.
|
||||
fn composite_normal_fill(b: vec4<f32>, s: vec4<f32>, opacity: f32, fill: f32) -> vec4<f32> {
|
||||
let ab = b.a;
|
||||
let over = s.a * opacity * clamp(fill, 0.0, 1.0);
|
||||
if (over <= 0.0) { return b; }
|
||||
let ao = ab + (1.0 - ab) * over;
|
||||
if (ao <= 0.0) { return vec4(0.0); }
|
||||
let wb = ab * (1.0 - over) / ao;
|
||||
let wbs = ab * over / ao;
|
||||
let ws = (1.0 - ab) * over / ao;
|
||||
return vec4(wb * b.rgb + wbs * s.rgb + ws * s.rgb, ao);
|
||||
}
|
||||
|
||||
// photocraft_color::convert::{srgb_to_lab, lab_to_srgb} (D50, Bradford to sRGB).
|
||||
const D50: vec3<f32> = vec3(0.96422, 1.0, 0.82521);
|
||||
fn lab_f(t: f32) -> f32 {
|
||||
@@ -812,12 +838,9 @@ fn fs_mask(in: VOut) -> @location(0) vec4<f32> {
|
||||
return c;
|
||||
}
|
||||
|
||||
// blend_into(backdrop = A, src = B, mode, opacity), including Dissolve.
|
||||
@fragment
|
||||
fn fs_blend(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
let b = textureLoad(tex_a, p, 0);
|
||||
var s = textureLoad(tex_b, p, 0);
|
||||
// blend_into(backdrop = b, src = s, mode, opacity) at chunk pixel p, including Dissolve.
|
||||
fn blend_px(p: vec2<i32>, b: vec4<f32>, src: vec4<f32>) -> vec4<f32> {
|
||||
var s = src;
|
||||
if (s.a <= 0.0) { return b; }
|
||||
if (op.mode == M_DISSOLVE) {
|
||||
s.a = select(0.0, 1.0, dissolve_noise(doc_px(p)) < s.a * op.opacity * op.fill);
|
||||
@@ -826,6 +849,85 @@ fn fs_blend(in: VOut) -> @location(0) vec4<f32> {
|
||||
return composite_fill(op.mode, b, s, op.opacity, op.fill, (op.flags & F_TEXT_GAMMA) != 0u);
|
||||
}
|
||||
|
||||
// blend_into(backdrop = A, src = B, mode, opacity), including Dissolve.
|
||||
@fragment
|
||||
fn fs_blend(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
return blend_px(p, textureLoad(tex_a, p, 0), textureLoad(tex_b, p, 0));
|
||||
}
|
||||
|
||||
// fs_content then fs_blend in one pass: the layer's own pixels (times its mask) blended straight
|
||||
// onto A, without writing them to a chunk texture and reading them back.
|
||||
@fragment
|
||||
fn fs_layerblend(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
let d = doc_px(p);
|
||||
var s = layer_texel(d);
|
||||
s.a = s.a * mask_value(d);
|
||||
return blend_px(p, textureLoad(tex_a, p, 0), s);
|
||||
}
|
||||
|
||||
// The pixel at document pixel d of an extra layer of fs_layerbatch: an unmasked plain layer whose
|
||||
// pixels are in texture `t` (0: tex_b, 1: tex_c, 2: map_tex). Three vec4 of the record describe
|
||||
// it: geo = (texture origin x, y, size w, h), color = its colour outside the texture,
|
||||
// prm = (opacity, fill, mode, 1 when it has a texture), all whole numbers stored as floats.
|
||||
fn batch_texel(t: i32, d: vec2<i32>, geo: vec4<f32>, color: vec4<f32>, prm: vec4<f32>) -> vec4<f32> {
|
||||
let q = d - vec2<i32>(geo.xy);
|
||||
if (prm.w > 0.5 && inside(q, vec2<i32>(geo.zw))) {
|
||||
if (t == 0) {
|
||||
return textureLoad(tex_b, q, 0);
|
||||
} else if (t == 1) {
|
||||
return textureLoad(tex_c, q, 0);
|
||||
}
|
||||
return textureLoad(map_tex, q, 0);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// One extra layer of fs_layerbatch (see batch_texel) blended onto b at document pixel d.
|
||||
fn batch_layer(t: i32, d: vec2<i32>, b: vec4<f32>, geo: vec4<f32>, color: vec4<f32>, prm: vec4<f32>) -> vec4<f32> {
|
||||
let s = batch_texel(t, d, geo, color, prm);
|
||||
if (s.a <= 0.0) { return b; }
|
||||
return composite_fill(i32(prm.z), b, s, prm.x, prm.y, false);
|
||||
}
|
||||
|
||||
// batch_layer of a Normal layer outside Lab documents.
|
||||
fn batch_layer_normal(t: i32, d: vec2<i32>, b: vec4<f32>, geo: vec4<f32>, color: vec4<f32>, prm: vec4<f32>) -> vec4<f32> {
|
||||
let s = batch_texel(t, d, geo, color, prm);
|
||||
if (s.a <= 0.0) { return b; }
|
||||
return composite_normal_fill(b, s, prm.x, prm.y);
|
||||
}
|
||||
|
||||
// fs_layerblend of this pass's layer, then `op.kind` (at most 3) more plain layers: consecutive
|
||||
// LayerBlend passes the executor draws as one, reading and writing the chunk once for all of them.
|
||||
@fragment
|
||||
fn fs_layerbatch(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
let d = doc_px(p);
|
||||
var s = layer_texel(d);
|
||||
s.a = s.a * mask_value(d);
|
||||
var b = blend_px(p, textureLoad(tex_a, p, 0), s);
|
||||
if (op.kind >= 1) { b = batch_layer(0, d, b, op.p0, op.p1, op.p2); }
|
||||
if (op.kind >= 2) { b = batch_layer(1, d, b, op.p3, op.q0, op.q1); }
|
||||
if (op.kind >= 3) { b = batch_layer(2, d, b, op.q2, op.q3, op.q4); }
|
||||
return b;
|
||||
}
|
||||
|
||||
// fs_layerbatch for a run of Normal layers without text gamma, outside Lab documents.
|
||||
@fragment
|
||||
fn fs_layerbatch_normal(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
let d = doc_px(p);
|
||||
var s = layer_texel(d);
|
||||
s.a = s.a * mask_value(d);
|
||||
var b = textureLoad(tex_a, p, 0);
|
||||
if (s.a > 0.0) { b = composite_normal_fill(b, s, op.opacity, op.fill); }
|
||||
if (op.kind >= 1) { b = batch_layer_normal(0, d, b, op.p0, op.p1, op.p2); }
|
||||
if (op.kind >= 2) { b = batch_layer_normal(1, d, b, op.p3, op.q0, op.q1); }
|
||||
if (op.kind >= 3) { b = batch_layer_normal(2, d, b, op.q2, op.q3, op.q4); }
|
||||
return b;
|
||||
}
|
||||
|
||||
// composite_atop(base = A, src = B): blend as if the base were opaque, keep its alpha.
|
||||
@fragment
|
||||
fn fs_atop(in: VOut) -> @location(0) vec4<f32> {
|
||||
@@ -1168,8 +1270,12 @@ fn mix_premul(a: vec4<f32>, b: vec4<f32>, k: f32) -> vec4<f32> {
|
||||
fn fs_fxmerge(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
var w = textureLoad(tex_a, p, 0);
|
||||
let l = textureLoad(tex_b, p, 0);
|
||||
var l = textureLoad(tex_b, p, 0);
|
||||
let c = textureLoad(tex_c, p, 0);
|
||||
if ((op.flags & F_LAYER_SHAPE) != 0u) {
|
||||
// fs_fxinit INIT_INSIDE then INIT_ALPHA, on the shape in B.
|
||||
l = vec4(l.rgb, select(0.0, op.p0.x, l.a > INSIDE_EPS) * min(l.a, 1.0));
|
||||
}
|
||||
if (l.a > 0.0 && (op.flags & F_NO_LAYER) == 0u) { w = composite_g(op.mode, w, l, 1.0, (op.flags & F_TEXT_GAMMA) != 0u); }
|
||||
let atop = (op.flags & F_ATOP) != 0u;
|
||||
var before = c;
|
||||
|
||||
+260
-10
@@ -70,7 +70,7 @@ const MAP16: wgpu::TextureFormat = wgpu::TextureFormat::R16Float;
|
||||
pub const CHUNK: u32 = 1024;
|
||||
const STRIDE: u64 = 256;
|
||||
const CHUNK_UNIFORM: u64 = 16;
|
||||
const OP_UNIFORM: u64 = 176;
|
||||
const OP_UNIFORM: u64 = 256;
|
||||
/// GPU memory the effect-map cache may hold before evicting layers not drawn this frame.
|
||||
pub const FX_BUDGET: usize = 1536 << 20;
|
||||
/// Largest side of the square pages layer surfaces are stored in on the GPU (smaller when the
|
||||
@@ -139,6 +139,10 @@ pub struct Stats {
|
||||
pub prefix_hits: usize,
|
||||
/// Planned prefix passes skipped, summed over the reused chunks.
|
||||
pub prefix_passes_skipped: usize,
|
||||
/// Plain layers blended inside another layer's pass (`fs_layerbatch`), summed over chunks.
|
||||
pub layers_batched: usize,
|
||||
/// Batched passes drawn with Normal's code alone (`fs_layerbatch_normal`), summed over chunks.
|
||||
pub normal_batches: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -1161,7 +1165,17 @@ impl Compositor {
|
||||
|
||||
// Only render owns submission. A caller may discard encode's command buffer, so it
|
||||
// cannot establish (or consume) a checkpoint whose GPU copy might never execute.
|
||||
let checkpoint = if flush { self.prefix.prepare(doc, &plan) } else { None };
|
||||
// Runs of plain layers drawn as one pass each (`fs_layerbatch`). The grouping is the same
|
||||
// for every render of this plan, so a layer's bits don't depend on the cache: renders
|
||||
// capture and resume only where a run starts.
|
||||
let runs = batch_runs(&plan);
|
||||
let checkpoint = if flush {
|
||||
let starts = run_starts(&runs);
|
||||
let resumable: Vec<_> = plan.checkpoints.iter().filter(|c| starts.get(c.pass_end).copied().unwrap_or(false)).copied().collect();
|
||||
self.prefix.prepare(doc, &resumable)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Effect maps of whole regions first (they may render group shapes through sub-plans);
|
||||
// regions larger than the limit are built per cell below.
|
||||
@@ -1206,7 +1220,7 @@ impl Compositor {
|
||||
stats.chunks += chunks.len();
|
||||
work += part.width() as u64 * part.height() as u64 * plan.passes.len() as u64;
|
||||
let mut batch = flush.then_some(&mut last_submit);
|
||||
self.run_cell(device, queue, encoder, doc.id, &plan, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink, &mut batch);
|
||||
self.run_cell(device, queue, encoder, doc.id, &plan, &runs, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink, &mut batch);
|
||||
// The device was lost in a submit inside the cell: issue no more work.
|
||||
if let Some(f) = self.fault() {
|
||||
return Err(Unsupported(f.to_string()));
|
||||
@@ -1377,8 +1391,8 @@ impl Compositor {
|
||||
Bound { views, keys, maps }
|
||||
}
|
||||
|
||||
/// Run `plan` over the `chunks` of one page cell (document rects, at most a chunk square),
|
||||
/// handing each finished chunk to `sink`.
|
||||
/// Run `plan` (drawn as `runs`, its `batch_runs`) over the `chunks` of one page cell
|
||||
/// (document rects, at most a chunk square), handing each finished chunk to `sink`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_cell(
|
||||
&mut self,
|
||||
@@ -1387,6 +1401,7 @@ impl Compositor {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
doc: DocId,
|
||||
plan: &Plan<'_>,
|
||||
runs: &[usize],
|
||||
paged: &[bool],
|
||||
cell: Cell,
|
||||
fr: &FrameRes,
|
||||
@@ -1439,7 +1454,13 @@ impl Compositor {
|
||||
}
|
||||
let (tex, mask) = bound.views[i];
|
||||
let map = bound.maps[i].as_ref().map(|(_, r)| *r);
|
||||
let w = op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), map);
|
||||
let run = runs.get(i).copied().unwrap_or(1);
|
||||
let w = if run > 1 {
|
||||
let extras: Vec<_> = (i + 1..i + run).filter_map(|j| Some((plan.passes.get(j)?, bound.views.get(j).and_then(|v| v.0).map(|t| t.1)))).collect();
|
||||
batch_op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), &extras)
|
||||
} else {
|
||||
op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), map)
|
||||
};
|
||||
let off = (op_base + i as u64 * STRIDE) as usize;
|
||||
data[off..][..w.len()].copy_from_slice(&w);
|
||||
}
|
||||
@@ -1468,6 +1489,13 @@ impl Compositor {
|
||||
let lv = fr.luts.get(i).and_then(Option::as_ref).map_or(dummy, |(_, v)| v);
|
||||
let map = bound.maps[i].as_ref().map_or(dummy, |(v, _)| v);
|
||||
let pat = fr.patterns.get(i).and_then(Option::as_ref).unwrap_or(dummy);
|
||||
let run = runs.get(i).copied().unwrap_or(1);
|
||||
if run > 1 {
|
||||
// The extra layers' pixels in tex_b, tex_c and map_tex (`batch_layer`).
|
||||
let extra = |e: usize| bound.views.get(i + e).and_then(|v| v.0).map_or(dummy, |(k, _)| resident_views[k]);
|
||||
bg1.push(Some(texture_group(device, &self.kit.bgl1, &[slot(p.a), extra(1), tv, mv, lv, extra(2), extra(3), pat])));
|
||||
continue;
|
||||
}
|
||||
bg1.push(Some(texture_group(device, &self.kit.bgl1, &[slot(p.a), slot(p.b), tv, mv, lv, slot(p.c), map, pat])));
|
||||
}
|
||||
|
||||
@@ -1481,8 +1509,10 @@ impl Compositor {
|
||||
stats.prefix_hits = stats.prefix_hits.saturating_add(1);
|
||||
stats.prefix_passes_skipped = stats.prefix_passes_skipped.saturating_add(resume);
|
||||
}
|
||||
// Passes drawn inside the previous `fs_layerbatch` pass.
|
||||
let mut batched_to = 0;
|
||||
for (i, p) in plan.passes.iter().enumerate() {
|
||||
if i < resume {
|
||||
if i < resume || i < batched_to {
|
||||
continue;
|
||||
}
|
||||
// Chunk textures, uniforms and bind groups outlive the submit, and the queue
|
||||
@@ -1542,7 +1572,21 @@ impl Compositor {
|
||||
_ => {}
|
||||
}
|
||||
self.recorded += 1;
|
||||
let target = &self.pool[p.dst as usize].1;
|
||||
// A run draws as one pass into the slot its last layer writes.
|
||||
let run = runs.get(i).copied().unwrap_or(1);
|
||||
let (kernel, dst) = match plan.passes.get(i + run - 1) {
|
||||
Some(last) if run > 1 => {
|
||||
batched_to = i + run;
|
||||
stats.layers_batched = stats.layers_batched.saturating_add(run - 1);
|
||||
let kernel = plan.passes.get(i..i + run).map_or(Kernel::LayerBatch, batch_kernel);
|
||||
if kernel == Kernel::LayerBatchNormal {
|
||||
stats.normal_batches = stats.normal_batches.saturating_add(1);
|
||||
}
|
||||
(kernel, last.dst)
|
||||
}
|
||||
_ => (p.kernel, p.dst),
|
||||
};
|
||||
let target = &self.pool[dst as usize].1;
|
||||
let clear = p.kernel == Kernel::Clear;
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("pc_compose_pass"),
|
||||
@@ -1563,7 +1607,7 @@ impl Compositor {
|
||||
if clear {
|
||||
continue;
|
||||
}
|
||||
pass.set_pipeline(&self.kit.pipelines[&(p.kernel, self.acc_format)]);
|
||||
pass.set_pipeline(&self.kit.pipelines[&(kernel, self.acc_format)]);
|
||||
pass.set_scissor_rect(scissor.x0 as u32, scissor.y0 as u32, scissor.width(), scissor.height());
|
||||
pass.set_bind_group(0, &bg0, &[(ci as u64 * STRIDE) as u32, (op_base + i as u64 * STRIDE) as u32]);
|
||||
pass.set_bind_group(1, bg1[i].as_ref(), &[]);
|
||||
@@ -2061,6 +2105,38 @@ fn web_time_now() -> Option<std::time::Instant> {
|
||||
|
||||
/// The uniform record of a pass.
|
||||
fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, map: Option<Rect>) -> Vec<u8> {
|
||||
words(&op_values(p, tex, mask, map))
|
||||
}
|
||||
|
||||
/// The uniform record of the `fs_layerbatch` pass that draws `p` (whose texture and mask cover
|
||||
/// `tex` and `mask`) and then `extras`, each an unmasked plain layer's `LayerBlend` pass with the
|
||||
/// area its texture covers. Layout: see `batch_layer` in compose.wgsl.
|
||||
fn batch_op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, extras: &[(&plan::Pass<'_>, Option<[i32; 4]>)]) -> Vec<u8> {
|
||||
let mut v = op_values(p, tex, mask, None);
|
||||
// Word offsets of the record's vec4s holding the extra layers, three each: p0, p1, p2 | p3,
|
||||
// q0, q1 | q2, q3, q4.
|
||||
const AT: [usize; 9] = [20, 24, 28, 32, 44, 48, 52, 56, 60];
|
||||
if let Some(kind) = v.get_mut(1) {
|
||||
*kind = I(extras.len().min(AT.len() / 3) as i32);
|
||||
}
|
||||
for (e, (x, area)) in extras.iter().take(AT.len() / 3).enumerate() {
|
||||
let r = area.unwrap_or([0; 4]);
|
||||
let geo = [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32];
|
||||
let prm = [x.opacity, x.fill, plan::mode_index(x.mode) as f32, if area.is_some() { 1.0 } else { 0.0 }];
|
||||
for (k, vals) in [geo, x.color, prm].into_iter().enumerate() {
|
||||
let at = AT[e * 3 + k];
|
||||
for (c, f) in vals.into_iter().enumerate() {
|
||||
if let Some(w) = v.get_mut(at + c) {
|
||||
*w = F(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
words(&v)
|
||||
}
|
||||
|
||||
/// The words of a pass's uniform record (`Op` in compose.wgsl).
|
||||
fn op_values(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, map: Option<Rect>) -> Vec<W> {
|
||||
let mut flags = p.flags;
|
||||
let (mut to, mut ts, mut mo, mut ms) = ([0; 2], [0; 2], [0; 2], [0; 2]);
|
||||
if let Some(r) = tex {
|
||||
@@ -2107,7 +2183,82 @@ fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, m
|
||||
}
|
||||
v.extend([I(mpo[0]), I(mpo[1]), I(mps[0]), I(mps[1])]);
|
||||
v.extend(p.extra.iter().map(|f| F(*f)));
|
||||
words(&v)
|
||||
// q0..q4: only `fs_layerbatch` reads them (`batch_op_words`).
|
||||
v.extend((0..20).map(|_| F(0.0)));
|
||||
v
|
||||
}
|
||||
|
||||
/// Most layers one `fs_layerbatch` pass blends: its own and the three more its record holds.
|
||||
const BATCH: usize = 4;
|
||||
|
||||
/// For each pass, how many passes from it on the executor draws as one `fs_layerbatch` pass
|
||||
/// (1: the pass alone). A run starts at a `LayerBlend` pass over the whole chunk (no clip) that
|
||||
/// isn't a gradient (whose parameters use the words the extra layers go in), and continues while
|
||||
/// the next pass is a `LayerBlend` of an unmasked plain layer (no text gamma, no Dissolve) onto
|
||||
/// exactly what the previous pass wrote, a slot nothing else reads before it is written again.
|
||||
/// A run never ends in the slot it reads from.
|
||||
///
|
||||
/// The runs depend on the plan alone, never on the prefix cache: a compiler may give a layer
|
||||
/// other bits batched than drawn alone (FXC on D3D12 does), and reusing a prefix must stay exact
|
||||
/// (#5286). The cache captures and resumes only at `run_starts` instead.
|
||||
fn batch_runs(plan: &Plan<'_>) -> Vec<usize> {
|
||||
let passes = &plan.passes;
|
||||
let n = passes.len();
|
||||
let reads = |p: &plan::Pass<'_>, s: plan::Slot| [p.a, p.b, p.c, p.d].contains(&Some(s));
|
||||
// feeds_next[i]: what pass i writes is read by pass i + 1 alone.
|
||||
let feeds_next: Vec<bool> = (0..n)
|
||||
.map(|i| {
|
||||
let (Some(this), Some(next)) = (passes.get(i), passes.get(i + 1)) else { return false };
|
||||
let dst = this.dst;
|
||||
if next.a != Some(dst) || [next.b, next.c, next.d].contains(&Some(dst)) {
|
||||
return false;
|
||||
}
|
||||
for later in passes.iter().skip(i + 2) {
|
||||
// A copy into a slot keeps what lies outside its rect: that is a read too.
|
||||
if reads(later, dst) || (later.dst == dst && later.kernel == Kernel::CopyRect) {
|
||||
return false;
|
||||
}
|
||||
if later.dst == dst {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
dst != plan.root
|
||||
})
|
||||
.collect();
|
||||
let lead = |p: &plan::Pass<'_>| p.kernel == Kernel::LayerBlend && p.clip.is_none() && !p.gradient;
|
||||
let extra = |p: &plan::Pass<'_>| lead(p) && p.mask.is_none() && p.flags & plan::F_TEXT_GAMMA == 0 && p.mode != photocraft_color::BlendMode::Dissolve;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let Some(first) = passes.get(i).filter(|p| lead(p)) else { return 1 };
|
||||
let (mut k, mut best) = (1, 1);
|
||||
while k < BATCH && feeds_next.get(i + k - 1).copied().unwrap_or(false) && passes.get(i + k).is_some_and(extra) {
|
||||
k += 1;
|
||||
if passes.get(i + k - 1).is_some_and(|p| Some(p.dst) != first.a) {
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
best
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// For each pass and the end of the plan, whether the executor draws `runs` (`batch_runs`) so
|
||||
/// that a pass starts there rather than inside a run.
|
||||
fn run_starts(runs: &[usize]) -> Vec<bool> {
|
||||
let mut starts = vec![false; runs.len() + 1];
|
||||
let mut i = 0;
|
||||
while let Some(s) = starts.get_mut(i) {
|
||||
*s = true;
|
||||
i += runs.get(i).copied().unwrap_or(1).max(1);
|
||||
}
|
||||
starts
|
||||
}
|
||||
|
||||
/// The pipeline that draws `run` (passes `batch_runs` joined): `fs_layerbatch_normal` when every
|
||||
/// layer blends Normal without text gamma outside Lab documents, else `fs_layerbatch`.
|
||||
fn batch_kernel(run: &[plan::Pass<'_>]) -> Kernel {
|
||||
let normal = |p: &plan::Pass<'_>| p.mode == photocraft_color::BlendMode::Normal && p.flags & (plan::F_TEXT_GAMMA | plan::F_LAB) == 0;
|
||||
if run.iter().all(normal) { Kernel::LayerBatchNormal } else { Kernel::LayerBatch }
|
||||
}
|
||||
|
||||
/// Canvas rounded out to whole tiles.
|
||||
@@ -2552,6 +2703,105 @@ pub fn render_to_vec_stats(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A run joins `LayerBlend` passes that only feed each other, at most `BATCH` of them, and
|
||||
/// never ends in the slot it reads from.
|
||||
fn check_runs(p: &Plan<'_>) -> Vec<usize> {
|
||||
let runs = batch_runs(p);
|
||||
assert_eq!(runs.len(), p.passes.len());
|
||||
for (i, &k) in runs.iter().enumerate() {
|
||||
assert!((1..=BATCH).contains(&k), "pass {i}: run {k}");
|
||||
if k > 1 {
|
||||
assert_ne!(Some(p.passes[i + k - 1].dst), p.passes[i].a, "run at {i} renders into its input");
|
||||
for j in i + 1..i + k {
|
||||
assert_eq!(p.passes[j].kernel, Kernel::LayerBlend);
|
||||
assert_eq!(p.passes[j].a, Some(p.passes[j - 1].dst), "run at {i}: pass {j} doesn't read the one before");
|
||||
assert!(p.passes[j].mask.is_none() && p.passes[j].mode != photocraft_color::BlendMode::Dissolve, "run at {i}: pass {j}");
|
||||
}
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_layers_batch_and_runs_stop_where_a_pass_must_stand_alone() {
|
||||
use photocraft_color::{BlendMode, Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerMask};
|
||||
let mut d = Document::with_background("t", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
for i in 0..12 {
|
||||
let mut l = Layer::raster(format!("l{i}"), d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 8), &[0.2, 0.4, 0.6, 0.5]);
|
||||
d.layers.push(l);
|
||||
}
|
||||
let p = plan::plan(&d).unwrap();
|
||||
let runs = check_runs(&p);
|
||||
assert!(runs.iter().any(|k| *k >= 3), "{runs:?}");
|
||||
// The cache resumes only where a run starts, so a cached render draws the passes above
|
||||
// its checkpoint in the same runs as an uncached one; checkpoints inside a run are
|
||||
// skipped, but a stack of plain layers keeps some to resume at.
|
||||
let starts = run_starts(&runs);
|
||||
assert_eq!(starts.len(), runs.len() + 1);
|
||||
assert!(starts[0] && starts[runs.len()]);
|
||||
let (mut at, mut i) = (Vec::new(), 0);
|
||||
while i < runs.len() {
|
||||
at.push(i);
|
||||
i += runs[i];
|
||||
}
|
||||
assert_eq!(starts.iter().enumerate().filter(|(_, s)| **s).map(|(i, _)| i).collect::<Vec<_>>(), [at, vec![runs.len()]].concat());
|
||||
let resumable = p.checkpoints.iter().filter(|c| c.pass_end > 1 && starts[c.pass_end]).count();
|
||||
assert!(resumable >= 2 && resumable < p.checkpoints.len(), "{resumable} of {:?}", p.checkpoints);
|
||||
// A masked or Dissolve layer, or a clipped one, never joins a run as an extra layer.
|
||||
d.layers[3].mask = Some(LayerMask::hide_all());
|
||||
d.layers[6].blend = BlendMode::Dissolve;
|
||||
d.layers[9].clipped = true;
|
||||
check_runs(&plan::plan(&d).unwrap());
|
||||
// Nothing to batch: a lone layer.
|
||||
d.layers.truncate(1);
|
||||
assert_eq!(check_runs(&plan::plan(&d).unwrap()), vec![1; plan::plan(&d).unwrap().passes.len()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_records_hold_three_extra_layers_in_the_spare_words() {
|
||||
let lead = plan::Pass::new(Kernel::LayerBlend, 0);
|
||||
let mut x = plan::Pass::new(Kernel::LayerBlend, 1);
|
||||
x.opacity = 0.25;
|
||||
x.fill = 0.5;
|
||||
x.color = [0.1, 0.2, 0.3, 0.4];
|
||||
let w = batch_op_words(&lead, None, None, &[(&x, Some([1, 2, 3, 4])), (&x, None), (&x, None), (&x, None)]);
|
||||
assert_eq!(w.len() as u64, OP_UNIFORM);
|
||||
let word = |i: usize| <[u8; 4]>::try_from(&w[i * 4..i * 4 + 4]).unwrap();
|
||||
let f = |i: usize| f32::from_le_bytes(word(i));
|
||||
// At most three extras; the first's geometry, colour and parameters in p0, p1, p2.
|
||||
assert_eq!(i32::from_le_bytes(word(1)), 3);
|
||||
assert_eq!([f(20), f(21), f(22), f(23)], [1.0, 2.0, 3.0, 4.0]);
|
||||
assert_eq!([f(24), f(25), f(26), f(27)], [0.1, 0.2, 0.3, 0.4]);
|
||||
assert_eq!([f(28), f(29), f(31)], [0.25, 0.5, 1.0]);
|
||||
// The third's parameters in q4; one without pixels says so.
|
||||
assert_eq!([f(60), f(61), f(63)], [0.25, 0.5, 0.0]);
|
||||
// Other passes leave the batch words zero.
|
||||
let plain = op_words(&x, None, None, None);
|
||||
assert!(plain[44 * 4..].iter().all(|b| *b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_of_normal_layers_draw_with_normal_code_alone() {
|
||||
use photocraft_color::BlendMode;
|
||||
let pass = |mode: BlendMode, flags: u32| {
|
||||
let mut p = plan::Pass::new(Kernel::LayerBlend, 0);
|
||||
p.mode = mode;
|
||||
p.flags = flags;
|
||||
p
|
||||
};
|
||||
let normal = pass(BlendMode::Normal, plan::F_HDR);
|
||||
assert_eq!(batch_kernel(&[normal.clone(), pass(BlendMode::Normal, 0)]), Kernel::LayerBatchNormal);
|
||||
// Any other mode, text gamma or a Lab document anywhere in the run takes the full shader.
|
||||
for other in
|
||||
[pass(BlendMode::Multiply, 0), pass(BlendMode::Dissolve, 0), pass(BlendMode::Normal, plan::F_TEXT_GAMMA), pass(BlendMode::Normal, plan::F_LAB)]
|
||||
{
|
||||
assert_eq!(batch_kernel(&[normal.clone(), other.clone()]), Kernel::LayerBatch);
|
||||
assert_eq!(batch_kernel(&[other, normal.clone()]), Kernel::LayerBatch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_buffer_sizes_are_checked_before_allocation() {
|
||||
assert_eq!(uniform_buffer_len(1, 255, 65_536).unwrap(), 65_536);
|
||||
|
||||
+116
-22
@@ -34,6 +34,14 @@ pub enum Kernel {
|
||||
Content,
|
||||
Mask,
|
||||
Blend,
|
||||
/// `Content` then `Blend` in one pass: a plain layer's pixels blended straight onto A.
|
||||
LayerBlend,
|
||||
/// A run of consecutive `LayerBlend` passes drawn as one (never planned: the executor draws
|
||||
/// it in their place, see `batch_runs`).
|
||||
LayerBatch,
|
||||
/// `LayerBatch` of a run whose layers all blend Normal without text gamma, outside Lab
|
||||
/// documents (never planned: the executor picks it, see `batch_kernel`).
|
||||
LayerBatchNormal,
|
||||
Atop,
|
||||
Adjust,
|
||||
AdjMix,
|
||||
@@ -73,6 +81,9 @@ impl Kernel {
|
||||
Kernel::Content => "fs_content",
|
||||
Kernel::Mask => "fs_mask",
|
||||
Kernel::Blend => "fs_blend",
|
||||
Kernel::LayerBlend => "fs_layerblend",
|
||||
Kernel::LayerBatch => "fs_layerbatch",
|
||||
Kernel::LayerBatchNormal => "fs_layerbatch_normal",
|
||||
Kernel::Atop => "fs_atop",
|
||||
Kernel::Adjust => "fs_adjust",
|
||||
Kernel::AdjMix => "fs_adjmix",
|
||||
@@ -111,10 +122,13 @@ impl Kernel {
|
||||
}
|
||||
|
||||
/// Every kernel with a pipeline.
|
||||
pub const DRAWN: [Kernel; 21] = [
|
||||
pub const DRAWN: [Kernel; 24] = [
|
||||
Kernel::Content,
|
||||
Kernel::Mask,
|
||||
Kernel::Blend,
|
||||
Kernel::LayerBlend,
|
||||
Kernel::LayerBatch,
|
||||
Kernel::LayerBatchNormal,
|
||||
Kernel::Atop,
|
||||
Kernel::Adjust,
|
||||
Kernel::AdjMix,
|
||||
@@ -395,6 +409,10 @@ pub const F_ADD_DIFF: u32 = 16384;
|
||||
pub const F_BLEND_IF: u32 = 524288;
|
||||
/// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`.
|
||||
pub const F_TEXT_GAMMA: u32 = 8192;
|
||||
/// `FxMerge`: B is the layer's shape (its content); the layer is its colour at fill opacity
|
||||
/// (`p0.x`) inside the shape, times the shape's alpha: `FxInit` `INIT_INSIDE` then
|
||||
/// `INIT_ALPHA` in the merge, for a layer with no interior effects between them.
|
||||
pub const F_LAYER_SHAPE: u32 = 1_048_576;
|
||||
|
||||
/// A layer's Blend If ranges for `F_BLEND_IF`: Gray, then the colour channels, each as
|
||||
/// [this layer, underlying layer]; every range as two floats, `black low × 256 + black high` and
|
||||
@@ -764,26 +782,36 @@ impl<'a> Planner<'a> {
|
||||
}
|
||||
return Ok(backdrop);
|
||||
}
|
||||
let start = self.passes.len();
|
||||
let mut content = self.content(layer)?;
|
||||
for c in visible_clipped {
|
||||
content = self.atop(c, content)?;
|
||||
}
|
||||
if let Some(clip) = clip {
|
||||
// Everything that built the layer's content only matters inside its bounds.
|
||||
for p in &mut self.passes[start..] {
|
||||
if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) {
|
||||
p.clip = Some(clip);
|
||||
// A layer with nothing clipped to it whose content is one Content pass is blended
|
||||
// straight from its pixels (`Kernel::LayerBlend`): one pass instead of two, and no
|
||||
// scratch chunk written and read back.
|
||||
let mut p = if visible_clipped.is_empty() && single_content_pass(layer) {
|
||||
let mut p = self.content_pass(layer)?;
|
||||
p.kernel = Kernel::LayerBlend;
|
||||
p
|
||||
} else {
|
||||
let start = self.passes.len();
|
||||
let mut content = self.content(layer)?;
|
||||
for c in visible_clipped {
|
||||
content = self.atop(c, content)?;
|
||||
}
|
||||
if let Some(clip) = clip {
|
||||
// Everything that built the layer's content only matters inside its bounds.
|
||||
for p in &mut self.passes[start..] {
|
||||
if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) {
|
||||
p.clip = Some(clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut p = Pass::new(Kernel::Blend, 0);
|
||||
let mut p = Pass::new(Kernel::Blend, 0);
|
||||
p.b = Some(content);
|
||||
p
|
||||
};
|
||||
p.a = Some(if clip.is_some() { self.retain(backdrop) } else { backdrop });
|
||||
p.b = Some(content);
|
||||
p.mode = layer.blend;
|
||||
p.opacity = layer.opacity;
|
||||
p.fill = layer.fill_opacity;
|
||||
p.flags = gamma_flag(layer);
|
||||
p.flags |= gamma_flag(layer);
|
||||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||||
p.clip = clip;
|
||||
let merged = self.emit(p);
|
||||
@@ -804,6 +832,11 @@ impl<'a> Planner<'a> {
|
||||
/// Copy `merged` into `backdrop` over `clip` (in place when nothing else holds the backdrop);
|
||||
/// returns the new backdrop. Consumes both.
|
||||
fn write_back(&mut self, backdrop: Slot, merged: Slot, clip: Rect) -> Slot {
|
||||
// A clip over the whole canvas left no pixel of `merged` uncomputed: it is the backdrop.
|
||||
if clip.contains_rect(&self.cx.canvas) {
|
||||
self.release(backdrop);
|
||||
return merged;
|
||||
}
|
||||
let dst = if self.refs[backdrop as usize] == 1 {
|
||||
backdrop
|
||||
} else {
|
||||
@@ -1129,6 +1162,25 @@ impl<'a> Planner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
// Only exterior effects (drop shadows, outer glows): nothing is painted between the
|
||||
// layer's colour inside its shape and the shape's alpha, so the merge derives both from
|
||||
// the shape (`F_LAYER_SHAPE`) instead of two passes building the layer.
|
||||
let premerge = photocraft_compose::effects::premerges_layer(layer);
|
||||
if !relative && !premerge && items.iter().all(|e| matches!(e, Effect::DropShadow(_) | Effect::OuterGlow(_))) {
|
||||
let mut p = Pass::new(Kernel::FxMerge, 0);
|
||||
p.a = Some(w);
|
||||
p.b = Some(lay_src);
|
||||
p.c = Some(self.retain(backdrop));
|
||||
p.mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
|
||||
p.opacity = layer.opacity;
|
||||
p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer) | F_LAYER_SHAPE;
|
||||
p.params[0][0] = layer.fill_opacity;
|
||||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||||
p.clip = Some(clip);
|
||||
let merged = self.emit(p);
|
||||
self.release(content);
|
||||
return Ok(self.write_back(backdrop, merged, clip));
|
||||
}
|
||||
// The layer: its colour at fill opacity inside its shape; interior effects are painted
|
||||
// relative to the shape (coverage within it), then the shape's alpha applies.
|
||||
let mut l = if relative {
|
||||
@@ -1139,7 +1191,6 @@ impl<'a> Planner<'a> {
|
||||
};
|
||||
// Blend Interior Effects as Group off: the layer merges in its mode first and the
|
||||
// interior effects paint over the result (`effects::premerges_layer`).
|
||||
let premerge = photocraft_compose::effects::premerges_layer(layer);
|
||||
if premerge {
|
||||
let mut p = Pass::new(Kernel::Blend, 0);
|
||||
p.a = Some(self.retain(w));
|
||||
@@ -1806,6 +1857,16 @@ pub fn mode_index(m: BlendMode) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `Planner::content` draws `layer` as one `Planner::content_pass`: not a group's stack,
|
||||
/// an adjustment, or a pattern fill painted in passes.
|
||||
fn single_content_pass(layer: &Layer) -> bool {
|
||||
match &layer.content {
|
||||
LayerContent::Group(_) | LayerContent::Adjustment(_) => false,
|
||||
LayerContent::Fill(f @ Fill::Pattern { .. }) => layer.fill_cache.as_ref().is_some_and(|c| c.fill == *f),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1946,15 +2007,15 @@ mod tests {
|
||||
d.layers.push(Layer::raster(format!("l{i}"), d.pixel_format()));
|
||||
}
|
||||
let p = plan(&d).unwrap();
|
||||
// Empty layers draw nothing.
|
||||
assert_eq!(p.passes.len(), 1 + 2);
|
||||
// Empty layers draw nothing: the clear and the background's one fused pass.
|
||||
assert_eq!(p.passes.len(), 1 + 1);
|
||||
for (i, l) in d.layers.iter_mut().skip(1).enumerate() {
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(i as i32 % 4, 0, 4, 8), &[0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
let p = plan(&d).unwrap();
|
||||
assert!(p.slots <= 3, "{} slots", p.slots);
|
||||
// The background, then each small layer over its bounds and copied back.
|
||||
assert_eq!(p.passes.len(), 1 + 2 + 10 * 3);
|
||||
// The background, then each small layer blended over its bounds and copied back.
|
||||
assert_eq!(p.passes.len(), 1 + 1 + 10 * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1989,11 +2050,44 @@ mod tests {
|
||||
assert_eq!(p.fx.len(), 1);
|
||||
let m = photocraft_compose::effects::margin(&d.layers[1]);
|
||||
assert_eq!(p.fx[0].region, Rect::new(2, 2, 5, 5).inflate(m).intersect(&d.bounds().inflate(m)));
|
||||
// The effect result lands back in the backdrop slot.
|
||||
// The effect region covers the whole canvas: the merged result is the backdrop, no copy.
|
||||
assert!(p.fx[0].region.contains_rect(&d.bounds()));
|
||||
let last = p.passes.last().unwrap();
|
||||
assert_eq!(last.kernel, Kernel::CopyRect);
|
||||
assert_eq!(last.kernel, Kernel::FxMerge);
|
||||
assert_eq!(last.dst, p.root);
|
||||
assert!(p.slots <= 6, "{} slots", p.slots);
|
||||
// On a larger canvas the result lands back in the backdrop slot over the region only.
|
||||
let mut big = Document::with_background("t", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
big.layers.push(d.layers[1].clone());
|
||||
let p = plan(&big).unwrap();
|
||||
assert!(!p.fx[0].region.contains_rect(&big.bounds()));
|
||||
let last = p.passes.last().unwrap();
|
||||
assert_eq!((last.kernel, last.dst, last.clip), (Kernel::CopyRect, p.root, Some(p.fx[0].region)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exterior_effects_merge_from_the_shape() {
|
||||
// A drop shadow alone: the layer's pixels, the shadow, the merge. With an interior effect
|
||||
// the layer is built in passes of its own (colour inside the shape, the overlay, alpha).
|
||||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let mut l = Layer::raster("fx", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 0.5]);
|
||||
l.fill_opacity = 0.7;
|
||||
l.effects.items.push(photocraft_doc::Effect::default_drop_shadow());
|
||||
d.layers.push(l);
|
||||
let p = plan(&d).unwrap();
|
||||
let fx: Vec<Kernel> = p.passes.iter().skip(2).map(|p| p.kernel).collect();
|
||||
assert_eq!(fx, [Kernel::Content, Kernel::FxPaint, Kernel::FxMerge], "{:?}", p.passes);
|
||||
let merge = p.passes.last().unwrap();
|
||||
assert!(merge.flags & F_LAYER_SHAPE != 0);
|
||||
assert_eq!(merge.params[0][0], 0.7);
|
||||
d.layers[1]
|
||||
.effects
|
||||
.items
|
||||
.push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::WHITE });
|
||||
let p = plan(&d).unwrap();
|
||||
assert!(p.passes.iter().all(|p| p.flags & F_LAYER_SHAPE == 0));
|
||||
assert!(p.passes.iter().any(|p| p.kernel == Kernel::FxInit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::collections::HashMap;
|
||||
use photocraft_doc::{Document, Layer};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::plan::{Checkpoint, Plan};
|
||||
use crate::plan::Checkpoint;
|
||||
|
||||
pub(crate) const MAX_BYTES: u64 = 512 << 20;
|
||||
|
||||
@@ -104,8 +104,10 @@ impl Prefix {
|
||||
self.context.as_ref().map(|d| d.id)
|
||||
}
|
||||
|
||||
/// Drops the chunks `doc` invalidated and returns the checkpoint this render captures at.
|
||||
pub(crate) fn prepare(&mut self, doc: &Document, plan: &Plan<'_>) -> Option<Checkpoint> {
|
||||
/// Drops the chunks `doc` invalidated and returns the checkpoint this render captures at, one
|
||||
/// of `checkpoints` (the plan's that a batch run starts at). A chunk held at any other point
|
||||
/// is dropped too: resuming there would group the layers above it differently.
|
||||
pub(crate) fn prepare(&mut self, doc: &Document, checkpoints: &[Checkpoint]) -> Option<Checkpoint> {
|
||||
if self.budget == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -125,7 +127,7 @@ impl Prefix {
|
||||
if same_context {
|
||||
let mut freed = 0u64;
|
||||
self.chunks.retain(|_, c| {
|
||||
let keep = changed.is_none_or(|ch| c.checkpoint.layer_end <= ch) && plan.checkpoints.contains(&c.checkpoint);
|
||||
let keep = changed.is_none_or(|ch| c.checkpoint.layer_end <= ch) && checkpoints.contains(&c.checkpoint);
|
||||
if !keep {
|
||||
freed = freed.saturating_add(c.bytes);
|
||||
}
|
||||
@@ -135,12 +137,12 @@ impl Prefix {
|
||||
} else {
|
||||
self.clear();
|
||||
}
|
||||
let kept = self.checkpoint.filter(|c| same_context && changed.is_none() && plan.checkpoints.contains(c));
|
||||
let kept = self.checkpoint.filter(|c| same_context && changed.is_none() && checkpoints.contains(c));
|
||||
let checkpoint = kept.or_else(|| match changed {
|
||||
// Just below the edit; nothing when it is at the bottom.
|
||||
Some(changed) if same_context => plan.checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied(),
|
||||
Some(changed) if same_context => checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied(),
|
||||
_ => {
|
||||
let mut useful = plan.checkpoints.iter().rev().filter(|c| c.pass_end > 1);
|
||||
let mut useful = checkpoints.iter().rev().filter(|c| c.pass_end > 1);
|
||||
let last = useful.next().copied();
|
||||
useful.next().copied().or(last)
|
||||
}
|
||||
|
||||
+202
-30
@@ -78,32 +78,49 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The default adapter, or the one wgpu's `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choose: e.g.
|
||||
/// `WGPU_BACKEND=dx12 WGPU_DX12_COMPILER=fxc` tests D3D12 with FXC, the app's default on Windows,
|
||||
/// where the default adapter may be Vulkan. A backend asked for that can't run the tests fails
|
||||
/// them rather than skipping.
|
||||
fn gpu() -> Option<Gpu> {
|
||||
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let instance = wgpu::Instance::default();
|
||||
let required = std::env::var_os("WGPU_BACKEND").is_some();
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
|
||||
let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
assert!(!required, "WGPU_BACKEND is set but has no adapter: {e}");
|
||||
eprintln!("skipping GPU parity tests: no adapter ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
|
||||
eprintln!("GPU parity backend: {:?}", adapter.get_info().backend);
|
||||
// Exact parity needs 32-bit float targets. Adapters without them (e.g. GL software
|
||||
// rasterizers) use the CPU compositor in the app, so there is nothing to compare there.
|
||||
if Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
|
||||
eprintln!("skipping GPU parity tests: adapter can't render Rgba32Float");
|
||||
return None;
|
||||
}
|
||||
let comp = match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float) {
|
||||
Ok(c) => c,
|
||||
let info = adapter.get_info();
|
||||
let (device, queue) = match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("skipping GPU parity tests: {e}");
|
||||
assert!(!required, "WGPU_BACKEND is set but {} has no device: {e}", info.name);
|
||||
eprintln!("skipping GPU parity tests: no device ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut paged = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float).ok()?;
|
||||
eprintln!("GPU parity backend: {:?} ({}, {:?})", info.backend, info.name, info.device_type);
|
||||
// Exact parity needs 32-bit float targets. Adapters without them (e.g. GL software
|
||||
// rasterizers) use the CPU compositor in the app, so there is nothing to compare there.
|
||||
if Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
|
||||
assert!(!required, "WGPU_BACKEND is set but {} can't render Rgba32Float", info.name);
|
||||
eprintln!("skipping GPU parity tests: adapter can't render Rgba32Float");
|
||||
return None;
|
||||
}
|
||||
let compositor = || match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
assert!(!required, "WGPU_BACKEND is set but {} can't build the compositor: {e}", info.name);
|
||||
eprintln!("skipping GPU parity tests: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let comp = compositor()?;
|
||||
let mut paged = compositor()?;
|
||||
paged.set_texture_limit(PAGED_LIMIT);
|
||||
Some(Gpu { device, queue, comp, paged, _lock: lock })
|
||||
}
|
||||
@@ -151,6 +168,14 @@ fn mask(rect: Rect, seed: u32, default: f32) -> LayerMask {
|
||||
m
|
||||
}
|
||||
|
||||
/// `l` with a mask that reveals everything, so the executor draws it in a pass of its own rather
|
||||
/// than batched with the layer below: prefix tests resume at any layer (only where a batch run
|
||||
/// starts, `batch_runs`).
|
||||
fn alone(mut l: Layer) -> Layer {
|
||||
l.mask = Some(LayerMask::reveal_all());
|
||||
l
|
||||
}
|
||||
|
||||
fn base_doc(w: u32, h: u32) -> Document {
|
||||
let mut d = Document::new("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8);
|
||||
d.layers.push(noise_layer("bg", PixelFormat::RGBA8, Rect::from_xywh(0, 0, w, h), 1, 0.3));
|
||||
@@ -232,7 +257,7 @@ fn stack_prefix_reuse_matches_uncached_gpu_and_cpu_at_every_depth() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut doc = Document::new("reuse", Size::new(300, 70), ColorMode::Rgb, depth);
|
||||
for seed in 1..=4 {
|
||||
doc.layers.push(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2));
|
||||
doc.layers.push(alone(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2)));
|
||||
}
|
||||
check(&mut g, &doc, "populate prefix");
|
||||
for (opacity, fill, blend, visible) in [
|
||||
@@ -270,22 +295,31 @@ fn stack_prefix_reuse_matches_uncached_gpu_and_cpu_at_every_depth() {
|
||||
/// Duplicating a layer high in the stack, undoing and redoing it (#5286): the render resumes
|
||||
/// from the lower cached prefix and moves the cache up to just below the copy, so the undo and
|
||||
/// the redo skip more passes. Every render matches an uncached compositor bit for bit, at every
|
||||
/// depth.
|
||||
/// depth, also when plain layers are drawn several to a pass and the cache can only resume where
|
||||
/// a batch starts.
|
||||
#[test]
|
||||
fn prefix_follows_an_edit_up_the_stack_and_stays_exact() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut uncached = Compositor::new(&g.device);
|
||||
uncached.set_composite_cache_budget(0);
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for (depth, batched) in [SampleType::U8, SampleType::U16, SampleType::F32].into_iter().flat_map(|d| [(d, false), (d, true)]) {
|
||||
let mut doc = Document::new("follow", Size::new(300, 70), ColorMode::Rgb, depth);
|
||||
for seed in 1..=6 {
|
||||
doc.layers.push(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2));
|
||||
let l = noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2);
|
||||
doc.layers.push(if batched { l } else { alone(l) });
|
||||
}
|
||||
let mut exact = |g: &mut Gpu, doc: &Document, what: &str| -> photocraft_gpu::Stats {
|
||||
let stats = diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}"));
|
||||
let cached = render_to_vec(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).unwrap();
|
||||
let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, doc.bounds()).unwrap();
|
||||
assert_eq!(cached, cold, "{depth:?} {what}: reuse preserves the GPU accumulator exactly");
|
||||
let (cold, cold_stats) = photocraft_gpu::render_to_vec_stats(&mut uncached, &g.device, &g.queue, doc, doc.bounds()).unwrap();
|
||||
assert_eq!(cached, cold, "{depth:?} batched {batched} {what}: reuse preserves the GPU accumulator exactly");
|
||||
// Batched and single-layer draws needn't give the same bits on every backend (FXC
|
||||
// on D3D12): a render that captures the prefix groups the layers as an uncached one
|
||||
// does, wherever its checkpoint is.
|
||||
if stats.prefix_hits == 0 {
|
||||
assert_eq!(stats.layers_batched, cold_stats.layers_batched, "{depth:?} {what}: capturing changed the batches");
|
||||
}
|
||||
assert_eq!(cold_stats.layers_batched > 0, batched, "{depth:?} {what}");
|
||||
stats
|
||||
};
|
||||
exact(&mut g, &doc, "populate");
|
||||
@@ -296,15 +330,17 @@ fn prefix_follows_an_edit_up_the_stack_and_stays_exact() {
|
||||
let copy = doc.layers[4].duplicate();
|
||||
doc.layers.insert(5, copy);
|
||||
let first = exact(&mut g, &doc, "duplicate");
|
||||
assert!(first.prefix_hits > 0, "{depth:?} the duplicate resumes from the low prefix");
|
||||
let undo = exact(&mut g, &low, "undo");
|
||||
assert!(undo.prefix_hits > 0, "{depth:?}");
|
||||
assert!(undo.prefix_passes_skipped > first.prefix_passes_skipped, "{depth:?} the cache moved up: {first:?} {undo:?}");
|
||||
assert!(undo.prefix_hits > 0, "{depth:?} batched {batched}");
|
||||
if !batched {
|
||||
assert!(first.prefix_hits > 0, "{depth:?} the duplicate resumes from the low prefix");
|
||||
assert!(undo.prefix_passes_skipped > first.prefix_passes_skipped, "{depth:?} the cache moved up: {first:?} {undo:?}");
|
||||
}
|
||||
let redo = exact(&mut g, &doc, "redo");
|
||||
assert_eq!(redo.prefix_passes_skipped, undo.prefix_passes_skipped, "{depth:?}");
|
||||
assert_eq!(redo.prefix_passes_skipped, undo.prefix_passes_skipped, "{depth:?} batched {batched}");
|
||||
// An edit below the moved cache still invalidates it.
|
||||
doc.layers[2].opacity = 0.4;
|
||||
assert_eq!(exact(&mut g, &doc, "lower edit").prefix_hits, 0, "{depth:?}");
|
||||
assert_eq!(exact(&mut g, &doc, "lower edit").prefix_hits, 0, "{depth:?} batched {batched}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +355,7 @@ fn chunks_keep_their_own_checkpoint_after_a_partial_refresh() {
|
||||
uncached.set_composite_cache_budget(0);
|
||||
let mut doc = base_doc(600, 300);
|
||||
for seed in 2..=5 {
|
||||
doc.layers.push(noise_layer("layer", PixelFormat::RGBA8, doc.bounds(), seed, 0.2));
|
||||
doc.layers.push(alone(noise_layer("layer", PixelFormat::RGBA8, doc.bounds(), seed, 0.2)));
|
||||
}
|
||||
let mut render = |g: &mut Gpu, doc: &Document, rect: Rect, what: &str| -> photocraft_gpu::Stats {
|
||||
let (cached, stats) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, doc, rect).unwrap();
|
||||
@@ -435,8 +471,8 @@ fn disjoint_partial_damages_admit_whole_chunks_not_damage_rects() {
|
||||
fn prefix_partial_damage_context_occlusion_and_budget_are_conservative() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut doc = base_doc(600, 300);
|
||||
doc.layers.push(noise_layer("middle", PixelFormat::RGBA8, doc.bounds(), 2, 0.2));
|
||||
doc.layers.push(noise_layer("top", PixelFormat::RGBA8, doc.bounds(), 3, 0.2));
|
||||
doc.layers.push(alone(noise_layer("middle", PixelFormat::RGBA8, doc.bounds(), 2, 0.2)));
|
||||
doc.layers.push(alone(noise_layer("top", PixelFormat::RGBA8, doc.bounds(), 3, 0.2)));
|
||||
check(&mut g, &doc, "full prefix");
|
||||
// A partial rectangle inside an already-covered whole chunk is served as a sub-rect copy —
|
||||
// after a full render every in-bounds rectangle is covered, which is the point of whole
|
||||
@@ -1220,21 +1256,103 @@ fn small_layers_composite_over_their_bounds() {
|
||||
fn many_layers_submit_inside_one_chunk() {
|
||||
// P12: a refresh recorded every pass of a page cell into one command buffer, and with a few
|
||||
// hundred layers Metal ran out of command buffers (device lost, "pc_compose_uniforms is
|
||||
// invalid"). 700 layers need well over 1000 passes in the document's only chunk.
|
||||
// invalid"). 1200 layers need well over 1000 passes in the document's only chunk: each is
|
||||
// masked, so it is drawn in a pass of its own rather than batched with its neighbours.
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut d = base_doc(64, 48);
|
||||
let modes: Vec<BlendMode> = BlendMode::layer_modes().collect();
|
||||
for (i, mode) in modes.iter().copied().cycle().take(700).enumerate() {
|
||||
for (i, mode) in modes.iter().copied().cycle().take(1200).enumerate() {
|
||||
let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 500 + i as u32, 0.0);
|
||||
l.blend = mode;
|
||||
l.opacity = 0.9;
|
||||
l.mask = Some(mask(Rect::from_xywh(0, 0, 64, 48), 3000 + i as u32, 1.0));
|
||||
d.layers.push(l);
|
||||
}
|
||||
let stats = diff_rect(&mut g, &d, d.bounds(), "700 layers").unwrap_or_else(|e| panic!("{e}"));
|
||||
let stats = diff_rect(&mut g, &d, d.bounds(), "1200 layers").unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(stats.layers_batched, 0, "{stats:?}");
|
||||
assert_eq!(stats.chunks, 1);
|
||||
assert!(stats.flushes > 0, "{stats:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_of_plain_layers_blend_in_one_pass() {
|
||||
// Consecutive plain layers are drawn several to a pass (`fs_layerbatch`). Every layer mode at
|
||||
// varied opacity and fill (the fill-special modes included), solid fills, layers that end
|
||||
// inside the canvas, and the layers that end a run: masked, Dissolve, small (clipped to their
|
||||
// bounds) and clipping groups. Then an edit in the middle, so the prefix cache resumes
|
||||
// part-way up the stack and runs start at its checkpoint.
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut d = base_doc(96, 64);
|
||||
let modes: Vec<BlendMode> = BlendMode::layer_modes().collect();
|
||||
for (i, mode) in modes.iter().copied().cycle().take(90).enumerate() {
|
||||
let i32_ = i as i32;
|
||||
let rect = if i % 5 == 2 { Rect::from_xywh(-8, 10, 70, 40) } else { Rect::from_xywh(0, 0, 96, 64) };
|
||||
let mut l = noise_layer("l", PixelFormat::RGBA8, rect, 900 + i as u32, 0.0);
|
||||
l.blend = mode;
|
||||
l.opacity = 0.35 + 0.05 * (i % 13) as f32;
|
||||
l.fill_opacity = if i % 4 == 1 { 0.6 } else { 1.0 };
|
||||
if i % 11 == 7 {
|
||||
l.mask = Some(mask(Rect::from_xywh(i32_ % 30, 0, 50, 64), 70 + i as u32, 0.0));
|
||||
}
|
||||
if i % 23 == 9 {
|
||||
l.clipped = true;
|
||||
}
|
||||
d.layers.push(l);
|
||||
if i % 9 == 4 {
|
||||
let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.3, 0.6, 0.2, 0.4))));
|
||||
f.blend = modes[i % modes.len()];
|
||||
d.layers.push(f);
|
||||
}
|
||||
if i % 17 == 8 {
|
||||
d.layers.push(noise_layer("small", PixelFormat::RGBA8, Rect::from_xywh(20, 20, 6, 5), 40 + i as u32, 0.5));
|
||||
}
|
||||
}
|
||||
let stats = diff_rect(&mut g, &d, d.bounds(), "plain layer runs").unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(stats.layers_batched > 0, "no layers were batched: {stats:?}");
|
||||
let mid = d.layers.len() / 2;
|
||||
d.layers[mid].opacity = 0.5;
|
||||
let stats = diff_rect(&mut g, &d, d.bounds(), "plain layer runs, edited in the middle").unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(stats.layers_batched > 0, "{stats:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_of_normal_layers_blend_with_normal_code_alone() {
|
||||
// Runs whose layers all blend Normal draw with `fs_layerbatch_normal`: varied opacity and fill
|
||||
// over a partly transparent backdrop, masked layers leading runs, solid fills and layers that
|
||||
// end inside the canvas, at every depth. Lab documents mix Normal in CIELAB, so their runs
|
||||
// keep the full shader.
|
||||
let Some(mut g) = gpu() else { return };
|
||||
for (mode, depth) in
|
||||
[(ColorMode::Rgb, SampleType::U8), (ColorMode::Rgb, SampleType::U16), (ColorMode::Rgb, SampleType::F32), (ColorMode::Lab, SampleType::U8)]
|
||||
{
|
||||
let mut d = Document::new("normal", Size::new(96, 64), mode, depth);
|
||||
let fmt = d.pixel_format();
|
||||
d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, 96, 64), 5, 0.0));
|
||||
for i in 0..40u32 {
|
||||
let rect = if i % 5 == 3 { Rect::from_xywh(-6, 9, 70, 44) } else { Rect::from_xywh(0, 0, 96, 64) };
|
||||
let mut l = noise_layer("n", fmt, rect, 600 + i, if i % 3 == 0 { 0.0 } else { 0.4 });
|
||||
l.opacity = 0.2 + 0.07 * (i % 11) as f32;
|
||||
l.fill_opacity = [1.0, 0.55, 0.0, 0.8][i as usize % 4];
|
||||
if i % 7 == 2 {
|
||||
l.mask = Some(mask(Rect::from_xywh(i as i32 % 25, 0, 60, 64), 50 + i, 0.0));
|
||||
}
|
||||
d.layers.push(l);
|
||||
if i % 6 == 4 {
|
||||
let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.7, 0.2, 0.5, 0.6))));
|
||||
f.opacity = 0.45;
|
||||
d.layers.push(f);
|
||||
}
|
||||
}
|
||||
let what = format!("normal runs {mode:?} {depth:?}");
|
||||
let stats = diff_rect(&mut g, &d, d.bounds(), &what).unwrap_or_else(|e| panic!("{e}"));
|
||||
if mode == ColorMode::Lab {
|
||||
assert!(stats.layers_batched > 0 && stats.normal_batches == 0, "{what}: {stats:?}");
|
||||
} else {
|
||||
assert!(stats.normal_batches > 0, "{what}: {stats:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn blob(name: &str, fmt: PixelFormat, cx: f32, cy: f32, r: f32, color: [f32; 3]) -> Layer {
|
||||
let mut l = Layer::raster(name, fmt);
|
||||
let rect = Rect::new((cx - r - 12.0) as i32, (cy - r - 4.0) as i32, (cx + r + 14.0) as i32, (cy + r + 4.0) as i32);
|
||||
@@ -1570,6 +1688,60 @@ fn layer_effects_every_kind() {
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exterior_effects_merge_from_the_shape_at_any_fill() {
|
||||
// Drop shadows and outer glows alone: the merge builds the layer from its shape (fill inside
|
||||
// it, times its alpha) instead of passes of its own. Fill from 0 to 1, opacity, modes
|
||||
// (including one that blends with the knocked-out shadow), both depths, a layer covering the
|
||||
// canvas (no copy back), and the layer clipped onto a base (merged atop it).
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut ds = shadow(BlendMode::Multiply, 0.75, 120.0, 6.0, 8.0, 0.0);
|
||||
let mut ds_through = shadow(BlendMode::Normal, 0.9, 45.0, 4.0, 5.0, 0.2);
|
||||
ds_through.knocks_out = false;
|
||||
let og = glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 10.0, 0.3, GlowSource::Edge);
|
||||
ds.use_global_light = true;
|
||||
let stacks = [
|
||||
("drop shadow", vec![Effect::DropShadow(ds.clone())]),
|
||||
("drop shadow, not knocked out", vec![Effect::DropShadow(ds_through)]),
|
||||
("outer glow", vec![Effect::OuterGlow(og.clone())]),
|
||||
("shadow and glow", vec![Effect::OuterGlow(og), Effect::DropShadow(ds)]),
|
||||
];
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
for (name, fx) in &stacks {
|
||||
for (opacity, fill, blend) in [
|
||||
(1.0, 1.0, BlendMode::Normal),
|
||||
(0.8, 0.6, BlendMode::Normal),
|
||||
(1.0, 0.0, BlendMode::Normal),
|
||||
(0.7, 0.35, BlendMode::Multiply),
|
||||
(1.0, 0.5, BlendMode::Screen),
|
||||
] {
|
||||
let what = format!("{name} {depth:?} opacity {opacity} fill {fill} {blend:?}");
|
||||
let mut d = fx_doc(96, 80, depth);
|
||||
let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.6, 0.9]);
|
||||
l.effects.items = fx.clone();
|
||||
(l.opacity, l.fill_opacity, l.blend) = (opacity, fill, blend);
|
||||
d.layers.push(l.clone());
|
||||
fx_check(&mut g, &d, &what);
|
||||
// The same layer clipped onto a base.
|
||||
let mut base = blob("base", d.pixel_format(), 40.0, 44.0, 30.0, [0.9, 0.8, 0.3]);
|
||||
base.effects.items.clear();
|
||||
d.layers.pop();
|
||||
d.layers.push(base);
|
||||
l.clipped = true;
|
||||
d.layers.push(l.clone());
|
||||
fx_check(&mut g, &d, &format!("{what}, clipped"));
|
||||
// A partly transparent layer over the whole canvas.
|
||||
let mut d = fx_doc(64, 48, depth);
|
||||
let mut full = noise_layer("full", d.pixel_format(), Rect::from_xywh(0, 0, 64, 48), 7, 0.2);
|
||||
full.effects.items = fx.clone();
|
||||
(full.opacity, full.fill_opacity, full.blend) = (opacity, fill, blend);
|
||||
d.layers.push(full);
|
||||
fx_check(&mut g, &d, &format!("{what}, full canvas"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_effects_opacity_fill_blend_and_off_canvas() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
|
||||
@@ -59,7 +59,7 @@ fn a_deep_stack_above_the_device_buffer_limit_returns_an_error_without_panicking
|
||||
let Some((adapter, device, queue)) = constrained_device() else { return };
|
||||
let mut compositor = compositor(&adapter, &device);
|
||||
healthy_control(&mut compositor, &device, &queue);
|
||||
let deep = fills(150);
|
||||
let deep = fills(300);
|
||||
eprintln!(
|
||||
"uniform-limit reproduction: backend={:?}, buffer limit={}, page={}, passes={}",
|
||||
adapter.get_info().backend,
|
||||
@@ -80,7 +80,7 @@ fn an_oversized_stack_falls_back_without_faulting_the_watched_device() {
|
||||
let mut compositor = compositor(&adapter, &device);
|
||||
compositor.set_health(health.clone());
|
||||
healthy_control(&mut compositor, &device, &queue);
|
||||
let deep = fills(150);
|
||||
let deep = fills(300);
|
||||
let fallback = match render_to_vec(&mut compositor, &device, &queue, &deep, deep.bounds()) {
|
||||
Ok(_) => panic!("the oversized stack must return Unsupported for CPU fallback"),
|
||||
Err(_) => photocraft_compose::flatten(&deep),
|
||||
@@ -104,7 +104,8 @@ fn the_exact_buffer_limit_still_renders_with_cpu_parity() {
|
||||
let health = DeviceHealth::watch(&device);
|
||||
let mut compositor = compositor(&adapter, &device);
|
||||
compositor.set_health(health.clone());
|
||||
let boundary = fills(127);
|
||||
// Every fill layer is one pass (`Kernel::LayerBlend`) after the clear.
|
||||
let boundary = fills(254);
|
||||
assert_eq!(plan(&boundary).unwrap().passes.len(), 255, "one chunk plus255passes uses exactly65536bytes");
|
||||
let expected = photocraft_compose::flatten(&boundary);
|
||||
let actual = render_to_vec(&mut compositor, &device, &queue, &boundary, boundary.bounds()).expect("the exact supported buffer boundary is accepted");
|
||||
@@ -121,7 +122,7 @@ fn later_oversized_page_cells_are_rejected_before_render_or_encode_calls_the_sin
|
||||
let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some((adapter, device, queue)) = constrained_device() else { return };
|
||||
let health = DeviceHealth::watch(&device);
|
||||
let doc = fills_size(126, Size::new(4096, 2048));
|
||||
let doc = fills_size(252, Size::new(4096, 2048));
|
||||
let region = photocraft_geom::Rect::new(2047, 0, 4096, 2048);
|
||||
assert_eq!(plan(&doc).unwrap().passes.len(), 253);
|
||||
// Default pages are2048, chunks1024: the narrow first page has2chunks and fits;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//!
|
||||
//! Options (defaults in brackets): `--brushes N` [600], `--edge PX` largest tip edge [2000],
|
||||
//! `--min-edge PX` smallest tip edge [800], `--depth 8|16` [16], `--per-file N` presets per file [100],
|
||||
//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's 512 MB decoded cap.
|
||||
//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's decoded cap (1 GiB; 512 MiB on 32-bit).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use photocraft_paint::procedural::*;
|
||||
|
||||
+266
-19
@@ -294,6 +294,29 @@ pub(crate) fn gradient_stops_editable(grad: &Descriptor, method: Option<&[u8]>)
|
||||
raw
|
||||
}
|
||||
|
||||
/// The exact, constant-RGB subset of Photoshop noise gradients. If every RGB component has the
|
||||
/// same minimum and maximum, the generated field is constant regardless of seed or roughness.
|
||||
/// Other models, transparency, and varying ranges are not approximated.
|
||||
fn constant_rgb_noise_color(grad: &Descriptor) -> Option<Color> {
|
||||
if enum_of(grad, "GrdF")? != b"ClNs" || enum_of(grad, "ClrS")? != b"RGBC" || bool_of(grad, "ShTr") {
|
||||
return None;
|
||||
}
|
||||
let components = |key: &str| -> Option<[f32; 4]> {
|
||||
let Some(Value::List(values)) = grad.get(key) else { return None };
|
||||
if values.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let converted: Vec<f32> = values.iter().map(|value| num(Some(value)).map(|v| v as f32)).collect::<Option<_>>()?;
|
||||
converted.try_into().ok()
|
||||
};
|
||||
let min = components("Mnm ")?;
|
||||
let max = components("Mxm ")?;
|
||||
if min.iter().zip(max).take(3).any(|(a, b)| !a.is_finite() || *a < 0.0 || *a > 100.0 || *a != b) {
|
||||
return None;
|
||||
}
|
||||
Some(Color::rgb(min[0] / 100.0, min[1] / 100.0, min[2] / 100.0))
|
||||
}
|
||||
|
||||
fn gradient_stops_raw(grad: &Descriptor) -> RawStops {
|
||||
let mut stops = Vec::new();
|
||||
let mut mids = Vec::new();
|
||||
@@ -413,7 +436,8 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option<Fill> {
|
||||
b"GdFl" => {
|
||||
let angle = num(d.get("Angl")).unwrap_or(90.0) as f32;
|
||||
let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0);
|
||||
let raw = get_desc(d, "Grad").map(|g| gradient_stops_editable(g, gradient_method(d)));
|
||||
let gradient = get_desc(d, "Grad");
|
||||
let raw = gradient.map(|g| gradient_stops_editable(g, gradient_method(d)));
|
||||
let (mut stops, midpoints) = raw.map(|r| (r.stops, r.mids)).unwrap_or_default();
|
||||
// Opacity stops with Photoshop's smoothness and opacity midpoints applied (raw when
|
||||
// the interpolation is plain); none when fully opaque.
|
||||
@@ -422,7 +446,17 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option<Fill> {
|
||||
opacity_stops.clear();
|
||||
}
|
||||
if stops.is_empty() {
|
||||
stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)];
|
||||
// A provably constant-RGB noise gradient renders one exact colour. Any other
|
||||
// noise gradient (variable ranges, transparency, other models) has no stops to
|
||||
// read: keep the placeholder ramp rather than failing the whole fill — the
|
||||
// layer stays a fill layer, Photoshop's own pixels ride along as `fill_cache`,
|
||||
// and the raw `GdFl` block survives the round trip byte for byte. Failing
|
||||
// here would import the layer as pixels and drop `GdFl` on save (#6272).
|
||||
stops = gradient
|
||||
.filter(|g| enum_of(g, "GrdF") == Some(b"ClNs"))
|
||||
.and_then(constant_rgb_noise_color)
|
||||
.map(|color| vec![(0.0, color), (1.0, color)])
|
||||
.unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]);
|
||||
}
|
||||
let pct = |o: &Descriptor, k: &str| num(o.get(k)).map_or(0.0, |v| v as f32 / 100.0);
|
||||
let offset = get_desc(d, "Ofst").map_or((0.0, 0.0), |o| (pct(o, "Hrzn"), pct(o, "Vrtc")));
|
||||
@@ -525,22 +559,25 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool,
|
||||
)
|
||||
}
|
||||
|
||||
/// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions,
|
||||
/// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh. Read-only: the raw
|
||||
/// block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd`
|
||||
/// The warp of a placed layer (`SoLd`/`SoLE` `quiltWarp` or `warp` descriptor): style, bend,
|
||||
/// distortions, axis, bounds and, for `warpCustom`, the `customEnvelopeWarp` mesh: one 4 × 4
|
||||
/// patch, or the multi-patch grid Photoshop's split warp writes as `quiltWarp`. Read-only: the
|
||||
/// raw block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd`
|
||||
/// record keeps its warp elsewhere: see [`parse_plld_warp`].
|
||||
pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option<photocraft_geom::warp::Warp> {
|
||||
if key != b"SoLd" && key != b"SoLE" {
|
||||
return None;
|
||||
}
|
||||
let d = data.get(8..).and_then(parse_prefix_versioned)?;
|
||||
warp_of(get_desc(&d, "warp")?).flatten()
|
||||
// A split (quilt) warp is stored under `quiltWarp`; `warp` then holds no usable mesh.
|
||||
warp_of(get_desc(&d, "quiltWarp").or_else(|| get_desc(&d, "warp"))?).flatten()
|
||||
}
|
||||
|
||||
/// A `warp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` record's corners)
|
||||
/// as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves nothing), `None` when it
|
||||
/// can't be read (an unknown style, missing bounds, a mesh that isn't 4 × 4).
|
||||
fn warp_of(w: &Descriptor) -> Option<Option<photocraft_geom::warp::Warp>> {
|
||||
/// A `warp` or `quiltWarp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd`
|
||||
/// record's corners) as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves
|
||||
/// nothing), `None` when it can't be read (an unknown style, missing bounds, a mesh that is
|
||||
/// neither one 4 × 4 patch nor a consistent quilt grid).
|
||||
pub(crate) fn warp_of(w: &Descriptor) -> Option<Option<photocraft_geom::warp::Warp>> {
|
||||
use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle};
|
||||
let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?;
|
||||
if style == WarpStyle::None {
|
||||
@@ -561,16 +598,81 @@ fn warp_of(w: &Descriptor) -> Option<Option<photocraft_geom::warp::Warp>> {
|
||||
_ => None,
|
||||
};
|
||||
let (xs, ys) = (vals("Hrzn")?, vals("Vrtc")?);
|
||||
if xs.len() != 16 || ys.len() != 16 {
|
||||
if xs.len() != ys.len() {
|
||||
return None;
|
||||
}
|
||||
let mesh = BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points: xs.iter().zip(&ys).map(|(x, y)| [*x, *y]).collect() };
|
||||
let points: Vec<[f64; 2]> = xs.iter().zip(&ys).map(|(x, y)| [*x, *y]).collect();
|
||||
let mesh = if points.len() == 16 && env.get("quiltSliceX").is_none() {
|
||||
BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points }
|
||||
} else {
|
||||
quilt_mesh(w, env, points)?
|
||||
};
|
||||
warp.mesh = mesh.is_valid().then_some(mesh);
|
||||
warp.mesh.as_ref()?;
|
||||
}
|
||||
Some((!warp.is_identity()).then_some(warp))
|
||||
}
|
||||
|
||||
/// The most patches a `quiltWarp` is read with along each side.
|
||||
pub(crate) const QUILT_MAX_PATCHES: usize = 64;
|
||||
|
||||
/// The control-point grid of a split ("quilt") custom warp. `deformNumCols` × `deformNumRows`
|
||||
/// control points, three per patch side plus one, in row-major order; `quiltSliceX` and
|
||||
/// `quiltSliceY` are the patch boundaries in the mesh's own units, first and last at its edges,
|
||||
/// which become the knots on the unit square. `None` for anything inconsistent.
|
||||
fn quilt_mesh(w: &Descriptor, env: &Descriptor, points: Vec<[f64; 2]>) -> Option<photocraft_geom::warp::BezierMesh> {
|
||||
// Photoshop's split grid tops out far below this; it only bounds what a file can claim.
|
||||
const MAX_SIDE: usize = 3 * QUILT_MAX_PATCHES + 1;
|
||||
let count = |k: &str| -> Option<usize> {
|
||||
let n = num(w.get(k))?;
|
||||
(n.is_finite() && n >= 4.0 && n <= MAX_SIDE as f64 && n.fract() == 0.0).then_some(n as usize)
|
||||
};
|
||||
let (cols, rows) = (count("deformNumCols")?, count("deformNumRows")?);
|
||||
if (cols - 1) % 3 != 0 || (rows - 1) % 3 != 0 || points.len() != cols.checked_mul(rows)? {
|
||||
return None;
|
||||
}
|
||||
let us = quilt_knots(env, "quiltSliceX", (cols - 1) / 3)?;
|
||||
let vs = quilt_knots(env, "quiltSliceY", (rows - 1) / 3)?;
|
||||
Some(photocraft_geom::warp::BezierMesh { us, vs, points })
|
||||
}
|
||||
|
||||
/// The knots of a quilt's patch boundaries `key` (`quiltSliceX` or `quiltSliceY` in its
|
||||
/// `customEnvelopeWarp`): `patches + 1` strictly increasing slices, mapped onto `0..=1` with the
|
||||
/// first and last at the ends. `None` for anything else.
|
||||
pub(crate) fn quilt_knots(env: &Descriptor, key: &str, patches: usize) -> Option<Vec<f64>> {
|
||||
// Stored as an object array holding one unit-float list under the same key; a plain list
|
||||
// of numbers is taken too.
|
||||
let s: Vec<f64> = match env.get(key)? {
|
||||
Value::ObjectArray(arr) => match arr.body.get(key)? {
|
||||
Value::UnitFloats { values, .. } => values.clone(),
|
||||
Value::List(l) => l.iter().map(|v| num(Some(v))).collect::<Option<_>>()?,
|
||||
_ => return None,
|
||||
},
|
||||
Value::List(l) => l.iter().map(|v| num(Some(v))).collect::<Option<_>>()?,
|
||||
_ => return None,
|
||||
};
|
||||
let (first, last) = (*s.first()?, *s.last()?);
|
||||
let span = last - first;
|
||||
if s.len() != patches + 1 || !(span.is_finite() && span > 0.0) || !s.windows(2).all(|p| p[1] > p[0]) {
|
||||
return None;
|
||||
}
|
||||
let n = s.len() - 1;
|
||||
Some(
|
||||
s.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
if i == 0 {
|
||||
0.0
|
||||
} else if i == n {
|
||||
1.0
|
||||
} else {
|
||||
(v - first) / span
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Text content and transform from a `TySh` block (best effort).
|
||||
pub fn parse_tysh(data: &[u8]) -> Option<(String, Affine)> {
|
||||
let f = |at: usize| data.get(at..at + 8).map(|b| f64::from_be_bytes(b.try_into().unwrap_or([0; 8])));
|
||||
@@ -593,13 +695,12 @@ pub(crate) fn parse_prefix_versioned(data: &[u8]) -> Option<Descriptor> {
|
||||
}
|
||||
|
||||
/// The source-space box Photoshop maps onto a placed layer's `Trnf` quad: the bounding box of a
|
||||
/// single-patch custom warp's 16 control points, which may spill past the source, else the source
|
||||
/// rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice and stretches the
|
||||
/// placement. A degenerate or non-finite box falls back to the source rect.
|
||||
/// custom warp's control points (one patch or a split grid), which may spill past the source,
|
||||
/// else the source rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice
|
||||
/// and stretches the placement. A degenerate or non-finite box falls back to the source rect.
|
||||
pub(crate) fn placed_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h: f64) -> [f64; 4] {
|
||||
let rect = [0.0, 0.0, w, h];
|
||||
let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.points.len() == 16)
|
||||
else {
|
||||
let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.is_valid()) else {
|
||||
return rect;
|
||||
};
|
||||
let inf = f64::INFINITY;
|
||||
@@ -757,6 +858,43 @@ pub fn effects_enabled(lfx2: &[u8]) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn noise_gradient_fill(min: [i32; 4], max: [i32; 4], add_transparency: bool) -> Descriptor {
|
||||
let values = |v: [i32; 4]| Value::List(v.into_iter().map(Value::Integer).collect());
|
||||
let gradient = Descriptor::new("Grdn")
|
||||
.with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("ClNs") })
|
||||
.with("ClrS", Value::Enumerated { type_id: Id::new("ClrS"), value: Id::new("RGBC") })
|
||||
.with("ShTr", Value::Boolean(add_transparency))
|
||||
.with("VctC", Value::Boolean(true))
|
||||
.with("RndS", Value::Integer(9052369))
|
||||
.with("Smth", Value::Integer(2048))
|
||||
.with("Mnm ", values(min))
|
||||
.with("Mxm ", values(max));
|
||||
Descriptor::new("null").with("Grad", Value::Descriptor(gradient))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_rgb_noise_fill_uses_its_exact_colour() {
|
||||
use photocraft_doc::Fill;
|
||||
let fill = fill_from_desc(b"GdFl", &noise_gradient_fill([50, 25, 0, 0], [50, 25, 0, 100], false));
|
||||
let Some(Fill::Gradient { stops, .. }) = fill else { panic!("constant noise gradient") };
|
||||
assert_eq!(stops, vec![(0.0, Color::rgb(0.5, 0.25, 0.0)), (1.0, Color::rgb(0.5, 0.25, 0.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_or_transparent_noise_stays_a_fill_with_the_placeholder_ramp() {
|
||||
use photocraft_doc::Fill;
|
||||
// Photoshop's default Noise gradient (min 0, max 100) and a transparent variant: no
|
||||
// exact colour is provable, but the fill must parse — the layer keeps its type, its
|
||||
// stored pixels (`fill_cache`) and its raw `GdFl` block. Only the uncached preview
|
||||
// shows the placeholder ramp.
|
||||
for descriptor in [noise_gradient_fill([0, 0, 0, 0], [100, 100, 100, 100], false), noise_gradient_fill([50, 25, 0, 0], [50, 25, 0, 100], true)] {
|
||||
let Some(Fill::Gradient { stops, .. }) = fill_from_desc(b"GdFl", &descriptor) else { panic!("variable noise gradient must stay a fill layer") };
|
||||
assert_eq!(stops, vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels): a
|
||||
/// gradient fill's opacity stops reach the model (as its opacity stops; written from stop
|
||||
/// alpha) and survive a write; the ramp's alpha follows them.
|
||||
@@ -888,8 +1026,6 @@ mod tests {
|
||||
assert_eq!(super::brst_data(0), None);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn placed_layer_warp_is_read() {
|
||||
use photocraft_geom::warp::WarpStyle;
|
||||
@@ -1212,6 +1348,117 @@ mod more_tests {
|
||||
data
|
||||
}
|
||||
|
||||
/// `SoLd` data with a split (quilt) custom warp, as Photoshop writes it: a `quiltWarp`
|
||||
/// descriptor with `deformNumCols` × `deformNumRows` control points and the patch boundaries
|
||||
/// in `quiltSliceX` / `quiltSliceY`. With `plain_warp`, a `warpNone` `warp` key sits next to it.
|
||||
fn sold_with_quilt(trnf: [f64; 8], cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]], plain_warp: bool) -> Vec<u8> {
|
||||
use photocraft_psd::descriptor::ObjectArray;
|
||||
let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) };
|
||||
let bounds = || {
|
||||
Descriptor::new("Rctn")
|
||||
.with("Top ", Value::Double(0.0))
|
||||
.with("Left", Value::Double(0.0))
|
||||
.with("Btom", Value::Double(25.0))
|
||||
.with("Rght", Value::Double(50.0))
|
||||
};
|
||||
let slices = |k: &str, v: &[f64]| {
|
||||
let body = Descriptor::new("null").with(k, Value::UnitFloats { unit: *b"#Pxl", values: v.to_vec() });
|
||||
Value::ObjectArray(ObjectArray { prefix: v.len() as u32, body })
|
||||
};
|
||||
let mesh = Descriptor::new("null")
|
||||
.with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[0]).collect() })
|
||||
.with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[1]).collect() });
|
||||
let envelope = Descriptor::new("customEnvelopeWarp")
|
||||
.with("quiltSliceX", slices("quiltSliceX", sx))
|
||||
.with("quiltSliceY", slices("quiltSliceY", sy))
|
||||
.with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body: mesh }));
|
||||
let quilt = Descriptor::new("quiltWarp")
|
||||
.with("warpStyle", e("warpStyle", "warpCustom"))
|
||||
.with("bounds", Value::Descriptor(bounds()))
|
||||
.with("deformNumRows", Value::Integer(rows as i32))
|
||||
.with("deformNumCols", Value::Double(cols))
|
||||
.with("customEnvelopeWarp", Value::Descriptor(envelope));
|
||||
let mut d = Descriptor::new("null")
|
||||
.with("Idnt", Value::Text(UnicodeString::new_nul("uuid-1")))
|
||||
.with("Trnf", Value::List(trnf.map(Value::Double).to_vec()))
|
||||
.with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(50.0)).with("Hght", Value::Double(25.0))));
|
||||
if plain_warp {
|
||||
d = d.with(
|
||||
"warp",
|
||||
Value::Descriptor(Descriptor::new("warp").with("warpStyle", e("warpStyle", "warpNone")).with("bounds", Value::Descriptor(bounds()))),
|
||||
);
|
||||
}
|
||||
d = d.with("quiltWarp", Value::Descriptor(quilt));
|
||||
let mut data = b"soLD".to_vec();
|
||||
data.extend_from_slice(&4u32.to_be_bytes());
|
||||
data.extend(VersionedDescriptor::new(d).to_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
/// A 3 × 2 patch identity grid over the 50 × 25 source with one interior control point pulled.
|
||||
fn quilt_points() -> Vec<[f64; 2]> {
|
||||
let mut pts = photocraft_geom::warp::BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 3, 2).points;
|
||||
if let Some(p) = pts.get_mut(10 * 3 + 4) {
|
||||
p[1] += 3.0;
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// Photoshop's split warp (`quiltWarp`, mockups' usual warp) reads as a multi-patch mesh: the
|
||||
/// patch boundaries become the knots, the control points stay in order, and the quilt wins over
|
||||
/// the `warpNone` `warp` key written next to it. Before, any mesh but 4 × 4 was dropped and the
|
||||
/// layer re-rendered flat.
|
||||
#[test]
|
||||
fn quilt_warp_is_read_as_a_multi_patch_mesh() {
|
||||
let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0];
|
||||
let pts = quilt_points();
|
||||
// Photoshop pads the outer boundaries a little past the grid's edges.
|
||||
let data = sold_with_quilt(quad, 10.0, 7.0, &[-0.5, 12.5, 37.5, 50.5], &[-0.5, 12.5, 25.5], &pts, true);
|
||||
let warp = parse_placed_warp(b"SoLd", &data).expect("a quilt warp");
|
||||
let mesh = warp.mesh.as_ref().expect("its mesh");
|
||||
assert_eq!(mesh.points, pts);
|
||||
assert_eq!(mesh.us.len(), 4);
|
||||
assert_eq!(mesh.vs.len(), 3);
|
||||
assert_eq!((mesh.us[0], mesh.us[3], mesh.vs[0], mesh.vs[2]), (0.0, 1.0, 0.0, 1.0));
|
||||
assert!((mesh.us[1] - 13.0 / 51.0).abs() < 1e-12 && (mesh.vs[1] - 13.0 / 26.0).abs() < 1e-12);
|
||||
assert!(mesh.is_valid());
|
||||
}
|
||||
|
||||
/// A quilt's control points frame the placed quad as a single patch's do (#3677): a grid
|
||||
/// spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box.
|
||||
#[test]
|
||||
fn quilt_warp_control_box_frames_the_quad() {
|
||||
let mut pts = quilt_points();
|
||||
pts[0] = [-10.0, -5.0];
|
||||
if let Some(last) = pts.last_mut() {
|
||||
*last = [60.0, 25.0];
|
||||
}
|
||||
let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0];
|
||||
let data = sold_with_quilt(quad, 10.0, 7.0, &[0.0, 12.5, 37.5, 50.0], &[0.0, 12.5, 25.0], &pts, false);
|
||||
assert_eq!(parse_smart(b"SoLd", &data).1.m, [2.0, 0.0, 0.0, 2.0, 120.0, 210.0]);
|
||||
}
|
||||
|
||||
/// An inconsistent quilt reads as no warp rather than a wrong one, and never panics.
|
||||
#[test]
|
||||
fn inconsistent_quilt_warps_are_not_read() {
|
||||
let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0];
|
||||
let pts = quilt_points();
|
||||
let (sx, sy) = ([0.0, 12.5, 37.5, 50.0], [0.0, 12.5, 25.0]);
|
||||
let read =
|
||||
|cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]]| parse_placed_warp(b"SoLd", &sold_with_quilt(quad, cols, rows, sx, sy, pts, false));
|
||||
assert!(read(10.0, 7.0, &sx, &sy, &pts).is_some());
|
||||
assert!(read(9.0, 7.0, &sx, &sy, &pts).is_none(), "not three points per patch side plus one");
|
||||
assert!(read(13.0, 7.0, &sx, &sy, &pts).is_none(), "more points claimed than stored");
|
||||
assert!(read(10.0, 7.0, &[0.0, 12.5, 50.0], &sy, &pts).is_none(), "one slice per patch boundary");
|
||||
assert!(read(10.0, 7.0, &[0.0, 37.5, 12.5, 50.0], &sy, &pts).is_none(), "slices must increase");
|
||||
assert!(read(10.0, 7.0, &[0.0, f64::NAN, 37.5, 50.0], &sy, &pts).is_none());
|
||||
assert!(read(1e9, 7.0, &sx, &sy, &pts).is_none(), "an absurd grid is refused before any arithmetic");
|
||||
assert!(read(10.0, 7.0, &sx, &sy, &pts[..pts.len() - 1]).is_none());
|
||||
let mut nan = pts.clone();
|
||||
nan[3] = [f64::NAN, 0.0];
|
||||
assert!(read(10.0, 7.0, &sx, &sy, &nan).is_none());
|
||||
}
|
||||
|
||||
/// Photoshop's `Trnf` is the image of the custom warp's control-point bounding box, not of the
|
||||
/// source rect: a mesh spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box.
|
||||
#[test]
|
||||
|
||||
@@ -22,11 +22,12 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
return import_tiff_page(name, bytes, None);
|
||||
}
|
||||
let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?;
|
||||
// A palette BMP or PNG opens in Indexed Color mode with its colour table, as in Photoshop
|
||||
// (#3802, #4804).
|
||||
// A palette BMP, PNG or GIF opens in Indexed Color mode with its colour table, as in
|
||||
// Photoshop (#3802, #4804, #7886). A GIF is always palette-based: the first frame's table.
|
||||
let palette = match format {
|
||||
Some(Format::Bmp) => codecs::bmp_palette(bytes).ok().flatten().map(|c| (c, None)),
|
||||
Some(Format::Png) => codecs::png_palette(bytes).ok().flatten().map(|p| (p.colors, p.transparent)),
|
||||
Some(Format::Gif) => codecs::gif_palette(bytes),
|
||||
_ => None,
|
||||
};
|
||||
let mut r = image_to_document(name, &img)?;
|
||||
|
||||
@@ -51,6 +51,7 @@ mod kra;
|
||||
pub mod linked;
|
||||
mod multichannel_map;
|
||||
mod ora;
|
||||
mod palette_map;
|
||||
pub use ora::losses as ora_losses;
|
||||
pub mod pattern_map;
|
||||
pub mod pdf;
|
||||
|
||||
@@ -119,7 +119,27 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
|
||||
resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&names)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels, false)));
|
||||
}
|
||||
resources.extend(crate::psd_export::path_resources(doc, &mut warnings));
|
||||
// Alpha identifiers: one u32 per channel, only while they still match the channels.
|
||||
let keep = |id: u16, data: &[u8]| id != ALPHA_IDENTIFIERS || data.len() == channels.len().saturating_mul(4);
|
||||
let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth);
|
||||
let file = layerless_file(doc, header, image_data, color_mode_data, resources, keep, &mut warnings);
|
||||
(file, warnings)
|
||||
}
|
||||
|
||||
/// A PSD without layers around `image_data`: `resources` (the mode's own) plus the paths, XMP,
|
||||
/// EXIF and every other imported resource `keep` accepts (except the ones that only describe
|
||||
/// layers and any already in `resources`), and the document-level tagged blocks. Shared by the
|
||||
/// modes that have no layers in Photoshop: Multichannel, Indexed Color and Bitmap.
|
||||
pub(crate) fn layerless_file(
|
||||
doc: &Document,
|
||||
header: Header,
|
||||
image_data: ImageData,
|
||||
color_mode_data: Vec<u8>,
|
||||
mut resources: Vec<ImageResource>,
|
||||
keep: impl Fn(u16, &[u8]) -> bool,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> PsdFile {
|
||||
resources.extend(crate::psd_export::path_resources(doc, warnings));
|
||||
// The XMP and EXIF resolution follow the ResolutionInfo resource, so no copy contradicts it.
|
||||
let ppi = Some((doc.resolution_dpi, doc.resolution_dpi)).filter(|d| d.0 > 0.0);
|
||||
if let Some(x) = &doc.metadata.xmp {
|
||||
@@ -132,11 +152,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
|
||||
// ones that only describe layers and any already written above.
|
||||
let written: Vec<u16> = resources.iter().map(|r| r.id).collect();
|
||||
for (id, name, data) in &doc.metadata.psd_resources {
|
||||
if LAYER_ONLY_RESOURCES.contains(id) || written.contains(id) || !crate::psd_export::keep_imported_resource(doc, *id, data) {
|
||||
continue;
|
||||
}
|
||||
// Alpha identifiers: one u32 per channel, only while they still match the channels.
|
||||
if *id == ALPHA_IDENTIFIERS && data.len() != channels.len().saturating_mul(4) {
|
||||
if LAYER_ONLY_RESOURCES.contains(id) || written.contains(id) || !crate::psd_export::keep_imported_resource(doc, *id, data) || !keep(*id, data) {
|
||||
continue;
|
||||
}
|
||||
let mut r = ImageResource::new(*id, data.to_vec());
|
||||
@@ -152,10 +168,9 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
|
||||
let global_blocks: Vec<TaggedBlock> = crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc))
|
||||
.into_iter()
|
||||
.filter(|(_, key, _)| !matches!(key, b"Layr" | b"Lr16" | b"Lr32"))
|
||||
.map(|(sig, key, data)| crate::psd_export::global_block(sig, key, &data))
|
||||
.map(|(sig, key, data)| crate::psd_export::global_block(crate::psd_export::block_signature(sig, key, header.version), key, &data))
|
||||
.collect();
|
||||
let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth);
|
||||
let file = PsdFile {
|
||||
PsdFile {
|
||||
header,
|
||||
color_mode_data,
|
||||
resources,
|
||||
@@ -165,6 +180,5 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
|
||||
global_blocks,
|
||||
layer_mask_trailing: Vec::new(),
|
||||
image_data,
|
||||
};
|
||||
(file, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Indexed Color and Bitmap documents -> PSD colour modes 2 and 0.
|
||||
//!
|
||||
//! Neither mode has layers in Photoshop, so the file holds only the merged image (the document
|
||||
//! flattened): for Indexed Color one plane of palette indices, with the Color Table as Color Mode
|
||||
//! Data (768 bytes: 256 reds, 256 greens, 256 blues), its length in resource 1046 and the
|
||||
//! transparent entry in resource 1047; for Bitmap one plane of 1 bit per pixel, 1 = black.
|
||||
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::{ColorTable, Document};
|
||||
use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids};
|
||||
use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile};
|
||||
|
||||
/// Indexed Color Table Count: the number of colours in the table (u16).
|
||||
pub(crate) const COLOR_TABLE_COUNT: u16 = 1046;
|
||||
/// Transparency Index: the colour table entry shown as transparent (u16).
|
||||
pub(crate) const TRANSPARENCY_INDEX: u16 = 1047;
|
||||
/// Channel resources (names, display info, alpha identifiers): these modes have no extra channels.
|
||||
const CHANNEL_RESOURCES: [u16; 4] = [1006, 1045, 1053, crate::channel_map::DISPLAY_INFO];
|
||||
|
||||
/// The PSD for an Indexed Color document with a colour table of at most 256 entries, one of them
|
||||
/// opaque, or a Bitmap document; None for any other document (it is written like the others). A
|
||||
/// document with alpha channels or a Quick Mask is written like the others too: the importer does
|
||||
/// not read the extra channels of these modes back, so they would be lost.
|
||||
pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFile, Vec<String>)> {
|
||||
if !doc.channels.is_empty() || doc.quick_mask.is_some() {
|
||||
return None;
|
||||
}
|
||||
let table = doc.color_table.as_ref().filter(|t| t.colors.len() <= 256 && (0..t.colors.len()).any(|i| Some(i) != t.transparent.map(usize::from)));
|
||||
let (mode, plane, color_mode_data) = match doc.mode {
|
||||
ColorMode::Indexed => {
|
||||
let table = table?;
|
||||
(PsdMode::Indexed, indices(doc, table), palette(table))
|
||||
}
|
||||
ColorMode::Bitmap => (PsdMode::Bitmap, bits(doc), Vec::new()),
|
||||
_ => return None,
|
||||
};
|
||||
let mut warnings = Vec::new();
|
||||
if doc.layers.len() > 1 {
|
||||
warnings.push(format!("{:?} documents have no layers in Photoshop: written flattened", doc.mode));
|
||||
}
|
||||
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||||
let size_exceeds_limit = !force_psb && !big && crate::psd_export::psd_size_exceeds_limit(doc);
|
||||
let version = crate::psd_export::psd_version(doc, force_psb, size_exceeds_limit);
|
||||
let depth = if mode == PsdMode::Bitmap { 1 } else { 8 };
|
||||
let header = Header::new(version, doc.size.width, doc.size.height, 1, depth, mode);
|
||||
let image_data = ImageData::encode(Compression::Rle, &plane, &header)
|
||||
.or_else(|_| ImageData::encode(Compression::Raw, &plane, &header))
|
||||
.unwrap_or(ImageData { compression: Compression::Raw, data: plane });
|
||||
let mut resources = vec![
|
||||
ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes()),
|
||||
ImageResource::new(ids::GLOBAL_ANGLE, (doc.global_light.angle.round() as i32).to_be_bytes().to_vec()),
|
||||
ImageResource::new(ids::GLOBAL_ALTITUDE, (doc.global_light.altitude.round() as i32).to_be_bytes().to_vec()),
|
||||
ImageResource::new(1032, crate::psd_export::guides_resource(doc)),
|
||||
];
|
||||
if let (PsdMode::Indexed, Some(t)) = (mode, table) {
|
||||
resources.push(ImageResource::new(COLOR_TABLE_COUNT, (t.colors.len() as u16).to_be_bytes().to_vec()));
|
||||
if let Some(i) = t.transparent {
|
||||
resources.push(ImageResource::new(TRANSPARENCY_INDEX, u16::from(i).to_be_bytes().to_vec()));
|
||||
}
|
||||
// Photoshop keeps the RGB profile of an Indexed document (Bitmap has none). A profile of
|
||||
// another colour space (kept from a Grayscale or CMYK source) does not describe the table.
|
||||
if let Some(icc) = doc.icc_profile.as_ref().filter(|icc| icc.get(16..20) == Some(b"RGB ".as_slice())) {
|
||||
resources.push(ImageResource::new(ids::ICC_PROFILE, icc.to_vec()));
|
||||
}
|
||||
}
|
||||
// An imported transparency index that no longer applies is not copied either.
|
||||
let keep = |id: u16, _: &[u8]| !CHANNEL_RESOURCES.contains(&id) && id != TRANSPARENCY_INDEX && id != COLOR_TABLE_COUNT;
|
||||
let file = crate::multichannel_map::layerless_file(doc, header, image_data, color_mode_data, resources, keep, &mut warnings);
|
||||
Some((file, warnings))
|
||||
}
|
||||
|
||||
/// The Color Table as Color Mode Data: 256 reds, 256 greens, 256 blues (unused entries black).
|
||||
fn palette(t: &ColorTable) -> Vec<u8> {
|
||||
let mut m = vec![0u8; 768];
|
||||
for (i, c) in t.colors.iter().take(256).enumerate() {
|
||||
m[i] = c[0];
|
||||
m[256 + i] = c[1];
|
||||
m[512 + i] = c[2];
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The flattened document as palette indices: the transparent entry where the document is less
|
||||
/// than half covered, else the nearest other entry (the transparent one may duplicate an opaque
|
||||
/// colour, as in many PNG-8 files). Without a transparent entry the document is flattened over
|
||||
/// white, as Photoshop does.
|
||||
fn indices(doc: &Document, t: &ColorTable) -> Vec<u8> {
|
||||
let (opaque_indices, colors): (Vec<u8>, Vec<_>) =
|
||||
t.colors.iter().copied().enumerate().filter(|(i, _)| Some(*i) != t.transparent.map(usize::from)).map(|(i, c)| (i as u8, c)).unzip();
|
||||
let opaque = ColorTable { colors, transparent: None };
|
||||
let mut idx = Vec::with_capacity(doc.size.area() as usize);
|
||||
let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> {
|
||||
idx.extend(band.px.iter().map(|p| match t.transparent {
|
||||
Some(i) if p[3] < 0.5 => i,
|
||||
Some(_) => opaque_indices.get(opaque.nearest([p[0], p[1], p[2]])).copied().unwrap_or(0),
|
||||
None => {
|
||||
let over_white = |c: f32| c * p[3] + (1.0 - p[3]);
|
||||
t.nearest([over_white(p[0]), over_white(p[1]), over_white(p[2])]) as u8
|
||||
}
|
||||
}));
|
||||
Ok(())
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
/// The flattened document over white as 1-bit rows (padded to a byte), 1 = black: pixels darker
|
||||
/// than middle grey.
|
||||
fn bits(doc: &Document) -> Vec<u8> {
|
||||
let w = doc.size.width as usize;
|
||||
let row = w.div_ceil(8);
|
||||
let mut out = Vec::with_capacity(row * doc.size.height as usize);
|
||||
let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> {
|
||||
for line in band.px.chunks(w.max(1)) {
|
||||
let mut bytes = vec![0u8; row];
|
||||
for (x, p) in line.iter().enumerate() {
|
||||
let grey = (p[0] + p[1] + p[2]) / 3.0 * p[3] + (1.0 - p[3]);
|
||||
if grey < 0.5 {
|
||||
bytes[x / 8] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
out.extend(bytes);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
out
|
||||
}
|
||||
+89
-23
@@ -1516,9 +1516,16 @@ pub(crate) fn estimate_psd_size(doc: &Document) -> Option<u64> {
|
||||
|
||||
/// The signature a kept tagged block is written with. `8B64` exists only in PSB files: a PSD
|
||||
/// with an `8B64` block (kept from a PSB source, e.g. its global `FMsk` or `cinf`) is refused by
|
||||
/// Photoshop as "not compatible with this version" (measured in Photoshop 27.11).
|
||||
fn block_signature(sig: [u8; 4], version: Version) -> [u8; 4] {
|
||||
if version == Version::Psd && sig == *b"8B64" { *b"8BIM" } else { sig }
|
||||
/// Photoshop as "not compatible with this version" (measured in Photoshop 27.11). The other way
|
||||
/// round, a PSB block with an 8-byte length is `8B64`, as Photoshop writes it: an `8BIM` `lnk2`,
|
||||
/// `pths` or `cinf` (kept from a PSD source, whose lengths the PSB widens) makes Photoshop read
|
||||
/// the length as 4 bytes and refuse the whole file (#7937).
|
||||
pub(crate) fn block_signature(sig: [u8; 4], key: [u8; 4], version: Version) -> [u8; 4] {
|
||||
match version {
|
||||
Version::Psd if sig == *b"8B64" => *b"8BIM",
|
||||
Version::Psb if sig == *b"8BIM" && photocraft_psd::tagged::uses_long_length(version, &key) => *b"8B64",
|
||||
_ => sig,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn psd_version(doc: &Document, force_psb: bool, size_exceeds_limit: bool) -> Version {
|
||||
@@ -1535,7 +1542,17 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
if doc.mode == ColorMode::Multichannel {
|
||||
return crate::multichannel_map::document_to_psd(doc, opts.force_psb);
|
||||
}
|
||||
if let Some(file) = crate::palette_map::document_to_psd(doc, opts.force_psb) {
|
||||
return file;
|
||||
}
|
||||
let fmt = doc.pixel_format();
|
||||
// A Duotone keeps the inks it was imported with (their Color Mode Data is undocumented and
|
||||
// kept verbatim) over its grey plate; Photoshop has no 32-bit Duotone.
|
||||
let duotone_inks = doc
|
||||
.duotone
|
||||
.as_ref()
|
||||
.and_then(|d| d.psd_raw.as_ref())
|
||||
.filter(|_| doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample != SampleType::F32);
|
||||
let sample = fmt.sample;
|
||||
let cc = fmt.mode.color_channels();
|
||||
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||||
@@ -1568,7 +1585,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
} else if size_exceeds_limit && !opts.force_psb {
|
||||
ex.warnings.push("estimated encoded size exceeds 2 GB; written as PSB".into());
|
||||
}
|
||||
if fmt.mode != doc.mode {
|
||||
if fmt.mode != doc.mode && duotone_inks.is_none() {
|
||||
ex.warnings.push(format!("{:?} document written as {:?}", doc.mode, fmt.mode));
|
||||
}
|
||||
// Assign layer ids up front (stable across round trips): keep unique
|
||||
@@ -1597,26 +1614,36 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
ex.used_ids = used;
|
||||
// Text objects' number in the generated `Txt2` (#1348): a preserved `TySh` keeps the
|
||||
// number it already names — its `Txt2` object must sit at that slot for `apply_txt2` —
|
||||
// and new layers get the next free number.
|
||||
// and new layers get numbers past every kept one and every object in the file's `Txt2`.
|
||||
// Kept numbers are claimed first: a layer added below others must not take a number a
|
||||
// layer above it keeps, or each of those would be renumbered onto its neighbour's object
|
||||
// and take its style (#7773); nor may it land on a deleted layer's object.
|
||||
let mut used_txt = std::collections::HashSet::new();
|
||||
let mut next_txt = 0i32;
|
||||
ex.text_index = Default::default();
|
||||
let mut fresh = Vec::new();
|
||||
for (_, _, l) in &walk {
|
||||
let LayerContent::Text(t) = &l.content else {
|
||||
continue;
|
||||
};
|
||||
let kept = t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d));
|
||||
let index = match kept {
|
||||
Some(i) if used_txt.insert(i) => i,
|
||||
_ => {
|
||||
while used_txt.contains(&next_txt) {
|
||||
next_txt += 1;
|
||||
}
|
||||
used_txt.insert(next_txt);
|
||||
next_txt
|
||||
match t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)) {
|
||||
Some(i) if used_txt.insert(i) => {
|
||||
ex.text_index.insert(l.id, i);
|
||||
}
|
||||
};
|
||||
ex.text_index.insert(l.id, index);
|
||||
_ => fresh.push(l.id),
|
||||
}
|
||||
}
|
||||
let objects =
|
||||
doc.metadata.psd_global_blocks.iter().find(|(_, key, _)| key == b"Txt2").map_or(0, |(_, _, d)| photocraft_text::psd::txt2_object_count(d));
|
||||
let mut next_txt = used_txt.iter().max().map_or(0, |m| m + 1).max(i32::try_from(objects).unwrap_or(i32::MAX));
|
||||
if next_txt.saturating_add(fresh.len() as i32) > photocraft_text::psd::MAX_TEXT_INDEX + 1 {
|
||||
next_txt = 0;
|
||||
}
|
||||
for id in fresh {
|
||||
while used_txt.contains(&next_txt) {
|
||||
next_txt += 1;
|
||||
}
|
||||
used_txt.insert(next_txt);
|
||||
ex.text_index.insert(id, next_txt);
|
||||
}
|
||||
}
|
||||
// Progress: one unit per layer record, then the merged composite (#3389).
|
||||
@@ -1679,7 +1706,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0));
|
||||
}
|
||||
let channels = (cc + usize::from(has_alpha) + extra.len()) as u16;
|
||||
let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), psd_mode(fmt.mode));
|
||||
let mode = if duotone_inks.is_some() { PsdMode::Duotone } else { psd_mode(fmt.mode) };
|
||||
let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), mode);
|
||||
let mcomp = if sample == SampleType::F32 { Compression::Raw } else { opts.merged_compression };
|
||||
let image_data = ImageData::encode(mcomp, &planes, &header)
|
||||
.or_else(|_| ImageData::encode(Compression::Raw, &planes, &header))
|
||||
@@ -1745,8 +1773,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
// writes its empty `<< /0 << >> >>` stub — readers such as Photopea reject it (#3514). Keep the
|
||||
// block when the file had one (round-trip its pen positions/extras) or a layer is Optical (the
|
||||
// one mode EngineData can't carry; #1348).
|
||||
let txt2 =
|
||||
(previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers)).then(|| photocraft_text::psd::build_txt2(&text_layers, previous.as_deref()));
|
||||
let txt2 = (previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers))
|
||||
.then(|| photocraft_text::psd::build_txt2_at(&text_layers, previous.as_deref(), ex.dpi));
|
||||
for (sig, key, data) in preserved {
|
||||
if txt2.is_some() && key == *b"Txt2" {
|
||||
continue;
|
||||
@@ -1757,7 +1785,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") {
|
||||
continue;
|
||||
}
|
||||
global_blocks.push(global_block(block_signature(sig, version), key, &data));
|
||||
global_blocks.push(global_block(block_signature(sig, key, version), key, &data));
|
||||
}
|
||||
if let Some(txt2) = txt2 {
|
||||
let mut tb = TaggedBlock::new(*b"Txt2", txt2.clone());
|
||||
@@ -1822,7 +1850,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
}
|
||||
// 32-bit files carry Photoshop's HDR toning records as Color Mode Data; Photoshop will not
|
||||
// open a 32-bit file without them (#291).
|
||||
let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth);
|
||||
let color_mode_data = match duotone_inks {
|
||||
Some(raw) => raw.to_vec(),
|
||||
None => photocraft_psd::hdr::color_mode_data_for_depth(header.depth),
|
||||
};
|
||||
let file = PsdFile {
|
||||
header,
|
||||
color_mode_data,
|
||||
@@ -1896,8 +1927,14 @@ mod tests {
|
||||
let doc = crate::import("flat.psd", &file.to_bytes().unwrap()).unwrap().document;
|
||||
let bg = &doc.layers[0];
|
||||
assert!(bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks);
|
||||
// Bitmap has no layers in Photoshop (#4814): the file is saved flat in Bitmap mode, without
|
||||
// a layer section, and opens as a Background again.
|
||||
let out = document_to_psd(&doc);
|
||||
assert!(out.layer_info.unwrap().layers[0].channels.iter().all(|c| c.id >= 0));
|
||||
assert_eq!(out.header.color_mode, PsdMode::Bitmap);
|
||||
assert!(out.layer_info.as_ref().is_none_or(|li| li.layers.is_empty()));
|
||||
let back = crate::import("flat.psd", &out.to_bytes().unwrap()).unwrap().document;
|
||||
let bg = &back.layers[0];
|
||||
assert!(back.layers.len() == 1 && bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2077,6 +2114,35 @@ mod tests {
|
||||
assert_eq!(signatures(true), vec![(*b"8B64", *b"FMsk"), (*b"8BIM", *b"Patt")], "a PSB keeps 8B64");
|
||||
}
|
||||
|
||||
/// A PSB writes every global block with an 8-byte length as `8B64`, the way Photoshop does:
|
||||
/// kept from a PSD source, `lnk2`, `pths` and `cinf` arrive as `8BIM`, and with that signature
|
||||
/// and the PSB's 8-byte length Photoshop 27.11 refused the whole file (#7937). Blocks with a
|
||||
/// 4-byte length keep `8BIM`, and a PSD keeps `8BIM` throughout.
|
||||
#[test]
|
||||
fn psb_writes_long_length_blocks_as_8b64() {
|
||||
let mut doc = document(4, 4);
|
||||
doc.metadata.psd_global_blocks = vec![
|
||||
(*b"8BIM", *b"cinf", std::sync::Arc::new(vec![0, 0, 0, 16, 0, 0, 0, 0])),
|
||||
(*b"8BIM", *b"pths", std::sync::Arc::new(vec![0, 0, 0, 16, 0, 0, 0, 0])),
|
||||
(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new())),
|
||||
];
|
||||
let written = |force_psb| {
|
||||
let opts = PsdExportOptions { force_psb, ..PsdExportOptions::default() };
|
||||
let bytes = document_to_psd_with(&doc, &opts).0.to_bytes().unwrap();
|
||||
let file = PsdFile::from_bytes(&bytes).unwrap();
|
||||
let blocks = file.global_blocks.iter().map(|b| (b.signature, b.key, b.data.len())).collect::<Vec<_>>();
|
||||
(bytes, blocks)
|
||||
};
|
||||
let (bytes, blocks) = written(true);
|
||||
assert_eq!(blocks, vec![(*b"8B64", *b"cinf", 8), (*b"8B64", *b"pths", 8), (*b"8BIM", *b"Patt", 0)]);
|
||||
// On disk: the signature, the key, then the 8-byte length.
|
||||
let at = bytes.windows(8).position(|w| w == b"8B64cinf").expect("8B64cinf in the file");
|
||||
assert_eq!(bytes.get(at + 8..at + 16), Some(&[0u8, 0, 0, 0, 0, 0, 0, 8][..]));
|
||||
assert!(!bytes.windows(8).any(|w| w == b"8BIMcinf" || w == b"8BIMpths"));
|
||||
let (_, blocks) = written(false);
|
||||
assert_eq!(blocks, vec![(*b"8BIM", *b"cinf", 8), (*b"8BIM", *b"pths", 8), (*b"8BIM", *b"Patt", 0)], "a PSD keeps 8BIM");
|
||||
}
|
||||
|
||||
/// A layered document with transparency in the merged image and an alpha channel: the name
|
||||
/// and DisplayInfo resources hold one entry per extra channel, the merged transparency first,
|
||||
/// as in every file Photoshop opens (one short, Photoshop 27.11 refused the file with "Could
|
||||
|
||||
@@ -1001,8 +1001,12 @@ pub(crate) fn psd_to_document_decoded(
|
||||
if h.color_mode == PsdMode::Indexed && file.color_mode_data.len() >= 768 {
|
||||
// Planar palette: 256 reds, 256 greens, 256 blues (the Color Table).
|
||||
let m = &file.color_mode_data;
|
||||
let colors = (0..256).map(|i| [m[i], m[256 + i], m[512 + i]]).collect();
|
||||
doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent: None });
|
||||
// Resource 1046 says how many entries are used, 1047 which one is transparent.
|
||||
let u16_of = |id: u16| file.resource(id).and_then(|r| r.data.get(..2)).map(|b| u16::from_be_bytes([b[0], b[1]]));
|
||||
let count = u16_of(crate::palette_map::COLOR_TABLE_COUNT).map_or(256, usize::from).clamp(1, 256);
|
||||
let colors = (0..count).map(|i| [m[i], m[256 + i], m[512 + i]]).collect();
|
||||
let transparent = u16_of(crate::palette_map::TRANSPARENCY_INDEX).filter(|&i| usize::from(i) < count).map(|i| i as u8);
|
||||
doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent });
|
||||
} else if h.color_mode == PsdMode::Duotone && !file.color_mode_data.is_empty() {
|
||||
// The duotone ink block is undocumented: keep it raw; the image shows as its gray plate.
|
||||
doc.duotone =
|
||||
@@ -1012,7 +1016,19 @@ pub(crate) fn psd_to_document_decoded(
|
||||
cx.warn("color mode data is not preserved");
|
||||
}
|
||||
match flat_composite(file, merged.as_ref(), photocraft_psd::compression::MAX_DECODED_BYTES) {
|
||||
Ok(img) => {
|
||||
Ok(mut img) => {
|
||||
// Resource 1047: pixels of the transparent entry are transparent.
|
||||
if h.color_mode == PsdMode::Indexed
|
||||
&& let Some(t) = doc.color_table.as_ref().and_then(|t| t.transparent)
|
||||
&& let Some(Ok(all)) = &merged
|
||||
&& let Some(indices) = sizes.and_then(|(_, plane)| merged_plane(all, 0, plane))
|
||||
{
|
||||
for (px, &i) in img.data.as_chunks_mut::<4>().0.iter_mut().zip(indices) {
|
||||
if i == t {
|
||||
px[3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = Surface::new(fmt);
|
||||
write_rgba8(&mut s, canvas, &img.data);
|
||||
drop(img);
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use photocraft_cms::{Builtin, Intent, Profile, Transform};
|
||||
use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType};
|
||||
use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance};
|
||||
pub use photocraft_raw::{Tone, retone};
|
||||
pub use photocraft_raw::{EXTENSIONS, Tone, retone};
|
||||
|
||||
use crate::flat::image_to_document;
|
||||
use crate::{ImportResult, IoError};
|
||||
|
||||
+351
-50
@@ -24,7 +24,7 @@
|
||||
|
||||
use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{LayerMask, SmartFilter};
|
||||
use photocraft_geom::warp::{Warp, WarpStyle};
|
||||
use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle};
|
||||
use photocraft_geom::{Affine, Rect as GeomRect};
|
||||
use photocraft_psd::Compression;
|
||||
use photocraft_psd::Rect as PsdRect;
|
||||
@@ -716,6 +716,16 @@ fn set(d: &mut Descriptor, key: &str, v: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces item `key` in place, or inserts it before item `before` (appends it without one).
|
||||
fn set_before(d: &mut Descriptor, key: &str, v: Value, before: &str) {
|
||||
if let Some(e) = d.items.iter_mut().find(|(k, _)| k.is(key)) {
|
||||
e.1 = v;
|
||||
return;
|
||||
}
|
||||
let at = d.items.iter().position(|(k, _)| k.is(before)).unwrap_or(d.items.len());
|
||||
d.items.insert(at, (Id::new(key), v));
|
||||
}
|
||||
|
||||
/// Everything needed to write the placed-layer blocks of a smart object.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlacedSpec<'a> {
|
||||
@@ -735,16 +745,17 @@ pub struct PlacedSpec<'a> {
|
||||
}
|
||||
|
||||
/// Where the frame corners land (top-left, top-right, bottom-right, bottom-left), as x, y pairs.
|
||||
/// The frame is [`crate::blocks::placed_frame`] for an affine placement (the inverse of
|
||||
/// `parse_smart`), the source rect for a projective one (as `parse_smart_perspective` reads it).
|
||||
fn quad_points(s: &PlacedSpec<'_>) -> [f64; 8] {
|
||||
/// The frame is [`crate::blocks::placed_frame`] of `warp`, the warp the block stores, for an
|
||||
/// affine placement (the inverse of `parse_smart`), the source rect for a projective one (as
|
||||
/// `parse_smart_perspective` reads it).
|
||||
fn quad_points(s: &PlacedSpec<'_>, warp: Option<&Warp>) -> [f64; 8] {
|
||||
let (w, h) = s.size;
|
||||
let [a, b, c, d, e, f] = s.transform.m;
|
||||
let map = |x: f64, y: f64| match &s.perspective {
|
||||
Some(p) => photocraft_algo::transform::Homography(*p).apply(x, y),
|
||||
None => (a * x + c * y + e, b * x + d * y + f),
|
||||
};
|
||||
let [x0, y0, x1, y1] = if s.perspective.is_some() { [0.0, 0.0, w, h] } else { crate::blocks::placed_frame(s.warp, w, h) };
|
||||
let [x0, y0, x1, y1] = if s.perspective.is_some() { [0.0, 0.0, w, h] } else { crate::blocks::placed_frame(warp, w, h) };
|
||||
let mut out = [0.0; 8];
|
||||
for (i, (x, y)) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].into_iter().enumerate() {
|
||||
(out[2 * i], out[2 * i + 1]) = map(x, y);
|
||||
@@ -753,15 +764,15 @@ fn quad_points(s: &PlacedSpec<'_>) -> [f64; 8] {
|
||||
}
|
||||
|
||||
/// The transform quad: the source corners (top-left, top-right, bottom-right, bottom-left).
|
||||
fn quad(s: &PlacedSpec<'_>) -> Value {
|
||||
Value::List(quad_points(s).into_iter().map(Value::Double).collect())
|
||||
fn quad(s: &PlacedSpec<'_>, warp: Option<&Warp>) -> Value {
|
||||
Value::List(quad_points(s, warp).into_iter().map(Value::Double).collect())
|
||||
}
|
||||
|
||||
/// Has the placement moved from the quad a template descriptor stores (`Trnf`)?
|
||||
fn quad_moved(template: &Descriptor, s: &PlacedSpec<'_>) -> bool {
|
||||
fn quad_moved(template: &Descriptor, s: &PlacedSpec<'_>, warp: Option<&Warp>) -> bool {
|
||||
let Some(Value::List(pts)) = template.get("Trnf") else { return true };
|
||||
let old: Vec<f64> = pts.iter().filter_map(|v| crate::blocks::num(Some(v))).collect();
|
||||
let new = quad_points(s);
|
||||
let new = quad_points(s, warp);
|
||||
old.len() != 8 || old.iter().zip(new).any(|(a, b)| (a - b).abs() > 1e-6 * (1.0 + a.abs()))
|
||||
}
|
||||
|
||||
@@ -769,27 +780,33 @@ fn enumv(t: &str, v: &str) -> Value {
|
||||
Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }
|
||||
}
|
||||
|
||||
/// The `warp` descriptor (also the `PlLd` warp). Multi-patch meshes have no 1 × 1 envelope and
|
||||
/// are written as no warp, with a message.
|
||||
fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec<String>) -> Value {
|
||||
let none = Warp::none([0.0, 0.0, size.0, size.1]);
|
||||
let mut w = w.unwrap_or(&none).clone();
|
||||
let mesh = match (&w.style, &w.mesh) {
|
||||
(WarpStyle::Custom, Some(m)) if m.points.len() == 16 => Some(m.clone()),
|
||||
(WarpStyle::Custom, _) => {
|
||||
warnings.push("a custom warp with more than one patch was written to PSD without its warp".into());
|
||||
w = none.clone();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
/// The mesh of a split warp: a custom warp of more than one patch, which `SoLd` stores as
|
||||
/// `quiltWarp` and `PlLd` can't store.
|
||||
fn split_mesh(w: Option<&Warp>) -> Option<&BezierMesh> {
|
||||
w.filter(|w| w.style == WarpStyle::Custom).and_then(|w| w.mesh.as_ref()).filter(|m| m.points.len() != 16 && m.is_valid())
|
||||
}
|
||||
|
||||
/// The warp `SoLd` stores: none, with a message, for a split warp of more patches along a side
|
||||
/// than a `quiltWarp` is read with, so its corners are framed by the source rect as on reopen.
|
||||
fn sold_warp<'a>(w: Option<&'a Warp>, warnings: &mut Vec<String>) -> Option<&'a Warp> {
|
||||
let too_big = split_mesh(w).is_some_and(|m| m.us.len().max(m.vs.len()) > crate::blocks::QUILT_MAX_PATCHES + 1);
|
||||
if too_big {
|
||||
warnings.push(format!("a split warp of more than {} patches along a side was written to PSD without its warp", crate::blocks::QUILT_MAX_PATCHES));
|
||||
return None;
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// Style, bend, distortions, axis, bounds and order: the keys a `warp` and a `quiltWarp`
|
||||
/// descriptor share, in Photoshop's order.
|
||||
fn warp_head(class: &str, w: &Warp) -> Descriptor {
|
||||
let [x0, y0, x1, y1] = w.bounds;
|
||||
let bounds = Descriptor::new("classFloatRect")
|
||||
.with("Top ", Value::Double(y0))
|
||||
.with("Left", Value::Double(x0))
|
||||
.with("Btom", Value::Double(y1))
|
||||
.with("Rght", Value::Double(x1));
|
||||
let mut d = Descriptor::new("warp")
|
||||
Descriptor::new(class)
|
||||
.with("warpStyle", enumv("warpStyle", w.style.psd_name()))
|
||||
.with("warpValue", Value::Double(w.bend))
|
||||
.with("warpPerspective", Value::Double(w.h_distort))
|
||||
@@ -797,19 +814,74 @@ fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec<String>) ->
|
||||
.with("warpRotate", enumv("Ornt", if w.vertical { "Vrtc" } else { "Hrzn" }))
|
||||
.with("bounds", Value::Descriptor(bounds))
|
||||
.with("uOrder", Value::Integer(4))
|
||||
.with("vOrder", Value::Integer(4));
|
||||
.with("vOrder", Value::Integer(4))
|
||||
}
|
||||
|
||||
/// The control points of a custom warp: an object array of their x and y.
|
||||
fn mesh_points(m: &BezierMesh) -> Value {
|
||||
let body = Descriptor::new("rationalPoint")
|
||||
.with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: m.points.iter().map(|p| p[0]).collect() })
|
||||
.with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: m.points.iter().map(|p| p[1]).collect() });
|
||||
Value::ObjectArray(ObjectArray { prefix: m.points.len() as u32, body })
|
||||
}
|
||||
|
||||
/// The `warp` descriptor (also the `PlLd` warp). A split warp is written as no warp: `SoLd`
|
||||
/// keeps it in `quiltWarp` ([`quilt_desc`]), next to this `warpNone`, as Photoshop does. A
|
||||
/// custom warp without a usable mesh is written as no warp, with a message.
|
||||
fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec<String>) -> Value {
|
||||
let none = Warp::none([0.0, 0.0, size.0, size.1]);
|
||||
let mut w = w.unwrap_or(&none).clone();
|
||||
let mesh = match (&w.style, &w.mesh) {
|
||||
(WarpStyle::Custom, Some(m)) if m.points.len() == 16 => Some(m.clone()),
|
||||
(WarpStyle::Custom, Some(m)) if m.is_valid() => {
|
||||
w = none.clone();
|
||||
None
|
||||
}
|
||||
(WarpStyle::Custom, _) => {
|
||||
warnings.push("a custom warp without a valid mesh was written to PSD without its warp".into());
|
||||
w = none.clone();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let mut d = warp_head("warp", &w);
|
||||
if let Some(m) = mesh {
|
||||
let body = Descriptor::new("rationalPoint")
|
||||
.with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: m.points.iter().map(|p| p[0]).collect() })
|
||||
.with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: m.points.iter().map(|p| p[1]).collect() });
|
||||
d = d.with(
|
||||
"customEnvelopeWarp",
|
||||
Value::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body }))),
|
||||
);
|
||||
d = d.with("customEnvelopeWarp", Value::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", mesh_points(&m))));
|
||||
}
|
||||
Value::Descriptor(d)
|
||||
}
|
||||
|
||||
/// The `quiltWarp` descriptor of a split warp, in Photoshop's layout: `deformNumCols` ×
|
||||
/// `deformNumRows` control points, and the patch boundaries (`quiltSliceX` / `quiltSliceY`) at
|
||||
/// the knots across the warp's box. The imported `quiltWarp` (`old`) gives its own slices where
|
||||
/// they still bound the mesh's patches, so their units and padding stay Photoshop's.
|
||||
fn quilt_desc(w: &Warp, mesh: &BezierMesh, old: Option<&Descriptor>) -> Value {
|
||||
let old_env = old.and_then(|q| get_desc(q, "customEnvelopeWarp"));
|
||||
let [x0, y0, x1, y1] = w.bounds;
|
||||
let slices = |key: &str, knots: &[f64], lo: f64, hi: f64| {
|
||||
if let Some(env) = old_env
|
||||
&& crate::blocks::quilt_knots(env, key, knots.len() - 1).as_deref() == Some(knots)
|
||||
&& let Some(v) = env.get(key)
|
||||
{
|
||||
return v.clone();
|
||||
}
|
||||
let (lo, hi) = if (hi - lo).is_finite() && hi > lo { (lo, hi) } else { (0.0, 1.0) };
|
||||
let values: Vec<f64> = knots.iter().map(|u| lo + u * (hi - lo)).collect();
|
||||
let prefix = values.len() as u32;
|
||||
Value::ObjectArray(ObjectArray { prefix, body: Descriptor::new("UntF").with(key, Value::UnitFloats { unit: *b"#Pxl", values }) })
|
||||
};
|
||||
let envelope = Descriptor::new("customEnvelopeWarp")
|
||||
.with("quiltSliceX", slices("quiltSliceX", &mesh.us, x0, x1))
|
||||
.with("quiltSliceY", slices("quiltSliceY", &mesh.vs, y0, y1))
|
||||
.with("meshPoints", mesh_points(mesh));
|
||||
Value::Descriptor(
|
||||
warp_head("quiltWarp", w)
|
||||
.with("deformNumRows", Value::Integer(mesh.ny() as i32))
|
||||
.with("deformNumCols", Value::Integer(mesh.nx() as i32))
|
||||
.with("customEnvelopeWarp", Value::Descriptor(envelope)),
|
||||
)
|
||||
}
|
||||
|
||||
fn ratio(n: i32, d: i32) -> Value {
|
||||
Value::Descriptor(Descriptor::new("null").with("numerator", Value::Integer(n)).with("denominator", Value::Integer(d)))
|
||||
}
|
||||
@@ -823,20 +895,36 @@ fn pad4(mut v: Vec<u8>) -> Vec<u8> {
|
||||
|
||||
/// `SoLd` block data. With `template` (the imported descriptor) every key it has is kept and only
|
||||
/// the placed id (for a duplicate), transform (when it moved), warp and filter stack are rewritten;
|
||||
/// otherwise a fresh descriptor in Photoshop's key order.
|
||||
/// otherwise a fresh descriptor in Photoshop's key order. A split warp goes in `quiltWarp`.
|
||||
pub fn sold_bytes(template: Option<&Descriptor>, s: &PlacedSpec<'_>, warnings: &mut Vec<String>) -> Vec<u8> {
|
||||
let warp = warp_desc(s.warp, s.size, warnings);
|
||||
let stored = sold_warp(s.warp, warnings);
|
||||
let warp = warp_desc(stored, s.size, warnings);
|
||||
let split = stored.zip(split_mesh(stored));
|
||||
let d = match template {
|
||||
Some(t) => {
|
||||
let mut d = t.clone();
|
||||
if text_of(t, "placed").as_deref() != Some(s.placed) {
|
||||
set(&mut d, "placed", Value::Text(UnicodeString::new_nul(s.placed)));
|
||||
}
|
||||
if quad_moved(t, s) {
|
||||
set(&mut d, "Trnf", quad(s));
|
||||
set(&mut d, "nonAffineTransform", quad(s));
|
||||
if quad_moved(t, s, stored) {
|
||||
set(&mut d, "Trnf", quad(s, stored));
|
||||
set(&mut d, "nonAffineTransform", quad(s, stored));
|
||||
}
|
||||
set(&mut d, "warp", warp);
|
||||
// `quiltWarp` is read before `warp`, so the imported one stays only while it still
|
||||
// says what the layer's warp is (unchanged, or unreadable while the layer has none);
|
||||
// otherwise it is rewritten, or dropped for a warp that isn't split.
|
||||
let old = get_desc(t, "quiltWarp");
|
||||
let kept = old.is_some_and(|q| match crate::blocks::warp_of(q) {
|
||||
Some(w) => w.as_ref() == stored,
|
||||
None => s.warp.is_none(),
|
||||
});
|
||||
if !kept {
|
||||
match split {
|
||||
Some((w, m)) => set_before(&mut d, "quiltWarp", quilt_desc(w, m, old), "warp"),
|
||||
None => d.items.retain(|(k, _)| !k.is("quiltWarp")),
|
||||
}
|
||||
}
|
||||
match &s.filter_fx {
|
||||
Some(fx) => set(&mut d, "filterFX", Value::Descriptor(fx.clone())),
|
||||
None => d.items.retain(|(k, _)| !k.is("filterFX")),
|
||||
@@ -855,8 +943,12 @@ pub fn sold_bytes(template: Option<&Descriptor>, s: &PlacedSpec<'_>, warnings: &
|
||||
.with("frameCount", Value::Integer(1))
|
||||
.with("Annt", Value::Integer(16))
|
||||
.with("Type", Value::Integer(2))
|
||||
.with("Trnf", quad(s))
|
||||
.with("nonAffineTransform", quad(s))
|
||||
.with("Trnf", quad(s, stored))
|
||||
.with("nonAffineTransform", quad(s, stored));
|
||||
if let Some((w, m)) = split {
|
||||
d = d.with("quiltWarp", quilt_desc(w, m, None));
|
||||
}
|
||||
let mut d = d
|
||||
.with("warp", warp)
|
||||
.with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(s.size.0)).with("Hght", Value::Double(s.size.1))))
|
||||
.with("Rslt", unit(b"#Rsl", s.dpi));
|
||||
@@ -874,8 +966,9 @@ pub fn sold_bytes(template: Option<&Descriptor>, s: &PlacedSpec<'_>, warnings: &
|
||||
}
|
||||
|
||||
/// `PlLd` block data (`plcL` version 3): the legacy placed-layer record Photoshop writes next to
|
||||
/// `SoLd`.
|
||||
/// `SoLd`. It has no split warp: it stores none, its corners framed by the source rect.
|
||||
pub fn plld_bytes(s: &PlacedSpec<'_>, warnings: &mut Vec<String>) -> Vec<u8> {
|
||||
let warp = s.warp.filter(|_| split_mesh(s.warp).is_none());
|
||||
let mut out = b"plcL".to_vec();
|
||||
out.extend_from_slice(&3u32.to_be_bytes());
|
||||
let id = s.idnt.as_bytes();
|
||||
@@ -885,7 +978,7 @@ pub fn plld_bytes(s: &PlacedSpec<'_>, warnings: &mut Vec<String>) -> Vec<u8> {
|
||||
for v in [1i32, 1, 16, 2] {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
if let Value::List(q) = quad(s) {
|
||||
if let Value::List(q) = quad(s, warp) {
|
||||
for v in q {
|
||||
if let Value::Double(x) = v {
|
||||
out.extend_from_slice(&x.to_be_bytes());
|
||||
@@ -893,7 +986,7 @@ pub fn plld_bytes(s: &PlacedSpec<'_>, warnings: &mut Vec<String>) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&0u32.to_be_bytes());
|
||||
let Value::Descriptor(w) = warp_desc(s.warp, s.size, warnings) else { return pad4(out) };
|
||||
let Value::Descriptor(w) = warp_desc(warp, s.size, warnings) else { return pad4(out) };
|
||||
out.extend(VersionedDescriptor::new(w).to_bytes());
|
||||
pad4(out)
|
||||
}
|
||||
@@ -1516,14 +1609,14 @@ mod tests {
|
||||
let mut w = Vec::new();
|
||||
let mut spec = PlacedSpec { idnt: "id", placed: "pl", transform: t, perspective: None, size: (20.0, 10.0), dpi: 72.0, warp: None, filter_fx: None };
|
||||
let template = parse_sold(&sold_bytes(None, &spec, &mut w)).expect("parses").descriptor;
|
||||
assert!(!quad_moved(&template, &spec), "same placement");
|
||||
assert!(!quad_moved(&template, &spec, spec.warp), "same placement");
|
||||
spec.transform = Affine { m: [1.0, 0.0, 0.0, 1.0, 3.5, 4.0] };
|
||||
assert!(quad_moved(&template, &spec), "moved half a pixel");
|
||||
assert!(quad_moved(&template, &spec, spec.warp), "moved half a pixel");
|
||||
spec.transform = t;
|
||||
spec.perspective =
|
||||
photocraft_algo::transform::Homography::rect_to_quad([0.0, 0.0, 20.0, 10.0], [[3.0, 4.0], [23.0, 4.0], [20.0, 12.0], [3.0, 14.0]]).map(|h| h.0);
|
||||
assert!(quad_moved(&template, &spec), "one corner moved (Distort)");
|
||||
assert!(quad_moved(&Descriptor::new("null"), &spec), "no stored quad");
|
||||
assert!(quad_moved(&template, &spec, spec.warp), "one corner moved (Distort)");
|
||||
assert!(quad_moved(&Descriptor::new("null"), &spec, spec.warp), "no stored quad");
|
||||
}
|
||||
|
||||
/// Corrupted placed-layer data and filter descriptors never panic: they parse to something
|
||||
@@ -1579,13 +1672,15 @@ mod tests {
|
||||
let mut custom = BezierMesh::identity(bounds, 1, 1);
|
||||
custom.points[15] = [67.0, 30.0];
|
||||
let mut multi = Warp::preset(WarpStyle::Custom, 0.0, bounds);
|
||||
multi.mesh = Some(BezierMesh::identity(bounds, 2, 2));
|
||||
let mut split = BezierMesh::identity(bounds, 2, 2);
|
||||
split.points[8][1] += 4.0;
|
||||
multi.mesh = Some(split);
|
||||
let mut arc = Warp::preset(WarpStyle::Arc, 40.0, bounds);
|
||||
arc.vertical = true;
|
||||
arc.h_distort = 10.0;
|
||||
let mut c = Warp::preset(WarpStyle::Custom, 0.0, bounds);
|
||||
c.mesh = Some(custom);
|
||||
for (w, back) in [(Some(arc.clone()), Some(arc)), (Some(c.clone()), Some(c)), (Some(multi), None), (None, None)] {
|
||||
for (w, back) in [(Some(arc.clone()), Some(arc)), (Some(c.clone()), Some(c)), (Some(multi.clone()), Some(multi)), (None, None)] {
|
||||
let mut warnings = Vec::new();
|
||||
let spec = PlacedSpec {
|
||||
idnt: "i",
|
||||
@@ -1599,10 +1694,216 @@ mod tests {
|
||||
};
|
||||
let sold = sold_bytes(None, &spec, &mut warnings);
|
||||
assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), back);
|
||||
assert_eq!(warnings.len(), usize::from(w.is_some() && back.is_none()));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed layer over a 50 × 25 source, at `transform`, with `warp`.
|
||||
fn placed<'a>(transform: Affine, warp: Option<&'a Warp>) -> PlacedSpec<'a> {
|
||||
PlacedSpec { idnt: "i", placed: "p", transform, perspective: None, size: (50.0, 25.0), dpi: 72.0, warp, filter_fx: None }
|
||||
}
|
||||
|
||||
/// A 3 × 2 patch split warp over the 50 × 25 source with one interior control point pulled.
|
||||
fn split_warp() -> Warp {
|
||||
use photocraft_geom::warp::BezierMesh;
|
||||
let mut mesh = BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 3, 2);
|
||||
mesh.points[10 * 3 + 4][1] += 3.0;
|
||||
Warp::custom(mesh, [0.0, 0.0, 50.0, 25.0])
|
||||
}
|
||||
|
||||
/// An imported `soLD` descriptor holding `warp` as Photoshop writes a split warp: a
|
||||
/// `quiltWarp` whose outer slices sit 0.6 px past the source, before a `warpNone` `warp`.
|
||||
fn photoshop_template(warp: &Warp) -> Descriptor {
|
||||
let mesh = warp.mesh.as_ref().expect("a mesh");
|
||||
let slices = |k: &str, knots: &[f64], len: f64| {
|
||||
let n = knots.len() - 1;
|
||||
let values = knots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, u)| {
|
||||
if i == 0 {
|
||||
-0.6
|
||||
} else if i == n {
|
||||
len + 0.6
|
||||
} else {
|
||||
u * len
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let prefix = values.len() as u32;
|
||||
Value::ObjectArray(ObjectArray { prefix, body: Descriptor::new("UntF").with(k, Value::UnitFloats { unit: *b"#Pxl", values }) })
|
||||
};
|
||||
let Value::Descriptor(plain) = warp_desc(None, (50.0, 25.0), &mut Vec::new()) else { panic!("a descriptor") };
|
||||
let mut quilt = plain.clone();
|
||||
quilt.class_id = Id::new("quiltWarp");
|
||||
set(&mut quilt, "warpStyle", enumv("warpStyle", "warpCustom"));
|
||||
let points = Descriptor::new("rationalPoint")
|
||||
.with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: mesh.points.iter().map(|p| p[0]).collect() })
|
||||
.with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: mesh.points.iter().map(|p| p[1]).collect() });
|
||||
let envelope = Descriptor::new("customEnvelopeWarp")
|
||||
.with("quiltSliceX", slices("quiltSliceX", &mesh.us, 50.0))
|
||||
.with("quiltSliceY", slices("quiltSliceY", &mesh.vs, 25.0))
|
||||
.with("meshPoints", Value::ObjectArray(ObjectArray { prefix: mesh.points.len() as u32, body: points }));
|
||||
quilt = quilt
|
||||
.with("deformNumRows", Value::Integer(mesh.ny() as i32))
|
||||
.with("deformNumCols", Value::Integer(mesh.nx() as i32))
|
||||
.with("customEnvelopeWarp", Value::Descriptor(envelope));
|
||||
let mut d = sold_descriptor(&sold_bytes(None, &placed(Affine::IDENTITY, None), &mut Vec::new())).expect("a descriptor");
|
||||
let at = d.items.iter().position(|(k, _)| k.is("warp")).expect("a warp key");
|
||||
d.items.insert(at, (Id::new("quiltWarp"), Value::Descriptor(quilt)));
|
||||
d
|
||||
}
|
||||
|
||||
fn sold_of(d: Descriptor) -> Vec<u8> {
|
||||
let mut out = b"soLD".to_vec();
|
||||
out.extend_from_slice(&4u32.to_be_bytes());
|
||||
out.extend(VersionedDescriptor::new(d).to_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn quilt_of(d: &Descriptor) -> Option<&Descriptor> {
|
||||
get_desc(d, "quiltWarp")
|
||||
}
|
||||
|
||||
/// A split warp of more patches along a side than a `quiltWarp` is read with (Warp Grid 64,
|
||||
/// then one more split) is written as no warp, with a message, framed by the source rect, so
|
||||
/// the layer reopens where it was. Before, its `quiltWarp` was refused on reopen while `Trnf`
|
||||
/// still framed its control box: the layer came back stretched and shifted.
|
||||
#[test]
|
||||
fn a_split_warp_past_the_quilt_limit_keeps_its_placement() {
|
||||
use photocraft_geom::warp::BezierMesh;
|
||||
let mut mesh = BezierMesh::identity([0.0, 0.0, 50.0, 25.0], crate::blocks::QUILT_MAX_PATCHES, 1);
|
||||
assert!(mesh.split_u(0.005));
|
||||
mesh.points[0] = [-10.0, -5.0];
|
||||
let warp = Warp::custom(mesh, [0.0, 0.0, 50.0, 25.0]);
|
||||
let transform = Affine { m: [2.0, 0.0, 0.0, 2.0, 120.0, 210.0] };
|
||||
let mut warnings = Vec::new();
|
||||
let sold = sold_bytes(None, &placed(transform, Some(&warp)), &mut warnings);
|
||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||
assert_eq!(crate::blocks::parse_smart(b"SoLd", &sold).1, transform);
|
||||
assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), None);
|
||||
assert!(quilt_of(&sold_descriptor(&sold).expect("a descriptor")).is_none());
|
||||
// Over an imported split warp, the stale `quiltWarp` goes and the placement holds.
|
||||
let template = photoshop_template(&split_warp());
|
||||
let mut warnings = Vec::new();
|
||||
let sold = sold_bytes(Some(&template), &placed(transform, Some(&warp)), &mut warnings);
|
||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||
assert_eq!(crate::blocks::parse_smart(b"SoLd", &sold).1, transform);
|
||||
assert!(quilt_of(&sold_descriptor(&sold).expect("a descriptor")).is_none());
|
||||
// So does one this reader can't read, which Photoshop would still apply.
|
||||
let mut template = photoshop_template(&split_warp());
|
||||
let Some((_, Value::Descriptor(q))) = template.items.iter_mut().find(|(k, _)| k.is("quiltWarp")) else { panic!("a quiltWarp") };
|
||||
set(q, "deformNumRows", Value::Integer(0));
|
||||
let sold = sold_bytes(Some(&template), &placed(transform, Some(&warp)), &mut Vec::new());
|
||||
assert!(quilt_of(&sold_descriptor(&sold).expect("a descriptor")).is_none());
|
||||
// At the limit the split warp is still written.
|
||||
let mut mesh = BezierMesh::identity([0.0, 0.0, 50.0, 25.0], crate::blocks::QUILT_MAX_PATCHES, 1);
|
||||
mesh.points[0] = [-10.0, -5.0];
|
||||
let warp = Warp::custom(mesh, [0.0, 0.0, 50.0, 25.0]);
|
||||
let mut warnings = Vec::new();
|
||||
let sold = sold_bytes(None, &placed(transform, Some(&warp)), &mut warnings);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(crate::blocks::parse_smart(b"SoLd", &sold).1, transform);
|
||||
assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), Some(warp));
|
||||
}
|
||||
|
||||
/// A split (multi-patch) custom warp is written as Photoshop writes it, a `quiltWarp` before a
|
||||
/// `warpNone` `warp`, and reads back with its mesh and its transform. Before, the warp was
|
||||
/// written as none while `Trnf` was still the image of its control-point box, so the layer
|
||||
/// came back stretched and shifted.
|
||||
#[test]
|
||||
fn split_warp_round_trips_through_sold() {
|
||||
use photocraft_geom::warp::BezierMesh;
|
||||
let mut mesh = BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 2, 1);
|
||||
mesh.points[0] = [-10.0, -5.0];
|
||||
let warp = Warp::custom(mesh, [0.0, 0.0, 50.0, 25.0]);
|
||||
let transform = Affine { m: [2.0, 0.0, 0.0, 2.0, 120.0, 210.0] };
|
||||
let mut warnings = Vec::new();
|
||||
let sold = sold_bytes(None, &placed(transform, Some(&warp)), &mut warnings);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(crate::blocks::parse_smart(b"SoLd", &sold).1, transform);
|
||||
assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), Some(warp));
|
||||
let d = sold_descriptor(&sold).expect("a descriptor");
|
||||
let at = |key: &str| d.items.iter().position(|(k, _)| k.is(key));
|
||||
assert!(at("quiltWarp").expect("a quiltWarp") < at("warp").expect("a warp"));
|
||||
let plain = get_desc(&d, "warp").expect("a warp");
|
||||
assert_eq!(enum_of(plain, "warpStyle"), Some(&b"warpNone"[..]));
|
||||
assert!(plain.get("customEnvelopeWarp").is_none());
|
||||
let quilt = quilt_of(&d).expect("a quiltWarp");
|
||||
assert!(quilt.class_id.is("quiltWarp"));
|
||||
assert_eq!((quilt.get("deformNumCols"), quilt.get("deformNumRows")), (Some(&Value::Integer(7)), Some(&Value::Integer(4))));
|
||||
let env = get_desc(quilt, "customEnvelopeWarp").expect("an envelope");
|
||||
let prefix = |k: &str| match env.get(k) {
|
||||
Some(Value::ObjectArray(a)) => Some(a.prefix),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!((prefix("quiltSliceX"), prefix("quiltSliceY"), prefix("meshPoints")), (Some(3), Some(2), Some(28)));
|
||||
}
|
||||
|
||||
/// A template's split warp is replaced along with its `warp`: a warp changed to Arc, to none,
|
||||
/// to one patch or to another mesh reads back as changed, not as the imported split warp.
|
||||
#[test]
|
||||
fn a_replaced_split_warp_does_not_come_back() {
|
||||
use photocraft_geom::warp::BezierMesh;
|
||||
let bounds = [0.0, 0.0, 50.0, 25.0];
|
||||
let split = split_warp();
|
||||
let template = photoshop_template(&split);
|
||||
let arc = Warp::preset(WarpStyle::Arc, 30.0, bounds);
|
||||
let mut one = BezierMesh::identity(bounds, 1, 1);
|
||||
one.points[5][0] += 2.0;
|
||||
let one = Warp::custom(one, bounds);
|
||||
let mut resplit = BezierMesh::identity(bounds, 2, 2);
|
||||
resplit.points[8][1] += 4.0;
|
||||
let resplit = Warp::custom(resplit, bounds);
|
||||
for w in [Some(arc), None, Some(one), Some(resplit)] {
|
||||
let mut warnings = Vec::new();
|
||||
let sold = sold_bytes(Some(&template), &placed(Affine::IDENTITY, w.as_ref()), &mut warnings);
|
||||
assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), w);
|
||||
let d = sold_descriptor(&sold).expect("a descriptor");
|
||||
assert_eq!(quilt_of(&d).is_some(), w.as_ref().is_some_and(|w| w.mesh.as_ref().is_some_and(|m| m.points.len() > 16)), "{w:?}");
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An imported split warp that nothing changed keeps Photoshop's `quiltWarp` as stored when
|
||||
/// the layer is rewritten for something else (here a move); a mesh edited on the same grid
|
||||
/// keeps Photoshop's slices.
|
||||
#[test]
|
||||
fn an_imported_split_warp_keeps_photoshop_s_quilt() {
|
||||
let template = photoshop_template(&split_warp());
|
||||
let read = crate::blocks::parse_placed_warp(b"SoLd", &sold_of(template.clone())).expect("the split warp");
|
||||
let moved = Affine { m: [1.0, 0.0, 0.0, 1.0, 4.0, 0.0] };
|
||||
let sold = sold_bytes(Some(&template), &placed(moved, Some(&read)), &mut Vec::new());
|
||||
let d = sold_descriptor(&sold).expect("a descriptor");
|
||||
assert_eq!(d.get("quiltWarp"), template.get("quiltWarp"));
|
||||
assert_eq!(crate::blocks::parse_smart(b"SoLd", &sold).1, moved);
|
||||
let mut edited = read.clone();
|
||||
if let Some(m) = edited.mesh.as_mut() {
|
||||
m.points[12][0] += 1.5;
|
||||
}
|
||||
let sold = sold_bytes(Some(&template), &placed(Affine::IDENTITY, Some(&edited)), &mut Vec::new());
|
||||
assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), Some(edited));
|
||||
let slices = |d: &Descriptor| {
|
||||
let env = get_desc(quilt_of(d)?, "customEnvelopeWarp")?;
|
||||
Some((env.get("quiltSliceX").cloned(), env.get("quiltSliceY").cloned()))
|
||||
};
|
||||
assert_eq!(slices(&sold_descriptor(&sold).expect("a descriptor")), slices(&template));
|
||||
}
|
||||
|
||||
/// The legacy `PlLd` record has no split warp: it stores none, with its corners framed by the
|
||||
/// source rect, so a reader of `PlLd` alone places the layer where its transform puts it.
|
||||
#[test]
|
||||
fn plld_of_a_split_warp_is_framed_by_the_source() {
|
||||
let warp = split_warp();
|
||||
let transform = Affine { m: [2.0, 0.0, 0.0, 2.0, 120.0, 210.0] };
|
||||
let mut warnings = Vec::new();
|
||||
let plld = plld_bytes(&placed(transform, Some(&warp)), &mut warnings);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(crate::blocks::parse_plld_warp(&plld), crate::blocks::PlldWarp::None);
|
||||
let (_, corners) = crate::blocks::parse_plld(&plld).expect("a record");
|
||||
assert_eq!(crate::blocks::plld_transform(&corners, Some((50.0, 25.0)), None).0, transform);
|
||||
}
|
||||
|
||||
/// Export writes `Trnf` as the image of the custom warp's control-point box, the inverse of
|
||||
/// import: a spilled mesh comes back with the same transform and the quad Photoshop expects.
|
||||
#[test]
|
||||
@@ -1622,7 +1923,7 @@ mod tests {
|
||||
assert_eq!(q, [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]);
|
||||
assert_eq!(p.transform, transform);
|
||||
// The imported quad reads as unmoved, so the template's `Trnf` is kept as stored.
|
||||
assert!(!quad_moved(&p.descriptor, &spec));
|
||||
assert!(!quad_moved(&p.descriptor, &spec, spec.warp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -217,6 +217,16 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
|
||||
maximize_compatibility: true,
|
||||
document_path: opts.document_path.clone(),
|
||||
};
|
||||
// TIFF has no palette, bitmap or duotone layout: the PSD model of an Indexed Color, Bitmap
|
||||
// or Duotone document is made in its pixel format (RGB or Grayscale), which the PSD exporter
|
||||
// otherwise writes in the document's own mode.
|
||||
let plain;
|
||||
let doc = if matches!(doc.mode, ColorMode::Indexed | ColorMode::Bitmap | ColorMode::Duotone) {
|
||||
plain = Document { mode: doc.pixel_format().mode, ..doc.clone() };
|
||||
&plain
|
||||
} else {
|
||||
doc
|
||||
};
|
||||
let (mut file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl));
|
||||
// A cancelled conversion stops early with an incomplete model: never encode it.
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
|
||||
@@ -118,6 +118,60 @@ fn indexed_png_opens_with_its_color_table() {
|
||||
assert_eq!(s.rgba(0, 1)[3], 0.0, "the tRNS entry is transparent");
|
||||
}
|
||||
|
||||
/// A 2 × 2 GIF89a of palette indices `idx` over `pal` (padded to 4 entries), with `transparent`
|
||||
/// set through a Graphic Control Extension. Its LZW stream clears before every pixel, so every
|
||||
/// code stays 3 bits: the simplest valid stream any decoder reads.
|
||||
fn indexed_gif(pal: &[[u8; 3]; 4], idx: [u8; 4], transparent: Option<u8>) -> Vec<u8> {
|
||||
let mut b = b"GIF89a".to_vec();
|
||||
b.extend([2, 0, 2, 0, 0x81, 0, 0]); // 2 × 2, global table of 4
|
||||
pal.iter().for_each(|c| b.extend(c));
|
||||
if let Some(t) = transparent {
|
||||
b.extend([0x21, 0xF9, 4, 0x01, 0, 0, t, 0]);
|
||||
}
|
||||
b.extend([0x2C, 0, 0, 0, 0, 2, 0, 2, 0, 0]);
|
||||
let (clear, end) = (4u32, 5u32);
|
||||
let codes: Vec<u32> = idx.iter().flat_map(|&i| [clear, u32::from(i)]).chain([end]).collect();
|
||||
let (mut bits, mut n, mut data) = (0u32, 0u32, Vec::new());
|
||||
for c in codes {
|
||||
bits |= c << n;
|
||||
n += 3;
|
||||
while n >= 8 {
|
||||
data.push((bits & 0xFF) as u8);
|
||||
bits >>= 8;
|
||||
n -= 8;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
data.push(bits as u8);
|
||||
}
|
||||
b.push(2); // LZW minimum code size
|
||||
b.push(data.len() as u8);
|
||||
b.extend(&data);
|
||||
b.extend([0, 0x3B]);
|
||||
b
|
||||
}
|
||||
|
||||
/// An indexed GIF opens in Indexed Color mode with its colour table and transparent entry, as a
|
||||
/// palette PNG does (#7886); it used to open as RGB.
|
||||
#[test]
|
||||
fn indexed_gif_opens_with_its_color_table() {
|
||||
let pal = [[10, 20, 30], [200, 100, 50], [7, 8, 9], [0, 255, 0]];
|
||||
let doc = import("indexed.gif", &indexed_gif(&pal, [1, 0, 2, 1], Some(2))).unwrap().document;
|
||||
assert_eq!(doc.mode, ColorMode::Indexed);
|
||||
assert_eq!(doc.depth, SampleType::U8);
|
||||
let table = doc.color_table.as_ref().unwrap();
|
||||
assert_eq!(table.colors, pal, "every entry, used or not");
|
||||
assert_eq!(table.transparent, Some(2));
|
||||
let s = doc.layers[0].surface().unwrap();
|
||||
let px = s.rgba(0, 0);
|
||||
assert!((px[0] - 200.0 / 255.0).abs() < 1e-6 && (px[3] - 1.0).abs() < 1e-6, "{px:?}");
|
||||
assert_eq!(s.rgba(0, 1)[3], 0.0, "the transparent entry is transparent");
|
||||
// Without a transparent entry, the table alone.
|
||||
let doc = import("opaque.gif", &indexed_gif(&pal, [3, 3, 0, 1], None)).unwrap().document;
|
||||
assert_eq!(doc.mode, ColorMode::Indexed);
|
||||
assert_eq!(doc.color_table.as_ref().unwrap().transparent, None);
|
||||
}
|
||||
|
||||
/// FITS header cards travel as the document's text metadata, so an astro frame keeps its
|
||||
/// OBJECT, EXPTIME and the rest through an edit.
|
||||
#[test]
|
||||
|
||||
@@ -144,6 +144,154 @@ fn duplicate_preserved_text_indices_get_distinct_txt2_objects() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A type layer added below preserved ones takes a number past theirs: claiming the next free
|
||||
/// number on the way up renumbered every layer above it onto its neighbour's `Txt2` object
|
||||
/// (#7773, 70 of 74 layers on the #7698 file).
|
||||
#[test]
|
||||
fn new_text_layer_below_keeps_the_preserved_text_indices() {
|
||||
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
for (i, text) in ["AV", "BC"].into_iter().enumerate() {
|
||||
let mut t = layer(text, &[(2, Kerning::Optical, 0.0)]);
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None);
|
||||
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, i as i32).unwrap()));
|
||||
doc.layers.push(Layer::new(text, LayerContent::Text(t)));
|
||||
}
|
||||
let mut t = layer("new", &[(3, Kerning::Metrics, 0.0)]);
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
doc.layers.insert(0, Layer::new("new", LayerContent::Text(t)));
|
||||
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("k.psd", &bytes).unwrap().document;
|
||||
let indices: Vec<_> = back
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let LayerContent::Text(t) = &l.content else { panic!("not text") };
|
||||
(l.name.as_str(), t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(indices, [("new", Some(2)), ("AV", Some(0)), ("BC", Some(1))]);
|
||||
}
|
||||
|
||||
/// A Photoshop-shaped `Txt2` block: one object per `(text, style keys)` pair, each with its view
|
||||
/// naming its own frame.
|
||||
fn photoshop_txt2(objects: &[(&str, &str)]) -> Vec<u8> {
|
||||
let bom = "\u{fe}\u{ff}";
|
||||
let frame = "<< /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ";
|
||||
let mut frames = String::new();
|
||||
let mut body = String::new();
|
||||
for (i, (text, style)) in objects.iter().enumerate() {
|
||||
let utf16: String = format!("{text}\r").chars().flat_map(|c| ['\0', c]).collect();
|
||||
let len = text.len() + 1;
|
||||
frames.push_str(frame);
|
||||
body.push_str(&format!(
|
||||
"<< /0 << /0 ({bom}{utf16}) /5 << /0 [ << /0 << /0 << /0 ({bom}) /5 << /7 1.2 >> >> >> /1 {len} >> ] >> /6 << /0 [ << /0 << /0 << /0 ({bom}) /6 << /0 0 /1 12.0 /11 1 /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 0.0 0.0 0.0 ] >> >> {style} >> >> >> /1 {len} >> ] >> >> /1 << /0 [ << /0 {i} >> ] /1 << /0 [ << /0 << /1 1 >> /1 {len} >> ] >> /2 [ << /99 /PC /6 [ << /99 /F /10 {i} >> ] >> ] >> >> "
|
||||
));
|
||||
}
|
||||
let font: String = "MyriadPro-Regular".chars().flat_map(|c| ['\0', c]).collect();
|
||||
format!("\n\n/98 << /0 7 >> /0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 ({bom}{font}) /2 1 >> >> >> ] >> /8 << /0 [ {frames}] >> >> /1 << /0 << /0 1 >> /1 [ {body}] /2 << /0 7 >> >>").chars().map(|c| c as u32 as u8).collect()
|
||||
}
|
||||
|
||||
/// A reopened type layer: its name, `TextIndex` and font features.
|
||||
type Reopened = (String, Option<i32>, Vec<String>);
|
||||
|
||||
/// A Photoshop file whose `Txt2` holds `objects` and whose type layers are `kept` (text and
|
||||
/// `TextIndex`, bottom first), with a type layer "Hi" made the way the Type tool makes one
|
||||
/// (`build_tysh`, no index of its own) inserted at `at`, saved and reopened: each layer's
|
||||
/// index and font features (but `calt`, on by default and written on as Photoshop does), and
|
||||
/// "Hi"'s style run in the saved `Txt2`.
|
||||
fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec<Reopened>, E) {
|
||||
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
for (text, index) in kept {
|
||||
let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]);
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None);
|
||||
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, *index).unwrap()));
|
||||
doc.layers.push(Layer::new(*text, LayerContent::Text(t)));
|
||||
}
|
||||
doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))];
|
||||
let mut t = layer("Hi", &[(2, Kerning::Metrics, 0.0)]);
|
||||
t.runs[0].style.size_pt = 30.0;
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None)));
|
||||
doc.layers.insert(at, Layer::new("Hi", LayerContent::Text(t)));
|
||||
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
|
||||
let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap();
|
||||
let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).unwrap();
|
||||
let back = photocraft_io::import("k.psd", &bytes).unwrap().document;
|
||||
let layers: Vec<_> = back
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let LayerContent::Text(t) = &l.content else { panic!("not text") };
|
||||
let index = t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d));
|
||||
(l.name.clone(), index, t.char_runs().iter().flat_map(|r| r.style.features.iter().filter(|f| f.tag != "calt").map(|f| f.tag.clone())).collect())
|
||||
})
|
||||
.collect();
|
||||
let hi = layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap() as usize;
|
||||
let run = txt2.path(&["1", "1"]).and_then(E::as_array).unwrap()[hi].path(&["0", "6", "0"]).and_then(E::as_array).unwrap()[0].clone();
|
||||
(layers, run)
|
||||
}
|
||||
|
||||
/// The layer `name` in `add_type_layer`'s result.
|
||||
fn named<'a>(layers: &'a [Reopened], name: &str) -> &'a Reopened {
|
||||
layers.iter().find(|l| l.0 == name).unwrap()
|
||||
}
|
||||
|
||||
/// A new type layer takes the structure of one of the file's objects but not its style: the
|
||||
/// template's style runs carried its font features (here `salt` and `ss01`) into the new layer.
|
||||
/// Its style run is the template's complete one (Photoshop quit on a run holding only the
|
||||
/// auto-kern mode, and reads the character style from it) with the layer's own size.
|
||||
#[test]
|
||||
fn new_text_layer_takes_no_style_from_the_txt2_template() {
|
||||
let (layers, run) = add_type_layer(&[("AV", ""), ("BC", "/28 true /85 1")], &[("AV", 0), ("BC", 1)], 2);
|
||||
assert_eq!(named(&layers, "Hi").1..=named(&layers, "Hi").1, Some(2)..=Some(2));
|
||||
assert!(named(&layers, "Hi").2.is_empty(), "{layers:?}");
|
||||
let style = run.path(&["0", "0", "6"]).unwrap();
|
||||
assert_eq!(style.get("1").and_then(E::as_f64), Some(30.0), "the layer's own size");
|
||||
assert_eq!(style.get("28"), Some(&E::Bool(false)));
|
||||
assert_eq!(style.path(&["53", "0", "0"]), Some(&E::Int(1)), "the layer's fill colour, RGB");
|
||||
assert_eq!(style.get("11"), Some(&E::Int(1)));
|
||||
}
|
||||
|
||||
/// A new type layer is numbered past the file's `Txt2` objects, not only past the kept indices:
|
||||
/// a deleted type layer's object ("Gone") is not reused with its style, whether it held the
|
||||
/// highest index or index 0 (the Type tool's layer used to bring a `TextIndex` 0 of its own).
|
||||
#[test]
|
||||
fn new_text_layer_skips_a_deleted_layers_txt2_object() {
|
||||
let (layers, _) = add_type_layer(&[("AV", ""), ("BC", ""), ("Gone", "/28 true")], &[("AV", 0), ("BC", 1)], 2);
|
||||
assert_eq!((named(&layers, "Hi").1, named(&layers, "Hi").2.is_empty()), (Some(3), true));
|
||||
let (layers, _) = add_type_layer(&[("Gone", "/28 true"), ("AV", ""), ("BC", "")], &[("AV", 1), ("BC", 2)], 2);
|
||||
assert_eq!((named(&layers, "Hi").1, named(&layers, "Hi").2.is_empty()), (Some(3), true));
|
||||
}
|
||||
|
||||
/// A type layer added below the one holding index 0 neither takes that index nor its style.
|
||||
#[test]
|
||||
fn new_text_layer_at_the_bottom_keeps_index_0_where_it_was() {
|
||||
let (layers, _) = add_type_layer(&[("AV", "/28 true"), ("BC", "")], &[("AV", 0), ("BC", 1)], 0);
|
||||
assert_eq!(named(&layers, "AV").1, Some(0));
|
||||
assert_eq!(named(&layers, "AV").2, ["salt"]);
|
||||
assert_eq!((named(&layers, "Hi").1, named(&layers, "Hi").2.is_empty()), (Some(2), true));
|
||||
}
|
||||
|
||||
/// An edited object takes the layer's style, not the old text's: "Hello world" with Stylistic
|
||||
/// Alternates on "Hello " edited to "world" without them comes back without `salt`.
|
||||
#[test]
|
||||
fn edited_text_layer_takes_its_own_style_runs() {
|
||||
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
let mut t = layer("world", &[(5, Kerning::Metrics, 0.0)]);
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None);
|
||||
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, 0).unwrap()));
|
||||
doc.layers.push(Layer::new("edited", LayerContent::Text(t)));
|
||||
doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(&[("Hello world", "/28 true")])))];
|
||||
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("k.psd", &bytes).unwrap().document;
|
||||
let LayerContent::Text(t) = &back.layers[0].content else { panic!("not text") };
|
||||
assert_eq!(t.text, "world");
|
||||
assert!(t.char_runs().iter().all(|r| r.style.features.iter().all(|f| f.tag == "calt")), "the old text's salt came back");
|
||||
}
|
||||
|
||||
/// A file-controlled `TextIndex` must not size the save: `i32::MAX` is ignored (the regenerated
|
||||
/// `Txt2` is sized by real text objects, never by a file's numbers) and the document still saves
|
||||
/// and reopens with its text.
|
||||
@@ -186,6 +334,9 @@ fn txt2_extras_survive_a_resave_of_unchanged_text() {
|
||||
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
let mut r = t.clone();
|
||||
photocraft_text::TextEngine::new().render_layer(&mut r, doc.resolution_dpi, doc.pixel_format());
|
||||
// The file's layer: its `TySh` names object 0.
|
||||
let raw = photocraft_text::psd::build_tysh(&r, doc.resolution_dpi, None);
|
||||
r.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, 0).unwrap()));
|
||||
doc.layers.push(Layer::new("kept", LayerContent::Text(r)));
|
||||
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"Txt2", std::sync::Arc::new(prev.clone())));
|
||||
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! #6272: a Photoshop noise `GdFl` gradient that is not provably constant-RGB still opens as
|
||||
//! a fill layer — the placeholder ramp stands in where the model has no stops, Photoshop's
|
||||
//! own pixels ride along as the fill cache — and its raw `GdFl` block survives a re-save byte
|
||||
//! for byte. Before the fix, `fill_from_desc` returned `None` for those gradients: the layer
|
||||
//! imported as pixels and the export dropped the `GdFl` (`RASTER_STALE_KEYS`), so opening and
|
||||
//! re-saving a file silently turned the fill layer into a plain pixel layer.
|
||||
|
||||
use photocraft_doc::{Fill, LayerContent};
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
|
||||
use photocraft_psd::testgen;
|
||||
use photocraft_psd::{Compression, LayerRecord, PsdFile, TaggedBlock, Version};
|
||||
|
||||
/// Photoshop's default Noise gradient (RGB, min 0/0/0, max 100/100/100) as `GdFl` block data.
|
||||
fn default_noise_gdfl() -> Vec<u8> {
|
||||
let values = |v: [i32; 4]| Value::List(v.into_iter().map(Value::Integer).collect());
|
||||
let gradient = Descriptor::new("Grdn")
|
||||
.with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("ClNs") })
|
||||
.with("ClrS", Value::Enumerated { type_id: Id::new("ClrS"), value: Id::new("RGBC") })
|
||||
.with("ShTr", Value::Boolean(false))
|
||||
.with("VctC", Value::Boolean(true))
|
||||
.with("RndS", Value::Integer(9052369))
|
||||
.with("Smth", Value::Integer(2048))
|
||||
.with("Mnm ", values([0, 0, 0, 0]))
|
||||
.with("Mxm ", values([100, 100, 100, 100]));
|
||||
let d = Descriptor::new("null")
|
||||
.with("Grad", Value::Descriptor(gradient))
|
||||
.with("Angl", Value::UnitFloat { unit: *b"#Ang", value: 0.0 })
|
||||
.with("Type", Value::Enumerated { type_id: Id::new("Type"), value: Id::new("CstS") });
|
||||
VersionedDescriptor::new(d).to_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_noise_gradient_stays_a_fill_layer_and_keeps_its_gdfl() {
|
||||
let _ = UnicodeString::new_nul("x");
|
||||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||||
let gdfl = default_noise_gdfl();
|
||||
f.layers_mut().push(LayerRecord {
|
||||
name: b"noise fill".to_vec(),
|
||||
blocks: vec![TaggedBlock::unicode_name("noise fill"), TaggedBlock::new(*b"GdFl", gdfl.clone())],
|
||||
..Default::default()
|
||||
});
|
||||
for b in f.layers().last().unwrap().blocks.iter() {
|
||||
b.check_structure().unwrap();
|
||||
}
|
||||
let bytes = f.to_bytes().unwrap();
|
||||
let imp = import("in.psd", &bytes).unwrap();
|
||||
let l = imp.document.layers.iter().find(|l| l.name == "noise fill").unwrap();
|
||||
assert!(matches!(l.content, LayerContent::Fill(Fill::Gradient { .. })), "a variable noise GdFl opens as a gradient fill layer, not pixels");
|
||||
let out = export(&imp.document, "out.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
let back = PsdFile::from_bytes(&out).unwrap();
|
||||
let rec = back.layers().iter().find(|l| l.block(b"GdFl").is_some()).expect("the re-saved layer carries its GdFl");
|
||||
assert_eq!(rec.block(b"GdFl").unwrap().data, gdfl, "the raw GdFl survives the round trip byte for byte");
|
||||
}
|
||||
@@ -177,6 +177,144 @@ fn fallback_modes_import_flattened() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_and_duotone_modes_are_written_in_their_own_mode() {
|
||||
// Indexed Color and Bitmap are written flat in their own mode (palette / 1 bit); a Duotone
|
||||
// keeps the ink data it was imported with over its grey plate. Same image, same mode data.
|
||||
for mode in [ColorMode::Indexed, ColorMode::Bitmap, ColorMode::Duotone] {
|
||||
let depth = testgen::mode_depths(mode)[0];
|
||||
let f = testgen::merged_only(Version::Psd, mode, depth, Compression::Rle, 9, 5);
|
||||
let (d, _) = psd_to_document(&f);
|
||||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||||
assert!(out.warnings.iter().all(|w| !w.contains("written as")), "{mode:?}: {:?}", out.warnings);
|
||||
let back = PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
assert_eq!((back.header.color_mode, back.header.depth, back.header.channels), (mode, f.header.depth, 1), "{mode:?}");
|
||||
assert_eq!(back.composite_rgba8().unwrap(), f.composite_rgba8().unwrap(), "{mode:?}");
|
||||
if mode != ColorMode::Bitmap {
|
||||
assert_eq!(back.color_mode_data, f.color_mode_data, "{mode:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_psd_keeps_the_table_length_and_transparent_entry() {
|
||||
use photocraft_doc::ColorTable;
|
||||
let mut d = photocraft_doc::Document::with_background(
|
||||
"i",
|
||||
photocraft_doc::Size::new(4, 2),
|
||||
photocraft_color::ColorMode::Rgb,
|
||||
photocraft_color::SampleType::U8,
|
||||
photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0),
|
||||
);
|
||||
d.mode = photocraft_color::ColorMode::Indexed;
|
||||
d.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 0, 0], [0, 0, 255]], transparent: Some(2) });
|
||||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||||
let f = PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
assert_eq!(f.header.color_mode, ColorMode::Indexed);
|
||||
assert_eq!(f.resource(1046).map(|r| r.data.clone()), Some(vec![0, 3]));
|
||||
assert_eq!(f.resource(1047).map(|r| r.data.clone()), Some(vec![0, 2]));
|
||||
assert_eq!(f.decode_merged().unwrap(), vec![1; 8], "every pixel is entry 1 (red)");
|
||||
let (back, _) = psd_to_document(&f);
|
||||
assert_eq!(back.color_table, d.color_table);
|
||||
}
|
||||
|
||||
/// A 4x2 document filled with `fill` in Indexed Color mode with `colors` and `transparent`.
|
||||
fn indexed_doc(fill: photocraft_color::Color, colors: Vec<[u8; 3]>, transparent: Option<u8>) -> photocraft_doc::Document {
|
||||
let mut d = photocraft_doc::Document::with_background(
|
||||
"i",
|
||||
photocraft_doc::Size::new(4, 2),
|
||||
photocraft_color::ColorMode::Rgb,
|
||||
photocraft_color::SampleType::U8,
|
||||
fill,
|
||||
);
|
||||
d.mode = photocraft_color::ColorMode::Indexed;
|
||||
d.color_table = Some(photocraft_doc::ColorTable { colors, transparent });
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_psd_never_writes_an_opaque_pixel_as_the_transparent_entry() {
|
||||
// The transparent entry duplicates opaque black (common in PNG-8 files) and comes first.
|
||||
let d = indexed_doc(photocraft_color::Color::rgba(0.0, 0.0, 0.0, 1.0), vec![[0, 0, 0], [0, 0, 0], [255, 255, 255]], Some(0));
|
||||
let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
assert_eq!(f.decode_merged().unwrap(), vec![1; 8], "opaque black is entry 1, not the transparent entry 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_psd_transparency_survives_a_round_trip() {
|
||||
let d = indexed_doc(photocraft_color::Color::rgba(1.0, 0.0, 0.0, 0.0), vec![[255, 0, 0], [255, 255, 255]], Some(1));
|
||||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||||
let f = PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
assert_eq!(f.decode_merged().unwrap(), vec![1; 8]);
|
||||
let back = import("x.psd", &out.bytes).unwrap().document;
|
||||
let s = back.layers[0].surface().unwrap();
|
||||
let px = s.read_region(back.bounds());
|
||||
assert!(px.chunks_exact(s.channels()).all(|p| p.last() == Some(&0.0)), "transparent pixels came back opaque");
|
||||
assert!(!back.layers[0].locks.transparency);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_psd_without_a_transparent_entry_flattens_over_white() {
|
||||
let d = indexed_doc(photocraft_color::Color::rgba(0.0, 0.0, 0.0, 0.0), vec![[0, 0, 0], [255, 255, 255]], None);
|
||||
let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
assert_eq!(f.decode_merged().unwrap(), vec![1; 8], "empty areas are white, as Photoshop flattens them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_psd_keeps_the_icc_profile() {
|
||||
// Bytes 16..20 of a profile name its colour space; only an RGB one describes the table.
|
||||
let profile = |space: &[u8; 4]| {
|
||||
let mut p = vec![7u8; 132];
|
||||
p[16..20].copy_from_slice(space);
|
||||
p
|
||||
};
|
||||
for (space, kept) in [(b"RGB ", true), (b"GRAY", false)] {
|
||||
let mut d = indexed_doc(photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0), vec![[255, 0, 0]], None);
|
||||
d.icc_profile = Some(std::sync::Arc::new(profile(space)));
|
||||
let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
assert_eq!(f.header.color_mode, ColorMode::Indexed);
|
||||
assert_eq!(f.resource(1039).map(|r| r.data.clone()), kept.then(|| profile(space)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_document_whose_only_entry_is_transparent_is_not_written_as_indexed() {
|
||||
// Every opaque pixel would have to take the transparent entry and vanish: written as RGB.
|
||||
let d = indexed_doc(photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0), vec![[255, 0, 0]], Some(0));
|
||||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||||
let f = PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
assert_eq!(f.header.color_mode, ColorMode::Rgb, "{:?}", out.warnings);
|
||||
assert_eq!(&f.composite_rgba8().unwrap().data[..4], &[255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_and_bitmap_documents_with_channels_keep_them() {
|
||||
// PhotoCraft does not read extra channels of a palette-mode PSD back, so these documents keep
|
||||
// the RGB / Grayscale file that holds their alpha channels and Quick Mask.
|
||||
for mode in [photocraft_color::ColorMode::Indexed, photocraft_color::ColorMode::Bitmap] {
|
||||
let mut d = indexed_doc(photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0), vec![[255, 255, 255]], None);
|
||||
d.mode = mode;
|
||||
let alpha = || photocraft_doc::AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8));
|
||||
d.channels.push(alpha());
|
||||
d.quick_mask = Some(alpha());
|
||||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||||
let back = import("x.psd", &out.bytes).unwrap().document;
|
||||
assert_eq!((back.channels.len(), back.quick_mask.is_some()), (1, true), "{mode:?}: {:?}", out.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layerless_psd_never_keeps_psb_only_block_signatures() {
|
||||
for mode in [photocraft_color::ColorMode::Indexed, photocraft_color::ColorMode::Bitmap, photocraft_color::ColorMode::Multichannel] {
|
||||
let mut d = indexed_doc(photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0), vec![[255, 255, 255]], None);
|
||||
d.mode = mode;
|
||||
d.metadata.psd_global_blocks.push((*b"8B64", *b"FMsk", std::sync::Arc::new(vec![0; 12])));
|
||||
let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
assert!(f.global_blocks.iter().any(|b| &b.key == b"FMsk"), "{mode:?}");
|
||||
assert!(f.global_blocks.iter().all(|b| &b.signature == b"8BIM"), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multichannel_imports_ink_channels() {
|
||||
for depth in testgen::mode_depths(ColorMode::Multichannel) {
|
||||
|
||||
@@ -26,11 +26,15 @@ pub struct MixerSettings {
|
||||
pub flow: f32,
|
||||
/// Pick up colour from all visible layers instead of the target layer only.
|
||||
pub sample_all_layers: bool,
|
||||
/// Load the brush with the stroke color after each stroke (the automatic Load option).
|
||||
pub load_after_stroke: bool,
|
||||
/// Empty the brush after each stroke (the automatic Clean option).
|
||||
pub clean_after_stroke: bool,
|
||||
}
|
||||
|
||||
impl Default for MixerSettings {
|
||||
fn default() -> Self {
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false }
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, load_after_stroke: true, clean_after_stroke: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,18 +48,47 @@ pub struct MixerState {
|
||||
pub amount: f32,
|
||||
/// Colour picked up from the canvas (straight RGBA), None = clean.
|
||||
pub pickup: Option<[f32; 4]>,
|
||||
/// The color recorded by the last stroke (its color) or the last Load Brush or Clean Brush
|
||||
/// (the foreground). None = no stroke, load or clean yet.
|
||||
pub color: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl MixerState {
|
||||
/// Load the reservoir with a colour (full).
|
||||
/// Load Brush: fill the reservoir with a color (full).
|
||||
pub fn load(&mut self, color: [f32; 4]) {
|
||||
self.reservoir = Some(color);
|
||||
self.amount = 1.0;
|
||||
}
|
||||
/// Clean the brush (drop the picked-up colour).
|
||||
/// Clean Brush: empty the reservoir and the pickup well.
|
||||
pub fn clean(&mut self) {
|
||||
self.reservoir = None;
|
||||
self.amount = 0.0;
|
||||
self.pickup = None;
|
||||
}
|
||||
/// Before a stroke in `color`: choosing a color other than [`MixerState::color`] loads the
|
||||
/// reservoir with it.
|
||||
pub fn begin_stroke(&mut self, color: [f32; 4]) {
|
||||
if self.color != Some(color) {
|
||||
self.load(color);
|
||||
self.color = Some(color);
|
||||
}
|
||||
}
|
||||
/// After a stroke in `color`: the automatic Clean empties the brush, then the automatic Load
|
||||
/// fills the reservoir again.
|
||||
pub fn end_stroke(&mut self, color: [f32; 4], clean: bool, load: bool) {
|
||||
if clean {
|
||||
self.clean();
|
||||
}
|
||||
if load {
|
||||
self.load(color);
|
||||
}
|
||||
self.color = Some(color);
|
||||
}
|
||||
/// The reservoir color the next stroke in `color` starts with (the Current Brush Load
|
||||
/// swatch); None = a clean brush.
|
||||
pub fn next_load(&self, color: [f32; 4]) -> Option<[f32; 4]> {
|
||||
if self.color != Some(color) { Some(color) } else { self.reservoir }
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself
|
||||
@@ -209,6 +242,48 @@ mod tests {
|
||||
assert!(st.pickup.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_brush_paints_only_the_canvas_color_it_picks_up() {
|
||||
let mut st = MixerState::default();
|
||||
st.load([0.0, 0.0, 1.0, 1.0]);
|
||||
st.pickup = Some([0.0, 1.0, 0.0, 1.0]);
|
||||
st.clean();
|
||||
assert_eq!((st.reservoir, st.amount, st.pickup), (None, 0.0, None));
|
||||
// On red, the clean wet brush smears red and lays down none of the blue it was loaded with.
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 200, 20), &[1.0, 0.0, 0.0, 1.0]);
|
||||
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false);
|
||||
let c = s.rgba(100, 10);
|
||||
assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_color_loads_the_brush_and_the_automatic_options_run_after_the_stroke() {
|
||||
let (blue, green) = ([0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
||||
let mut st = MixerState::default();
|
||||
// A brush never loaded takes the stroke's color.
|
||||
assert_eq!(st.next_load(blue), Some(blue));
|
||||
st.begin_stroke(blue);
|
||||
assert_eq!((st.reservoir, st.amount), (Some(blue), 1.0));
|
||||
// Clean on, Load off: the next stroke in the same color starts empty.
|
||||
st.end_stroke(blue, true, false);
|
||||
assert_eq!(st.next_load(blue), None);
|
||||
st.begin_stroke(blue);
|
||||
assert_eq!((st.reservoir, st.pickup), (None, None));
|
||||
// Choosing another color loads it.
|
||||
assert_eq!(st.next_load(green), Some(green));
|
||||
st.begin_stroke(green);
|
||||
assert_eq!((st.reservoir, st.amount), (Some(green), 1.0));
|
||||
// Load on, Clean off: the reservoir is full again and the pickup stays.
|
||||
st.amount = 0.1;
|
||||
st.pickup = Some(blue);
|
||||
st.end_stroke(green, false, true);
|
||||
assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, Some(blue)));
|
||||
// Both on: a fresh load with no pickup.
|
||||
st.end_stroke(green, true, true);
|
||||
assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_empty_brush_paints_nothing() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
|
||||
@@ -43,8 +43,11 @@ use crate::patterns::{PatternBudget, PsdPattern, parse_pattern_block_with, write
|
||||
pub const MAX_EDGE: u32 = 8192;
|
||||
/// Most brushes read from one file.
|
||||
pub const MAX_BRUSHES: usize = 20_000;
|
||||
/// Cap on decoded sample bytes over the whole file.
|
||||
pub const MAX_TOTAL_BYTES: usize = 512 << 20;
|
||||
/// Cap on decoded sample bytes over the whole file. 32-bit targets (wasm) keep 512 MiB: the file,
|
||||
/// its base64 copies and the decoded tips must all fit a 4 GiB address space, and running out
|
||||
/// there aborts instead of returning an error.
|
||||
pub const MAX_TOTAL_BYTES: usize = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 512 << 20 };
|
||||
const _: () = assert!(cfg!(target_pointer_width = "64") || MAX_TOTAL_BYTES == 512 << 20);
|
||||
|
||||
/// A sampled tip: one grayscale plane where the maximum value is full paint.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
||||
@@ -29,7 +29,7 @@ const MAX_EDGE: u32 = 30_000;
|
||||
/// Budget for zero-padded channel planes summed over every pattern of one parse (a block, a `.pat`
|
||||
/// file, an `.abr`). Photoshop writes every channel at the pattern rectangle, so a smaller channel
|
||||
/// rectangle is only padding; without this cap a few hundred bytes (1x1 channels, a huge pattern
|
||||
/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros. Matches `abr::MAX_TOTAL_BYTES`.
|
||||
/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros.
|
||||
const MAX_PADDED_PATTERN_BYTES: u64 = 512 << 20;
|
||||
// A PackBits run packet emits at most 128 bytes from a 2-byte packet.
|
||||
const MAX_RLE_EXPANSION: u64 = 64;
|
||||
|
||||
@@ -3,11 +3,12 @@ name = "photocraft-tablet"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2)"
|
||||
description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2), and barrel button on Windows"
|
||||
|
||||
# The one isolated `unsafe` helper crate (craftrules never-crash, AGENTS.md): the AppKit event
|
||||
# monitor needs Objective-C interop. Only `src/macos.rs` allows `unsafe`; everything else is
|
||||
# denied, and every other crate keeps the workspace's `unsafe_code = "forbid"`.
|
||||
# monitor on macOS and the WH_GETMESSAGE pointer hook on Windows need platform interop. Only
|
||||
# `src/macos.rs` and `src/windows.rs` allow `unsafe`; everything else is denied, and every other
|
||||
# crate keeps the workspace's `unsafe_code = "forbid"`.
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
//! - **macOS** ([`macos::Monitor`]): an AppKit local event monitor reads the tablet fields of
|
||||
//! each `NSEvent` (`pressure`, `tilt`, `rotation`, `subtype`, and `pointingDeviceType` from
|
||||
//! proximity events) before winit sees the event, so every pointer event the UI handles already
|
||||
//! has its pen sample. AppKit's block-based monitor API needs Objective-C interop, so
|
||||
//! `macos.rs` is the only module in the workspace that allows `unsafe` (craftrules
|
||||
//! never-crash: an isolated, tested helper crate).
|
||||
//! has its pen sample.
|
||||
//! - **Windows** ([`windows::Monitor`]): a thread-local message hook inspects `WM_POINTER*`
|
||||
//! messages on the UI thread's message queue to read `PEN_FLAG_BARREL` (`GetPointerPenInfo`),
|
||||
//! which winit 0.30 drops by mapping pen inputs to touch events.
|
||||
//! AppKit's monitor API and Windows' thread hook need OS interop, so `macos.rs` and `windows.rs`
|
||||
//! allow `unsafe` (craftrules never-crash: an isolated, tested helper crate with `SAFETY:`
|
||||
//! comments on every block).
|
||||
//! - **Linux X11** ([`x11::spawn`]): a second, pure-Rust X connection (x11rb) selects XInput2
|
||||
//! raw events on the root window and maps the tablet device's valuators ("Abs Pressure",
|
||||
//! "Abs Tilt X/Y", "Abs Rotary Z") to samples. Raw events reach every client that asks, also
|
||||
@@ -31,6 +35,8 @@ pub mod xi;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod windows;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod x11;
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Windows pointer hook for tablet data dropped by winit (such as the pen barrel button).
|
||||
//!
|
||||
//! winit 0.30 drops `POINTER_PEN_INFO::penFlags` (`PEN_FLAG_BARREL`) on Windows and turns pen
|
||||
//! inputs into `WindowEvent::Touch`, mapping them to `PointerButton::Primary`. Windows Ink also
|
||||
//! does not update `GetAsyncKeyState(VK_RBUTTON)` for stylus barrel buttons. This thread-local
|
||||
//! `WH_GETMESSAGE` hook inspects `WM_POINTER*` messages on the UI thread's message queue to
|
||||
//! track the physical barrel button state.
|
||||
|
||||
#![allow(unsafe_code, clippy::upper_case_acronyms)]
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static BARREL_DOWN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[repr(C)]
|
||||
struct POINT {
|
||||
x: i32,
|
||||
y: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct MSG {
|
||||
hwnd: *mut std::ffi::c_void,
|
||||
message: u32,
|
||||
w_param: usize,
|
||||
l_param: isize,
|
||||
time: u32,
|
||||
pt: POINT,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct POINTER_INFO {
|
||||
pointer_type: u32,
|
||||
pointer_id: u32,
|
||||
frame_id: u32,
|
||||
pointer_flags: u32,
|
||||
source_device: *mut std::ffi::c_void,
|
||||
hwnd_target: *mut std::ffi::c_void,
|
||||
pt_pixel_location: POINT,
|
||||
pt_himetric_location: POINT,
|
||||
pt_pixel_location_raw: POINT,
|
||||
pt_himetric_location_raw: POINT,
|
||||
dw_time: u32,
|
||||
history_count: u32,
|
||||
input_data: i32,
|
||||
dw_key_states: u32,
|
||||
performance_count: u64,
|
||||
button_change_type: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct POINTER_PEN_INFO {
|
||||
pointer_info: POINTER_INFO,
|
||||
pen_flags: u32,
|
||||
pen_mask: u32,
|
||||
pressure: u32,
|
||||
rotation: u32,
|
||||
tilt_x: i32,
|
||||
tilt_y: i32,
|
||||
}
|
||||
|
||||
#[link(name = "user32")]
|
||||
unsafe extern "system" {
|
||||
fn SetWindowsHookExW(
|
||||
id_hook: i32,
|
||||
lpfn: unsafe extern "system" fn(i32, usize, isize) -> isize,
|
||||
h_mod: *mut std::ffi::c_void,
|
||||
dw_thread_id: u32,
|
||||
) -> *mut std::ffi::c_void;
|
||||
fn UnhookWindowsHookEx(hhk: *mut std::ffi::c_void) -> i32;
|
||||
fn CallNextHookEx(hhk: *mut std::ffi::c_void, n_code: i32, w_param: usize, l_param: isize) -> isize;
|
||||
fn GetCurrentThreadId() -> u32;
|
||||
fn GetPointerPenInfo(pointer_id: u32, pen_info: *mut POINTER_PEN_INFO) -> i32;
|
||||
}
|
||||
|
||||
const WH_GETMESSAGE: i32 = 3;
|
||||
const WM_POINTERUPDATE: u32 = 0x0245;
|
||||
const WM_POINTERDOWN: u32 = 0x0246;
|
||||
const WM_POINTERUP: u32 = 0x0247;
|
||||
const WM_POINTERLEAVE: u32 = 0x024A;
|
||||
|
||||
/// `PEN_FLAG_BARREL` (winuser.h): the barrel button of the pen is pressed.
|
||||
pub const PEN_FLAG_BARREL: u32 = 0x00000001;
|
||||
|
||||
/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (winuser.h): the secondary action button is pressed.
|
||||
pub const POINTER_FLAG_SECONDBUTTON: u32 = 0x00000020;
|
||||
|
||||
/// Pure decode of whether the stylus barrel button is pressed.
|
||||
///
|
||||
/// Plain tip contact sets `POINTER_FLAG_INCONTACT` (`0x0004`) and optionally `POINTER_FLAG_FIRSTBUTTON`
|
||||
/// (`0x0010`), which must never be confused with the barrel button (`POINTER_FLAG_SECONDBUTTON`, `0x0020`).
|
||||
pub fn is_barrel_pressed(pen_flags: Option<u32>, pointer_message_flags: u32) -> bool {
|
||||
let second_button = (pointer_message_flags & POINTER_FLAG_SECONDBUTTON) != 0;
|
||||
match pen_flags {
|
||||
Some(pen) => (pen & PEN_FLAG_BARREL) != 0 || second_button,
|
||||
None => second_button,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn get_msg_proc(code: i32, w_param: usize, l_param: isize) -> isize {
|
||||
if code >= 0 && l_param != 0 {
|
||||
// SAFETY: Win32 guarantees that for code >= 0, l_param points to a valid MSG struct
|
||||
// dispatched to this thread's message queue.
|
||||
let msg = unsafe { &*(l_param as *const MSG) };
|
||||
match msg.message {
|
||||
WM_POINTERDOWN | WM_POINTERUPDATE => {
|
||||
let pointer_id = (msg.w_param & 0xFFFF) as u32;
|
||||
let pointer_message_flags = (msg.w_param >> 16) as u32;
|
||||
// SAFETY: `pen_info` is a stack-allocated buffer initialized to zero, and `pointer_id`
|
||||
// is the pointer identifier from the valid WM_POINTER message.
|
||||
let pen_flags = unsafe {
|
||||
let mut info: POINTER_PEN_INFO = std::mem::zeroed();
|
||||
(GetPointerPenInfo(pointer_id, &mut info) != 0).then_some(info.pen_flags)
|
||||
};
|
||||
let is_barrel = is_barrel_pressed(pen_flags, pointer_message_flags);
|
||||
BARREL_DOWN.store(is_barrel, Ordering::Relaxed);
|
||||
}
|
||||
WM_POINTERUP | WM_POINTERLEAVE => {
|
||||
BARREL_DOWN.store(false, Ordering::Relaxed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// SAFETY: CallNextHookEx passes the parameters down the hook chain. MSDN notes the first
|
||||
// parameter is ignored and can be null.
|
||||
unsafe { CallNextHookEx(std::ptr::null_mut(), code, w_param, l_param) }
|
||||
}
|
||||
|
||||
/// Keeps the thread-local pointer message hook installed; dropping it removes the hook.
|
||||
/// Main-thread only (not `Send`).
|
||||
pub struct Monitor {
|
||||
hook: *mut std::ffi::c_void,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// Installs the thread-local pointer message hook on the UI thread.
|
||||
pub fn install() -> Result<Self, crate::Error> {
|
||||
// SAFETY: GetCurrentThreadId has no preconditions. SetWindowsHookExW installs
|
||||
// a thread-specific WH_GETMESSAGE hook on the calling thread's message queue
|
||||
// using our extern "system" callback.
|
||||
let hook = unsafe {
|
||||
let thread_id = GetCurrentThreadId();
|
||||
SetWindowsHookExW(WH_GETMESSAGE, get_msg_proc, std::ptr::null_mut(), thread_id)
|
||||
};
|
||||
if hook.is_null() {
|
||||
return Err(crate::Error::Platform("SetWindowsHookExW returned null".into()));
|
||||
}
|
||||
Ok(Self { hook })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Monitor {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.hook` is the non-null handle returned by `SetWindowsHookExW`,
|
||||
// unhooked exactly once here on the thread that created it.
|
||||
unsafe {
|
||||
UnhookWindowsHookEx(self.hook);
|
||||
}
|
||||
BARREL_DOWN.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the stylus barrel button is physically pressed.
|
||||
pub fn is_barrel_button_down() -> bool {
|
||||
BARREL_DOWN.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const POINTER_FLAG_INCONTACT: u32 = 0x0004;
|
||||
const POINTER_FLAG_FIRSTBUTTON: u32 = 0x0010;
|
||||
|
||||
#[test]
|
||||
fn plain_pen_contact_is_not_treated_as_barrel() {
|
||||
// Plain tip touch on digitizer: INCONTACT is set, first button may be set, no barrel flag.
|
||||
assert!(!is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT));
|
||||
assert!(!is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT | POINTER_FLAG_FIRSTBUTTON));
|
||||
assert!(!is_barrel_pressed(None, POINTER_FLAG_INCONTACT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_barrel_flag_is_detected() {
|
||||
assert!(is_barrel_pressed(Some(PEN_FLAG_BARREL), 0));
|
||||
assert!(is_barrel_pressed(Some(PEN_FLAG_BARREL), POINTER_FLAG_INCONTACT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_message_second_button_is_detected() {
|
||||
assert!(is_barrel_pressed(None, POINTER_FLAG_SECONDBUTTON));
|
||||
assert!(is_barrel_pressed(Some(0), POINTER_FLAG_SECONDBUTTON));
|
||||
assert!(is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT | POINTER_FLAG_SECONDBUTTON));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,14 @@ flate2 = { workspace = true }
|
||||
parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts"] }
|
||||
rayon = { workspace = true }
|
||||
|
||||
# macOS only: the `system` feature adds fontique's Core Text backend, which lists every font the
|
||||
# system offers wherever its file lives (fonts a font manager or Adobe Fonts activated, Office's
|
||||
# own, Apple's on-demand downloads). `fonts.rs` reads only its list of files and registers them
|
||||
# like the folder scan's, so our own collection keeps its behaviour (generic families, merging
|
||||
# installed faces into the bundled families). It uses no fontconfig, unlike `system` on Linux.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts", "system"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Deflates the bundled fonts (build.rs); `flate2` above inflates them.
|
||||
flate2 = { workspace = true }
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use parley::FontContext;
|
||||
#[cfg(any(target_os = "macos", all(test, not(target_arch = "wasm32"))))]
|
||||
use parley::fontique::SourceKind;
|
||||
use parley::fontique::{Blob, Collection, CollectionOptions, FontInfoOverride, FontStyle, FontWeight, FontWidth, GenericFamily, SourceCache};
|
||||
use skrifa::raw::{FileRef, TableProvider, types::Tag};
|
||||
use skrifa::{MetadataProvider, string::StringId};
|
||||
@@ -254,6 +256,10 @@ impl FontDb {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
files.extend(fontconfig_font_paths());
|
||||
// Core Text knows every font macOS offers, also those activated from outside the
|
||||
// font folders (#6683).
|
||||
#[cfg(target_os = "macos")]
|
||||
files.extend(coretext_font_paths());
|
||||
// PingFang (the macOS Chinese UI font) lives outside the font folders.
|
||||
if cfg!(target_os = "macos") {
|
||||
files.extend(crate::cjk::mac_pingfang());
|
||||
@@ -761,6 +767,49 @@ mod fontconfig_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The font files Core Text offers: the installed fonts and those activated from any other folder
|
||||
/// (a font manager, Adobe Fonts, Office, Apple's on-demand downloads). A scratch collection with
|
||||
/// fontique's Core Text backend lists them; its fonts are not used, only their files, so our own
|
||||
/// collection registers them like any scanned file.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn coretext_font_paths() -> Vec<std::path::PathBuf> {
|
||||
// Reading arbitrary fonts must not take the scan down: no files is the folder scan's result.
|
||||
std::panic::catch_unwind(|| {
|
||||
let mut system = Collection::new(CollectionOptions { shared: false, system_fonts: true });
|
||||
let mut files = font_files_of(&mut system);
|
||||
files.retain(|f| !f.starts_with(MACOS_PRIVATE_FONTS));
|
||||
files
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fonts reserved for the system UI (PingFangUI.ttc among them). Core Text offers them, but
|
||||
/// fontique can't draw their outlines (it works around PingFangUI for the same reason), so they
|
||||
/// are not listed.
|
||||
#[cfg(target_os = "macos")]
|
||||
const MACOS_PRIVATE_FONTS: &str = "/System/Library/PrivateFrameworks";
|
||||
|
||||
/// The files `collection`'s fonts are read from, each once and in a fixed order. Fonts
|
||||
/// registered from memory have no file.
|
||||
#[cfg(any(target_os = "macos", all(test, not(target_arch = "wasm32"))))]
|
||||
fn font_files_of(collection: &mut Collection) -> Vec<std::path::PathBuf> {
|
||||
let names: Vec<String> = collection.family_names().map(str::to_string).collect();
|
||||
let mut files = HashSet::new();
|
||||
for name in names {
|
||||
let Some(family) = collection.family_by_name(&name) else {
|
||||
continue;
|
||||
};
|
||||
for font in family.fonts() {
|
||||
if let SourceKind::Path(path) = font.source().kind() {
|
||||
files.insert(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files: Vec<_> = files.into_iter().collect();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
use std::path::PathBuf;
|
||||
@@ -903,6 +952,63 @@ mod tests {
|
||||
assert_eq!(super::localized_name("X", &[]), None);
|
||||
}
|
||||
|
||||
/// The files a collection's fonts come from (what Core Text's fonts are loaded from, #6683):
|
||||
/// each once, none for fonts held in memory.
|
||||
#[test]
|
||||
fn font_files_of_lists_each_font_file_once() {
|
||||
use super::{Blob, Collection, CollectionOptions};
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-font-files-{}", std::process::id()));
|
||||
let activated = dir.join("activated");
|
||||
std::fs::create_dir_all(&activated).unwrap();
|
||||
let (inter, mono) = (dir.join("inter.ttf"), activated.join("mono.ttf"));
|
||||
std::fs::write(&inter, super::INTER_REGULAR.as_slice()).unwrap();
|
||||
std::fs::write(&mono, super::JETBRAINS_MONO_REGULAR.as_slice()).unwrap();
|
||||
let mut collection = Collection::new(CollectionOptions { shared: false, system_fonts: false });
|
||||
collection.load_fonts_from_paths([&dir]);
|
||||
// The same file again and a font from memory add no files.
|
||||
collection.load_fonts_from_paths([&inter]);
|
||||
collection.register_fonts(Blob::new(std::sync::Arc::new(super::INTER_MEDIUM.clone())), None);
|
||||
let files = super::font_files_of(&mut collection);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut expected = vec![inter, mono];
|
||||
expected.sort();
|
||||
assert_eq!(files, expected);
|
||||
}
|
||||
|
||||
/// Core Text lists the system's font files, all readable, none of the reserved UI fonts.
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn coretext_lists_the_system_font_files() {
|
||||
let files = super::coretext_font_paths();
|
||||
assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len());
|
||||
assert!(files.iter().all(|f| f.is_file() && !f.starts_with("/System/Library/PrivateFrameworks")));
|
||||
}
|
||||
|
||||
/// Every family macOS offers is in the font list, not only those in the font folders (#6683).
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn every_family_coretext_offers_is_listed() {
|
||||
use super::{Collection, CollectionOptions, SourceKind};
|
||||
let mut system = Collection::new(CollectionOptions { shared: false, system_fonts: true });
|
||||
// Its names include localized aliases of a family ("Systemschrift"); the family has one name.
|
||||
let aliases: Vec<String> = system.family_names().map(str::to_string).collect();
|
||||
let mut offered = Vec::new();
|
||||
for family in aliases.iter().filter_map(|n| system.family_by_name(n)) {
|
||||
// A family only the reserved UI fonts provide (PingFang before its download) is not listed.
|
||||
let listed_file =
|
||||
|font: &parley::fontique::FontInfo| matches!(font.source().kind(), SourceKind::Path(p) if !p.starts_with(super::MACOS_PRIVATE_FONTS));
|
||||
if !super::is_hidden_family(family.name()) && family.fonts().iter().any(listed_file) {
|
||||
offered.push(family.name().to_string());
|
||||
}
|
||||
}
|
||||
offered.sort();
|
||||
offered.dedup();
|
||||
assert!(!offered.is_empty());
|
||||
let listed = super::FontDb::with_system_fonts().families();
|
||||
let missing: Vec<&String> = offered.iter().filter(|n| !listed.contains(n)).collect();
|
||||
assert!(missing.is_empty(), "macOS offers these families, the font list lacks them: {missing:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_font_dirs_are_skipped() {
|
||||
let mut files = Vec::new();
|
||||
|
||||
@@ -22,7 +22,7 @@ use parley::{
|
||||
PositionedLayoutItem, StyleProperty,
|
||||
};
|
||||
use photocraft_doc::TextLayer;
|
||||
use photocraft_doc::text::{Caps, CharStyle, Kerning, Orientation, TextAlign, TextDirection, TextShape};
|
||||
use photocraft_doc::text::{Caps, CharStyle, FontBaseline, Kerning, Orientation, TextAlign, TextDirection, TextShape};
|
||||
use skrifa::raw::types::Tag;
|
||||
|
||||
use crate::fonts::FontDb;
|
||||
@@ -31,6 +31,23 @@ use crate::fonts::FontDb;
|
||||
/// readable hierarchy for every font instead of silently rendering lowercase text unchanged.
|
||||
const SYNTHETIC_SMALL_CAPS_SCALE: f32 = 0.7;
|
||||
|
||||
/// Photoshop's default superscript / subscript size and position (Type preferences, stored in a
|
||||
/// PSD's text resources as `SuperscriptSize` .583, `SuperscriptPosition` .333 and their
|
||||
/// `Subscript` twins): the run is drawn at 58.3% of its size and moved by 33.3% of its size.
|
||||
const SCRIPT_SIZE: f32 = 0.583;
|
||||
const SCRIPT_POSITION: f32 = 0.333;
|
||||
|
||||
/// The style a run is drawn with: superscript and subscript shrink it and shift its baseline.
|
||||
fn drawn_style(s: &mut CharStyle) {
|
||||
let dir = match s.baseline {
|
||||
FontBaseline::Normal => return,
|
||||
FontBaseline::Superscript => 1.0,
|
||||
FontBaseline::Subscript => -1.0,
|
||||
};
|
||||
s.baseline_shift_pt += dir * SCRIPT_POSITION * s.size_pt;
|
||||
s.size_pt *= SCRIPT_SIZE;
|
||||
}
|
||||
|
||||
/// Photoshop's default tab stops: every half inch, measured from the anchor of point text or the
|
||||
/// left edge of a paragraph box. PSD type carries no custom tab stops, so these are the only ones.
|
||||
pub const DEFAULT_TAB_STOP_PT: f32 = 36.0;
|
||||
@@ -480,6 +497,7 @@ impl Layouter {
|
||||
crate::served::request(&s.font_family);
|
||||
}
|
||||
fonts.select_named_face(&mut s);
|
||||
drawn_style(&mut s);
|
||||
out.styles.push(s);
|
||||
}
|
||||
let run_starts: Vec<usize> = runs
|
||||
|
||||
+356
-25
@@ -11,7 +11,9 @@
|
||||
|
||||
use photocraft_color::{Color, ColorMode};
|
||||
use photocraft_doc::TextLayer;
|
||||
use photocraft_doc::text::{AntiAlias, Caps, CharStyle, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp};
|
||||
use photocraft_doc::text::{
|
||||
AntiAlias, Caps, CharStyle, FontBaseline, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp,
|
||||
};
|
||||
use photocraft_geom::{Affine, Point};
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor};
|
||||
|
||||
@@ -626,6 +628,11 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C
|
||||
Some(2) => Caps::AllCaps,
|
||||
_ => Caps::Normal,
|
||||
};
|
||||
s.baseline = match g("FontBaseline").and_then(E::as_i64) {
|
||||
Some(1) => FontBaseline::Superscript,
|
||||
Some(2) => FontBaseline::Subscript,
|
||||
_ => FontBaseline::Normal,
|
||||
};
|
||||
if let Some(c) = g("FillColor") {
|
||||
// Positions matter (alpha, then the channels): a non-finite entry voids the colour.
|
||||
let v: Vec<f64> = c.get("Values").and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default();
|
||||
@@ -820,21 +827,21 @@ pub const OPENTYPE_KEYS: [(&str, &str); 8] = [
|
||||
("Fractions", "frac"),
|
||||
];
|
||||
|
||||
pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E {
|
||||
let c = &s.color;
|
||||
let values = match c.mode {
|
||||
ColorMode::Cmyk => vec![real(c.alpha), real(c.c[0]), real(c.c[1]), real(c.c[2]), real(c.c[3])],
|
||||
ColorMode::Grayscale => vec![real(c.alpha), real(c.c[0])],
|
||||
/// A fill colour as EngineData and `Txt2` store it: the colour type (0 gray, 1 RGB, 2 CMYK) and
|
||||
/// its values, alpha first.
|
||||
fn fill_color(c: &Color) -> (i64, Vec<E>) {
|
||||
match c.mode {
|
||||
ColorMode::Cmyk => (2, vec![real(c.alpha), real(c.c[0]), real(c.c[1]), real(c.c[2]), real(c.c[3])]),
|
||||
ColorMode::Grayscale => (0, vec![real(c.alpha), real(c.c[0])]),
|
||||
_ => {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
vec![real(c.alpha), real(r), real(g), real(b)]
|
||||
(1, vec![real(c.alpha), real(r), real(g), real(b)])
|
||||
}
|
||||
};
|
||||
let color_type = match c.mode {
|
||||
ColorMode::Cmyk => 2,
|
||||
ColorMode::Grayscale => 0,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E {
|
||||
let (color_type, values) = fill_color(&s.color);
|
||||
let mut opentype: Vec<(String, E)> = OPENTYPE_KEYS
|
||||
.iter()
|
||||
.filter(|(_, tag)| s.features.iter().any(|f| f.tag == *tag && f.value > 0))
|
||||
@@ -861,6 +868,14 @@ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E {
|
||||
Caps::AllCaps => 2,
|
||||
}),
|
||||
),
|
||||
(
|
||||
"FontBaseline".into(),
|
||||
E::Int(match s.baseline {
|
||||
FontBaseline::Normal => 0,
|
||||
FontBaseline::Superscript => 1,
|
||||
FontBaseline::Subscript => 2,
|
||||
}),
|
||||
),
|
||||
("Underline".into(), E::Bool(s.underline)),
|
||||
("Strikethrough".into(), E::Bool(s.strikethrough)),
|
||||
("Ligatures".into(), E::Bool(s.ligatures)),
|
||||
@@ -904,6 +919,30 @@ pub(crate) fn font_entry(name: &str) -> E {
|
||||
E::Dict(vec![("Name".into(), E::String(name.into())), ("Script".into(), E::Int(0)), ("FontType".into(), E::Int(1)), ("Synthetic".into(), E::Int(0))])
|
||||
}
|
||||
|
||||
/// Keep imported font metadata while retaining the requested font indices. New fonts use
|
||||
/// the normal defaults; same-name duplicates retain their own positional records.
|
||||
pub(crate) fn font_set(names: &[String], original: Option<&E>) -> E {
|
||||
let entries = original.and_then(E::as_array).unwrap_or_default();
|
||||
// Resolve fallback names once, retaining the first match just as the original scan did.
|
||||
// Missing names and reordered resource dictionaries must not rescan all entries per font.
|
||||
let mut first_by_name = std::collections::HashMap::new();
|
||||
for entry in entries {
|
||||
if let Some(name) = entry.get("Name").and_then(E::as_str) {
|
||||
first_by_name.entry(name).or_insert(entry);
|
||||
}
|
||||
}
|
||||
E::Array(
|
||||
names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let matches = |entry: &&E| entry.get("Name").and_then(E::as_str) == Some(name.as_str());
|
||||
entries.get(i).filter(matches).or_else(|| first_by_name.get(name.as_str()).copied()).cloned().unwrap_or_else(|| font_entry(name))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The `Txt2` auto-kern mode of a style: 0 manual, 1 metrics, 2 optical — the values
|
||||
/// [`apply_txt2`] maps back to [`Kerning`]. EngineData's `AutoKerning` can't say Metrics from
|
||||
/// Optical, so the mode only lives in `Txt2`.
|
||||
@@ -938,6 +977,90 @@ fn txt2_mode_runs(layer: &TextLayer) -> Vec<(i64, usize)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs,
|
||||
/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set
|
||||
/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which
|
||||
/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking
|
||||
/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode
|
||||
/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit
|
||||
/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is
|
||||
/// larger than a template may be (it is copied once per run).
|
||||
fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec<E>, dpi: f32) -> Option<Vec<E>> {
|
||||
if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) {
|
||||
return None;
|
||||
}
|
||||
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
|
||||
let runs = layer.char_runs();
|
||||
let ed_text = format!("{}\r", layer.text.replace('\n', "\r"));
|
||||
let mut spans: Vec<(usize, i64)> = Vec::new();
|
||||
let (mut ri, mut run_end) = (0usize, runs.first().map_or(0, |r| r.len));
|
||||
for (b, ch) in ed_text.char_indices() {
|
||||
while b >= run_end && ri + 1 < runs.len() {
|
||||
ri += 1;
|
||||
run_end = run_end.saturating_add(runs.get(ri).map_or(0, |r| r.len));
|
||||
}
|
||||
let n = ch.len_utf16() as i64;
|
||||
match spans.last_mut() {
|
||||
Some(last) if last.0 == ri => last.1 += n,
|
||||
_ => spans.push((ri, n)),
|
||||
}
|
||||
}
|
||||
let default = CharStyle::default();
|
||||
let mut out = Vec::with_capacity(spans.len());
|
||||
let mut looked_up = std::collections::HashMap::new();
|
||||
for (ri, n) in spans {
|
||||
let s = runs.get(ri).map_or(&default, |r| &r.style);
|
||||
let font = *looked_up.entry(postscript_for(s)).or_insert_with_key(|name| txt2_font(fonts, name));
|
||||
let mut run = base.clone();
|
||||
let style = run.get_mut("0")?.get_mut("0")?.get_mut("6")?;
|
||||
if let Some(font) = font {
|
||||
style.set("0", E::Int(font));
|
||||
}
|
||||
style.set("1", real(s.size_pt * k));
|
||||
style.set("2", E::Bool(s.faux_bold));
|
||||
style.set("3", E::Bool(s.faux_italic));
|
||||
style.set("4", E::Bool(s.leading_pt.is_none()));
|
||||
style.set("5", real(s.leading_pt.unwrap_or(0.0) * k));
|
||||
style.set("6", real(s.horizontal_scale));
|
||||
style.set("7", real(s.vertical_scale));
|
||||
style.set("8", E::Int(s.tracking.round() as i64));
|
||||
if let Some(paint @ E::Dict(_)) = style.get_mut("53").and_then(|p| p.get_mut("0")) {
|
||||
let (kind, values) = fill_color(&s.color);
|
||||
paint.set("0", E::Int(kind));
|
||||
paint.set("1", E::Array(values));
|
||||
}
|
||||
let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0);
|
||||
for (key, tag) in TXT2_FEATURES {
|
||||
style.set(key, E::Bool(feature(tag).unwrap_or(tag == "calt")));
|
||||
}
|
||||
let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n);
|
||||
style.set(TXT2_STYLISTIC_SETS, E::Int(sets));
|
||||
style.set("11", E::Int(txt2_mode(s)));
|
||||
run.set("1", E::Int(n));
|
||||
out.push(run);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The index of the PostScript font `name` in a `Txt2` font list (`<< /0 << /99 /CoolTypeFont
|
||||
/// /0 << /0 name /2 type >> >> >>` entries), appending it (shaped like the list's first entry)
|
||||
/// when missing. `None` for an empty list: there is no entry to shape a new one on.
|
||||
fn txt2_font(fonts: &mut Vec<E>, name: &str) -> Option<i64> {
|
||||
let named = |f: &E| f.path(&["0", "0", "0"]).and_then(E::as_str) == Some(name);
|
||||
if let Some(i) = fonts.iter().position(named) {
|
||||
return i64::try_from(i).ok();
|
||||
}
|
||||
let mut entry = fonts.first()?.clone();
|
||||
let font = entry.get_mut("0")?.get_mut("0")?;
|
||||
let kind = font.get("2").cloned();
|
||||
*font = E::Dict(vec![("0".into(), E::String(name.to_string()))]);
|
||||
if let Some(kind) = kind {
|
||||
font.set("2", kind);
|
||||
}
|
||||
fonts.push(entry);
|
||||
i64::try_from(fonts.len() - 1).ok()
|
||||
}
|
||||
|
||||
/// The highest text-object number honoured here (`TextIndex`, and the slot array [`build_txt2`]
|
||||
/// sizes). PSD text objects are numbered densely from 0; a crafted file can name object
|
||||
/// `i32::MAX`, and sizing the array by it would make every save allocate gigabytes.
|
||||
@@ -964,15 +1087,30 @@ pub fn needs_txt2(objects: &[(i32, &TextLayer)]) -> bool {
|
||||
/// unchanged — keeps everything the file held beyond the two regenerated keys (its model's text
|
||||
/// and style runs): Photoshop stores glyph pen positions under `/21 /1` there, and a save with
|
||||
/// no text edit must not drop them. Its own style runs keep every style key too: only their
|
||||
/// auto-kern mode is set (split where a mode changes inside a run). A changed text invalidates
|
||||
/// those extras along with the old runs, so they are dropped rather than left stale. Everything
|
||||
/// auto-kern mode is set (split where a mode changes inside a run). An edited object — the
|
||||
/// file's object at that slot, its text changed — keeps the object too, with its text replaced
|
||||
/// and its run arrays refitted to the new text (#6968: a bare stub crashed Photoshop); only its
|
||||
/// glyph pen positions, which belong to the old text, are dropped. Everything
|
||||
/// else the file's block held — the other keys beside the text objects, objects no layer names —
|
||||
/// is kept, and a block whose content comes out unchanged is returned byte for byte (#2374:
|
||||
/// Photoshop refused files whose `Txt2` an unedited save had stripped).
|
||||
pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec<u8> {
|
||||
build_txt2_at(objects, previous, 72.0)
|
||||
}
|
||||
|
||||
/// [`build_txt2`] for a document of `dpi` (the style runs of new and edited objects carry sizes
|
||||
/// in the document's points, as EngineData does).
|
||||
pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi: f32) -> Vec<u8> {
|
||||
let prev = previous.and_then(parse_txt2);
|
||||
let prev_objects: &[E] = prev.as_ref().and_then(|p| p.path(&["1", "1"]).and_then(E::as_array)).unwrap_or(&[]);
|
||||
let mut slots: Vec<E> = prev_objects.to_vec();
|
||||
let mut fonts: Vec<E> = prev.as_ref().and_then(|p| p.path(&["0", "1", "0"]).and_then(E::as_array)).map(<[E]>::to_vec).unwrap_or_default();
|
||||
let fonts_before = fonts.len();
|
||||
// Looked up once per save, not per layer: a crafted block with many objects must not cost
|
||||
// a pass over all of them for every type layer.
|
||||
let all_stubs = prev_objects.iter().all(is_stub);
|
||||
// A template is copied once per new layer, so a large one is skipped (the stub is written).
|
||||
let template = prev_objects.iter().rev().find(|o| names_one_frame(o) && fits(o, &mut { TEMPLATE_NODES }) && edited_object(o, "\r").is_some());
|
||||
for (index, layer) in objects {
|
||||
if !(0..=MAX_TEXT_INDEX).contains(index) {
|
||||
continue;
|
||||
@@ -980,11 +1118,34 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
|
||||
let text = format!("{}\r", layer.text.replace('\n', "\r"));
|
||||
let modes = txt2_mode_runs(layer);
|
||||
let at = *index as usize;
|
||||
let kept = prev_objects.get(at).filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()));
|
||||
let mut object = kept.cloned().unwrap_or_else(E::dict);
|
||||
// A stub an earlier save wrote beside Photoshop's objects counts as no object at all, so
|
||||
// it is replaced like a missing one (below) and re-saving such a file repairs it, whether
|
||||
// or not the layer's text changed.
|
||||
let slot = prev_objects.get(at).filter(|o| !is_stub(o) || all_stubs);
|
||||
let kept = slot.filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()));
|
||||
// An edited object (#6968): the file's object at this slot with its text changed. It keeps
|
||||
// the file's structure — paragraph runs, the view naming its frame, the rest — with every
|
||||
// run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in
|
||||
// Photoshop's block (no paragraph runs, no view) made Photoshop quit while opening the file.
|
||||
let edited = kept.is_none().then(|| slot.and_then(|o| edited_object(o, &text))).flatten();
|
||||
// A new object (#7698): a layer the file's block has no object for (a type layer added
|
||||
// here) takes the shape of one of the file's own objects, refitted the same way, with its
|
||||
// view naming its own frame. A bare stub beside Photoshop's objects, its slot without a
|
||||
// frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file.
|
||||
let fresh = (kept.is_none() && edited.is_none()).then(|| template.and_then(|t| new_object(t, at, &text))).flatten();
|
||||
let reuse = kept.is_some() || edited.is_some();
|
||||
let unchanged = kept.is_some();
|
||||
let mut object = kept.cloned().or(edited).or(fresh).unwrap_or_else(E::dict);
|
||||
let mut model = object.get("0").cloned().filter(|m| matches!(m, E::Dict(_))).unwrap_or_else(E::dict);
|
||||
let file_runs = kept.and_then(|_| model.path(&["6", "0"]).and_then(E::as_array)).and_then(|runs| with_modes(runs, &modes));
|
||||
let runs = file_runs.unwrap_or_else(|| {
|
||||
// A new or edited object takes the layer's own style: Photoshop reads the character style
|
||||
// from these runs, so the template's (or the old text's) would show, and come back on
|
||||
// reopen. They are built on one of the object's own runs, every key Photoshop wrote kept.
|
||||
let styled = (!unchanged && !is_stub(&object))
|
||||
.then(|| model.path(&["6", "0"]).and_then(E::as_array).and_then(<[E]>::first).cloned())
|
||||
.flatten()
|
||||
.and_then(|base| txt2_style_runs(layer, &base, &mut fonts, dpi));
|
||||
let file_runs = reuse.then(|| model.path(&["6", "0"]).and_then(E::as_array)).flatten().and_then(|runs| with_modes(runs, &modes));
|
||||
let runs = styled.or(file_runs).unwrap_or_else(|| {
|
||||
modes
|
||||
.iter()
|
||||
.map(|&(mode, len)| {
|
||||
@@ -1011,6 +1172,12 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
|
||||
// The outer dictionary keeps whatever the block held (`/98` version, block extras, …), and
|
||||
// `/1` whatever it holds beside the text objects.
|
||||
let mut outer = prev.clone().unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())]));
|
||||
pad_frames(&mut outer, slots.len());
|
||||
if fonts.len() != fonts_before
|
||||
&& let Some(list @ E::Dict(_)) = outer.get_mut("0").and_then(|v| v.get_mut("1"))
|
||||
{
|
||||
list.set("0", E::Array(fonts));
|
||||
}
|
||||
match outer.get_mut("1") {
|
||||
Some(objs @ E::Dict(_)) => objs.set("1", E::Array(slots)),
|
||||
_ => outer.set("1", E::Dict(vec![("1".into(), E::Array(slots))])),
|
||||
@@ -1028,6 +1195,149 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
|
||||
}
|
||||
}
|
||||
|
||||
/// The most values a `Txt2` object may hold to serve as the template for new objects. Photoshop's
|
||||
/// own objects hold a few hundred (611 at most in the file from #7698).
|
||||
const TEMPLATE_NODES: usize = 4096;
|
||||
|
||||
/// Whether `v` holds at most `budget` values, counting down `budget` (stops at the first one over).
|
||||
fn fits(v: &E, budget: &mut usize) -> bool {
|
||||
let Some(left) = budget.checked_sub(1) else { return false };
|
||||
*budget = left;
|
||||
match v {
|
||||
E::Dict(items) => items.iter().all(|(_, x)| fits(x, budget)),
|
||||
E::Array(items) => items.iter().all(|x| fits(x, budget)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the text object `object` has a view naming one frame (`/1 /0 [ << /0 n >> ]`), as
|
||||
/// Photoshop writes it.
|
||||
fn names_one_frame(object: &E) -> bool {
|
||||
object.path(&["1", "0"]).and_then(E::as_array).is_some_and(|f| f.len() == 1 && f.first().and_then(|x| x.get("0")).is_some())
|
||||
}
|
||||
|
||||
/// A text object for the slot `at`, which the file's block has none for: the file's object
|
||||
/// `template` refitted to `text` by [`edited_object`], its view now naming frame `at` and its view
|
||||
/// runs (`/1 /1 /0`, one per paragraph) remade for the new paragraphs. `None` when the template
|
||||
/// can't be refitted.
|
||||
fn new_object(template: &E, at: usize, text: &str) -> Option<E> {
|
||||
let paragraphs: Vec<usize> = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect();
|
||||
let frame = template.path(&["1", "0"]).and_then(E::as_array).and_then(|f| f.first()).and_then(|f| f.get("0")).cloned();
|
||||
let at = i64::try_from(at).ok()?;
|
||||
let mut object = edited_object(template, text)?;
|
||||
let view = object.get_mut("1")?;
|
||||
view.set("0", E::Array(vec![E::Dict(vec![("0".into(), E::Int(at))])]));
|
||||
// The rendered strikes' frame strike (`/F`) names the template's frame too.
|
||||
if let Some(E::Array(strikes)) = view.get_mut("2") {
|
||||
for s in strikes.iter_mut() {
|
||||
let Some(E::Array(parts)) = s.get_mut("6") else { continue };
|
||||
for p in parts.iter_mut().filter(|p| p.get("99").and_then(E::as_str) == Some("F") && p.get("10") == frame.as_ref()) {
|
||||
p.set("10", E::Int(at));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(runs) = view.path(&["1", "0"]).and_then(E::as_array) {
|
||||
let fitted = runs_per(runs, ¶graphs)?;
|
||||
view.get_mut("1")?.set("0", E::Array(fitted));
|
||||
}
|
||||
Some(object)
|
||||
}
|
||||
|
||||
/// Whether `object` is the bare `<< /0 << /0 text /6 runs >> >>` object [`build_txt2`] writes
|
||||
/// when it has nothing to take a shape from: no view, no paragraph runs.
|
||||
fn is_stub(object: &E) -> bool {
|
||||
let only = |v: &E, keys: &[&str]| matches!(v, E::Dict(items) if items.iter().all(|(k, _)| keys.contains(&k.as_str())));
|
||||
only(object, &["0"]) && object.get("0").is_some_and(|m| only(m, &["0", "6"]))
|
||||
}
|
||||
|
||||
/// Gives the block `outer` a frame (`/0 /8 /0`) for each of its `count` text objects: Photoshop
|
||||
/// writes one per object, and a reader indexes them by object number. Missing frames are the
|
||||
/// small point-text frame Photoshop writes (`<< /0 << /2 << /6 [ -1.0 -1.0 ] /11 << /4 -1 /7
|
||||
/// false >> >> >> >>`), never copies of a file's frame: the slot count reaches
|
||||
/// [`MAX_TEXT_INDEX`], and a large frame copied that often would blow up the save. A block
|
||||
/// without a frame list is left as it is.
|
||||
fn pad_frames(outer: &mut E, count: usize) {
|
||||
let Some(E::Array(frames)) = outer.get_mut("0").and_then(|v| v.get_mut("8")).and_then(|v| v.get_mut("0")) else { return };
|
||||
let count = count.min(MAX_TEXT_INDEX as usize + 1);
|
||||
if frames.len() < count {
|
||||
let kind = E::Dict(vec![("4".into(), E::Int(-1)), ("7".into(), E::Bool(false))]);
|
||||
let data = E::Dict(vec![("6".into(), E::Array(vec![E::Real(-1.0), E::Real(-1.0)])), ("11".into(), kind)]);
|
||||
let frame = E::Dict(vec![("0".into(), E::Dict(vec![("2".into(), data)]))]);
|
||||
frames.resize(count, frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's `Txt2` text object `object` (`<< /0 model /1 view … >>`) for the changed engine
|
||||
/// text `text` (`\r` breaks, trailing `\r`): the model's text replaced, its paragraph runs (`/5`)
|
||||
/// one per paragraph of the new text (each keeping the style of the file's paragraph at that
|
||||
/// position, else its last), and every other run array of the model (style runs `/6`, …) cut or
|
||||
/// extended at its end to the new length. The view and the other keys are kept, except the glyph
|
||||
/// pen positions (`/21`), which belong to the old text. `None` when the object or one of its run
|
||||
/// arrays isn't shaped as Photoshop writes it: the caller writes a fresh object instead.
|
||||
fn edited_object(object: &E, text: &str) -> Option<E> {
|
||||
let mut object = object.clone();
|
||||
let E::Dict(items) = &mut object else { return None };
|
||||
items.retain(|(k, _)| k != "21");
|
||||
let model = object.get_mut("0").filter(|m| matches!(m, E::Dict(_)))?;
|
||||
let total = utf16_len(text) as usize;
|
||||
let paragraphs: Vec<usize> = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect();
|
||||
let E::Dict(entries) = model else { return None };
|
||||
for (key, value) in entries.iter_mut() {
|
||||
if key.as_str() == "0" {
|
||||
*value = E::String(text.to_string());
|
||||
continue;
|
||||
}
|
||||
let Some(runs) = value.get("0").and_then(E::as_array).filter(|r| r.iter().all(|x| matches!(x, E::Dict(_)) && x.get("1").is_some())) else {
|
||||
continue;
|
||||
};
|
||||
let fitted = if key.as_str() == "5" { runs_per(runs, ¶graphs)? } else { runs_to_length(runs, total)? };
|
||||
value.set("0", E::Array(fitted));
|
||||
}
|
||||
Some(object)
|
||||
}
|
||||
|
||||
/// `runs` (`<< … /1 length >>`) remade with the lengths `lens`: run i keeps the content of the
|
||||
/// file's run i, else of its last one. `None` for an empty `runs`.
|
||||
fn runs_per(runs: &[E], lens: &[usize]) -> Option<Vec<E>> {
|
||||
let last = runs.last()?;
|
||||
lens.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &len)| {
|
||||
let mut r = runs.get(i).unwrap_or(last).clone();
|
||||
r.set("1", E::Int(i64::try_from(len).ok()?));
|
||||
Some(r)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `runs` (`<< … /1 length >>`) covering `total` UTF-16 units: the leading runs as they were, cut
|
||||
/// where `total` ends, the last kept run extended when `total` is longer. Empty runs are dropped.
|
||||
/// `None` when `runs` is empty or a length isn't a count.
|
||||
fn runs_to_length(runs: &[E], total: usize) -> Option<Vec<E>> {
|
||||
let mut out: Vec<(E, usize)> = Vec::new();
|
||||
let mut left = total;
|
||||
for r in runs {
|
||||
let len = r.get("1").and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())?;
|
||||
let take = len.min(left);
|
||||
if take > 0 {
|
||||
out.push((r.clone(), take));
|
||||
left -= take;
|
||||
}
|
||||
}
|
||||
if left > 0 {
|
||||
match out.last_mut() {
|
||||
Some(last) => last.1 = last.1.checked_add(left)?,
|
||||
None => out.push((runs.last()?.clone(), left)),
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
.map(|(mut r, len)| {
|
||||
r.set("1", E::Int(i64::try_from(len).ok()?));
|
||||
Some(r)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The file's own `Txt2` style runs `runs` with the auto-kern modes `modes` (`(mode, UTF-16
|
||||
/// length)`, see [`txt2_mode_runs`]) set at `/0 /0 /6 /11`, keeping every other style key. A
|
||||
/// run a mode change falls inside is split there. `None` when the runs don't cover the text or
|
||||
@@ -1085,13 +1395,24 @@ pub fn text_index(tysh: &[u8]) -> Option<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many text objects the document `Txt2` block `data` holds (`/1 /1`), named by a layer or
|
||||
/// not; 0 when it doesn't parse. PSD export numbers new type layers past them, so a new layer
|
||||
/// never lands on a deleted layer's object.
|
||||
pub fn txt2_object_count(data: &[u8]) -> usize {
|
||||
parse_txt2(data).and_then(|t| t.path(&["1", "1"]).and_then(E::as_array).map(<[E]>::len)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Rewrites a `TySh`'s `TextIndex`: the layer's text-object number in the document's `Txt2`
|
||||
/// block ([`build_txt2`] writes them in this order). `None` when `tysh` doesn't parse.
|
||||
pub fn set_text_index(tysh: &[u8], index: i32) -> Option<Vec<u8>> {
|
||||
let mut t = parse_tysh(tysh)?;
|
||||
match t.text.items.iter_mut().find(|(k, _)| k.is("TextIndex")) {
|
||||
Some(e) => e.1 = D::Integer(index),
|
||||
None => t.text.items.push((Id::new("TextIndex"), D::Integer(index))),
|
||||
// Where Photoshop writes it: right before `EngineData`.
|
||||
None => {
|
||||
let at = t.text.items.iter().position(|(k, _)| k.is("EngineData")).unwrap_or(t.text.items.len());
|
||||
t.text.items.insert(at, (Id::new("TextIndex"), D::Integer(index)));
|
||||
}
|
||||
}
|
||||
Some(write_tysh(&t))
|
||||
}
|
||||
@@ -1153,7 +1474,6 @@ pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E
|
||||
)]));
|
||||
plens.push(E::Int(n));
|
||||
}
|
||||
let fontset = E::Array(fonts.iter().map(|f| font_entry(f)).collect());
|
||||
|
||||
let Some(dict) = e.get_mut("EngineDict") else {
|
||||
return fresh();
|
||||
@@ -1234,7 +1554,8 @@ pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E
|
||||
continue;
|
||||
}
|
||||
let Some(rd) = e.get_mut(key) else { continue };
|
||||
rd.set("FontSet", fontset.clone());
|
||||
let fontset = font_set(&fonts, rd.get("FontSet"));
|
||||
rd.set("FontSet", fontset);
|
||||
if rd.get("StyleSheetSet").is_none() {
|
||||
let base = runs.first().map(|r| r.style.clone()).unwrap_or_default();
|
||||
rd.set(
|
||||
@@ -1322,9 +1643,6 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec<u8>
|
||||
};
|
||||
set(&mut text, "bounds", rect("bounds"));
|
||||
set(&mut text, "boundingBox", rect("boundingBox"));
|
||||
if text.get("TextIndex").is_none() {
|
||||
set(&mut text, "TextIndex", D::Integer(0));
|
||||
}
|
||||
set(&mut text, "EngineData", D::RawData(ed::write(&e)));
|
||||
let warp = match (&layer.warp, old.as_ref().and_then(|o| o.warp.clone())) {
|
||||
(Some(w), _) => Some(
|
||||
@@ -1365,6 +1683,19 @@ mod nonfinite_tests {
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0);
|
||||
}
|
||||
|
||||
/// #5645: `FontBaseline` (1 superscript, 2 subscript) is read and written back.
|
||||
#[test]
|
||||
fn font_baseline_round_trips() {
|
||||
use photocraft_doc::text::FontBaseline;
|
||||
let d = parse(b"<< /FontBaseline 2 >>").unwrap();
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Subscript);
|
||||
let d = parse(b"<< /FontBaseline 1 >>").unwrap();
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Superscript);
|
||||
let s = CharStyle { baseline: FontBaseline::Subscript, ..Default::default() };
|
||||
let back = char_style(None, &style_sheet_data(&s, 0, 1.0), &[], 1.0);
|
||||
assert_eq!(back.baseline, FontBaseline::Subscript);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_values_beyond_f32_clamp_to_the_warp_range() {
|
||||
let warp = Descriptor::new("warp")
|
||||
|
||||
@@ -187,7 +187,8 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d
|
||||
rd.set("ParagraphSheetSet", E::Array(pset));
|
||||
rd.set("TheNormalStyleSheet", E::Int(0));
|
||||
rd.set("TheNormalParagraphSheet", E::Int(0));
|
||||
rd.set("FontSet", E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect()));
|
||||
let fontset = crate::psd::font_set(&fonts, rd.get("FontSet"));
|
||||
rd.set("FontSet", fontset);
|
||||
}
|
||||
let char_fonts = e.path(&["ResourceDict", "FontSet"]).or_else(|| e.path(&["DocumentResources", "FontSet"])).map(|f| names(Some(f))).unwrap_or_default();
|
||||
let char_ix = |id: Option<u32>| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1);
|
||||
|
||||
@@ -504,6 +504,32 @@ fn styles_change_pixels() {
|
||||
assert!(hrect.width() as f32 > prect.width() as f32 * 1.7);
|
||||
}
|
||||
|
||||
/// #5645: superscript and subscript runs are drawn at 58.3% of their size, raised or lowered by
|
||||
/// 33.3% of it, as Photoshop draws them; they used to be drawn as plain text.
|
||||
#[test]
|
||||
fn superscript_and_subscript_shrink_and_shift_the_run() {
|
||||
use photocraft_doc::text::FontBaseline;
|
||||
let mut e = TextEngine::new();
|
||||
let base = CharStyle { size_pt: 60.0, ..Default::default() };
|
||||
let rect = |e: &mut TextEngine, baseline: FontBaseline| {
|
||||
let (_, r) =
|
||||
e.render(&TextLayer { transform: Affine::translate(5.0, 100.0), ..styled("H", CharStyle { baseline, ..base.clone() }) }, 72.0, PixelFormat::RGBA8);
|
||||
r.rect
|
||||
};
|
||||
let plain = rect(&mut e, FontBaseline::Normal);
|
||||
let sup = rect(&mut e, FontBaseline::Superscript);
|
||||
let sub = rect(&mut e, FontBaseline::Subscript);
|
||||
// Scripts render at 58.3% of the size. The pixel-snapped bounds of the antialiased glyph land
|
||||
// within 2 px of the exact scaled height (measured: +2.2 / +1.2 px at 46 px plain height).
|
||||
let expect = plain.height() as f32 * 0.583;
|
||||
for r in [&sup, &sub] {
|
||||
assert!((r.height() as f32 - expect).abs() <= 2.5, "plain {plain:?}, scripted {r:?}");
|
||||
}
|
||||
// The cap's foot sits on the shifted baseline: 20 px (33.3% of 60) above or below.
|
||||
assert!((plain.y1 - sup.y1 - 20).abs() <= 2, "plain {plain:?}, superscript {sup:?}");
|
||||
assert!((sub.y1 - plain.y1 - 20).abs() <= 2, "plain {plain:?}, subscript {sub:?}");
|
||||
}
|
||||
|
||||
/// #4130: Faux bold at 72 dpi looked pixelated because the offset copies were summed into one
|
||||
/// coverage buffer, saturating every antialiased edge pixel. Edges must stay antialiased and the
|
||||
/// ink must grow only by the dilation (about perimeter x radius), not by edge saturation.
|
||||
@@ -1129,6 +1155,143 @@ fn edited_txt2_keeps_the_files_styles_and_objects() {
|
||||
assert_eq!(modes, vec![(1, Kerning::Optical), (2, Kerning::Metrics)]);
|
||||
}
|
||||
|
||||
/// #6968: editing the text of a Photoshop type layer keeps its `Txt2` object — paragraph runs,
|
||||
/// the view naming its frame, the model's other run arrays and its style keys — refitted to the
|
||||
/// new text. It was replaced by a bare `<< /0 << /0 text /6 runs >> >>` stub, and Photoshop quit
|
||||
/// while opening the file.
|
||||
#[test]
|
||||
fn edited_text_keeps_the_files_txt2_object() {
|
||||
use crate::engine_data::Value as E;
|
||||
let run = |extra: &str, len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 {extra} >> >> >> /1 {len} >> ");
|
||||
// "AB\rCD\r": two paragraphs, two style runs, a kern run, a view and pen positions.
|
||||
let prev = format!(
|
||||
"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}{}] >> /7 << /0 [ << /0 << >> /1 6 >> ] >> >> /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>",
|
||||
run("", 3),
|
||||
run("", 3),
|
||||
run("/11 1", 2),
|
||||
run("/11 1 /53 (red)", 4)
|
||||
);
|
||||
// Every character above is below U+0100: one raw byte each (the BOM `FE FF` included).
|
||||
let prev: Vec<u8> = prev.chars().map(|c| c as u32 as u8).collect();
|
||||
let before = crate::psd::parse_txt2(&prev).unwrap();
|
||||
let before_object = before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0].clone();
|
||||
assert_eq!(before_object.path(&["0", "0"]).and_then(E::as_str), Some("AB\rCD\r"));
|
||||
let lens = |o: &E, key: &str| -> Vec<i64> {
|
||||
o.path(&["0", key, "0"]).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect()
|
||||
};
|
||||
for (text, paragraphs, styles) in
|
||||
[("Bevex\nCD", vec![6, 3], vec![9]), ("AB\nCD\nEFG", vec![3, 3, 4], vec![10]), ("X", vec![2], vec![2]), ("AB\nCZ", vec![3, 3], vec![6])]
|
||||
{
|
||||
let t = runs_of(text, &[(text.len(), CharStyle::default())]);
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap();
|
||||
for key in [&["0"][..], &["98"], &["1", "0"], &["1", "2"]] {
|
||||
assert_eq!(out.path(key), before.path(key), "{text:?}: /{} survives", key.join(" /"));
|
||||
}
|
||||
let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0];
|
||||
let engine = format!("{}\r", text.replace('\n', "\r"));
|
||||
let total = engine.encode_utf16().count() as i64;
|
||||
assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some(engine.as_str()));
|
||||
assert_eq!(object.get("1"), before_object.get("1"), "{text:?}: the view (its frame) is kept");
|
||||
assert!(object.get("21").is_none(), "{text:?}: pen positions of the old text are dropped");
|
||||
assert_eq!(lens(object, "5"), paragraphs, "{text:?}: one paragraph run per paragraph");
|
||||
// Photoshop reads the character style from these runs: they follow the layer's own runs
|
||||
// (one here), built on the file's first run.
|
||||
assert_eq!(lens(object, "6"), styles, "{text:?}: style runs cover the new text");
|
||||
assert_eq!(lens(object, "7"), vec![total], "{text:?}: other run arrays cover the new text");
|
||||
for r in object.path(&["0", "5", "0"]).and_then(E::as_array).unwrap() {
|
||||
assert_eq!(r.path(&["0", "0", "5", "7"]).and_then(E::as_f64), Some(1.2), "{text:?}: paragraph style survives");
|
||||
}
|
||||
for r in object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap() {
|
||||
assert_eq!(r.path(&["0", "0", "6", "1"]).and_then(E::as_f64), Some(f64::from(CharStyle::default().size_pt)), "{text:?}: the layer's font size");
|
||||
assert_eq!(r.path(&["0", "0", "5", "7"]).and_then(E::as_f64), Some(1.2), "{text:?}: the file's run keeps its other keys");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an
|
||||
/// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`,
|
||||
/// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out.
|
||||
#[test]
|
||||
fn new_text_layer_gets_a_txt2_object_and_frame() {
|
||||
use crate::engine_data::Value as E;
|
||||
let run = |len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 >> >> >> /1 {len} >> ");
|
||||
let prev = format!(
|
||||
"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}] >> >> /1 << /0 [ << /0 0 >> ] /1 << /0 [ << /0 << /1 1 >> /1 3 >> << /0 << /1 1 >> /1 3 >> ] >> /2 [ << /99 /PC /6 [ << /99 /F /10 0 >> ] >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>",
|
||||
run(3),
|
||||
run(3),
|
||||
run(6)
|
||||
);
|
||||
// The same block after an earlier save wrote a bare stub for "v1.0" (the file in #7698).
|
||||
let stub = "<< /0 << /0 (\u{fe}\u{ff}\0v\x001\0.\x000\0\r) /6 << /0 [ << /0 << /0 << /0 () /6 << /0 0 /11 1 >> >> >> /1 5 >> ] >> >> >> ";
|
||||
let broken = prev.replace(" >> ] /2 << /0 7 >>", &format!(" >> {stub}] /2 << /0 7 >>"));
|
||||
let broken: Vec<u8> = broken.chars().map(|c| c as u32 as u8).collect();
|
||||
let prev: Vec<u8> = prev.chars().map(|c| c as u32 as u8).collect();
|
||||
let before = crate::psd::parse_txt2(&prev).unwrap();
|
||||
let old = runs_of("AB\nCD", &[(5, CharStyle::default())]);
|
||||
let new = runs_of("v1.0", &[(4, CharStyle::default())]);
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&prev))).unwrap();
|
||||
let objects = out.path(&["1", "1"]).and_then(E::as_array).unwrap();
|
||||
assert_eq!(objects.len(), 2);
|
||||
let kept = &before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0];
|
||||
assert_eq!(objects[0].get("1"), kept.get("1"), "the file's object keeps its view");
|
||||
assert_eq!(objects[0].path(&["0", "5"]), kept.path(&["0", "5"]), "the file's object keeps its paragraphs");
|
||||
let frames = out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap();
|
||||
assert_eq!(frames.len(), 2, "one frame per object");
|
||||
let object = &objects[1];
|
||||
assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v1.0\r"));
|
||||
assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "the view names its own frame");
|
||||
assert!(object.get("21").is_none());
|
||||
assert_eq!(
|
||||
object.path(&["1", "2"]).and_then(E::as_array).and_then(|s| s[0].get("6")).and_then(E::as_array).and_then(|f| f[0].get("10")),
|
||||
Some(&E::Int(1)),
|
||||
"the frame strike names its own frame"
|
||||
);
|
||||
let lens =
|
||||
|path: &[&str]| -> Vec<i64> { object.path(path).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect() };
|
||||
assert_eq!(lens(&["0", "5", "0"]), vec![5]);
|
||||
assert_eq!(lens(&["0", "6", "0"]), vec![5]);
|
||||
assert_eq!(lens(&["1", "1", "0"]), vec![5], "view runs follow the new paragraphs");
|
||||
|
||||
// Re-saving the file with the stub repairs it: the stub is replaced like a missing object.
|
||||
assert_eq!(crate::psd::parse_txt2(&broken).unwrap().path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2);
|
||||
let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&broken))).unwrap();
|
||||
assert_eq!(repaired, out, "a stub from an earlier save is replaced");
|
||||
// Also when the stub's layer is edited before the re-save.
|
||||
let edited = runs_of("v2.0", &[(4, CharStyle::default())]);
|
||||
let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &edited)], Some(&broken))).unwrap();
|
||||
let object = &repaired.path(&["1", "1"]).and_then(E::as_array).unwrap()[1];
|
||||
assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v2.0\r"));
|
||||
assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "an edited stub is replaced too");
|
||||
|
||||
// No object to take the shape from: the fresh object as before, with the frames still padded.
|
||||
let bare = b"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 1 >> ] >> >> /1 << /1 [ << >> ] >> ";
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(1, &new)], Some(bare))).unwrap();
|
||||
assert_eq!(out.path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2);
|
||||
assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2);
|
||||
|
||||
// An oversized object is never the template (it would be copied once per new layer).
|
||||
let filler = "0 ".repeat(5000);
|
||||
let big = prev.iter().map(|&b| b as char).collect::<String>().replace("/2 [ << /99 /PC", &format!("/9 [ {filler}] /2 [ << /99 /PC"));
|
||||
let big: Vec<u8> = big.chars().map(|c| c as u32 as u8).collect();
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&big))).unwrap();
|
||||
let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[1];
|
||||
assert!(object.get("1").is_none(), "no copy of an oversized template");
|
||||
assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// The frame padding is bounded like the slots a layer can name: a crafted block of many empty
|
||||
/// objects and no frames must not make the save write a frame for each of them.
|
||||
#[test]
|
||||
fn txt2_frame_padding_is_bounded() {
|
||||
use crate::engine_data::Value as E;
|
||||
let objects = "<< >> ".repeat(70_000);
|
||||
let prev = format!("\n\n/98 << /0 7 >> /0 << /8 << /0 [ ] >> >> /1 << /1 [ {objects}] >> ");
|
||||
let t = runs_of("AV", &[(2, CharStyle::default())]);
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(prev.as_bytes()))).unwrap();
|
||||
let frames = out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len();
|
||||
assert_eq!(frames, crate::psd::MAX_TEXT_INDEX as usize + 1);
|
||||
}
|
||||
|
||||
/// The file's style runs are only reused when they cover the text: runs of another length (or
|
||||
/// hostile lengths) fall back to generated runs, without panicking.
|
||||
#[test]
|
||||
@@ -2096,6 +2259,136 @@ fn hyphenless_postscript_names_split_trailing_styles() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_rebuild_preserves_imported_font_metadata() {
|
||||
use crate::engine_data::Value as E;
|
||||
let layer = point("Original", 24.0);
|
||||
let mut template = crate::psd::build_engine_data(&layer, None, 72.0);
|
||||
for key in ["ResourceDict", "DocumentResources"] {
|
||||
let fonts = template.get_mut(key).unwrap().get_mut("FontSet").unwrap();
|
||||
let E::Array(fonts) = fonts else { panic!("font array") };
|
||||
for font in fonts.iter_mut() {
|
||||
font.set("Script", E::Int(4));
|
||||
font.set("FontType", E::Int(2));
|
||||
font.set("Synthetic", E::Int(3));
|
||||
font.set("PrivateFontFlag", E::Int(17));
|
||||
}
|
||||
}
|
||||
let expected = template.path(&["ResourceDict", "FontSet"]).unwrap().clone();
|
||||
let mut edited = layer;
|
||||
edited.text = "Edited".into();
|
||||
let rebuilt = crate::psd::build_engine_data(&edited, Some(template), 72.0);
|
||||
for key in ["ResourceDict", "DocumentResources"] {
|
||||
assert_eq!(rebuilt.path(&[key, "FontSet"]), Some(&expected), "existing font metadata must survive a text edit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_metadata_survives_tysh_and_named_style_rewrites() {
|
||||
use crate::engine_data::Value as E;
|
||||
let source = point("Original", 24.0);
|
||||
let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap();
|
||||
let mut data = crate::psd::engine_data(&tysh.text).unwrap();
|
||||
for (key, marker) in [("ResourceDict", 17), ("DocumentResources", 29)] {
|
||||
let E::Array(fonts) = data.get_mut(key).unwrap().get_mut("FontSet").unwrap() else { panic!("font array") };
|
||||
for font in fonts {
|
||||
font.set("PrivateFontFlag", E::Int(marker));
|
||||
font.set("Script", E::Int(4));
|
||||
}
|
||||
}
|
||||
for (key, value) in &mut tysh.text.items {
|
||||
if key.as_bytes() == b"EngineData" {
|
||||
*value = photocraft_psd::descriptor::Value::RawData(crate::engine_data::write(&data));
|
||||
}
|
||||
}
|
||||
let raw = crate::psd::write_tysh(&tysh);
|
||||
let mut imported = crate::psd::text_layer_from_tysh(&raw, 72.0).unwrap();
|
||||
// The IO importer retains the raw block separately from the parsed text model.
|
||||
imported.psd_raw = Some(raw.into());
|
||||
imported.text = "Edited".into();
|
||||
let rebuilt = crate::psd::build_tysh(&imported, 72.0, None);
|
||||
let styled = crate::psd_styles::write_style_sheets(&rebuilt, &imported, &photocraft_doc::TextStyles::default(), 72.0).unwrap();
|
||||
for bytes in [&rebuilt, &styled] {
|
||||
let parsed = crate::psd::parse_tysh(bytes).unwrap();
|
||||
let actual = crate::psd::engine_data(&parsed.text).unwrap();
|
||||
for key in ["ResourceDict", "DocumentResources"] {
|
||||
assert_eq!(actual.path(&[key, "FontSet"]), data.path(&[key, "FontSet"]));
|
||||
}
|
||||
assert_eq!(crate::psd::text_layer_from_tysh(bytes, 72.0).unwrap().text, "Edited");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_metadata_append_reorder_and_duplicate_indices() {
|
||||
use crate::engine_data::Value as E;
|
||||
let record = |name: &str, marker| E::Dict(vec![("Name".into(), E::String(name.into())), ("PrivateFontFlag".into(), E::Int(marker))]);
|
||||
let entries = vec![record("A", 1), record("A", 2), record("B", 3)];
|
||||
let original = E::Array(entries.clone());
|
||||
let names = ["A", "A", "B", "New"].map(str::to_string);
|
||||
let result = crate::psd::font_set(&names, Some(&original));
|
||||
let fonts = result.as_array().unwrap();
|
||||
assert_eq!(&fonts[..3], entries.as_slice());
|
||||
assert_eq!(fonts[3], crate::psd::font_entry("New"));
|
||||
let reordered = crate::psd::font_set(&["B".into(), "A".into()], Some(&original));
|
||||
assert_eq!(reordered, E::Array(vec![entries[2].clone(), entries[1].clone()]));
|
||||
// A non-positional duplicate uses the first matching record, not the last map insertion.
|
||||
let fallback = crate::psd::font_set(&["New".into(), "B".into(), "A".into()], Some(&original));
|
||||
assert_eq!(fallback, E::Array(vec![crate::psd::font_entry("New"), entries[2].clone(), entries[0].clone()]));
|
||||
let empty = record("", 9);
|
||||
assert_eq!(crate::psd::font_set(&["".into()], Some(&E::Array(vec![E::Int(0), empty.clone()]))), E::Array(vec![empty]));
|
||||
for malformed in [None, Some(&E::Int(0))] {
|
||||
assert_eq!(crate::psd::font_set(&["New".into()], malformed), E::Array(vec![crate::psd::font_entry("New")]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_metadata_large_nameless_lists_rebuild_after_edit() {
|
||||
use crate::engine_data::Value as E;
|
||||
let mut layer = point("Original", 24.0);
|
||||
let mut template = crate::psd::build_engine_data(&layer, None, 72.0);
|
||||
for key in ["ResourceDict", "DocumentResources"] {
|
||||
template.get_mut(key).unwrap().set("FontSet", E::Array(vec![E::Int(0); 10_000]));
|
||||
}
|
||||
layer.text = "Edited".into();
|
||||
let rebuilt = crate::psd::build_engine_data(&layer, Some(template), 72.0);
|
||||
for key in ["ResourceDict", "DocumentResources"] {
|
||||
let fonts = rebuilt.path(&[key, "FontSet"]).unwrap().as_array().unwrap();
|
||||
assert!(fonts.len() >= 10_000);
|
||||
assert!(fonts[..10_000].iter().all(|font| *font == crate::psd::font_entry("")));
|
||||
}
|
||||
}
|
||||
|
||||
/// Same-runner release probe: compare the original and repaired source, not a wall-clock CI limit.
|
||||
#[test]
|
||||
#[ignore = "release performance measurement for large imported FontSets"]
|
||||
fn font_metadata_large_lists_timing() {
|
||||
use crate::engine_data::Value as E;
|
||||
for reordered in [false, true] {
|
||||
let mut layer = point("Original", 24.0);
|
||||
let mut template = crate::psd::build_engine_data(&layer, None, 72.0);
|
||||
let n = 100_000;
|
||||
for key in ["ResourceDict", "DocumentResources"] {
|
||||
let entries = if reordered {
|
||||
(0..n).map(|i| crate::psd::font_entry(&format!("Font{}", if key == "DocumentResources" { n - 1 - i } else { i }))).collect()
|
||||
} else {
|
||||
vec![E::Int(0); n]
|
||||
};
|
||||
template.get_mut(key).unwrap().set("FontSet", E::Array(entries));
|
||||
}
|
||||
layer.text = "Edited".into();
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let input = template.clone();
|
||||
let start = std::time::Instant::now();
|
||||
let result = crate::psd::build_engine_data(&layer, Some(input), 72.0);
|
||||
std::hint::black_box(&result);
|
||||
times.push(start.elapsed().as_secs_f64());
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!("FONT_LOOKUP reordered={reordered} n={n} median_seconds={}", times[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A word wider than its paragraph box wraps at the box edge instead of running past it (#6694);
|
||||
/// a normal break opportunity still wins, and a box that fits the word lays out exactly as before.
|
||||
#[test]
|
||||
|
||||
@@ -254,6 +254,8 @@ struct Sizes {
|
||||
big_photo: (u32, u32),
|
||||
/// A4 at 300 ppi.
|
||||
a4: (u32, u32),
|
||||
/// A new 10 × 5 m document at 200 ppi (3.1 GP, #4017).
|
||||
huge_new: (u32, u32),
|
||||
}
|
||||
|
||||
const FULL: Sizes = Sizes {
|
||||
@@ -266,6 +268,7 @@ const FULL: Sizes = Sizes {
|
||||
ten_mp: (3872, 2592),
|
||||
big_photo: (6000, 4000),
|
||||
a4: (2480, 3508),
|
||||
huge_new: (78_740, 39_370),
|
||||
};
|
||||
|
||||
const QUICK: Sizes = Sizes {
|
||||
@@ -278,6 +281,7 @@ const QUICK: Sizes = Sizes {
|
||||
ten_mp: (1200, 800),
|
||||
big_photo: (1600, 1066),
|
||||
a4: (1240, 1754),
|
||||
huge_new: (20_000, 10_000),
|
||||
};
|
||||
|
||||
/// The layered document of #209: a background, six pixel layers with a drop shadow and a stroke,
|
||||
@@ -932,6 +936,7 @@ fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
}
|
||||
}
|
||||
liquify_preview_scenario(b);
|
||||
huge_new_scenario(b, sz);
|
||||
let cmyk = "brush dab on A4 300 ppi CMYK + refresh";
|
||||
if b.wanted(cmyk) {
|
||||
let (w, h) = sz.a4;
|
||||
@@ -995,6 +1000,30 @@ fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
}
|
||||
}
|
||||
|
||||
/// #4017: File > New at 10 × 5 m, 200 ppi (3.1 GP), and the first refresh of the canvas that
|
||||
/// draws a document too large for GPU textures (the reduced CPU canvas, `canvas::ensure_texture`).
|
||||
fn huge_new_scenario(b: &mut Bench, sz: &Sizes) {
|
||||
let name = "new 3 GP document + first reduced canvas refresh";
|
||||
if !b.wanted(name) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = sz.huge_new;
|
||||
let reps = b.reps.min(3);
|
||||
b.time(name, &mut Session::new(), reps, false, |_, s, _| {
|
||||
let t = Instant::now();
|
||||
exec(s, "file.new", json!({"width": w, "height": h, "resolution": 200, "background": "white"}))?;
|
||||
let doc = &s.active().ok_or("no document")?.doc;
|
||||
let factor = w.max(h).div_ceil(photocraft_ui_egui::canvas::MAX_TEXTURE).max(1);
|
||||
let reduced = photocraft_compose::render_reduced(doc, w / factor, h / factor);
|
||||
if reduced.px.first() != Some(&[1.0; 4]) {
|
||||
return Err("the new document is not white".into());
|
||||
}
|
||||
let v = ms(t);
|
||||
exec(s, "file.close", json!({}))?;
|
||||
Ok(v)
|
||||
});
|
||||
}
|
||||
|
||||
/// A source-resolution Liquify brush update on the visible part of a 3500×2200 layer.
|
||||
/// Reports the old whole-document proxy cost alongside the full-resolution cropped update.
|
||||
fn liquify_preview_scenario(b: &mut Bench) {
|
||||
|
||||
@@ -97,7 +97,15 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
|
||||
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
|
||||
b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1);
|
||||
b.transfer.mix = dynamic(0.45, Control::Fade, 0.2);
|
||||
b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true };
|
||||
b.mixer = paint::MixerSettings {
|
||||
wet: 0.9,
|
||||
load: 0.15,
|
||||
mix: 0.7,
|
||||
flow: 0.8,
|
||||
sample_all_layers: true,
|
||||
load_after_stroke: false,
|
||||
clean_after_stroke: false,
|
||||
};
|
||||
}
|
||||
7 => {
|
||||
let p = &mut b.pose;
|
||||
@@ -488,6 +496,56 @@ fn mixer_brush_options_update_the_persisted_mixer_settings() {
|
||||
assert_eq!(params["brush"]["mixer"]["sampleAllLayers"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_brush_clean_brush_and_the_automatic_load_keep_the_foreground_out() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = options_bar_harness(crate::state::Tool::MixerBrush);
|
||||
// A red image, an empty layer above it, a blue foreground, and a brush that samples all layers.
|
||||
for (id, p) in [
|
||||
("file.new", json!({"width": 200, "height": 40, "background": "transparent"})),
|
||||
("edit.fill", json!({"color": "#ff0000"})),
|
||||
("layer.new.layer", json!({})),
|
||||
("tools.setColors", json!({"foreground": "#0000ff"})),
|
||||
("tools.setBrush", json!({"brush": {"size": 8, "pressureSize": false, "mixer": {"sampleAllLayers": true}}})),
|
||||
] {
|
||||
h.state_mut().run(id, p).unwrap();
|
||||
}
|
||||
h.run_steps(3);
|
||||
for label in ["Current Brush Load", "Load the brush after each stroke", "Clean the brush after each stroke"] {
|
||||
assert!(h.get_by_label(label).rect().is_positive(), "missing Mixer Brush option {label}");
|
||||
}
|
||||
|
||||
// Turn the automatic Load off: one tools.setBrush call.
|
||||
h.get_by_label("Load the brush after each stroke").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.tools.brush.mixer.load_after_stroke);
|
||||
let (id, params) = last_journal(h.state()).unwrap();
|
||||
assert_eq!((id.as_str(), ¶ms["brush"]["mixer"]["loadAfterStroke"]), ("tools.setBrush", &json!(false)));
|
||||
|
||||
// Current Brush Load › Clean Brush empties the brush.
|
||||
h.get_by_label("Current Brush Load").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Clean Brush").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(last_journal(h.state()).map(|(id, _)| id), Some("brush.mixer.clean".to_string()));
|
||||
assert_eq!(h.state().session.tools.mixer.reservoir, None);
|
||||
|
||||
// The stroke the canvas commits on release lays down only the red it picks up.
|
||||
crate::retouch_ui::finish_stroke(h.state_mut(), crate::state::Tool::MixerBrush, &[[5.0, 20.0, 1.0], [195.0, 20.0, 1.0]], egui::Modifiers::NONE);
|
||||
let d = h.state().session.active().unwrap();
|
||||
let c = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(100, 20);
|
||||
assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the stroke carries no foreground: {c:?}");
|
||||
// With the automatic Load off, the stroke did not refill the brush either.
|
||||
assert_eq!(h.state().session.tools.mixer.reservoir, None);
|
||||
|
||||
// Load Brush fills it with the foreground again.
|
||||
h.get_by_label("Current Brush Load").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Load Brush").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.tools.mixer.reservoir, Some([0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_targets_and_actions_reorder_presets() {
|
||||
use crate::brushes_tab::{Action, apply, drop_target, group_key};
|
||||
|
||||
@@ -535,4 +535,33 @@ mod tests {
|
||||
app.session.prefs.edit(|p| p.cursors.brush_preview_color = "nonsense".into());
|
||||
assert_eq!(preview_tint(&app), Color32::from_rgba_unmultiplied(255, 0, 0, 110), "unreadable values keep the red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylus_barrel_button_resizes_with_alt_and_never_samples_color() {
|
||||
let mut a = app(Tool::Brush);
|
||||
a.services.right_button_down = Some(Box::new(|| true));
|
||||
a.sync_views();
|
||||
let mut h = canvas(a);
|
||||
let size0 = h.state().session.tools.brush.size;
|
||||
let c = h.state().last_canvas_rect.center();
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::ALT));
|
||||
h.event(egui::Event::PointerMoved(c));
|
||||
h.run_steps(1);
|
||||
button(&mut h, c, PointerButton::Primary, true, Modifiers::ALT);
|
||||
let mut p = c;
|
||||
for _ in 0..6 {
|
||||
p += vec2(10.0, 8.0);
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
}
|
||||
let r = h.state().brush_resize.expect("resizing via stylus barrel button");
|
||||
assert!(r.secondary);
|
||||
assert!(h.state().session.tools.brush.size > size0, "right grows the brush");
|
||||
assert!(h.state().session.tools.brush.hardness > 0.2, "down hardens the brush");
|
||||
assert!(!h.state().alt_sampling, "eyedropper never triggered");
|
||||
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "paint.stroke"), "nothing painted");
|
||||
button(&mut h, p, PointerButton::Primary, false, Modifiers::ALT);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().brush_resize.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,6 +565,8 @@ fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
pct(ui, tl!("Mix"), &mut m.mix, 1.0);
|
||||
pct(ui, tl!("Flow"), &mut m.flow, 1.0);
|
||||
widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers");
|
||||
widgets::checkbox(ui, &mut m.load_after_stroke, tl!("Load the brush after each stroke"));
|
||||
widgets::checkbox(ui, &mut m.clean_after_stroke, tl!("Clean the brush after each stroke"));
|
||||
}
|
||||
|
||||
/// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//!
|
||||
//! The preview runs [`photocraft_algo::camera_raw::develop`] on a CPU proxy of the layer
|
||||
//! (≤ 900 px, `pixel_scale` keeps pixel radii true to the full image), recomputed when a
|
||||
//! control changes. OK runs `filter.cameraRaw` with the non-default settings, so the result is
|
||||
//! control changes: on a worker for the dialog's own controls, so a drag never waits for it, and
|
||||
//! on the UI thread for the control channel, so a reply describes its own settings. OK runs `filter.cameraRaw` with the non-default settings, so the result is
|
||||
//! one history step (or a smart filter on a smart object) and exactly replayable.
|
||||
//!
|
||||
//! Control channel: `ui.menu.invoke {"id":"filter.cameraRaw","params":{"ui":{"set":{…},
|
||||
@@ -38,8 +39,8 @@ pub struct CameraRawDialog {
|
||||
curve_state: crate::state::PointCurveState,
|
||||
curve_rect: Option<ERect>,
|
||||
/// Proxy pixels (straight RGBA) and size.
|
||||
pub(crate) proxy: Vec<[f32; 4]>,
|
||||
pub(crate) selection: Option<Vec<f32>>,
|
||||
pub(crate) proxy: std::sync::Arc<Vec<[f32; 4]>>,
|
||||
pub(crate) selection: Option<std::sync::Arc<Vec<f32>>>,
|
||||
pub(crate) pw: usize,
|
||||
pub(crate) ph: usize,
|
||||
full_w: usize,
|
||||
@@ -68,7 +69,11 @@ pub struct CameraRawDialog {
|
||||
raw_open: Option<RawOpen>,
|
||||
/// The open-time dialog's camera tone curve: Shadows / Highlights act on the raw data there,
|
||||
/// in linear light before this curve, and the preview undoes it to show the same.
|
||||
raw_curve: Option<Vec<[f32; 2]>>,
|
||||
raw_curve: Option<std::sync::Arc<Vec<[f32; 2]>>>,
|
||||
/// The proxy develop running on a worker ([`Self::request_render`]).
|
||||
pending: Option<std::sync::mpsc::Receiver<Result<ProxyFrame, String>>>,
|
||||
/// The last worker failure the status bar shows, with the preview revision it failed on.
|
||||
preview_error: Option<(String, u64)>,
|
||||
/// Settings before each finished edit, so Ctrl+Z works inside the dialog (#4100).
|
||||
undo: Vec<CameraRaw>,
|
||||
redo: Vec<CameraRaw>,
|
||||
@@ -91,6 +96,7 @@ impl CameraRawDialog {
|
||||
result["openingRaw"] = json!(self.raw_open.as_ref().map(|r| json!({"name": r.name, "path": r.path})));
|
||||
result["view"] = json!(navigation);
|
||||
result["sourceSize"] = json!([self.full_w, self.full_h]);
|
||||
result["previewPending"] = json!(self.render_pending());
|
||||
result["previewApproximate"] = json!(!self.detail.ready && (self.pw != self.full_w || self.ph != self.full_h));
|
||||
result["detailPending"] = json!(self.detail.pending());
|
||||
result["detailError"] = json!(self.detail.error);
|
||||
@@ -139,45 +145,150 @@ impl CameraRawDialog {
|
||||
})
|
||||
}
|
||||
|
||||
/// Develops the proxy with the current settings and shows it, on this thread. The control
|
||||
/// channel and the first frame use this, so a reply always describes its own settings; the
|
||||
/// dialog's own controls go through [`Self::request_render`].
|
||||
pub(crate) fn render(&mut self, ctx: &egui::Context) {
|
||||
// A render still running on the worker has older settings than this one.
|
||||
self.pending = None;
|
||||
let frame = self.proxy_job().run();
|
||||
self.publish(ctx, frame);
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
fn proxy_job(&self) -> ProxyJob {
|
||||
ProxyJob {
|
||||
proxy: self.proxy.clone(),
|
||||
selection: self.selection.clone(),
|
||||
raw_curve: self.raw_curve.clone(),
|
||||
params: self.params.clone(),
|
||||
float: self.float,
|
||||
size: (self.pw, self.ph),
|
||||
full_w: self.full_w,
|
||||
display: self.display.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(&mut self, ctx: &egui::Context, frame: ProxyFrame) {
|
||||
match &mut self.tex {
|
||||
Some(t) => t.set(frame.image, egui::TextureOptions::LINEAR),
|
||||
None => self.tex = Some(ctx.load_texture("camera-raw-after", frame.image, egui::TextureOptions::LINEAR)),
|
||||
}
|
||||
if self.before_display_key != Some(frame.display.key) {
|
||||
let before = Self::image(&self.proxy, self.pw, self.ph, &frame.display);
|
||||
match &mut self.before_tex {
|
||||
Some(t) => t.set(before, egui::TextureOptions::LINEAR),
|
||||
None => self.before_tex = Some(ctx.load_texture("camera-raw-before", before, egui::TextureOptions::LINEAR)),
|
||||
}
|
||||
self.before_display_key = Some(frame.display.key);
|
||||
}
|
||||
self.after_histogram = frame.histogram;
|
||||
self.histogram_ms = frame.histogram_ms;
|
||||
self.processed = frame.px;
|
||||
self.scope.cache = None;
|
||||
self.render_ms = frame.ms;
|
||||
self.preview_revision = self.preview_revision.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Starts developing the proxy with the current settings on a worker, unless one is already
|
||||
/// running: then [`Self::poll_render`] starts the next one with the settings of that moment.
|
||||
/// A slider drag never waits for the develop, which takes hundreds of milliseconds with
|
||||
/// Shadows or Highlights set (#5964); the preview shows the latest finished frame.
|
||||
fn request_render(&mut self, ctx: &egui::Context) {
|
||||
if !self.dirty || self.pending.is_some() {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let job = self.proxy_job();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<ProxyFrame, String>>();
|
||||
let repaint = ctx.clone();
|
||||
let spawned = std::thread::Builder::new().name("camera-raw-proxy".into()).spawn(move || {
|
||||
let frame = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.run())).map_err(|_| "Camera Raw preview failed".to_string());
|
||||
// The dialog may have closed or rendered on its own meanwhile; then nobody listens.
|
||||
if tx.send(frame).is_ok() {
|
||||
repaint.request_repaint();
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => {
|
||||
self.pending = Some(rx);
|
||||
self.dirty = false;
|
||||
}
|
||||
// No thread to spare: develop here rather than show stale settings.
|
||||
Err(_) => self.render(ctx),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.render(ctx);
|
||||
}
|
||||
|
||||
/// Shows the worker's frame once it is done. `Err` when the develop failed; the preview keeps
|
||||
/// the last frame then.
|
||||
fn poll_render(&mut self, ctx: &egui::Context) -> Result<(), String> {
|
||||
let Some(rx) = &self.pending else { return Ok(()) };
|
||||
let frame = match rx.try_recv() {
|
||||
Ok(frame) => frame,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("Camera Raw preview worker stopped".to_string()),
|
||||
};
|
||||
self.pending = None;
|
||||
self.publish(ctx, frame?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn render_pending(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one develop of the proxy reads, owned, so it can run off the UI thread.
|
||||
struct ProxyJob {
|
||||
proxy: std::sync::Arc<Vec<[f32; 4]>>,
|
||||
selection: Option<std::sync::Arc<Vec<f32>>>,
|
||||
raw_curve: Option<std::sync::Arc<Vec<[f32; 2]>>>,
|
||||
params: CameraRaw,
|
||||
float: bool,
|
||||
size: (usize, usize),
|
||||
full_w: usize,
|
||||
display: std::sync::Arc<photocraft_engine::display_color::CanvasDisplay>,
|
||||
}
|
||||
|
||||
/// A developed proxy, ready to show.
|
||||
struct ProxyFrame {
|
||||
px: Vec<[f32; 4]>,
|
||||
histogram: RgbHistogram,
|
||||
histogram_ms: f64,
|
||||
image: egui::ColorImage,
|
||||
display: std::sync::Arc<photocraft_engine::display_color::CanvasDisplay>,
|
||||
ms: f64,
|
||||
}
|
||||
|
||||
impl ProxyJob {
|
||||
fn run(self) -> ProxyFrame {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let mut px = self.proxy.clone();
|
||||
let mut p = self.params.clone();
|
||||
p.pixel_scale = (self.pw as f32 / self.full_w.max(1) as f32).min(1.0);
|
||||
let (pw, ph) = self.size;
|
||||
let mut px = self.proxy.to_vec();
|
||||
let mut p = self.params;
|
||||
p.pixel_scale = (pw as f32 / self.full_w.max(1) as f32).min(1.0);
|
||||
if let Some(curve) = &self.raw_curve {
|
||||
let tone = raw_stage(&p);
|
||||
photocraft_io::raw::retone(&mut px, self.pw, self.ph, curve, tone);
|
||||
photocraft_io::raw::retone(&mut px, pw, ph, curve, tone);
|
||||
(p.shadows, p.highlights, p.clarity, p.texture, p.dehaze) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
photocraft_algo::camera_raw::develop(&mut px, self.pw, self.ph, &p, self.float);
|
||||
photocraft_algo::camera_raw::develop(&mut px, pw, ph, &p, self.float);
|
||||
if let Some(mask) = &self.selection {
|
||||
for ((out, original), coverage) in px.iter_mut().zip(&self.proxy).zip(mask) {
|
||||
for ((out, original), coverage) in px.iter_mut().zip(self.proxy.iter()).zip(mask.iter()) {
|
||||
for (out, original) in out.iter_mut().zip(original) {
|
||||
*out = *original + (*out - *original) * coverage;
|
||||
}
|
||||
}
|
||||
}
|
||||
let histogram_start = crate::gpu_canvas::now_ms();
|
||||
self.after_histogram = RgbHistogram::from_rgba(&px);
|
||||
self.histogram_ms = crate::gpu_canvas::now_ms() - histogram_start;
|
||||
let img = Self::image(&px, self.pw, self.ph, &self.display);
|
||||
match &mut self.tex {
|
||||
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
|
||||
None => self.tex = Some(ctx.load_texture("camera-raw-after", img, egui::TextureOptions::LINEAR)),
|
||||
}
|
||||
if self.before_display_key != Some(self.display.key) {
|
||||
let before = Self::image(&self.proxy, self.pw, self.ph, &self.display);
|
||||
match &mut self.before_tex {
|
||||
Some(t) => t.set(before, egui::TextureOptions::LINEAR),
|
||||
None => self.before_tex = Some(ctx.load_texture("camera-raw-before", before, egui::TextureOptions::LINEAR)),
|
||||
}
|
||||
self.before_display_key = Some(self.display.key);
|
||||
}
|
||||
self.processed = px;
|
||||
self.scope.cache = None;
|
||||
self.render_ms = crate::gpu_canvas::now_ms() - t0;
|
||||
self.preview_revision = self.preview_revision.saturating_add(1);
|
||||
self.dirty = false;
|
||||
let histogram = RgbHistogram::from_rgba(&px);
|
||||
let histogram_ms = crate::gpu_canvas::now_ms() - histogram_start;
|
||||
let image = CameraRawDialog::image(&px, pw, ph, &self.display);
|
||||
ProxyFrame { px, histogram, histogram_ms, image, display: self.display, ms: crate::gpu_canvas::now_ms() - t0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +356,7 @@ fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Resul
|
||||
};
|
||||
if let Some(d) = app.camera_raw.as_mut() {
|
||||
d.layer_name = raw.name.clone();
|
||||
d.raw_curve = curve;
|
||||
d.raw_curve = curve.map(std::sync::Arc::new);
|
||||
d.raw_open = Some(raw);
|
||||
}
|
||||
Ok(())
|
||||
@@ -611,8 +722,8 @@ fn open_pixels(
|
||||
after_histogram: RgbHistogram::default(),
|
||||
preview_revision: 0,
|
||||
histogram_ms: 0.0,
|
||||
proxy,
|
||||
selection,
|
||||
proxy: std::sync::Arc::new(proxy),
|
||||
selection: selection.map(std::sync::Arc::new),
|
||||
pw,
|
||||
ph,
|
||||
full_w: w,
|
||||
@@ -643,6 +754,8 @@ fn open_pixels(
|
||||
render_ms: 0.0,
|
||||
raw_open: None,
|
||||
raw_curve: None,
|
||||
pending: None,
|
||||
preview_error: None,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
edit_origin: None,
|
||||
@@ -1073,8 +1186,22 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) {
|
||||
}
|
||||
d.curve_rect = None;
|
||||
d.scope.vectorscope_rect = None;
|
||||
if d.dirty {
|
||||
d.render(ctx);
|
||||
if let Err(e) = d.poll_render(ctx) {
|
||||
d.preview_error = Some((e.clone(), d.preview_revision));
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
d.request_render(ctx);
|
||||
// A newer frame means the failure is over: take its message down, unless something else
|
||||
// has reported since.
|
||||
if let Some((e, revision)) = &d.preview_error
|
||||
&& *revision != d.preview_revision
|
||||
{
|
||||
if app.ui.status_error && app.ui.status == *e {
|
||||
app.ui.status.clear();
|
||||
app.ui.status_error = false;
|
||||
}
|
||||
d.preview_error = None;
|
||||
}
|
||||
let (full, _) = ui.allocate_exact_size(screen.size(), Sense::click());
|
||||
let painter = ui.painter().clone();
|
||||
@@ -1247,6 +1374,10 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) {
|
||||
if d.dirty {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if d.render_pending() {
|
||||
// The worker asks for a repaint when it is done; this catches a missed wake-up.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
// Footer.
|
||||
let foot = ERect::from_min_max(pos2(full.left(), full.bottom() - footer_h), full.max);
|
||||
painter.rect_filled(foot, 0.0, t.dock);
|
||||
@@ -1398,6 +1529,148 @@ mod tests {
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
/// A layer with tonal variation, a selection over part of it and the dialog open on it.
|
||||
fn worker_app() -> (PhotocraftApp, egui::Context) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#406080"})).unwrap();
|
||||
app.run("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
|
||||
open(&mut app, &ctx).unwrap();
|
||||
(app, ctx)
|
||||
}
|
||||
|
||||
fn finish_worker(d: &mut CameraRawDialog, ctx: &egui::Context) {
|
||||
for _ in 0..2000 {
|
||||
d.poll_render(ctx).unwrap();
|
||||
if !d.render_pending() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("the Camera Raw preview worker never finished");
|
||||
}
|
||||
|
||||
/// The dialog's controls develop the proxy on a worker (#5964): the frame it shows is the one
|
||||
/// the UI thread would have developed, and settings changed while it runs are developed next.
|
||||
#[test]
|
||||
fn worker_preview_matches_the_synchronous_render_and_ends_on_the_latest_settings() {
|
||||
let (mut app, ctx) = worker_app();
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
assert!(d.selection.is_some());
|
||||
let revision = d.preview_revision;
|
||||
d.params.exposure = 1.0;
|
||||
d.dirty = true;
|
||||
d.request_render(&ctx);
|
||||
assert!(d.render_pending() && !d.dirty);
|
||||
// A drag moves on while the first develop runs; the request waits for it.
|
||||
d.params.highlights = -60.0;
|
||||
d.params.shadows = 45.0;
|
||||
d.dirty = true;
|
||||
d.request_render(&ctx);
|
||||
finish_worker(d, &ctx);
|
||||
assert_eq!(d.preview_revision, revision + 1);
|
||||
assert!(d.dirty, "the newer settings are still to be developed");
|
||||
d.request_render(&ctx);
|
||||
finish_worker(d, &ctx);
|
||||
assert_eq!(d.preview_revision, revision + 2);
|
||||
let (from_worker, histogram) = (d.processed.clone(), d.after_histogram.channels);
|
||||
d.render(&ctx);
|
||||
assert_eq!(d.processed, from_worker, "the worker develops exactly what the UI thread does");
|
||||
assert_eq!(d.after_histogram.channels, histogram);
|
||||
assert!(d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)["previewPending"] == false);
|
||||
}
|
||||
|
||||
/// The control channel renders on its own thread so its reply describes its settings; a worker
|
||||
/// still running with older settings must not replace that frame afterwards.
|
||||
#[test]
|
||||
fn a_stale_worker_never_replaces_a_newer_synchronous_render() {
|
||||
let (mut app, ctx) = worker_app();
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
d.params.highlights = -80.0;
|
||||
d.dirty = true;
|
||||
d.request_render(&ctx);
|
||||
assert!(d.render_pending());
|
||||
let reply = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"highlights": 0, "exposure": -1.0}}})).unwrap().unwrap();
|
||||
assert_eq!(reply["previewPending"], false);
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
let (revision, shown) = (d.preview_revision, d.processed.clone());
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
d.poll_render(&ctx).unwrap();
|
||||
assert_eq!((d.preview_revision, &d.processed), (revision, &shown));
|
||||
d.render(&ctx);
|
||||
assert_eq!(d.processed, shown, "the frame shown is the one for the control channel's settings");
|
||||
}
|
||||
|
||||
/// Dragging a slider in the dialog itself never develops on the UI thread: the frame that
|
||||
/// takes the change returns with the develop still running, and the preview catches up.
|
||||
#[test]
|
||||
fn a_dialog_edit_develops_off_the_ui_thread() {
|
||||
let (mut app, ctx) = worker_app();
|
||||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
|
||||
let frame = |app: &mut PhotocraftApp| {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx())).textures_delta.clear();
|
||||
};
|
||||
frame(&mut app);
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
let revision = d.preview_revision;
|
||||
d.params.highlights = -60.0;
|
||||
d.dirty = true;
|
||||
frame(&mut app);
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
assert!(d.render_pending() && d.preview_revision == revision, "the frame returned before the develop finished");
|
||||
finish_worker(d, &ctx);
|
||||
assert_eq!(d.preview_revision, revision + 1);
|
||||
assert!(!d.dirty);
|
||||
frame(&mut app);
|
||||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||||
}
|
||||
|
||||
/// A failed worker develop shows its error until a later frame is published, and never takes
|
||||
/// down an error something else reported meanwhile.
|
||||
#[test]
|
||||
fn a_worker_failure_is_reported_until_the_next_frame() {
|
||||
let (mut app, ctx) = worker_app();
|
||||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
|
||||
let frame = |app: &mut PhotocraftApp| {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx())).textures_delta.clear();
|
||||
};
|
||||
let fail = |app: &mut PhotocraftApp| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
tx.send(Err("Camera Raw preview failed".to_string())).unwrap();
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
d.pending = Some(rx);
|
||||
d.dirty = false;
|
||||
};
|
||||
let edit = |app: &mut PhotocraftApp, exposure: f32| {
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
d.params.exposure = exposure;
|
||||
d.dirty = true;
|
||||
};
|
||||
frame(&mut app);
|
||||
fail(&mut app);
|
||||
frame(&mut app);
|
||||
assert!(app.ui.status_error && app.ui.status == "Camera Raw preview failed", "{}", app.ui.status);
|
||||
// No new frame yet: the error stays.
|
||||
frame(&mut app);
|
||||
assert!(app.ui.status_error);
|
||||
edit(&mut app, 0.5);
|
||||
frame(&mut app);
|
||||
finish_worker(app.camera_raw.as_mut().unwrap(), &ctx);
|
||||
frame(&mut app);
|
||||
assert!(!app.ui.status_error && app.ui.status.is_empty(), "{}", app.ui.status);
|
||||
|
||||
fail(&mut app);
|
||||
frame(&mut app);
|
||||
app.ui.status = "something else failed".into();
|
||||
edit(&mut app, 1.0);
|
||||
frame(&mut app);
|
||||
finish_worker(app.camera_raw.as_mut().unwrap(), &ctx);
|
||||
frame(&mut app);
|
||||
assert!(app.ui.status_error && app.ui.status == "something else failed", "{}", app.ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_filter_double_click_edits_the_stored_camera_raw_filter_in_its_own_dialog() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
|
||||
@@ -3013,7 +3013,7 @@ const DISPLAY_LUT: usize = 33;
|
||||
/// Keep the GPU display LUT under `key` (the document's texture or a preview of it) in step with
|
||||
/// `doc`'s colour management: document → monitor profile and View › Proof Colors / Gamut
|
||||
/// Warning (the 32-bit preview is applied by the canvas shader, see [`hdr_preview`]). Returns the canvas `display` mode (0 none — the identity, e.g. sRGB on
|
||||
/// an sRGB monitor —, 1 LUT, 2 LUT + gamut warning).
|
||||
/// an sRGB monitor —, 1 LUT, 2 LUT + gamut warning, 3 matrix/shaper).
|
||||
fn sync_display_lut(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, key: u64, display: Option<u32>) -> u8 {
|
||||
let Some(gpu) = app.gpu.clone() else { return 0 };
|
||||
let output = display.unwrap_or(0);
|
||||
@@ -3024,6 +3024,13 @@ fn sync_display_lut(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, key
|
||||
{
|
||||
return mode;
|
||||
}
|
||||
// Keep the curve/matrix/curve stages separate: interpolating a finished display
|
||||
// transform on a 33³ lattice crushes shadows when the monitor curve is steep near zero.
|
||||
if let Ok(Some(shaper)) = app.session.color.gpu_canvas_matrix_shaper_for(doc, display) {
|
||||
gpu.set_display_matrix_shaper(key, output, &shaper);
|
||||
gpu.cache_display_lut_signature(key, output, sig, 3);
|
||||
return 3;
|
||||
}
|
||||
let gamut = app.session.color.proof(doc.id).gamut_warning;
|
||||
let mode = match app.session.color.gpu_canvas_lut_for(doc, DISPLAY_LUT, display) {
|
||||
Ok(Some(bytes)) => {
|
||||
@@ -3735,7 +3742,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// Live painting tools start on the press, not once the pointer passes egui's click
|
||||
// distance: the first dab shows at once. The drag recognised later continues that stroke,
|
||||
// and a click (or a long press that never moved) just ends it.
|
||||
let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f());
|
||||
let right_down = ui.input(|i| i.pointer.secondary_down()) || physical_right;
|
||||
if strokes_live(tool)
|
||||
&& !right_down
|
||||
&& app.drag.is_none()
|
||||
&& response.is_pointer_button_down_on()
|
||||
&& ui.input(|i| i.pointer.primary_pressed())
|
||||
@@ -3785,7 +3795,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
};
|
||||
let press_this_frame = events.iter().any(|e| matches!(e, egui::Event::PointerButton { button: b, pressed: true, .. } if *b == button));
|
||||
let down_at_start = !press_this_frame && (gesture_active_before || buttons.started);
|
||||
let mut positions = if freehand_tool(tool) {
|
||||
let mut positions = if app.brush_resize.is_some() {
|
||||
response.interact_pointer_pos().into_iter().collect()
|
||||
} else if freehand_tool(tool) {
|
||||
pointer_moves(&events, button, down_at_start)
|
||||
} else if buttons.dragged {
|
||||
response.interact_pointer_pos().into_iter().collect()
|
||||
@@ -4099,7 +4111,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
t if resizing && crate::brush_resize::applies(t) => egui::CursorIcon::None,
|
||||
// ⌥ turns a painting tool into the Eyedropper (`alt_eyedropper`): its cursor too,
|
||||
// unless Preferences › Cursors › Other Cursors asks for the precise crosshair.
|
||||
t if app.alt_sampling || (app.drag.is_none() && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => {
|
||||
t if app.alt_sampling
|
||||
|| (app.drag.is_none()
|
||||
&& !app.services.right_button_down.as_ref().is_some_and(|f| f())
|
||||
&& alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) =>
|
||||
{
|
||||
// While it samples (button held), the comparison ring replaces the cursor (#213).
|
||||
if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) {
|
||||
icon
|
||||
@@ -5183,7 +5199,8 @@ pub(crate) fn paint_bucket_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorI
|
||||
/// painting and sampling.
|
||||
fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
|
||||
if matches!(ev, ToolEvent::Down { .. }) {
|
||||
app.alt_sampling = alt_samples(app.active_tool(), mods);
|
||||
let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f());
|
||||
app.alt_sampling = !physical_right && alt_samples(app.active_tool(), mods);
|
||||
}
|
||||
if !app.alt_sampling {
|
||||
return false;
|
||||
@@ -6863,7 +6880,7 @@ mod tests {
|
||||
let key = doc.id.0;
|
||||
// sRGB on the sRGB display needs no LUT; on the P3 display it does.
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(1)), 0);
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 1);
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 3);
|
||||
let gpu = app.gpu.clone().unwrap();
|
||||
let (s1, s4) = (gpu.display_lut_signature(key, 1).unwrap(), gpu.display_lut_signature(key, 4).unwrap());
|
||||
assert_ne!(s1.0, s4.0);
|
||||
@@ -6871,7 +6888,7 @@ mod tests {
|
||||
// Drawing one window leaves the other's LUT alone.
|
||||
for _ in 0..3 {
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(1)), 0);
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 1);
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 3);
|
||||
}
|
||||
assert_eq!((gpu.display_lut_signature(key, 1), gpu.display_lut_signature(key, 4)), (Some(s1), Some(s4)));
|
||||
assert!(gpu.has_display_lut(key, 4));
|
||||
|
||||
@@ -1717,7 +1717,7 @@ mod tests {
|
||||
let image = clipboard.clone();
|
||||
let mut app = PhotocraftApp::new(
|
||||
photocraft_engine::Session::new(),
|
||||
crate::Services { clipboard_get_image: Some(Box::new(move || image.lock().ok()?.clone())), ..Default::default() },
|
||||
crate::Services { clipboard_get_image: Some(Box::new(move || Ok(image.lock().unwrap().clone()))), ..Default::default() },
|
||||
);
|
||||
let ctx = egui::Context::default();
|
||||
let opened = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"newDocument"}));
|
||||
|
||||
@@ -772,6 +772,44 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 {
|
||||
app.ui.open_dialog(DialogKind::Command, f)
|
||||
}
|
||||
|
||||
/// Whether the driver's paper, as `driver_page` took it, is wider than tall: the Orientation
|
||||
/// buttons show landscape then.
|
||||
fn driver_paper_is_wide(f: &Map<String, Value>) -> bool {
|
||||
let side = |i: usize| f.get("paper").and_then(Value::as_array).and_then(|a| a.get(i)).and_then(Value::as_f64);
|
||||
matches!((side(0), side(1)), (Some(w), Some(h)) if w > h)
|
||||
}
|
||||
|
||||
/// The Orientation buttons: the driver settings (or the printer's own) turned portrait or
|
||||
/// landscape through `file.print.page`. An error stays on the dialog, with the settings left as
|
||||
/// they were.
|
||||
fn turn_driver(app: &mut PhotocraftApp, f: &mut Map<String, Value>, landscape: bool) {
|
||||
let mut q = json!({"printer": s(f, "printer", ""), "orientation": if landscape { "landscape" } else { "portrait" }});
|
||||
if let (Some(h), Some(o)) = (f.get("printerSettings").cloned(), q.as_object_mut()) {
|
||||
o.insert("printerSettings".into(), h);
|
||||
}
|
||||
match app.session.execute("file.print.page", q) {
|
||||
Ok(r) => take_turned_page(f, &r),
|
||||
Err(e) => {
|
||||
f.insert("__pageError".into(), json!(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The turned settings and the paper they give, both from the one `file.print.page` answer, as
|
||||
/// `driver_page` takes a page: asking the driver again could fail and leave new settings with the
|
||||
/// old paper. An answer without both leaves the dialog as it was, with the reason shown.
|
||||
fn take_turned_page(f: &mut Map<String, Value>, r: &Value) {
|
||||
let paper = r.get("paper").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
let (Some(settings), Some((w, h))) = (r.get("printerSettings").and_then(Value::as_str), paper) else {
|
||||
f.insert("__pageError".into(), json!("the printer did not report the turned page"));
|
||||
return;
|
||||
};
|
||||
f.insert("printerSettings".into(), json!(settings));
|
||||
f.insert("paper".into(), json!([w, h]));
|
||||
f.insert("orientation".into(), json!("portrait"));
|
||||
f.remove("__pageError");
|
||||
}
|
||||
|
||||
/// Takes the paper from the printer's driver settings (Windows), as Photoshop does.
|
||||
fn driver_page(f: &mut Map<String, Value>) {
|
||||
let printer = s(f, "printer", "");
|
||||
@@ -1006,6 +1044,17 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
|
||||
}
|
||||
}
|
||||
});
|
||||
// Photoshop's Layout: Portrait / Landscape turn the driver settings, so the paper
|
||||
// and the preview turn with them without opening Print Settings….
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Orientation")).color(t.text_dim));
|
||||
let wide = driver_paper_is_wide(f);
|
||||
for (landscape, label) in [(false, "Portrait"), (true, "Landscape")] {
|
||||
if crate::widgets::pill_tab(ui, label, wide == landscape).clicked() && wide != landscape {
|
||||
turn_driver(app, f, landscape);
|
||||
}
|
||||
}
|
||||
});
|
||||
let paper = p.get("paper").and_then(Value::as_array).map(|a| (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)));
|
||||
let text = match paper {
|
||||
Some((Some(w), Some(h))) => format!("{:.0} × {:.0} mm · {}", w / 72.0 * 25.4, h / 72.0 * 25.4, tl!("set in Print Settings…")),
|
||||
@@ -1530,6 +1579,65 @@ mod tests {
|
||||
std::fs::remove_file(out).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The Orientation buttons show the driver paper's shape: landscape when it is wider than tall.
|
||||
#[test]
|
||||
fn orientation_follows_the_driver_paper() {
|
||||
let with = |paper: Value| {
|
||||
let mut f = Map::new();
|
||||
f.insert("paper".into(), paper);
|
||||
f
|
||||
};
|
||||
assert!(driver_paper_is_wide(&with(json!([1191.14, 842.29]))));
|
||||
assert!(!driver_paper_is_wide(&with(json!([842.29, 1191.14]))));
|
||||
assert!(!driver_paper_is_wide(&with(json!("a3"))), "a named paper is not a driver page");
|
||||
assert!(!driver_paper_is_wide(&Map::new()));
|
||||
}
|
||||
|
||||
/// Turning a printer whose driver can't be asked keeps its settings and says why on the dialog.
|
||||
#[test]
|
||||
fn turning_without_a_driver_keeps_the_settings_and_shows_the_error() {
|
||||
// Valid driver settings (a minimal DEVMODEW: 220 bytes, no private part), so the command
|
||||
// gets past reading them and fails where it asks the (missing) printer for its page.
|
||||
let mut dm = vec![0u8; 220];
|
||||
dm[68..70].copy_from_slice(&220u16.to_le_bytes());
|
||||
let hex: String = dm.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert!(craft_print::rgb::DevMode::from_hex(&hex).is_ok());
|
||||
let (mut app, _) = app();
|
||||
let mut f = Map::new();
|
||||
f.insert("printer".into(), json!("photocraft-no-such-printer"));
|
||||
f.insert("printerSettings".into(), json!(hex));
|
||||
f.insert("paper".into(), json!([595.28, 841.89]));
|
||||
turn_driver(&mut app, &mut f, true);
|
||||
assert_eq!(f.get("printerSettings"), Some(&json!(hex)));
|
||||
assert_eq!(f.get("paper"), Some(&json!([595.28, 841.89])));
|
||||
let error = f.get("__pageError").and_then(Value::as_str).unwrap_or_default();
|
||||
// Reading bad settings fails with "printer settings are too short / damaged"; this must not.
|
||||
assert!(!error.is_empty() && !error.contains("printer settings are"), "failed at the printer, not at reading the settings: {error}");
|
||||
}
|
||||
|
||||
/// A turn takes the settings and the paper from the same answer, never one without the other.
|
||||
#[test]
|
||||
fn a_turn_takes_settings_and_paper_together() {
|
||||
let fields = || {
|
||||
let mut f = Map::new();
|
||||
f.insert("printerSettings".into(), json!("old"));
|
||||
f.insert("paper".into(), json!([595.28, 841.89]));
|
||||
f.insert("__pageError".into(), json!("earlier"));
|
||||
f
|
||||
};
|
||||
let mut f = fields();
|
||||
take_turned_page(&mut f, &json!({"printerSettings": "new", "paper": [841.89, 595.28]}));
|
||||
assert_eq!((f.get("printerSettings"), f.get("paper")), (Some(&json!("new")), Some(&json!([841.89, 595.28]))));
|
||||
assert_eq!(f.get("orientation"), Some(&json!("portrait")), "the driver paper is already turned");
|
||||
assert!(f.get("__pageError").is_none());
|
||||
for partial in [json!({"printerSettings": "new"}), json!({"paper": [841.89, 595.28]}), json!({"printerSettings": "new", "paper": [841.89]})] {
|
||||
let mut f = fields();
|
||||
take_turned_page(&mut f, &partial);
|
||||
assert_eq!((f.get("printerSettings"), f.get("paper")), (Some(&json!("old")), Some(&json!([595.28, 841.89]))), "{partial}");
|
||||
assert!(f.get("__pageError").is_some(), "{partial}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -79,7 +79,7 @@ pub struct ViewParams {
|
||||
/// Distinguishes views painted in the same frame (main canvas, extra windows).
|
||||
pub view_key: u64,
|
||||
/// Display transform: 0 none, 1 the document's display LUT (document → monitor profile,
|
||||
/// Proof Colors), 2 LUT plus the gamut warning (see [`GpuCanvas::set_display_lut`]).
|
||||
/// Proof Colors), 2 LUT plus the gamut warning, 3 matrix/shaper (see [`GpuCanvas::set_display_lut`]).
|
||||
pub display: u8,
|
||||
/// The display the view is on (its LUT is per document and display, #569; 0 = unknown).
|
||||
pub output: u32,
|
||||
@@ -798,12 +798,25 @@ impl GpuCanvas {
|
||||
log::warn!("set_display_lut: {} bytes for a {size}³ LUT; ignored", bytes.len());
|
||||
return;
|
||||
}
|
||||
let bg = lut_bind_group(device, queue, &res.lut_bgl, size, bytes);
|
||||
let bg = lut_bind_group(device, queue, &res.lut_bgl, size, bytes, None);
|
||||
res.luts.insert((doc, output), bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a separately sampled curve/matrix/curve display transform. CMS validates this
|
||||
/// representation and provides bounded tables; no profile interpretation lives in the UI.
|
||||
pub fn set_display_matrix_shaper(&self, doc: u64, output: u32, shaper: &photocraft_cms::GpuMatrixShaper) {
|
||||
if !self.health.is_ok() {
|
||||
return;
|
||||
}
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
|
||||
let bg = lut_bind_group(&self.rs.device, &self.rs.queue, &res.lut_bgl, 2, &identity_lut_bytes(), Some(shaper));
|
||||
res.display_lut_signatures.remove(&(doc, output));
|
||||
res.luts.insert((doc, output), bg);
|
||||
}
|
||||
|
||||
/// Read back level 0 of document `key`'s canvas texture: its format, size and the stored
|
||||
/// (premultiplied) texels as `f32` RGBA, row-major. Waits for the GPU; for tests, benchmarks
|
||||
/// and debugging.
|
||||
@@ -1062,6 +1075,9 @@ pub struct GpuInfo {
|
||||
pub video_memory_mb: Option<u64>,
|
||||
/// `performance.gpuBackend` at launch.
|
||||
pub preference: String,
|
||||
/// Every graphics adapter the window could have used, by name (Preferences › Performance ›
|
||||
/// Graphics Adapter lists them). Empty in hosts that don't set it.
|
||||
pub adapters: Vec<String>,
|
||||
/// The backend startup chose ("auto", "dx12", "cpu", …, or "env" for `WGPU_BACKEND`).
|
||||
pub selected: String,
|
||||
/// Why startup didn't use the preference as is (previous start crashed, `--safe-gpu`,
|
||||
@@ -1077,6 +1093,17 @@ pub struct GpuInfo {
|
||||
}
|
||||
|
||||
impl GpuInfo {
|
||||
/// Record the adapters the instance found, without duplicates (one GPU shows up once per backend).
|
||||
pub fn set_available(&mut self, adapters: &[wgpu::Adapter]) {
|
||||
self.adapters.clear();
|
||||
for a in adapters {
|
||||
let name = a.get_info().name;
|
||||
if !name.is_empty() && !self.adapters.contains(&name) {
|
||||
self.adapters.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the adapter fields from `info`.
|
||||
pub fn set_adapter(&mut self, info: &wgpu::AdapterInfo) {
|
||||
self.adapter = info.name.clone();
|
||||
@@ -1595,8 +1622,16 @@ impl Default for CanvasStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `size`³ RGBA8 3D texture bound for the canvas shader.
|
||||
fn lut_bind_group(device: &wgpu::Device, queue: &wgpu::Queue, bgl: &wgpu::BindGroupLayout, size: u32, bytes: &[u8]) -> wgpu::BindGroup {
|
||||
/// A display transform: either the existing 3D LUT or separate full-float shapers and
|
||||
/// matrix. Pack curves into short rows to stay within WebGL2's texture-size limits.
|
||||
fn lut_bind_group(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
bgl: &wgpu::BindGroupLayout,
|
||||
size: u32,
|
||||
bytes: &[u8],
|
||||
shaper: Option<&photocraft_cms::GpuMatrixShaper>,
|
||||
) -> wgpu::BindGroup {
|
||||
let extent = wgpu::Extent3d { width: size, height: size, depth_or_array_layers: size };
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("pc_display_lut"),
|
||||
@@ -1615,10 +1650,48 @@ fn lut_bind_group(device: &wgpu::Device, queue: &wgpu::Queue, bgl: &wgpu::BindGr
|
||||
extent,
|
||||
);
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let (width, height, curves, matrix) = if let Some(s) = shaper {
|
||||
let width = 1024usize;
|
||||
let count = s.input_table().len() + s.output_table().len();
|
||||
let height = count.div_ceil(width);
|
||||
let mut curves = Vec::with_capacity(width * height * 16);
|
||||
for value in s.input_table().iter().chain(s.output_table()).flatten() {
|
||||
curves.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
curves.resize(width * height * 16, 0);
|
||||
(width as u32, height as u32, curves, s.matrix_rows())
|
||||
} else {
|
||||
(1, 1, vec![0; 16], [[0.0; 4]; 3])
|
||||
};
|
||||
let extent = wgpu::Extent3d { width, height, depth_or_array_layers: 1 };
|
||||
let curves_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("pc_display_shapers"),
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
// textureLoad + explicit interpolation needs no optional float-filtering feature.
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture: &curves_texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
&curves,
|
||||
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(width * 16), rows_per_image: Some(height) },
|
||||
extent,
|
||||
);
|
||||
let curves_view = curves_texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let matrix_bytes: Vec<u8> = matrix.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let matrix_buffer = init_buffer(device, "pc_display_matrix", &matrix_bytes, wgpu::BufferUsages::UNIFORM);
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pc_display_lut"),
|
||||
layout: bgl,
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }],
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&curves_view) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: matrix_buffer.as_entire_binding() },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1731,7 +1804,20 @@ impl Resources {
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let lut_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_display_lut"), entries: &[lut_entry] });
|
||||
let curves_entry = wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
multisampled: false,
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let lut_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("pc_display_lut"),
|
||||
entries: &[lut_entry, curves_entry, uniform_entry(2, 48, wgpu::ShaderStages::FRAGMENT)],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("pc_canvas"),
|
||||
bind_group_layouts: &[Some(&view_bgl), Some(&tile_bgl), Some(&lut_bgl)],
|
||||
@@ -1802,7 +1888,7 @@ impl Resources {
|
||||
});
|
||||
let encode_pipeline = pipeline(device, "pc_encode", &encode_layout, &encode_module, ("vs", "fs"), FORMAT, None);
|
||||
let encode_pipeline_high = high.then(|| pipeline(device, "pc_encode_16f", &encode_layout, &encode_module, ("vs", "fs"), FORMAT_HIGH, None));
|
||||
let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes());
|
||||
let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes(), None);
|
||||
Self {
|
||||
lut_bgl,
|
||||
luts: HashMap::new(),
|
||||
@@ -1881,11 +1967,34 @@ fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], usage: wgpu:
|
||||
|
||||
impl DocTextures {
|
||||
/// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit
|
||||
/// the canvas budget.
|
||||
/// the canvas budget or the GPU's memory. A set the GPU can't hold (a 3 GP canvas is 16 GB
|
||||
/// of textures, #4017) lowers the budget below it, so that canvas and larger ones are drawn
|
||||
/// on the CPU canvas instead of the out-of-memory error disabling the GPU for the session.
|
||||
fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option<Self> {
|
||||
if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget.load(Ordering::Relaxed) {
|
||||
let bytes = canvas_texture_bytes(size, tile, texel_bytes(format));
|
||||
if bytes > res.canvas_budget.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let set = Self::create(device, res, size, tile, format);
|
||||
// Native wgpu reports these synchronously; where they arrive later (WebGPU) none is seen.
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
let mut error = None;
|
||||
for pop in [memory.pop(), validation.pop()] {
|
||||
if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
if let Some(e) = error {
|
||||
log::warn!("{} MB of canvas textures failed ({e}); larger canvases are drawn on the CPU", bytes >> 20);
|
||||
res.canvas_budget.fetch_min(bytes.saturating_sub(1), Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
Some(set)
|
||||
}
|
||||
|
||||
fn create(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Self {
|
||||
let mut tiles = Vec::new();
|
||||
for ty in (0..size[1]).step_by(tile as usize) {
|
||||
for tx in (0..size[0]).step_by(tile as usize) {
|
||||
@@ -1920,7 +2029,7 @@ impl DocTextures {
|
||||
tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform });
|
||||
}
|
||||
}
|
||||
Some(Self { size, format, tiles })
|
||||
Self { size, format, tiles }
|
||||
}
|
||||
|
||||
/// Write `size` pixels at document `origin` from `texels` (in the texture format, row stride
|
||||
@@ -2236,7 +2345,7 @@ struct View {
|
||||
a: vec4<f32>, // screen_w, screen_h, scale (device px per doc px), pixels_per_point
|
||||
b: vec4<f32>, // doc origin x, y (device px), doc w, h (doc px)
|
||||
c: vec4<f32>, // filter mode, lod, grid alpha, checker square (device px)
|
||||
d: vec4<f32>, // pattern preview offset x, y (doc px), display (0 none, 1 LUT, 2 LUT + gamut), output linear
|
||||
d: vec4<f32>, // pattern preview offset x, y (doc px), display (0 none, 1 LUT, 2 LUT + gamut, 3 matrix/shaper), output linear
|
||||
e: vec4<f32>, // checker light rgb, gamut warning opacity
|
||||
f: vec4<f32>, // checker dark rgb, 32-bit preview gain (2^exposure; 0 = off)
|
||||
g: vec4<f32>, // gamut warning rgb, 32-bit preview 1 / gamma
|
||||
@@ -2249,6 +2358,36 @@ struct Tile { r: vec4<f32> }; // x, y, w, h in doc px
|
||||
@group(1) @binding(0) var tex: texture_2d<f32>;
|
||||
@group(1) @binding(1) var<uniform> tile: Tile;
|
||||
@group(2) @binding(0) var lut: texture_3d<f32>;
|
||||
@group(2) @binding(1) var display_curves: texture_2d<f32>;
|
||||
struct DisplayMatrix { r: vec4<f32>, g: vec4<f32>, b: vec4<f32> };
|
||||
@group(2) @binding(2) var<uniform> display_matrix: DisplayMatrix;
|
||||
|
||||
fn curve_entry(index: u32) -> vec3<f32> {
|
||||
let width = textureDimensions(display_curves).x;
|
||||
return textureLoad(display_curves, vec2<i32>(i32(index % width), i32(index / width)), 0).rgb;
|
||||
}
|
||||
|
||||
fn display_curve(c: vec3<f32>, offset: u32, intervals: f32) -> vec3<f32> {
|
||||
let p = clamp(c, vec3(0.0), vec3(1.0)) * intervals;
|
||||
let lo = vec3<u32>(floor(p));
|
||||
let hi = min(lo + vec3(1u), vec3(u32(intervals)));
|
||||
let f = fract(p);
|
||||
return vec3(
|
||||
mix(curve_entry(offset + lo.r).r, curve_entry(offset + hi.r).r, f.r),
|
||||
mix(curve_entry(offset + lo.g).g, curve_entry(offset + hi.g).g, f.g),
|
||||
mix(curve_entry(offset + lo.b).b, curve_entry(offset + hi.b).b, f.b)
|
||||
);
|
||||
}
|
||||
|
||||
fn matrix_display(c: vec3<f32>) -> vec3<f32> {
|
||||
// Layout matches cms::GpuMatrixShaper: 4097 input entries, then 4096 inverse-curve
|
||||
// entries indexed by the fourth root. This preserves near-black precision without
|
||||
// interpolating across clipped corners of a coarse RGB cube.
|
||||
let v = vec4(display_curve(c, 0u, 4096.0), 1.0);
|
||||
let linear = vec3(dot(display_matrix.r, v), dot(display_matrix.g, v), dot(display_matrix.b, v));
|
||||
let u = sqrt(sqrt(clamp(linear, vec3(0.0), vec3(1.0))));
|
||||
return display_curve(u, 4097u, 4095.0);
|
||||
}
|
||||
|
||||
struct VOut { @builtin(position) pos: vec4<f32> };
|
||||
|
||||
@@ -2393,10 +2532,15 @@ fn fs_tile(in: VOut) -> @location(0) vec4<f32> {
|
||||
if (view.d.z > 0.5 && col.a > 0.0) {
|
||||
// Colour management, Proof Colors, Gamut Warning: the document's display LUT (alpha flags
|
||||
// out-of-gamut colours).
|
||||
let n = f32(textureDimensions(lut).x);
|
||||
let c = clamp(col.rgb / col.a, vec3(0.0), vec3(1.0));
|
||||
let l = textureSampleLevel(lut, samp, (c * (n - 1.0) + 0.5) / n, 0.0);
|
||||
let shown = select(l.rgb, mix(l.rgb, view.g.xyz, view.e.w), view.d.z > 1.5 && l.a > 0.5);
|
||||
var shown: vec3<f32>;
|
||||
if (view.d.z > 2.5) {
|
||||
shown = matrix_display(c);
|
||||
} else {
|
||||
let n = f32(textureDimensions(lut).x);
|
||||
let l = textureSampleLevel(lut, samp, (c * (n - 1.0) + 0.5) / n, 0.0);
|
||||
shown = select(l.rgb, mix(l.rgb, view.g.xyz, view.e.w), view.d.z > 1.5 && l.a > 0.5);
|
||||
}
|
||||
col = vec4(shown * col.a, col.a);
|
||||
}
|
||||
var rgb = col.rgb + checker(d) * (1.0 - col.a);
|
||||
|
||||
@@ -64,6 +64,31 @@ fn canvas_mips_preserve_opaque_pixels_and_partial_updates() {
|
||||
gpu.rs.renderer.write().callback_resources.get_mut::<Resources>().unwrap().separate_mip_targets = separate;
|
||||
check_mips(&gpu);
|
||||
}
|
||||
// On the same device (see the top of the file).
|
||||
canvas_textures_the_gpu_refuses_leave_it_working(&gpu);
|
||||
}
|
||||
|
||||
/// #4017: a 3 GP canvas's 16 GB of textures failed with Out of Memory, and the uncaptured error
|
||||
/// disabled the GPU for the session. A refused set is now caught: nothing is kept, the budget
|
||||
/// drops below it (that canvas goes to the CPU canvas) and the device stays usable. A texture
|
||||
/// wider than the device allows is refused the same way, synchronously, on any adapter.
|
||||
fn canvas_textures_the_gpu_refuses_leave_it_working(gpu: &GpuCanvas) {
|
||||
let max = gpu.rs.device.limits().max_texture_dimension_2d;
|
||||
let (size, tile) = ([max + 1, 1], max + 1);
|
||||
let bytes = canvas_texture_bytes(size, tile, texel_bytes(FORMAT));
|
||||
{
|
||||
let mut renderer = gpu.rs.renderer.write();
|
||||
let res = renderer.callback_resources.get_mut::<Resources>().unwrap();
|
||||
res.canvas_budget.store(u64::MAX, Ordering::Relaxed);
|
||||
assert!(DocTextures::new(&gpu.rs.device, res, size, tile, FORMAT).is_none());
|
||||
let budget = res.canvas_budget.load(Ordering::Relaxed);
|
||||
assert!(budget < bytes, "{budget} >= {bytes}");
|
||||
}
|
||||
assert!(gpu.health.is_ok(), "{:?}", gpu.fault());
|
||||
// Smaller canvases still get GPU textures.
|
||||
gpu.upload_buffer_full(3, &Buffer::filled(Rect::from_xywh(0, 0, 64, 64), [0.5, 0.5, 0.5, 1.0]), SampleType::U8);
|
||||
assert!(gpu.rs.renderer.read().callback_resources.get::<Resources>().unwrap().docs.contains_key(&3));
|
||||
assert!(gpu.health.is_ok(), "{:?}", gpu.fault());
|
||||
}
|
||||
|
||||
fn check_mips(gpu: &GpuCanvas) {
|
||||
|
||||
@@ -2171,6 +2171,11 @@
|
||||
Color Replacement Tool Nástroj Nahrazení barvy
|
||||
Color Replacement Nahrazení barvy
|
||||
Wet Vlhkost
|
||||
Current Brush Load Aktuální náplň štětce
|
||||
Load Brush Naplnit štětec
|
||||
Clean Brush Vyčistit štětec
|
||||
Load the brush after each stroke Naplnit štětec po každém tahu
|
||||
Clean the brush after each stroke Vyčistit štětec po každém tahu
|
||||
Mix Míchání
|
||||
Wetness Jitter Kolísání vlhkosti
|
||||
Mix Jitter Kolísání míchání
|
||||
@@ -3856,3 +3861,6 @@ navigator Zoom Zvětšení
|
||||
# DDS Options (#1327)
|
||||
DDS Options Možnosti DDS
|
||||
Generate Mipmaps Generovat mipmapy
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptér GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2140,6 +2140,11 @@
|
||||
Mixer Brush Tool Mischer-Pinsel-Werkzeug
|
||||
Color Replacement Tool Farbersatz-Werkzeug
|
||||
Wet Feucht
|
||||
Current Brush Load Aktuelle Pinselladung
|
||||
Load Brush Pinsel laden
|
||||
Clean Brush Pinsel reinigen
|
||||
Load the brush after each stroke Pinsel nach jedem Strich laden
|
||||
Clean the brush after each stroke Pinsel nach jedem Strich reinigen
|
||||
Mix Mischen
|
||||
Wetness Jitter Feuchtigkeitsschwankung
|
||||
Mix Jitter Mischschwankung
|
||||
@@ -2772,6 +2777,7 @@ curves Smooth Glätten
|
||||
Light Red Hellrot
|
||||
Medium Blue Mittelblau
|
||||
Light Gray Hellgrau
|
||||
Stop Stopp
|
||||
# Status messages, system info and tool names
|
||||
Join the ArtCraft Discord ({url}) Dem ArtCraft-Discord beitreten ({url})
|
||||
Nothing to paste: the clipboard holds no image Nichts einzufügen: Die Zwischenablage enthält kein Bild
|
||||
@@ -3250,3 +3256,6 @@ curves Smooth Glätten
|
||||
DDS Options DDS-Optionen
|
||||
Compression Komprimierung
|
||||
Generate Mipmaps Mipmaps erzeugen
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU-Adapter
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1984,6 +1984,11 @@
|
||||
Color Replacement Tool Αντικατάσταση χρώματος (Color Replacement Tool)
|
||||
Color Replacement Αντικατάσταση χρώματος
|
||||
Wet Υγρασία
|
||||
Current Brush Load Τρέχουσα φόρτωση πινέλου
|
||||
Load Brush Φόρτωση πινέλου
|
||||
Clean Brush Καθαρισμός πινέλου
|
||||
Load the brush after each stroke Φόρτωση του πινέλου μετά από κάθε πινελιά
|
||||
Clean the brush after each stroke Καθαρισμός του πινέλου μετά από κάθε πινελιά
|
||||
Load Φόρτωση
|
||||
Mix Ανάμειξη
|
||||
8 bit 8 bit ανά κανάλι
|
||||
@@ -3948,3 +3953,6 @@ curves Smooth Εξομάλυνση
|
||||
# DDS Options (#1327)
|
||||
DDS Options Επιλογές DDS
|
||||
Generate Mipmaps Δημιουργία mipmap
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Προσαρμογέας GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1030 and column 18.
|
@@ -2158,6 +2158,11 @@
|
||||
Mixer Brush Tool Herramienta Pincel mezclador
|
||||
Color Replacement Tool Herramienta Sustitución de color
|
||||
Wet Húmedo
|
||||
Current Brush Load Carga actual del pincel
|
||||
Load Brush Cargar pincel
|
||||
Clean Brush Limpiar pincel
|
||||
Load the brush after each stroke Cargar el pincel después de cada trazo
|
||||
Clean the brush after each stroke Limpiar el pincel después de cada trazo
|
||||
Mix Mezcla
|
||||
Wetness Jitter Variación de humedad
|
||||
Mix Jitter Variación de mezcla
|
||||
@@ -4210,3 +4215,6 @@ navigator Zoom Zoom
|
||||
# DDS Options (#1327)
|
||||
DDS Options Opciones de DDS
|
||||
Generate Mipmaps Generar mipmaps
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptador de GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2118,6 +2118,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape Appliquer l'inclinaison et la rotation du stylet à la forme de la pointe
|
||||
Mixer Brush Pinceau mélangeur
|
||||
Wet Humidité
|
||||
Current Brush Load Charge actuelle du pinceau
|
||||
Load Brush Charger le pinceau
|
||||
Clean Brush Nettoyer le pinceau
|
||||
Load the brush after each stroke Charger le pinceau après chaque trait
|
||||
Clean the brush after each stroke Nettoyer le pinceau après chaque trait
|
||||
Mix Mélange
|
||||
Wetness Jitter Variation de l'humidité
|
||||
Mix Jitter Variation du mélange
|
||||
@@ -3257,3 +3262,6 @@ curves Smooth Lisser
|
||||
DDS Options Options DDS
|
||||
Compression Compression
|
||||
Generate Mipmaps Générer les mipmaps
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptateur GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1407,6 +1407,11 @@
|
||||
Warp: Hajlítás:
|
||||
Weight Vastagság
|
||||
Wet Nedvesség
|
||||
Current Brush Load Az ecset jelenlegi töltete
|
||||
Load Brush Ecset feltöltése
|
||||
Clean Brush Ecset tisztítása
|
||||
Load the brush after each stroke Az ecset feltöltése minden ecsetvonás után
|
||||
Clean the brush after each stroke Az ecset tisztítása minden ecsetvonás után
|
||||
Wet Edges Nedves szélek
|
||||
Wetness Jitter Nedvesség ingadozása
|
||||
Width Szélesség
|
||||
@@ -4167,3 +4172,6 @@ cameraRaw Yellow Sárga
|
||||
# DDS Options (#1327)
|
||||
DDS Options DDS-beállítások
|
||||
Generate Mipmaps Mipmapek létrehozása
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU-adapter
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2112,6 +2112,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape Terapkan kemiringan dan rotasi pena ke bentuk ujung
|
||||
Mixer Brush Kuas Pencampur
|
||||
Wet Basah
|
||||
Current Brush Load Muatan Kuas Saat Ini
|
||||
Load Brush Muat Kuas
|
||||
Clean Brush Bersihkan Kuas
|
||||
Load the brush after each stroke Muat kuas setelah setiap goresan
|
||||
Clean the brush after each stroke Bersihkan kuas setelah setiap goresan
|
||||
Mix Campur
|
||||
Wetness Jitter Jitter Kebasahan
|
||||
Mix Jitter Jitter Campuran
|
||||
@@ -3244,3 +3249,6 @@ curves Smooth Haluskan
|
||||
DDS Options Opsi DDS
|
||||
Compression Kompresi
|
||||
Generate Mipmaps Buat mipmap
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptor GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2153,6 +2153,11 @@
|
||||
Mixer Brush Tool Strumento Pennello miscelatore
|
||||
Color Replacement Tool Strumento Sostituzione colore
|
||||
Wet Bagnato
|
||||
Current Brush Load Carico attuale del pennello
|
||||
Load Brush Carica pennello
|
||||
Clean Brush Pulisci pennello
|
||||
Load the brush after each stroke Carica il pennello dopo ogni tratto
|
||||
Clean the brush after each stroke Pulisci il pennello dopo ogni tratto
|
||||
Mix Miscela
|
||||
Wetness Jitter Variazione bagnato
|
||||
Mix Jitter Variazione miscela
|
||||
@@ -4365,3 +4370,6 @@ shape Appearance Aspetto
|
||||
# DDS Options (#1327)
|
||||
DDS Options Opzioni DDS
|
||||
Generate Mipmaps Genera mipmap
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adattatore GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2658 and column 53.
|
@@ -2153,6 +2153,11 @@
|
||||
Mixer Brush Tool 混合ブラシツール
|
||||
Color Replacement Tool 色の置き換えツール
|
||||
Wet ウェット
|
||||
Current Brush Load 現在のブラシの絵の具
|
||||
Load Brush ブラシに補充
|
||||
Clean Brush ブラシを洗浄
|
||||
Load the brush after each stroke ストロークごとにブラシに補充
|
||||
Clean the brush after each stroke ストロークごとにブラシを洗浄
|
||||
Mix 混合
|
||||
Wetness Jitter ウェットのジッター
|
||||
Mix Jitter 混合のジッター
|
||||
@@ -3926,3 +3931,6 @@ curves Smooth スムーズ
|
||||
DDS Options DDS オプション
|
||||
Compression 圧縮
|
||||
Generate Mipmaps ミップマップを生成
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU アダプター
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2114,6 +2114,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape 펜의 기울기와 회전을 브러시 끝 모양에 적용
|
||||
Mixer Brush 혼합 브러시
|
||||
Wet 젖음
|
||||
Current Brush Load 현재 브러시 물감
|
||||
Load Brush 브러시 채우기
|
||||
Clean Brush 브러시 세척
|
||||
Load the brush after each stroke 획마다 브러시 채우기
|
||||
Clean the brush after each stroke 획마다 브러시 세척
|
||||
Mix 혼합
|
||||
Wetness Jitter 젖음 지터
|
||||
Mix Jitter 혼합 지터
|
||||
@@ -3303,6 +3308,7 @@ curves Smooth 매끄럽게
|
||||
Light Red 연한 빨강
|
||||
Medium Blue 중간 파랑
|
||||
Light Gray 연한 회색
|
||||
Stop 정지
|
||||
Object Finder 개체 찾기
|
||||
No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요
|
||||
Remove after each stroke 획마다 제거
|
||||
@@ -3431,3 +3437,6 @@ curves Smooth 매끄럽게
|
||||
DDS Options DDS 옵션
|
||||
Compression 압축
|
||||
Generate Mipmaps 밉맵 생성
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU 어댑터
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3172 and column 53.
|
@@ -1272,7 +1272,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mixer_brush_ui_strings_have_translations_in_every_registered_language() {
|
||||
const STRINGS: &[&str] = &["Mixer Brush", "Mixer Brush Tool", "Wet", "Load", "Mix", "Flow", "Sample All Layers"];
|
||||
const STRINGS: &[&str] = &[
|
||||
"Mixer Brush",
|
||||
"Mixer Brush Tool",
|
||||
"Wet",
|
||||
"Load",
|
||||
"Mix",
|
||||
"Flow",
|
||||
"Sample All Layers",
|
||||
"Current Brush Load",
|
||||
"Load Brush",
|
||||
"Clean Brush",
|
||||
"Load the brush after each stroke",
|
||||
"Clean the brush after each stroke",
|
||||
];
|
||||
for lang in Lang::all() {
|
||||
for source in STRINGS {
|
||||
let translated = tr(lang, source);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user