diff --git a/.gitattributes b/.gitattributes index 5d79390a2..a854bbdb9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,16 @@ +# Font-build test fixtures are compared byte for byte: a checkout must not convert them. +crates/fonts-build/tests/fixture/** -text + +# The pinned font index is hashed and compiled in as committed: a checkout must not convert its +# line endings, or `Index::bundled` fails its checksum on Windows. +crates/font-store/index/sources.json -text +crates/font-store/index/indexes/*.gz binary + # Copies of the vendored krilla source diff, one per patch (see vendor/krilla/README.craft.md). vendor/krilla/patches/*.patch linguist-generated=true + +# Has a pinned digest and is embedded as bytes by craft-font-store. +crates/font-store/index/sources.json -text + +# The build fixture verifies these license files are copied byte-for-byte. +crates/fonts-build/tests/fixture/craft-fonts/fonts/**/OFL.txt -text diff --git a/.github/workflows/workspace.yml b/.github/workflows/workspace.yml index 4002900d9..cf9499419 100644 --- a/.github/workflows/workspace.yml +++ b/.github/workflows/workspace.yml @@ -12,64 +12,66 @@ permissions: jobs: native: - name: Workspace (${{ matrix.os }}) - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - name: Install software Vulkan renderer - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y mesa-vulkan-drivers libvulkan1 - echo 'WGPU_BACKEND=vulkan' >> "$GITHUB_ENV" - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - with: - key: workspace - - name: Prepare the Windows test driver - if: runner.os == 'Windows' - shell: powershell - run: | - $ErrorActionPreference = 'Stop' - Start-Service Spooler - Add-PrinterDriver -Name 'Generic / Text Only' - - run: cargo fmt --check - - run: cargo clippy --workspace --all-targets --locked -- -D warnings - - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - - run: cargo test --workspace --locked - env: - CRAFT_PRINT_REQUIRE_QUEUE: ${{ runner.os == 'Windows' && '1' || '0' }} - - run: cargo test --workspace --all-features --locked - env: - CRAFT_PRINT_REQUIRE_QUEUE: ${{ runner.os == 'Windows' && '1' || '0' }} + name: Workspace + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + os: '["ubuntu-latest","windows-latest","macos-latest"]' + timeout-minutes: 120 + tools: "" + packages: mesa-vulkan-drivers libvulkan1 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + linux-env: WGPU_BACKEND=vulkan + windows-env: CRAFT_PRINT_REQUIRE_QUEUE=1 + env: CRAFT_PRINT_REQUIRE_QUEUE=0 + setup-command: | + set -euo pipefail + corpus_fetcher=.test-inputs/photocraft + mkdir -p "$corpus_fetcher" + git -C "$corpus_fetcher" init + git -C "$corpus_fetcher" remote add origin https://github.com/storytold/PhotoCraft.git + git -C "$corpus_fetcher" fetch --quiet --depth=1 origin 74330157cb601573ae4523a95bb9132822fc3918 + git -C "$corpus_fetcher" checkout --quiet --detach FETCH_HEAD + (cd "$corpus_fetcher" && cargo xtask corpus --psd --psd-tools --atn) + python scripts/test-fetch-pdf-test-fonts.py + python scripts/fetch-pdf-test-fonts.py "$RUNNER_TEMP/craft-pdf-test-fonts" + printf 'CRAFT_PSD_CORPUS=%s\n' "$GITHUB_WORKSPACE/$corpus_fetcher/corpus" >> "$GITHUB_ENV" + printf 'CRAFT_PDF_TEST_FONT_DIR=%s\n' "$RUNNER_TEMP/craft-pdf-test-fonts" >> "$GITHUB_ENV" + command: | + cargo fmt --check + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + cargo test --workspace --locked + cargo test --workspace --all-features --locked + windows-command: | + powershell.exe -NoProfile -Command '$ErrorActionPreference = "Stop"; Start-Service Spooler; Add-PrinterDriver -Name "Generic / Text Only"' + cargo fmt --check + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + cargo test --workspace --locked + cargo test --workspace --all-features --locked + msrv: - name: Rust 1.95 (Linux libraries) - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.95.0 - - uses: Swatinem/rust-cache@v2 - with: - key: workspace-msrv - - run: cargo check --workspace --lib --all-features --locked + name: Rust 1.95 + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + os: '["ubuntu-latest"]' + toolchain: 1.95.0 + targets: "" + tools: "" + craft-fonts-ref: "" + require-craft-fonts: false + timeout-minutes: 15 + dev-debug: "" + command: cargo check --workspace --lib --all-features --locked + wasm: name: UI and settings WASM - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - with: - key: workspace-wasm - - run: cargo check -p craft-ui -p craft-settings --all-features --locked --target wasm32-unknown-unknown + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + os: '["ubuntu-latest"]' + targets: wasm32-unknown-unknown + tools: "" + craft-fonts-ref: "" + require-craft-fonts: false + timeout-minutes: 15 + command: cargo check -p craft-ui -p craft-settings --all-features --locked --target wasm32-unknown-unknown diff --git a/Cargo.lock b/Cargo.lock index 3c0f9c7e6..d3098e9db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1830,6 +1830,14 @@ dependencies = [ "craft-wordmodel", ] +[[package]] +name = "craft-materialx" +version = "0.1.0" +dependencies = [ + "craft-xml", + "thiserror 2.0.21", +] + [[package]] name = "craft-math" version = "0.1.0" @@ -2232,6 +2240,7 @@ dependencies = [ name = "craft-raster-codecs" version = "0.1.0" dependencies = [ + "base64 0.23.1", "craft-image-codecs", "image", "rayon", @@ -2316,6 +2325,15 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-stencilcraft-format" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-stream-codecs" version = "0.1.0" @@ -3034,7 +3052,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3581,7 +3599,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5804,7 +5822,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7118,7 +7136,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7688,7 +7706,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7757,7 +7775,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8930,10 +8948,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10085,7 +10103,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 53395992e..9b3b942d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ craft-latex = { path = "crates/latex" } craft-markdown = { path = "crates/markdown" } craft-math = { path = "crates/math" } craft-media = { path = "crates/media", default-features = false } +craft-materialx = { path = "crates/materialx" } craft-media-testkit = { path = "crates/media-testkit" } craft-midi = { path = "crates/midi" } craft-sf2 = { path = "crates/sf2" } @@ -111,6 +112,7 @@ craft-sketch = { path = "crates/sketch" } craft-settings = { path = "crates/settings" } craft-silhouette-studio = { path = "crates/silhouette-studio" } craft-svg = { path = "crates/svg" } +craft-stencilcraft-format = { path = "crates/stencilcraft-format" } craft-svg-paths = { path = "crates/svg-paths" } craft-stream-codecs = { path = "crates/stream-codecs" } craft-text = { path = "crates/text" } diff --git a/README.md b/README.md index a45fd5870..c79fa2aff 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,7 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit - [`craft-sheet`](crates/sheet/README.md): spreadsheet vocabulary shared by `craft-xls`, `craft-xlsx` and `craft-ods`: cell positions and A1 text, cell values and error values, sheet-name quoting, Excel's built-in number formats and indexed colour palette. Not a file format; no dependencies. - [`craft-sf2`](crates/sf2/README.md): SoundFont 2 (`.sf2`) reader: the sample pool and, per preset, the key and velocity regions a note sounds, with tuning, loop points, pan, level and volume envelope resolved from the preset and instrument zones. Bounded on hostile input. From LoopCraft. - [`craft-silhouette-studio`](crates/silhouette-studio/src/lib.rs): bounded, fail-closed reader for Silhouette Studio `.studio` and `.studio3` documents: outlines, line colours and page size in millimetres. Protected designs are refused. +- [`craft-stencilcraft-format`](crates/stencilcraft-format/README.md): the versioned `.stencilcraft` JSON wire schema and envelope: required members, version 1 defaults, optional fields, and header checks. The app retains its document model and domain validation. - [`craft-sketch`](crates/sketch/README.md): clean-room reader for the `.sketch` container (Sketch 48 and newer): the ZIP archive, the version cut, `document.json`, the pages in document order and images, as the JSON the file holds. Bounded on hostile input. Carved from FigCraft. - [`craft-svg`](crates/svg/src/lib.rs): SVG. `sniff` recognises SVG and SVGZ data; `svgz` unpacks SVGZ within a byte limit, decodes UTF-8, UTF-16 and ISO 8859-1, and compresses; `guard` bounds diff --git a/crates/audio/src/read/native.rs b/crates/audio/src/read/native.rs index cc0fac4c4..5502ba7e1 100644 --- a/crates/audio/src/read/native.rs +++ b/crates/audio/src/read/native.rs @@ -413,7 +413,7 @@ impl<'a> Native<'a> { let Some(next) = self.packets.next() else { // A track that declares no frames (an empty WAV or AIFF) is empty, not broken. let empty = self.frames_known && self.info.frames == 0 && self.errors == 0; - if !self.decoded && !empty && !(self.seeked && self.errors == 0) { + if !(self.decoded || empty || self.seeked && self.errors == 0) { return Err(self.no_audio("stream contains no audio")); } return Ok(None); diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index a59058f08..b1535f78c 100644 --- a/crates/cms/src/synth.rs +++ b/crates/cms/src/synth.rs @@ -111,9 +111,9 @@ impl CmykModel { // Overprints with black: multiplicative in reflectance above a first-surface floor. let floor = 0.006; let mut prim = [[0.0; 3]; 16]; - for m in 0..16 { + for (m, slot) in prim.iter_mut().enumerate() { let p = base[m & 7]; - prim[m] = if m & 8 == 0 { + *slot = if m & 8 == 0 { p } else { std::array::from_fn(|i| { @@ -133,7 +133,7 @@ impl CmykModel { (v + 4.0 * self.tvi[i] * v * (1.0 - v)).clamp(0.0, 1.0) }); let mut s = [0.0; 3]; - for m in 0..16 { + for (m, prim) in self.prim_n.iter().enumerate() { let mut w = 1.0; for (i, ai) in a.iter().enumerate() { w *= if m & (1 << i) != 0 { *ai } else { 1.0 - ai }; @@ -141,7 +141,7 @@ impl CmykModel { if w == 0.0 { continue; } - for (acc, p) in s.iter_mut().zip(&self.prim_n[m]) { + for (acc, p) in s.iter_mut().zip(prim) { *acc += w * p; } } diff --git a/crates/drawingml/src/chart/read.rs b/crates/drawingml/src/chart/read.rs index 9919433e9..38ce9f605 100644 --- a/crates/drawingml/src/chart/read.rs +++ b/crates/drawingml/src/chart/read.rs @@ -443,7 +443,7 @@ fn read_axes<'a>(c: &mut Chart, pa: &'a El, primary_val: &Option) -> (Op // A gridline whose `c:spPr/a:ln` has `a:noFill` is not drawn, though the element is there. let grid = |a: &El, k: &str| { a.child(Local(k)) - .is_some_and(|g| !g.child(Local("spPr")).and_then(|p| p.child(Local("ln"))).is_some_and(|l| l.child(Local("noFill")).is_some())) + .is_some_and(|g| g.child(Local("spPr")).and_then(|p| p.child(Local("ln"))).is_none_or(|l| l.child(Local("noFill")).is_none())) }; if c.kind == ChartType::Combo && c.series.iter().any(|s| s.secondary_axis) { let primary_id = val_ax.and_then(|a| a.child(Local("axId")).and_then(|c| c.attr_local("val"))); diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index c140ee513..0109e9040 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -768,7 +768,7 @@ fn ascii(tags: &[Tag], strings: Strings) -> Vec { /// Write tags as binary DXF (DXF Reference, "Binary DXF Files", R13+ layout): the sentinel, /// then per tag a 2-byte little-endian group code and the value in the binary form of the code's /// [`Kind`]: a zero-terminated string, an 8-byte real, a 2-, 4- or 8-byte integer, a 1-byte -/// boolean, or a binary chunk (one length byte, then up to 255 bytes; longer data is split into +/// boolean, or a binary chunk (one length byte, then up to 127 bytes; longer data is split into /// several groups of the same code). Strings are UTF-8 when `utf8` (R2007+), otherwise escaped as /// in [`write_ascii`]. A tag's value is converted to its code's kind, so a number written as text /// is stored as a number; a code outside the 16-bit range is skipped. @@ -822,8 +822,8 @@ fn binary(tags: &[Tag], strings: Strings) -> Vec { } Kind::Hex => { let bytes = hex_bytes(&t.value.as_str()); - // An empty chunk is still one group, as in ASCII. - let mut chunks: Vec<&[u8]> = bytes.chunks(255).collect(); + // At most 127 bytes per group, as in ASCII; an empty chunk is still one group. + let mut chunks: Vec<&[u8]> = bytes.chunks(127).collect(); if chunks.is_empty() { chunks.push(&[]); } diff --git a/crates/dxf/tests/binary_write.rs b/crates/dxf/tests/binary_write.rs index fb2d08210..4ecaf1709 100644 --- a/crates/dxf/tests/binary_write.rs +++ b/crates/dxf/tests/binary_write.rs @@ -44,9 +44,8 @@ fn binary_converts_values_to_their_codes_kind() { let back = parse(&write_binary(&tags, true)).unwrap(); assert_eq!(back[0], Tag::i(70, 12)); assert_eq!(back[1], Tag::i(62, i64::from(i16::MAX))); - assert_eq!(back[2].code, 310); - assert_eq!(back[3].code, 310, "300 bytes are split into chunks of at most 255"); - assert_eq!(back[2].str().len() + back[3].str().len(), 600); + let sizes: Vec = back.iter().filter(|t| t.code == 310).map(|t| t.str().len()).collect(); + assert_eq!(sizes, vec![254, 254, 92], "300 bytes are split into chunks of at most 127 (254 hex digits)"); } #[test] diff --git a/crates/dxf/tests/hex_chunks.rs b/crates/dxf/tests/hex_chunks.rs index 61100b082..80e9e9193 100644 --- a/crates/dxf/tests/hex_chunks.rs +++ b/crates/dxf/tests/hex_chunks.rs @@ -2,7 +2,6 @@ //! 1004 specify at most 127 payload bytes per chunk, even though a length byte represents 255. //! Inspect writer bytes without our parser; accept any compliant partition preserving payload, //! code and following-record framing. Oversized binary read fixtures test tolerance only. -//! Pending writer regressions extend the chunk-size finding in issue #1025. //! Public DXF Reference, Group Codes in Numerical Order and Binary DXF Files: //! //! @@ -100,7 +99,6 @@ fn binary_reader_tolerates_the_full_length_byte_domain() { } #[test] -#[ignore = "Confirmed ASCII DXF writer nonconformance: hex payloads above 127 bytes are emitted as one oversized chunk"] fn ascii_writer_splits_large_payloads_into_conforming_chunks() { for code in (310..=319).chain([1004]) { for length in [128, 254, 255, 256, 1024] { @@ -112,7 +110,6 @@ fn ascii_writer_splits_large_payloads_into_conforming_chunks() { } #[test] -#[ignore = "Confirmed binary DXF writer nonconformance: 255-byte partitioning exceeds the documented 127-byte group limit"] fn binary_writer_splits_large_payloads_into_conforming_chunks() { for code in (310..=319).chain([1004]) { for length in [128, 254, 255, 256, 1024] { diff --git a/crates/dxf/tests/roundtrip.rs b/crates/dxf/tests/roundtrip.rs index 87f1e812c..362bc725f 100644 --- a/crates/dxf/tests/roundtrip.rs +++ b/crates/dxf/tests/roundtrip.rs @@ -120,7 +120,7 @@ fn hex_chunks_keep_bytes_and_order() { // DXF groups 310–319 and 1004 permit 127 payload bytes per chunk. A binary length // byte can represent more, but that is reader tolerance, not writer conformance. // Larger payloads need chunk-size/reassembly checks rather than exact tag equality; - // see hex_chunks.rs for the independent wire checks and pending writer regressions. + // see hex_chunks.rs for the independent wire checks. for length in [0, 1, 2, 126, 127] { let hex: String = (0..length).fold(String::new(), |mut out, i| { use std::fmt::Write as _; diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs index df9634db6..3db892978 100644 --- a/crates/keymap/src/profiles.rs +++ b/crates/keymap/src/profiles.rs @@ -471,7 +471,7 @@ impl Loaded { /// notes why not otherwise. fn add(&mut self, name: &str, overrides: Option) { let shown = || name.chars().take(MAX_SET_NAME).collect::(); - if !set_name(name).is_some_and(|n| n == name) { + if set_name(name).is_none_or(|n| n != name) { self.note(SetProblem::BadName { name: shown() }); } else if self.sets.contains_key(name) { self.note(SetProblem::Duplicate { name: shown() }); diff --git a/crates/kra/src/lib.rs b/crates/kra/src/lib.rs index 7485b9dd5..12ae3468b 100644 --- a/crates/kra/src/lib.rs +++ b/crates/kra/src/lib.rs @@ -161,6 +161,16 @@ fn children<'a>(el: &'a El, container: &'static str, tag: &'static str) -> impl } fn node(el: &El, is_mask: bool, count: &mut usize) -> Result { + let mut out = node_fields(el, is_mask)?; + if !is_mask { + out.layers = layers(el, count)?; + out.masks = children(el, "masks", "mask").map(|m| node(m, true, count)).collect::>()?; + } + Ok(out) +} + +/// A node's own attributes, without its children. +fn node_fields(el: &El, is_mask: bool) -> Result { let owned = |name: &str| attr(el, name).map(str::to_string); let node_type = attr(el, "nodetype").unwrap_or("").to_string(); // Only the kinds whose pixels are read need sound numbers; a node an application will leave @@ -168,7 +178,7 @@ fn node(el: &El, is_mask: bool, count: &mut usize) -> Result { let read = matches!(node_type.as_str(), "paintlayer" | "grouplayer" | "transparencymask"); let lenient = |v: Result, default: f64| if read { v } else { Ok(v.unwrap_or(default)) }; let (x, y) = if is_mask && !read { (offset(el, "x").unwrap_or(0), offset(el, "y").unwrap_or(0)) } else { (offset(el, "x")?, offset(el, "y")?) }; - let mut out = Node { + let out = Node { node_type, name: owned("name"), visible: attr(el, "visible") != Some("0"), @@ -187,22 +197,39 @@ fn node(el: &El, is_mask: bool, count: &mut usize) -> Result { layers: Vec::new(), masks: Vec::new(), }; - if !is_mask { - out.layers = layers(el, count)?; - out.masks = children(el, "masks", "mask").map(|m| node(m, true, count)).collect::>()?; - } Ok(out) } -/// The `` of `el`. Recursion is bounded by [`MAX_XML_DEPTH`]. +/// The `` of `el`, walked with an explicit stack: groups can nest up to [`MAX_XML_DEPTH`] +/// deep, more than a small thread stack can recurse. fn layers(el: &El, count: &mut usize) -> Result> { + struct Frame<'a> { + node: Node, + rest: std::vec::IntoIter<&'a El>, + } + let mut root = children(el, "layers", "layer").collect::>().into_iter(); + let mut stack: Vec = Vec::new(); let mut out = Vec::new(); - for child in children(el, "layers", "layer") { + loop { + let next = match stack.last_mut() { + Some(frame) => frame.rest.next(), + None => root.next(), + }; + let Some(child) = next else { + let Some(frame) = stack.pop() else { break }; + match stack.last_mut() { + Some(parent) => parent.node.layers.push(frame.node), + None => out.push(frame.node), + } + continue; + }; *count += 1; if *count > MAX_NODES { return Err(invalid(format!("more than {MAX_NODES} layers"))); } - out.push(node(child, false, count)?); + let mut built = node_fields(child, false)?; + built.masks = children(child, "masks", "mask").map(|m| node(m, true, count)).collect::>()?; + stack.push(Frame { node: built, rest: children(child, "layers", "layer").collect::>().into_iter() }); } Ok(out) } diff --git a/crates/latex/src/tests.rs b/crates/latex/src/tests.rs index 55bde2489..ccf349c80 100644 --- a/crates/latex/src/tests.rs +++ b/crates/latex/src/tests.rs @@ -353,9 +353,9 @@ fn table_borders_and_borderless_round_trip() { let mut doc = Document::new(); let mut t_borderless = Table::new(2, 2, 400.0); t_borderless.props.style = None; - for r in 0..2 { - for c in 0..2 { - t_borderless.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; + for row in &mut t_borderless.rows { + for cell in &mut row.cells { + cell.blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; } } doc.body = vec![Arc::new(Block::Table(t_borderless))]; @@ -369,9 +369,9 @@ fn table_borders_and_borderless_round_trip() { let mut doc_bordered = Document::new(); let mut t_bordered = Table::new(2, 2, 400.0); t_bordered.props.style = Some("TableGrid".to_string()); - for r in 0..2 { - for c in 0..2 { - t_bordered.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; + for row in &mut t_bordered.rows { + for cell in &mut row.cells { + cell.blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; } } doc_bordered.body = vec![Arc::new(Block::Table(t_bordered))]; diff --git a/crates/materialx/Cargo.toml b/crates/materialx/Cargo.toml new file mode 100644 index 000000000..4a1dc37d8 --- /dev/null +++ b/crates/materialx/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "craft-materialx" +description = "MaterialX document parsing and writing" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-xml = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/materialx/src/lib.rs b/crates/materialx/src/lib.rs new file mode 100644 index 000000000..d60db3eef --- /dev/null +++ b/crates/materialx/src/lib.rs @@ -0,0 +1,593 @@ +//! MaterialX XML documents and node graphs. +//! +//! This crate owns the format boundary: bounded parsing of `.mtlx` documents, ordered access to +//! their nodes and nodegraph outputs, and XML serialization. Applications map shader inputs to +//! their own material models. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::collections::HashMap; + +use craft_xml::{Doctype, El, Error as XmlError, Limits, Options, UnknownEntity}; + +/// Maximum source document size accepted by the parser. +pub const MAX_BYTES: usize = 16 * 1024 * 1024; +/// Maximum XML nesting depth accepted by the parser. +pub const MAX_DEPTH: usize = 64; +/// Maximum XML element count accepted by the parser. +pub const MAX_NODES: usize = 200_000; + +/// An invalid or unsupported MaterialX document. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The document is not valid MaterialX XML. + #[error("invalid MaterialX document: {0}")] + Xml(#[from] XmlError), + /// The XML root element is not ``. + #[error("the XML root element is not ")] + Root, + /// The document does not contain the requested surface material. + #[error("no MaterialX material named {0:?}")] + MissingMaterial(String), + /// The document has an unsupported or incomplete shader connection. + #[error("unsupported MaterialX material: {0}")] + Unsupported(String), + /// The document's material references are malformed. + #[error("malformed MaterialX material: {0}")] + Malformed(String), +} + +/// A MaterialX document, with XML elements kept in source order. +#[derive(Clone, Debug, PartialEq)] +pub struct Document { + root: El, +} + +/// A recognized MaterialX surface shader. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Shader { + /// OpenPBR Surface. + OpenPbr, + /// Autodesk Standard Surface. + StandardSurface, + /// glTF PBR Surface. + GltfPbr, +} + +/// The value connected to a shader input. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + /// A finite numeric literal, kept in the source color space. `colorspace` is the input's + /// explicit tag; document-level defaults remain available on [`Document::root`]. + Constant { values: Vec, colorspace: Option }, + /// A file path from an image node, with the effective file prefix applied. + Image(String), +} + +/// A surface material resolved from a MaterialX document. +#[derive(Clone, Debug, PartialEq)] +pub struct Material { + /// Material name, or `Material` when omitted. + pub name: String, + /// Shader dialect represented by the material. + pub shader: Shader, + /// Resolved inputs in the supported shader table's deterministic order. + pub inputs: Vec<(String, Value)>, + /// Resolved displacement input, if present. + pub displacement: Option, + /// First valid image tiling value, or `[1, 1]`. + pub uv_tiling: [f32; 2], + /// Unsupported or ignored constructs encountered while resolving values. + pub warnings: Vec, +} + +#[derive(Clone, Debug)] +struct Found { + value: Value, + tiling: Option<[f32; 2]>, +} + +const OPEN_PBR_INPUTS: &[&str] = &[ + "base_color", + "base_metalness", + "specular_roughness", + "geometry_normal", + "geometry_opacity", + "emission_color", + "coat_weight", + "coat_roughness", + "geometry_coat_normal", + "fuzz_weight", + "fuzz_color", + "fuzz_roughness", + "specular_roughness_anisotropy", + "transmission_weight", + "subsurface_weight", +]; +const STANDARD_INPUTS: &[&str] = &[ + "base_color", + "metalness", + "specular_roughness", + "normal", + "opacity", + "emission_color", + "coat", + "coat_roughness", + "coat_normal", + "sheen", + "sheen_color", + "sheen_roughness", + "specular_anisotropy", + "transmission", + "subsurface", +]; +const GLTF_INPUTS: &[&str] = &[ + "base_color", + "metallic", + "roughness", + "normal", + "alpha", + "emission", + "occlusion", + "clearcoat", + "clearcoat_roughness", + "clearcoat_normal", + "sheen_color", + "sheen_roughness", + "transmission", +]; + +/// OpenPBR input kind accepted by [`write_open_pbr`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InputKind { + /// Color value or color texture. + Color, + /// Scalar value or data texture. + Float, + /// Vector normal texture. + Normal, + /// Displacement map, serialized as the MaterialX displacement shader input. + Height, +} + +/// A texture connected to an OpenPBR input. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Texture { + /// MaterialX/OpenPBR input name, such as `base_color` or `geometry_normal`. + pub input: String, + /// Texture filename relative to the MaterialX document. + pub file: String, + /// Color space used for color inputs; data maps ignore this field. + pub colorspace: String, +} + +const OPENPBR_WRITABLE: &[(&str, InputKind)] = &[ + ("base_color", InputKind::Color), + ("base_metalness", InputKind::Float), + ("specular_roughness", InputKind::Float), + ("specular_roughness_anisotropy", InputKind::Float), + ("coat_weight", InputKind::Float), + ("coat_roughness", InputKind::Float), + ("fuzz_weight", InputKind::Float), + ("fuzz_color", InputKind::Color), + ("fuzz_roughness", InputKind::Float), + ("transmission_weight", InputKind::Float), + ("subsurface_weight", InputKind::Float), + ("emission_color", InputKind::Color), + ("geometry_opacity", InputKind::Float), + ("geometry_normal", InputKind::Normal), + ("height", InputKind::Height), +]; + +impl Document { + /// Parses a MaterialX document while enforcing size, nesting and node-count limits. + pub fn parse(bytes: &[u8]) -> Result { + let limits = Limits { max_input: MAX_BYTES, max_depth: MAX_DEPTH, max_nodes: MAX_NODES, max_attrs: 256, max_attrs_total: 400_000 }; + let options = Options { + doctype: Doctype::Reject, + unknown_entity: UnknownEntity::Error, + drop_blank_text: true, + names: craft_xml::Names::Local, + ns_decls: false, + ..Options::default() + } + .strict() + .with_limits(limits); + let root = options.parse(bytes)?; + if root.name.rsplit(':').next() != Some("materialx") { + return Err(Error::Root); + } + Ok(Document { root }) + } + + /// Creates a document from a `` element. + pub fn from_root(root: El) -> Result { + if root.name.rsplit(':').next() != Some("materialx") { + return Err(Error::Root); + } + Ok(Document { root }) + } + + /// The root `` element. + pub fn root(&self) -> &El { + &self.root + } + + /// The root `` element, for application-specific editing. + pub fn root_mut(&mut self) -> &mut El { + &mut self.root + } + + /// Serializes the document as XML, preserving element and attribute order. + pub fn to_xml(&self) -> String { + format!("{}{}", craft_xml::DECL, self.root.to_xml_pretty(" ")) + } + + /// Nodegraphs in document order. + pub fn nodegraphs(&self) -> impl Iterator { + self.root.children("nodegraph") + } + + /// Outputs in a named nodegraph, in document order. + pub fn outputs(&self, graph: &str) -> impl Iterator { + self.nodegraphs().filter(move |g| g.attr("name") == Some(graph)).flat_map(|g| g.children("output")) + } + + /// The output of a named graph. An omitted output name selects the first output in source + /// order. For duplicate output names, the last definition wins, matching the consumer's + /// existing named lookup behavior. + pub fn output(&self, graph: &str, output: Option<&str>) -> Option<&El> { + match output { + Some(name) => self.outputs(graph).filter(|node| node.attr("name") == Some(name)).last(), + None => self.outputs(graph).next(), + } + } + + /// Resolves a surface material into shader inputs, image paths and warnings. + pub fn read_material(&self, material: Option<&str>) -> Result { + let materials: Vec<&El> = self.root.children("surfacematerial").collect(); + let mat = match material { + Some(name) => materials.iter().copied().find(|m| m.attr("name") == Some(name)).ok_or_else(|| Error::MissingMaterial(name.to_owned()))?, + None => materials.first().copied().ok_or_else(|| Error::Unsupported("document has no surfacematerial".into()))?, + }; + let mut nodes = HashMap::new(); + let mut outputs = Vec::new(); + for child in self.root.elements() { + if child.name == "nodegraph" { + let Some(graph) = child.attr("name") else { continue }; + for node in child.elements() { + let Some(name) = node.attr("name") else { continue }; + if node.name == "output" { + if let Some(from) = node.attr("nodename") { + outputs.push((format!("{graph}/{name}"), format!("{graph}/{from}"))); + } + } else { + nodes.insert(format!("{graph}/{name}"), node); + } + } + } else if let Some(name) = child.attr("name") { + nodes.insert(name.to_owned(), child); + } + } + let shader_name = mat.children("input").find(|i| i.attr("name") == Some("surfaceshader")).and_then(|i| i.attr("nodename")); + let shader = shader_name.and_then(|n| nodes.get(n).copied()).ok_or_else(|| Error::Malformed("material has no surface shader".into()))?; + let (kind, inputs) = match shader.name.as_str() { + "open_pbr_surface" => (Shader::OpenPbr, OPEN_PBR_INPUTS), + "standard_surface" => (Shader::StandardSurface, STANDARD_INPUTS), + "gltf_pbr" => (Shader::GltfPbr, GLTF_INPUTS), + other => return Err(Error::Unsupported(format!("surface shader is {other}"))), + }; + let emission_gate = match kind { + Shader::OpenPbr => Some(("emission_luminance", true)), + Shader::StandardSurface => Some(("emission", true)), + Shader::GltfPbr => None, + }; + let emission_enabled = emission_gate.is_none_or(|(name, _)| { + shader + .children("input") + .find(|i| i.attr("name") == Some(name)) + .is_some_and(|i| i.attr("value").is_none_or(|v| parse_numbers(v).and_then(|n| n.first().copied()).is_some_and(|v| v > 0.0))) + }); + let mut warnings = Vec::new(); + let base_weight_name = match kind { + Shader::OpenPbr => Some("base_weight"), + Shader::StandardSurface => Some("base"), + Shader::GltfPbr => None, + }; + if base_weight_name.and_then(|n| numeric_input(shader, n)).is_some_and(|w| (w - 1.0).abs() > 1e-6) { + warnings.push("the base weight isn't 1; it was ignored".into()); + } + let mut resolved = Vec::new(); + let mut tiling = None; + for name in inputs { + let Some(input) = shader.children("input").find(|i| i.attr("name") == Some(*name)) else { continue }; + let is_emission = matches!((kind, *name), (Shader::OpenPbr, "emission_color") | (Shader::StandardSurface, "emission_color")); + if is_emission && !emission_enabled { + warnings.push(format!("{name} was skipped: the shader's emission is 0")); + continue; + } + if let Some(found) = resolve_value(&self.root, input, None, &nodes, &outputs, 0, &mut warnings) { + if tiling.is_none() { + tiling = found.tiling; + } + resolved.push(((*name).to_owned(), found.value)); + } + } + let displacement = mat + .children("input") + .find(|i| i.attr("name") == Some("displacementshader")) + .and_then(|i| i.attr("nodename")) + .and_then(|n| nodes.get(n).copied()) + .and_then(|d| d.children("input").find(|i| i.attr("name") == Some("displacement"))) + .and_then(|i| resolve_value(&self.root, i, None, &nodes, &outputs, 0, &mut warnings).map(|f| f.value)); + Ok(Material { + name: mat.attr("name").unwrap_or("Material").to_owned(), + shader: kind, + inputs: resolved, + displacement, + uv_tiling: tiling.unwrap_or([1.0, 1.0]), + warnings, + }) + } +} + +fn numeric_input(shader: &El, name: &str) -> Option { + shader + .children("input") + .find(|i| i.attr("name") == Some(name)) + .and_then(|i| i.attr("value")) + .and_then(parse_numbers) + .and_then(|n| n.first().copied()) +} + +fn parse_numbers(text: &str) -> Option> { + let nums: Option> = text.split(',').map(|part| part.trim().parse::().ok().filter(|v| v.is_finite())).collect(); + nums.filter(|v| !v.is_empty() && v.len() <= 4) +} + +fn resolve_value( + root: &El, + input: &El, + scope: Option<&str>, + nodes: &HashMap, + outputs: &[(String, String)], + hops: usize, + warnings: &mut Vec, +) -> Option { + if hops > 16 { + warnings.push("a node chain is longer than 16 nodes (or loops); skipped".into()); + return None; + } + if let Some(value) = input.attr("value") { + return parse_numbers(value) + .map(|values| Found { value: Value::Constant { values, colorspace: input.attr("colorspace").map(str::to_owned) }, tiling: None }); + } + let target = if let Some(graph) = input.attr("nodegraph") { + let selected = match input.attr("output") { + Some(output) => outputs.iter().rev().find(|(k, _)| k == &format!("{graph}/{output}")), + None => outputs.iter().find(|(k, _)| k.starts_with(&format!("{graph}/"))), + }; + selected.map(|(_, node)| node.clone()) + } else if let Some(name) = input.attr("nodename") { + let qualified = scope.map(|g| format!("{g}/{name}")); + qualified.filter(|q| nodes.contains_key(q)).or_else(|| Some(name.to_owned())) + } else { + None + }?; + let node = nodes.get(&target).copied()?; + let node_scope = target.split_once('/').map(|(g, _)| g); + match node.name.as_str() { + "image" | "tiledimage" => { + let file = node.children("input").find(|i| i.attr("name") == Some("file")).and_then(|i| i.attr("value"))?; + let prefix = node + .children("input") + .find(|i| i.attr("name") == Some("file")) + .and_then(|i| i.attr("fileprefix")) + .or_else(|| node.attr("fileprefix")) + .or_else(|| root.attr("fileprefix")) + .unwrap_or(""); + let uv = node + .children("input") + .find(|i| i.attr("name") == Some("uvtiling")) + .and_then(|i| i.attr("value")) + .and_then(parse_numbers) + .filter(|n| n.len() == 2 && n.iter().all(|v| *v > 0.0)) + .map(|n| [n[0].clamp(0.001, 1000.0), n[1].clamp(0.001, 1000.0)]); + Some(Found { value: Value::Image(format!("{prefix}{file}")), tiling: uv }) + } + "normalmap" | "convert" => node + .children("input") + .find(|i| i.attr("name") == Some("in")) + .and_then(|i| resolve_value(root, i, node_scope, nodes, outputs, hops + 1, warnings)), + "constant" => node + .children("input") + .find(|i| i.attr("name") == Some("value")) + .and_then(|i| resolve_value(root, i, node_scope, nodes, outputs, hops + 1, warnings)), + other => { + warnings.push(format!("a {other} node ({:?}) is not supported; its input was skipped", node.attr("name").unwrap_or(""))); + None + } + } +} + +/// Writes an OpenPBR MaterialX document for the supplied textures. +/// +/// Unknown inputs and duplicate inputs are skipped; the first texture for an input wins. Color +/// spaces are written for color textures only, while data textures are left linear. +pub fn write_open_pbr(name: &str, textures: &[Texture], physical_size_cm: Option<[f32; 2]>) -> String { + let base = node_name(name); + let mut seen = Vec::new(); + let selected: Vec<(&Texture, InputKind)> = textures + .iter() + .filter_map(|t| OPENPBR_WRITABLE.iter().find(|(input, _)| *input == t.input).map(|(_, kind)| (t, *kind))) + .filter(|(texture, _)| { + if seen.iter().any(|name: &&str| *name == texture.input) { + false + } else { + seen.push(texture.input.as_str()); + true + } + }) + .collect(); + let has = |input: &str| selected.iter().any(|(texture, _)| texture.input == input); + let mut root = El::new("materialx").with_attr("version", "1.39").with_attr("colorspace", "lin_rec709"); + let shader_name = format!("OPBR_{base}"); + let mut shader = El::new("open_pbr_surface").with_attr("name", &shader_name).with_attr("type", "surfaceshader"); + for (texture, kind) in &selected { + let (ty, node) = match kind { + InputKind::Color => ("color3", image_node_name(&texture.input)), + InputKind::Float => ("float", image_node_name(&texture.input)), + InputKind::Normal => ("vector3", "normalmap".to_owned()), + InputKind::Height => continue, + }; + shader.push(El::new("input").with_attr("name", &texture.input).with_attr("type", ty).with_attr("nodename", node)); + } + if has("emission_color") { + shader.push(El::new("input").with_attr("name", "emission_luminance").with_attr("type", "float").with_attr("value", "1")); + } + root.push(shader); + for (texture, kind) in &selected { + let (ty, color) = match kind { + InputKind::Color => ("color3", Some(texture.colorspace.as_str())), + InputKind::Normal => ("vector3", None), + InputKind::Float | InputKind::Height => ("float", None), + }; + let mut image = El::new("tiledimage").with_attr("name", image_node_name(&texture.input)).with_attr("type", ty); + let mut file = El::new("input").with_attr("name", "file").with_attr("type", "filename").with_attr("value", &texture.file); + if let Some(space) = color { + file.set_attr("colorspace", space); + } + image.push(file); + root.push(image); + } + if has("geometry_normal") { + root.push(El::new("normalmap").with_attr("name", "normalmap").with_attr("type", "vector3").with_child( + El::new("input").with_attr("name", "in").with_attr("type", "vector3").with_attr("nodename", image_node_name("geometry_normal")), + )); + } + if has("height") { + root.push( + El::new("displacement") + .with_attr("name", format!("DISP_{base}")) + .with_attr("type", "displacementshader") + .with_child( + El::new("input").with_attr("name", "displacement").with_attr("type", "float").with_attr("nodename", image_node_name("height")), + ) + .with_child(El::new("input").with_attr("name", "scale").with_attr("type", "float").with_attr("value", "1")), + ); + } + let mut material = El::new("surfacematerial").with_attr("name", &base).with_attr("type", "material"); + if let Some([width, height]) = physical_size_cm.filter(|[w, h]| w.is_finite() && h.is_finite()) { + material.set_attr("doc", format!("Tiles every {width} x {height} cm")); + } + material.push(El::new("input").with_attr("name", "surfaceshader").with_attr("type", "surfaceshader").with_attr("nodename", shader_name)); + if has("height") { + material.push( + El::new("input") + .with_attr("name", "displacementshader") + .with_attr("type", "displacementshader") + .with_attr("nodename", format!("DISP_{base}")), + ); + } + root.push(material); + format!("{}{}", craft_xml::DECL, root.to_xml_pretty(" ")) +} + +fn image_node_name(input: &str) -> String { + format!("image_{}", node_name(input)) +} + +fn node_name(name: &str) -> String { + let mut out: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect(); + if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_and_serializes_a_materialx_document() { + let doc = Document::parse(br#""#).unwrap(); + assert_eq!(doc.root().attr("version"), Some("1.39")); + let roundtrip = Document::parse(doc.to_xml().as_bytes()).unwrap(); + assert_eq!(roundtrip, doc); + } + + #[test] + fn graph_outputs_keep_source_order_and_named_selection() { + let doc = Document::parse( + br#""#, + ) + .unwrap(); + assert_eq!(doc.output("g", None).and_then(|o| o.attr("name")), Some("second")); + assert_eq!(doc.output("g", Some("first")).and_then(|o| o.attr("nodename")), Some("a")); + } + + #[test] + fn duplicate_named_outputs_keep_last_definition() { + let doc = Document::parse( + br#""#, + ) + .unwrap(); + assert_eq!(doc.output("g", None).and_then(|o| o.attr("nodename")), Some("first")); + assert_eq!(doc.output("g", Some("o")).and_then(|o| o.attr("nodename")), Some("last")); + } + + #[test] + fn rejects_wrong_root_doctypes_and_oversized_documents() { + assert_eq!(Document::parse(b"").err(), Some(Error::Root)); + assert!(Document::parse(b"]>").is_err()); + assert!(matches!(Document::parse(&vec![b' '; MAX_BYTES + 1]), Err(Error::Xml(XmlError::TooLarge(_))))); + } + + #[test] + fn resolves_openpbr_values_images_graphs_and_displacement() { + let doc = Document::parse(br#" + + + + + + + + + + + + "#).unwrap(); + let material = doc.read_material(None).unwrap(); + assert_eq!(material.name, "M"); + assert_eq!(material.shader, Shader::OpenPbr); + assert_eq!(material.inputs[0], ("base_color".into(), Value::Image("tex/albedo.png".into()))); + assert_eq!(material.inputs[1], ("geometry_normal".into(), Value::Image("tex/normal.png".into()))); + assert_eq!(material.inputs[2].1, Value::Constant { values: vec![1.0, 0.5, 0.25], colorspace: None }); + assert_eq!(material.displacement, Some(Value::Constant { values: vec![0.25], colorspace: None })); + assert_eq!(material.uv_tiling, [2.0, 4.0]); + } + + #[test] + fn openpbr_writer_emits_texture_connections_and_roundtrips() { + let textures = vec![ + Texture { input: "base_color".into(), file: "albedo<&.png".into(), colorspace: "srgb_texture".into() }, + Texture { input: "specular_roughness".into(), file: "rough.png".into(), colorspace: "ignored".into() }, + Texture { input: "geometry_normal".into(), file: "normal.png".into(), colorspace: "ignored".into() }, + Texture { input: "height".into(), file: "height.png".into(), colorspace: "ignored".into() }, + ]; + let xml = write_open_pbr("Stone", &textures, Some([100.0, 50.0])); + let doc = Document::parse(xml.as_bytes()).unwrap(); + let material = doc.read_material(None).unwrap(); + assert_eq!(material.name, "Stone"); + assert_eq!(material.inputs[0], ("base_color".into(), Value::Image("albedo<&.png".into()))); + assert_eq!(material.inputs[1], ("specular_roughness".into(), Value::Image("rough.png".into()))); + assert_eq!(material.inputs[2], ("geometry_normal".into(), Value::Image("normal.png".into()))); + assert_eq!(material.displacement, Some(Value::Image("height.png".into()))); + assert!(xml.contains("colorspace=\"srgb_texture\"")); + assert!(xml.contains("doc=\"Tiles every 100 x 50 cm\"")); + assert!(!xml.contains("colorspace=\"ignored\"")); + } +} diff --git a/crates/mesh/src/gltf.rs b/crates/mesh/src/gltf.rs index 44477ddfb..9d45a4453 100644 --- a/crates/mesh/src/gltf.rs +++ b/crates/mesh/src/gltf.rs @@ -563,6 +563,19 @@ fn materials(j: &Value, limits: &Limits, scene: &mut Scene) -> Result<()> { _ => Wrap::Repeat, }; let images = scene.images.len(); + let texture_list = arr(j, "textures"); + if texture_list.len() > limits.max_materials { + return Err(too_large(KIND, format!("more than {} textures", limits.max_materials))); + } + scene.texture_variants = texture_list + .iter() + .filter_map(|texture| { + let sampler = idx(texture, "sampler").and_then(|s| arr(j, "samplers").get(s)); + let webp = || texture.get("extensions").and_then(|e| e.get("EXT_texture_webp")).and_then(|e| idx(e, "source")); + let image = idx(texture, "source").or_else(webp).filter(|i| *i < images)?; + Some(TextureRef { image, uv_set: 0, wrap: [wrap(sampler, "wrapS"), wrap(sampler, "wrapT")] }) + }) + .collect(); let tref = |v: Option<&Value>| -> Option { let v = v?; let texture = arr(j, "textures").get(idx(v, "index")?)?; diff --git a/crates/mesh/src/lib.rs b/crates/mesh/src/lib.rs index c4bf90661..59870ff31 100644 --- a/crates/mesh/src/lib.rs +++ b/crates/mesh/src/lib.rs @@ -621,6 +621,9 @@ pub struct Scene { pub meshes: Vec, pub materials: Vec, pub images: Vec, + /// Texture and sampler entries in the file, including ones no material uses. Their image + /// index and wrap modes are format properties; UV sets belong to each material slot. + pub texture_variants: Vec, /// Every format fills this: a format without a hierarchy gets one root node per mesh. pub nodes: Vec, /// The top-level nodes. @@ -684,6 +687,9 @@ impl Scene { return bad(format!("material `{}` names an image that does not exist", m.name)); } } + if self.texture_variants.iter().any(|t| t.image >= self.images.len()) { + return bad("a texture names an image that does not exist".into()); + } let n = self.nodes.len(); for node in &self.nodes { let refs = diff --git a/crates/mesh/src/tests.rs b/crates/mesh/src/tests.rs index 587ef4db1..160b5b3dc 100644 --- a/crates/mesh/src/tests.rs +++ b/crates/mesh/src/tests.rs @@ -79,6 +79,11 @@ fn validate_catches_what_writers_cannot_take() { s.validate().unwrap(); s.meshes[0].parts[0].material = Some(0); assert!(s.validate().is_err()); + let mut s = Scene::default(); + s.texture_variants.push(TextureRef::default()); + assert!(s.validate().is_err(), "a texture references a missing image"); + s.images.push(Image::default()); + s.validate().unwrap(); let mut s = Scene::from_meshes(vec![tri_mesh()], vec![]); s.nodes[0].children.push(7); assert!(s.validate().is_err()); diff --git a/crates/mesh/src/tests/gltf.rs b/crates/mesh/src/tests/gltf.rs index 011f4d75e..d35b63d57 100644 --- a/crates/mesh/src/tests/gltf.rs +++ b/crates/mesh/src/tests/gltf.rs @@ -194,6 +194,30 @@ fn embedded_and_external_resources() { assert!(parse(&gltf_text("data:;base64,!!!", "x.png").0).is_err()); } +#[test] +fn texture_and_sampler_inventory_includes_unreferenced_entries() { + let doc = serde_json::to_vec(&json!({ + "asset": {"version": "2.0"}, + "images": [{"uri": "image.png"}], + "samplers": [{"wrapS": 33071, "wrapT": 33648}], + "textures": [ + {"source": 0, "sampler": 0}, + {"source": 0} + ] + })) + .unwrap(); + let scene = read(&doc, &Limits::default(), &mut |_| Some(vec![])).unwrap(); + assert_eq!( + scene.texture_variants, + [TextureRef { image: 0, uv_set: 0, wrap: [Wrap::Clamp, Wrap::Mirror] }, TextureRef { image: 0, uv_set: 0, wrap: [Wrap::Repeat; 2] },] + ); + assert!(scene.validate().is_ok()); + + let mut invalid = scene; + invalid.texture_variants[0].image = invalid.images.len(); + assert!(matches!(invalid.validate(), Err(Error::Malformed(_)))); +} + #[test] fn base64_and_uri_decoding() { assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello"); diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index b4ac445fc..51159bdf3 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -1295,13 +1295,35 @@ impl Canvas { } } -/// An 8×8 hatch tile (HS_HORIZONTAL, HS_VERTICAL, HS_FDIAGONAL, HS_BDIAGONAL, HS_CROSS, -/// HS_DIAGCROSS): one-pixel lines in `fg` over `bg`, or over nothing when the background is -/// transparent. None for an unknown style. +/// An 8×8 GDI hatch tile (HS_HORIZONTAL through HS_DIAGCROSS). None for an unknown GDI style. pub(crate) fn hatch_tile(style: u32, fg: [u8; 4], bg: Option<[u8; 4]>) -> Option> { - if style > 5 { + hatch_tile_inner(style, fg, bg, false) +} + +/// An 8×8 GDI+ tile for the six basic and twelve percentage HatchStyle values. Percentage styles +/// use a Bayer tile with the specified foreground coverage. Other GDI+ styles still use the +/// fallback in `emfplus::Painter::fill` until their patterns have been mapped. +pub(crate) fn emfplus_hatch_tile(style: u32, fg: [u8; 4], bg: Option<[u8; 4]>) -> Option> { + hatch_tile_inner(style, fg, bg, true) +} + +fn hatch_tile_inner(style: u32, fg: [u8; 4], bg: Option<[u8; 4]>, gdi_plus: bool) -> Option> { + if style > if gdi_plus { 17 } else { 5 } { return None; } + // An 8×8 Bayer matrix keeps percentage hatches evenly distributed in a small, repeatable + // tile. The twelve values are GDI+'s Percent05 through Percent90 styles. + const BAYER: [[u8; 8]; 8] = [ + [0, 48, 12, 60, 3, 51, 15, 63], + [32, 16, 44, 28, 35, 19, 47, 31], + [8, 56, 4, 52, 11, 59, 7, 55], + [40, 24, 36, 20, 43, 27, 39, 23], + [2, 50, 14, 62, 1, 49, 13, 61], + [34, 18, 46, 30, 33, 17, 45, 29], + [10, 58, 6, 54, 9, 57, 5, 53], + [42, 26, 38, 22, 41, 25, 37, 21], + ]; + const PERCENT: [u32; 12] = [5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90]; let mut px = Vec::with_capacity(8 * 8 * 4); for y in 0..8u32 { for x in 0..8u32 { @@ -1312,7 +1334,12 @@ pub(crate) fn hatch_tile(style: u32, fg: [u8; 4], bg: Option<[u8; 4]>) -> Option 2 => f, 3 => b, 4 => h || v, - _ => f || b, + 5 => f || b, + 6..=17 if gdi_plus => { + let count = (PERCENT[(style - 6) as usize] * 64 + 50) / 100; + u32::from(BAYER[y as usize][x as usize]) < count + } + _ => return None, }; px.extend(if on { fg } else { bg.unwrap_or([0, 0, 0, 0]) }); } diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 5c45721a7..af406aa30 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -27,7 +27,7 @@ use std::sync::Arc; use crate::bytes::Bytes; -use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, hatch_tile, mul}; +use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, emfplus_hatch_tile, mul}; use crate::canvas::{Raw, RawClip}; use crate::cliprect::{MAX_AREA_CLIP_WORK, clip_polygon_within, convex, flatten, ops_bbox, pts_bbox, rect_poly}; use crate::dib::Rgba; @@ -845,9 +845,11 @@ impl Painter { cv.set_brush(Brush::new(BS_SOLID, *c)); cv.shape(ops.to_vec(), true, false); } - Fill::Hatch { style, fg, bg } => match hatch_tile(*style, *fg, Some(*bg)) { + Fill::Hatch { style, fg, bg } => match emfplus_hatch_tile(*style, *fg, Some(*bg)) { Some(px) => cv.tile_shape(ops, Arc::new(RawTile { w: 8, h: 8, px, cell: cv.pattern_px })), - // GDI+ has many more hatch styles than GDI: those fill with the blend of both colours. + // Percentage styles are tiled above. The remaining GDI+ hatch styles have no tile + // mapping yet, so keep the existing blended-colour fallback instead of inventing + // a pattern that could disagree with the file. None => { cv.set_brush(Brush::new(BS_SOLID, lerp(*fg, *bg, 0.5))); cv.shape(ops.to_vec(), true, false); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 806c95662..1a2a30fc6 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -2237,10 +2237,35 @@ fn emfplus_linear_gradient_fills_with_a_shaded_tile() { #[test] fn emfplus_hatch_brush_fills_with_an_8x8_tile() { - let brush = cat(vec![h32(0xDBC0_1002), h32(1), h32(4), h32(0xFF00_0000), h32(0xFFFF_FFFF)]); + // GDI+ HatchStyle.Percent50 (12), as used by System.Drawing's HatchBrush. + let brush = cat(vec![h32(0xDBC0_1002), h32(1), h32(12), h32(0xFF00_0000), h32(0xFFFF_FFFF)]); let fill = prec(0x400A, 0, &cat(vec![h32(0), h32(1), f32s(&[0.0, 0.0, 16.0, 16.0])])); let p = plus_pic(vec![prec(0x4008, 0x0100, &brush), fill]); - assert!(matches!(&p.items[0], Item::Pattern { tile, .. } if tile.width == 8 && tile.height == 8)); + let tile = pattern_of(&p.items[0]); + assert_eq!((tile.width, tile.height), (8, 8)); + let foreground = tile.rgba.as_chunks::<4>().0.iter().filter(|px| **px == [0, 0, 0, 255]).count(); + assert_eq!(foreground, 32, "Percent50 uses equal foreground and background coverage"); +} + +#[test] +fn gdi_plus_percentage_hatch_styles_keep_the_documented_coverage() { + const PERCENT: [usize; 12] = [5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90]; + let fg = [0, 0, 0, 255]; + let bg = [255, 255, 255, 255]; + for style in 0..=17 { + let tile = crate::canvas::emfplus_hatch_tile(style, fg, Some(bg)).expect("supported GDI+ hatch style"); + let foreground = tile.as_chunks::<4>().0.iter().filter(|px| **px == fg).count(); + assert!((1..64).contains(&foreground), "style {style} should mix both colours"); + if (6..=17).contains(&style) { + let percent = PERCENT[(style - 6) as usize]; + assert_eq!(foreground, (percent * 64 + 50) / 100, "percentage style {style}"); + } + } + for style in 18..=52 { + assert!(crate::canvas::emfplus_hatch_tile(style, fg, Some(bg)).is_none(), "style {style} has no confirmed tile mapping"); + } + assert!(crate::canvas::emfplus_hatch_tile(53, fg, Some(bg)).is_none(), "out-of-range values stay unsupported"); + assert!(crate::canvas::hatch_tile(9, fg, Some(bg)).is_none(), "GDI's six-style range is unchanged"); } #[test] diff --git a/crates/openpgp/src/tests.rs b/crates/openpgp/src/tests.rs index 4dc30a0d3..f78519ba0 100644 --- a/crates/openpgp/src/tests.rs +++ b/crates/openpgp/src/tests.rs @@ -443,7 +443,8 @@ fn the_unhashed_area_never_decides_who_is_blamed() { // An issuer only in the unhashed area finds a key, which counts because it fits. let alice = secret(ALICE, Make::default()); - let mut config = config(&alice.primary_key, pgp::packet::SignatureType::Binary, t, vec![]); + let alice_created = made(&alice); + let mut config = config(&alice.primary_key, pgp::packet::SignatureType::Binary, alice_created, vec![]); config.hashed_subpackets.retain(|p| matches!(p.data, SubpacketData::SignatureCreationTime(_))); config.unhashed_subpackets = vec![Subpacket::regular(SubpacketData::IssuerFingerprint(alice.primary_key.fingerprint())).unwrap()]; let sig = config.sign(&alice.primary_key, &Password::empty(), SIGNED_PART.as_bytes()).unwrap(); diff --git a/crates/ora/src/lib.rs b/crates/ora/src/lib.rs index b2a91cc40..69d494d0d 100644 --- a/crates/ora/src/lib.rs +++ b/crates/ora/src/lib.rs @@ -189,28 +189,63 @@ fn props(el: &El) -> Result { }) } -/// The elements of `stack`. Recursion is bounded by [`MAX_XML_DEPTH`]. +/// The elements of `stack`, walked with an explicit stack: a stack can nest up to +/// [`MAX_XML_DEPTH`] deep, more than a small thread stack can recurse. fn stack(el: &El, count: &mut usize) -> Result> { + struct Frame<'a> { + props: Props, + isolation: Option, + children: Vec, + rest: std::vec::IntoIter<&'a El>, + } + let mut root = el.elements().collect::>().into_iter(); + let mut frames: Vec = Vec::new(); let mut out = Vec::new(); - for child in el.elements() { + loop { + let next = match frames.last_mut() { + Some(frame) => frame.rest.next(), + None => root.next(), + }; + let Some(child) = next else { + let Some(frame) = frames.pop() else { break }; + let node = Node::Stack(Stack { props: frame.props, isolation: frame.isolation, children: frame.children }); + match frames.last_mut() { + Some(parent) => parent.children.push(node), + None => out.push(node), + } + continue; + }; *count += 1; if *count > MAX_NODES { return Err(invalid(format!("more than {MAX_NODES} layers"))); } - out.push(match child.name.as_str() { - "layer" => Node::Layer(Layer { - props: props(child)?, - x: int(child, "x")?, - y: int(child, "y")?, - src: child.attr("src").map(|s| s.trim().to_string()), - }), - "stack" => Node::Stack(Stack { + match child.name.as_str() { + "layer" => { + let node = Node::Layer(Layer { + props: props(child)?, + x: int(child, "x")?, + y: int(child, "y")?, + src: child.attr("src").map(|s| s.trim().to_string()), + }); + match frames.last_mut() { + Some(parent) => parent.children.push(node), + None => out.push(node), + } + } + "stack" => frames.push(Frame { props: props(child)?, isolation: child.attr("isolation").map(|s| s.trim().to_string()), - children: stack(child, count)?, + children: Vec::new(), + rest: child.elements().collect::>().into_iter(), }), - other => Node::Other { tag: other.to_string(), name: child.attr("name").map(str::to_string) }, - }); + other => { + let node = Node::Other { tag: other.to_string(), name: child.attr("name").map(str::to_string) }; + match frames.last_mut() { + Some(parent) => parent.children.push(node), + None => out.push(node), + } + } + } } Ok(out) } diff --git a/crates/plate-test/tests/scenes.rs b/crates/plate-test/tests/scenes.rs index 0e114471e..8b23e72d8 100644 --- a/crates/plate-test/tests/scenes.rs +++ b/crates/plate-test/tests/scenes.rs @@ -8,10 +8,10 @@ fn scenes() -> Vec { scene::load_dir(&scene::scenes_dir()).unwrap() } -/// On CI (`CI` set) the workflow provisions the font, so a scene that left text out fails. -/// Local runs without the font skip text and only note it. +/// When the licensed fixture directory is configured, every scene must write its text. +/// Workflows that do not provision those fixtures can still validate scenes without text. fn text_must_be_written() -> bool { - std::env::var_os("CI").is_some_and(|v| !v.is_empty() && v != "false") + std::env::var_os(reference::FONT_DIR_ENV).is_some_and(|v| !v.is_empty()) } fn missing_font(scene: &Scene) -> String { diff --git a/crates/plotter/tests/golden.rs b/crates/plotter/tests/golden.rs index 26aaae554..4d5029010 100644 --- a/crates/plotter/tests/golden.rs +++ b/crates/plotter/tests/golden.rs @@ -135,7 +135,7 @@ fn silhouette_registration_marks() { let p = silhouette::encode(&pro, &j).unwrap(); let (head, rest) = p.split_at_registration(); let text = |c: &[Command]| c.iter().map(Command::to_ascii).collect::>().join(" "); - assert!(text(head).ends_with(" FC18,1,1 TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB55,1 TB123,4600,3600,200,100")); + assert!(text(head).ends_with(" FC18,1,1 TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB54,0,0 TB55,1 TB123,4600,3600,200,100")); // The design is shifted so the first mark is the origin. assert_eq!(text(rest), "M100,100 D100,300 M100,0 SO0"); assert_eq!(p.registration_wait, Some(head.len())); @@ -148,7 +148,7 @@ fn silhouette_registration_marks() { reg.origin_right_mm = 0.0; assert!(ascii(®).ends_with(" TB124,4600,3600,0,0")); reg.search = false; - assert_eq!(ascii(®), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB55,1 TB23,4600,3600"); + assert_eq!(ascii(®), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB54,0,0 TB55,1 TB23,4600,3600"); } #[test] diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs index 966ded72f..08629a052 100644 --- a/crates/proof/src/hunspell.rs +++ b/crates/proof/src/hunspell.rs @@ -754,11 +754,12 @@ mod tests { /// spec's default `~/.local/share` applies, and with no home there is no folder. #[test] fn xdg_data_home_follows_the_spec() { - let home = Path::new("/home/a"); - assert_eq!(xdg_data_home(Some("/data".into()), Some(home)), Some(PathBuf::from("/data"))); - assert_eq!(xdg_data_home(Some("/data".into()), None), Some(PathBuf::from("/data"))); + let home = std::env::temp_dir().join("xdg-home"); + let data = std::env::temp_dir().join("xdg-data"); + assert_eq!(xdg_data_home(Some(data.as_os_str().to_owned()), Some(&home)), Some(data.clone())); + assert_eq!(xdg_data_home(Some(data.as_os_str().to_owned()), None), Some(data)); for xdg in [None, Some(""), Some("relative/data")] { - assert_eq!(xdg_data_home(xdg.map(Into::into), Some(home)), Some(home.join(".local/share")), "{xdg:?}"); + assert_eq!(xdg_data_home(xdg.map(Into::into), Some(&home)), Some(home.join(".local/share")), "{xdg:?}"); assert_eq!(xdg_data_home(xdg.map(Into::into), None), None, "{xdg:?}"); } } @@ -1010,7 +1011,12 @@ mod tests { assert!(load(&aff, &big).unwrap_err().contains("too large")); // A directory, and on Unix an endless device, are not dictionary files. (No clock: a // reader that read `/dev/zero` would never return, so the error itself is the proof.) - assert!(load(&aff, &dir).unwrap_err().contains("not a regular file")); + let error = load(&aff, &dir).unwrap_err(); + // Windows may reject opening a directory before the reader can inspect its type. + #[cfg(unix)] + assert!(error.contains("not a regular file"), "{error}"); + #[cfg(not(unix))] + assert!(!error.is_empty()); #[cfg(unix)] assert!(load(&aff, Path::new("/dev/zero")).unwrap_err().contains("not a regular file")); // Turkish reads through the same reader. diff --git a/crates/proof/src/tr/mod.rs b/crates/proof/src/tr/mod.rs index 5472fbe21..c728d5872 100644 --- a/crates/proof/src/tr/mod.rs +++ b/crates/proof/src/tr/mod.rs @@ -100,11 +100,7 @@ fn check(d: &TrDict, word: &str, opts: &crate::Options) -> bool { } // Compound words separated by hyphens (e.g., `afyon-karahisar`) - if w.contains('-') && w.split('-').all(|part| part.is_empty() || check(d, part, opts)) { - return true; - } - - false + w.contains('-') && w.split('-').all(|part| part.is_empty() || check(d, part, opts)) } #[cfg(test)] diff --git a/crates/psd/src/codepage.rs b/crates/psd/src/codepage.rs index 2ef7389ee..8cfe169d1 100644 --- a/crates/psd/src/codepage.rs +++ b/crates/psd/src/codepage.rs @@ -242,7 +242,7 @@ fn score(enc: &'static Encoding, name: &[u8], hint_letters: &std::collections::B // never writes this one, says the writer meant that ("état", not "Žtat"). let written = |c: char| c.to_lowercase().all(|l| hint_letters.contains(&l)); let mac = name.get(i..=i).map(|b| decode(b, MACINTOSH)).and_then(|s| s.chars().next()); - word_initial && !(mac.is_some_and(|m| m.is_alphabetic() && written(m)) && !written(ch)) + word_initial && (written(ch) || !mac.is_some_and(|m| m.is_alphabetic() && written(m))) } else if enc == WINDOWS_1251 && is_cyrillic(ch) { (cyrillic_name && !lone_after_digit(&decoded, i)) || cyrillic_word(&decoded, i) } else { diff --git a/crates/psd/src/head.rs b/crates/psd/src/head.rs index ecd19735e..ba5054c69 100644 --- a/crates/psd/src/head.rs +++ b/crates/psd/src/head.rs @@ -2,19 +2,67 @@ //! without the layer and mask section or any pixels. //! //! This is what a file browser or a metadata reader needs (size, depth, colour mode, ICC profile, -//! EXIF, XMP, resolution, thumbnail): a few kilobytes at the start of a file that may be -//! gigabytes long. [`Head::from_source`] reads exactly those bytes; [`Head::from_bytes`] accepts a -//! prefix of the file and reports how many more bytes it needs when the prefix is too short. +//! EXIF, XMP, resolution, thumbnail): a few bytes at the start of a file that may be gigabytes +//! long. [`Head::from_source`] reads exactly those bytes; [`Head::from_bytes`] accepts a prefix of +//! the file and reports how many more bytes it needs when the prefix is too short. Both return +//! owned data. For untrusted files with large resource sections, [`HeadView`] borrows the input +//! and parses resource records lazily. use crate::error::Result; use crate::header::Header; use crate::io::Reader; -use crate::resources::{self, ImageResource}; +use crate::resources::{self, ImageResource, ResourceIter}; use crate::source::Source; /// Length of the file header. const HEADER_LEN: u64 = 26; +/// A zero-copy view of a PSD/PSB head. +/// +/// The header and section lengths are validated eagerly. Colour-mode data and resource records +/// remain borrowed from the input; [`HeadView::resources`] validates one resource at a time and +/// stops at the first malformed record. This avoids cloning unknown or attacker-sized resource +/// payloads and lets callers keep valid records that precede a damaged one. +#[derive(Debug, Clone)] +pub struct HeadView<'a> { + /// The file header. + pub header: Header, + /// The colour mode data section (the palette of an indexed file, duotone or HDR toning data). + pub color_mode_data: &'a [u8], + resources: &'a [u8], + /// Offset in the file of the layer and mask information section (of its length field). + pub layer_section_offset: u64, +} + +impl<'a> HeadView<'a> { + /// Parses the fixed header and section bounds from `data` without copying colour data or + /// resources. Individual resource records are validated by the iterator returned from + /// [`HeadView::resources`]. + pub fn from_bytes(data: &'a [u8]) -> Result { + let header = Header::from_bytes(data)?; + let mut r = Reader::new(data); + r.skip(HEADER_LEN as usize)?; + let color_mode_len = r.u32()?; + let color_mode_data = r.bytes_u64(u64::from(color_mode_len))?; + let resource_len = r.u32()?; + let resources = r.bytes_u64(u64::from(resource_len))?; + Ok(HeadView { header, color_mode_data, resources, layer_section_offset: r.pos() as u64 }) + } + + /// Iterates over resource records without allocating. If a record is malformed, its error is + /// returned once and iteration stops; earlier records remain available to the caller. + pub fn resources(&self) -> ResourceIter<'a> { + ResourceIter::new(self.resources) + } + + /// Iterates over at most `max_records` resources. Returns one limit error if additional bytes + /// remain after that many records, then stops. Use this for untrusted files where record + /// processing itself needs a caller-defined work bound. + pub fn resources_limited(&self, max_records: usize) -> ResourceIter<'a> { + ResourceIter::with_limit(self.resources, max_records) + } +} + /// The first three sections of a file. The same values [`crate::PsdFile`] holds in its fields of /// the same names. #[derive(Debug, Clone, PartialEq, Eq)] @@ -132,6 +180,72 @@ mod tests { } } + #[test] + fn head_view_borrows_sections_and_records() { + for (file, bytes) in cases() { + let view = HeadView::from_bytes(&bytes).unwrap(); + let owned = Head::from_bytes(&bytes).unwrap(); + assert_eq!(view.header, owned.header); + assert_eq!(view.color_mode_data, owned.color_mode_data); + assert_eq!(view.layer_section_offset, owned.layer_section_offset); + let bytes_start = bytes.as_ptr() as usize; + let bytes_end = bytes_start + bytes.len(); + let color_start = view.color_mode_data.as_ptr() as usize; + assert!(bytes_start <= color_start && color_start + view.color_mode_data.len() <= bytes_end); + + let borrowed = view.resources().collect::>>().unwrap(); + assert_eq!(borrowed.iter().map(|r| (*r).into_owned()).collect::>(), file.resources); + let nonempty = borrowed.iter().find(|r| !r.data.is_empty()).expect("test resource has bytes"); + let data_start = nonempty.data.as_ptr() as usize; + assert!(bytes_start <= data_start && data_start + nonempty.data.len() <= bytes_end); + } + } + + #[test] + fn head_view_keeps_valid_resources_before_a_malformed_record() { + let (_, mut bytes) = cases().remove(0); + let color_len = u32::from_be_bytes(bytes[26..30].try_into().unwrap()) as usize; + let resource_len_at = 30 + color_len; + let resource_start = resource_len_at + 4; + let name_len = usize::from(bytes[resource_start + 6]); + let name_total = (1 + name_len + 1) & !1; + let data_len_at = resource_start + 6 + name_total; + let data_len = u32::from_be_bytes(bytes[data_len_at..data_len_at + 4].try_into().unwrap()) as usize; + let first_end = data_len_at + 4 + data_len + data_len % 2; + let resource_len = u32::from_be_bytes(bytes[resource_len_at..resource_len_at + 4].try_into().unwrap()); + let damaged_record = b"JUNK"; + bytes[resource_len_at..resource_len_at + 4].copy_from_slice(&(resource_len + damaged_record.len() as u32).to_be_bytes()); + bytes.splice(first_end..first_end, damaged_record.iter().copied()); + + let view = HeadView::from_bytes(&bytes).unwrap(); + let mut resources = view.resources(); + let first = resources.next().expect("first valid resource").unwrap(); + assert_eq!(first.id, 1005); + assert!(resources.next().expect("malformed second resource").is_err()); + assert!(resources.next().is_none(), "iteration stops after the first malformed record"); + assert!(Head::from_bytes(&bytes).is_err(), "the strict owned API still rejects malformed resources"); + } + + #[test] + fn head_view_resource_limit_is_enforced_after_valid_records() { + let (_, mut bytes) = cases().remove(0); + let color_len = u32::from_be_bytes(bytes[26..30].try_into().unwrap()) as usize; + let resource_len_at = 30 + color_len; + let resource_start = resource_len_at + 4; + let resource_len = u32::from_be_bytes(bytes[resource_len_at..resource_start].try_into().unwrap()) as usize; + let duplicate = bytes[resource_start..resource_start + resource_len].to_vec(); + bytes[resource_len_at..resource_start].copy_from_slice(&((resource_len * 2) as u32).to_be_bytes()); + bytes.splice(resource_start + resource_len..resource_start + resource_len, duplicate); + let view = HeadView::from_bytes(&bytes).unwrap(); + let all = view.resources().collect::>>().unwrap(); + assert!(all.len() > 1); + let mut limited = view.resources_limited(1); + assert!(limited.next().unwrap().is_ok()); + assert!(limited.size_hint().1.unwrap() >= 1, "limit error is included in the upper bound"); + assert!(limited.next().unwrap().is_err()); + assert!(limited.next().is_none()); + } + #[test] fn a_prefix_ending_with_the_resources_is_enough_and_one_byte_less_is_not() { for (_, bytes) in cases() { diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 2ad36b035..93acd5f39 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -105,13 +105,13 @@ pub use descriptor::{Descriptor, VersionedDescriptor}; pub use error::{PsdError, Result}; pub use file::{GlobalLayerMask, LayerInfoPlacement, PsdFile}; pub use float::FloatImage; -pub use head::Head; +pub use head::{Head, HeadView}; pub use header::{ColorMode, Header, Version}; pub use image_data::ImageData; pub use layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask, LayerRecord, MaskData, MaskParameters, RealMask, Rect}; pub use pixels::{GrayImage, Layer, RgbaImage}; pub use preview::Preview; -pub use resources::{ImageResource, ResolutionInfo, ResourceData}; +pub use resources::{ImageResource, ImageResourceView, ResolutionInfo, ResourceData, ResourceIter}; pub use rows::RowDecoder; #[cfg(any(unix, windows))] pub use source::FileSource; diff --git a/crates/psd/src/resources.rs b/crates/psd/src/resources.rs index bfb5a9f72..75b52b199 100644 --- a/crates/psd/src/resources.rs +++ b/crates/psd/src/resources.rs @@ -2,7 +2,7 @@ use crate::descriptor::UnicodeString; use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt, read_pascal, write_pascal}; +use crate::io::{Reader, WriteExt, write_pascal}; /// Well-known resource ids. pub mod ids { @@ -48,6 +48,124 @@ pub struct ImageResource { pub data: Vec, } +/// An image-resource record that borrows its name and data from the PSD/PSB input. +/// +/// Use [`ImageResourceView::into_owned`] only when the data must outlive the input bytes. Parsing a +/// view does not allocate proportional to the resource's declared size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImageResourceView<'a> { + /// Signature, normally `8BIM` (also `MeSa`, `AgHg`, `PHUT`, `DCSR` seen in the wild). + pub signature: [u8; 4], + /// Resource id. + pub id: u16, + /// Pascal name bytes (usually empty). + pub name: &'a [u8], + /// Resource data (without the even padding). + pub data: &'a [u8], +} + +impl ImageResourceView<'_> { + /// Copies this view into an owned resource. + pub fn into_owned(self) -> ImageResource { + ImageResource { signature: self.signature, id: self.id, name: self.name.to_vec(), data: self.data.to_vec() } + } +} + +impl<'a> ImageResourceView<'a> { + pub(crate) fn read(r: &mut Reader<'a>) -> Result { + let signature = r.array::<4>()?; + if !matches!(&signature, b"8BIM" | b"MeSa" | b"AgHg" | b"PHUT" | b"DCSR") { + return Err(PsdError::InvalidSignature { expected: "8BIM resource", found: signature }); + } + let id = r.u16()?; + let name_len = usize::from(r.u8()?); + let name = r.bytes(name_len)?; + let name_total = 1 + name_len; + let name_pad = (2 - name_total % 2) % 2; + r.skip(name_pad)?; + let len = r.u32()?; + let data = r.bytes_u64(u64::from(len))?; + if len % 2 == 1 { + r.skip(1)?; + } + Ok(ImageResourceView { signature, id, name, data }) + } +} + +/// Lazily parses image-resource records from a borrowed resource section. +/// +/// Records are yielded one at a time. If a record is malformed, the iterator yields its error +/// once and then stops; records yielded before it remain usable. +pub struct ResourceIter<'a> { + reader: Reader<'a>, + remaining_records: usize, + failed: bool, +} + +impl<'a> ResourceIter<'a> { + pub(crate) fn new(bytes: &'a [u8]) -> Self { + ResourceIter::with_limit(bytes, usize::MAX) + } + + pub(crate) fn with_limit(bytes: &'a [u8], max_records: usize) -> Self { + ResourceIter { reader: Reader::new(bytes), remaining_records: max_records, failed: false } + } +} + +impl<'a> Iterator for ResourceIter<'a> { + type Item = Result>; + + fn next(&mut self) -> Option { + if self.failed || self.reader.is_empty() { + return None; + } + if self.remaining_records == 0 { + self.failed = true; + return Some(Err(PsdError::LimitExceeded("image resource record count"))); + } + match ImageResourceView::read(&mut self.reader) { + Ok(resource) => { + self.remaining_records -= 1; + Some(Ok(resource)) + } + Err(error) => { + self.failed = true; + Some(Err(error)) + } + } + } + + fn size_hint(&self) -> (usize, Option) { + // A valid record consumes at least 12 bytes. A truncated record or exhausted limit can + // produce one final error without consuming a full record. + let upper = + if self.failed || self.reader.is_empty() { 0 } else { (self.reader.remaining() / 12).min(self.remaining_records).saturating_add(1) }; + (0, Some(upper)) + } +} + +impl std::iter::FusedIterator for ResourceIter<'_> {} + +#[cfg(test)] +mod view_tests { + use super::ResourceIter; + + #[test] + fn resource_size_hint_respects_the_record_limit() { + let data = vec![0; 12_000]; + let iter = ResourceIter::with_limit(&data, 3); + assert_eq!(iter.size_hint(), (0, Some(4))); + } + + #[test] + fn truncated_resource_size_hint_includes_error_item() { + let mut iter = ResourceIter::new(b"JUNK"); + assert!(iter.size_hint().1.unwrap() >= 1); + assert!(iter.next().unwrap().is_err()); + assert_eq!(iter.size_hint(), (0, Some(0))); + } +} + /// Fixed-point resolution info (resource 1005). /// /// `h_res_fixed` and `v_res_fixed` are always pixels per inch (16.16 fixed). @@ -292,18 +410,7 @@ impl ImageResource { } pub(crate) fn read(r: &mut Reader<'_>) -> Result { - let signature = r.array::<4>()?; - if !matches!(&signature, b"8BIM" | b"MeSa" | b"AgHg" | b"PHUT" | b"DCSR") { - return Err(PsdError::InvalidSignature { expected: "8BIM resource", found: signature }); - } - let id = r.u16()?; - let name = read_pascal(r, 2)?; - let len = r.u32()?; - let data = r.bytes_u64(u64::from(len))?.to_vec(); - if len % 2 == 1 { - r.skip(1)?; - } - Ok(ImageResource { signature, id, name, data }) + Ok(ImageResourceView::read(r)?.into_owned()) } pub(crate) fn write(&self, out: &mut Vec) -> Result<()> { diff --git a/crates/raster-codecs/Cargo.toml b/crates/raster-codecs/Cargo.toml index e8d6bc3ba..002012b80 100644 --- a/crates/raster-codecs/Cargo.toml +++ b/crates/raster-codecs/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true workspace = true [dependencies] +base64 = "0.23" craft-image-codecs = { workspace = true } thiserror = { workspace = true } image = { version = "0.25", default-features = false } diff --git a/crates/raster-codecs/src/common.rs b/crates/raster-codecs/src/common.rs index 925935871..666efaa4d 100644 --- a/crates/raster-codecs/src/common.rs +++ b/crates/raster-codecs/src/common.rs @@ -1,14 +1,14 @@ //! Bounded decoding and PNG encoding for common photographic and web image formats. //! -//! This facade is for applications that need a single RGBA pixel buffer regardless of whether -//! the source was PNG, JPEG, BMP, GIF, TIFF, or WebP. Format sniffing and codec dispatch stay -//! inside this crate. Callers choose their own stricter file and image limits. +//! This facade decodes supported images into a bounded straight-alpha RGBA buffer. Format +//! detection and codec dispatch stay inside `craft-image-codecs`; callers choose stricter limits. +use base64::{Engine as _, engine::general_purpose::STANDARD}; use image::RgbaImage; use crate::CodecError; -pub use image::RgbaImage as Pixels; +pub use image::{Rgba as Pixel, RgbaImage as Pixels}; /// Limits applied before allocating the decoded image. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -52,6 +52,24 @@ pub fn encode_png(pixels: &RgbaImage) -> Result, CodecError> { encode(pixels, craft_image_codecs::Format::Png, craft_image_codecs::EncodeOptions::default()) } +/// Encode RGBA pixels as standard Base64 containing PNG bytes, for an inline document image. +pub fn encode_png_base64(pixels: &RgbaImage) -> Result { + encode_png(pixels).map(|bytes| STANDARD.encode(bytes)) +} + +/// Decode standard Base64 containing one PNG into straight-alpha RGBA pixels. +pub fn decode_png_base64(text: &str, limits: DecodeLimits) -> Result { + let max_encoded_bytes = limits.max_file_bytes.div_ceil(3).saturating_mul(4); + if text.len() > max_encoded_bytes { + return Err(CodecError::LimitExceeded(format!("{} Base64 characters exceed max {max_encoded_bytes}", text.len()))); + } + let bytes = STANDARD.decode(text).map_err(|e| CodecError::malformed("PNG Base64", e))?; + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err(CodecError::malformed("PNG Base64", "decoded data has no PNG signature")); + } + decode_rgba(&bytes, limits) +} + /// Encode RGBA pixels as JPEG at the requested quality (1–100). pub fn encode_jpeg(pixels: &RgbaImage, quality: u8) -> Result, CodecError> { if !(1..=100).contains(&quality) { @@ -66,12 +84,18 @@ fn encode(pixels: &RgbaImage, format: craft_image_codecs::Format, options: craft craft_image_codecs::encode(&image, format, &options).map_err(map_codec_error) } -/// Encode pixels according to a destination filename's extension (`png`, `jpg`, or `jpeg`). +/// Encode pixels according to a destination filename's extension. pub fn encode_for_path(path: impl AsRef, pixels: &RgbaImage) -> Result, CodecError> { - let extension = path.as_ref().extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default(); - match craft_image_codecs::from_extension(extension) { + let extension = path.as_ref().extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); + match craft_image_codecs::from_extension(&extension) { Some(craft_image_codecs::Format::Png) => encode_png(pixels), Some(craft_image_codecs::Format::Jpeg) => encode_jpeg(pixels, 90), + Some( + format @ (craft_image_codecs::Format::Bmp + | craft_image_codecs::Format::Gif + | craft_image_codecs::Format::Tiff + | craft_image_codecs::Format::WebP), + ) => encode(pixels, format, craft_image_codecs::EncodeOptions::default()), _ => Err(CodecError::unsupported("image", format!("cannot encode files with the .{extension} extension"))), } } diff --git a/crates/stencilcraft-format/Cargo.toml b/crates/stencilcraft-format/Cargo.toml new file mode 100644 index 000000000..fec3ad3f9 --- /dev/null +++ b/crates/stencilcraft-format/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-stencilcraft-format" +description = "Reader and writer for the versioned StencilCraft JSON document format" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/stencilcraft-format/README.md b/crates/stencilcraft-format/README.md new file mode 100644 index 000000000..2391d3c56 --- /dev/null +++ b/crates/stencilcraft-format/README.md @@ -0,0 +1,23 @@ +# craft-stencilcraft-format + +Reader and writer for the versioned `.stencilcraft` JSON file. The JSON header is +`{"stencilcraft":{"name":"stencilcraft","version":1}}`; the document fields share the +top-level object with the header. `DocumentFields` owns field names, flattening, required fields, +version 1 defaults, and omitted optional members. Its values remain JSON so the consuming app +owns the domain model, geometry validation, and field-to-model mapping. + +The crate does not open files or set a file-size policy. Callers perform bounded file I/O before +calling `File::read`. + +```toml +craft-stencilcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "" } +``` + +`File::read` parses JSON, validates the name and exact supported version, reads `DocumentFields`, +and ignores unknown members for version 1 compatibility. `File::new` and `to_json_pretty` write +the current header and flattened fields. Absent `next-shape-id` defaults to 1 and absent `images` +defaults to an empty list, matching early version 1 files. + +`cargo test -p craft-stencilcraft-format` covers round-trip wire shape, version 1 defaults, +unknown members, wrong and missing headers, unsupported versions, malformed JSON, and missing +required fields. diff --git a/crates/stencilcraft-format/src/lib.rs b/crates/stencilcraft-format/src/lib.rs new file mode 100644 index 000000000..709bf7ac7 --- /dev/null +++ b/crates/stencilcraft-format/src/lib.rs @@ -0,0 +1,414 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +//! Reader and writer for the versioned JSON document format. +//! +//! The file stores a format header under `stencilcraft` and the document's wire fields at the +//! top level: +//! +//! ```json +//! { +//! "stencilcraft": { "name": "stencilcraft", "version": 1 }, +//! "board": { "width_mm": 100.0, "height_mm": 150.0 }, +//! "kerf": { "width_mm": 0.0, "apply_to_exports": false }, +//! "layers": [] +//! } +//! ``` +//! +//! [`DocumentFields`] owns the JSON field names, required and defaulted fields, and the +//! flattening rule. Its values are JSON because the consuming app owns the document model, +//! domain validation, and conversion between that model and the wire fields. The crate does not +//! open files or set a file-size policy; callers handle bounded file I/O. [`File::read_from`] +//! lets callers parse from a reader without first buffering source bytes in a string. + +use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value}; +use std::fmt; +use std::io::Read; + +/// The extension, without the leading dot. +pub const EXTENSION: &str = "stencilcraft"; +/// The format name stored in the header. +pub const FORMAT_NAME: &str = "stencilcraft"; +/// The newest document format version this crate reads and writes. +pub const FORMAT_VERSION: u64 = 1; +/// The top-level member containing the format header. +pub const HEADER_KEY: &str = "stencilcraft"; + +/// Wire fields in the flattened native document object. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DocumentFields { + /// Required millimetre artboard dimensions. + pub board: Value, + /// Required kerf settings. + pub kerf: Value, + /// Required drawing layers. + pub layers: Value, + /// Next document object id; absent in early version 1 files, where it defaults to 1. + #[serde(rename = "next-shape-id", default = "default_next_shape_id")] + pub next_shape_id: u64, + /// Embedded raster images; absent in early version 1 files, where it defaults to empty. + #[serde(default)] + pub images: Vec, + /// Optional canvas page setup, omitted when absent. + #[serde(rename = "board-setup", default, skip_serializing_if = "Option::is_none")] + pub board_setup: Option, + /// Optional pages, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pages: Option, + /// Optional guides, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub guides: Option, + /// Optional grid settings, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grid: Option, + /// Optional snap settings, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snap: Option, + /// Optional groups, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub groups: Option, + /// Optional user metadata, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +fn default_next_shape_id() -> u64 { + 1 +} + +/// A native document and its flattened wire fields. +#[derive(Debug, Clone, PartialEq)] +pub struct File { + /// The fields after the header have been parsed, with app-domain objects still encoded as + /// JSON values. + pub document: DocumentFields, +} + +impl File { + /// Builds a native document from its wire fields. + pub fn new(document: DocumentFields) -> Self { + Self { document } + } + + /// Parses JSON, validates the format header and version, then reads the wire fields. + pub fn read(text: &str) -> Result { + let wire: RawWire = serde_json::from_str(text).map_err(Error::Json)?; + Self::from_wire(wire) + } + + /// Parses JSON from a reader, without first buffering the source bytes in a string. + /// + /// Parsed document fields are still materialized as JSON values. Callers that impose a + /// total file-size policy should enforce it on the reader before calling this method. + pub fn read_from(reader: impl Read) -> Result { + let wire: RawWire = serde_json::from_reader(reader).map_err(Error::Json)?; + Self::from_wire(wire) + } + + fn from_wire(wire: RawWire) -> Result { + let header = wire.fields.get(HEADER_KEY).and_then(Value::as_object).ok_or(Error::NotStencilCraft)?; + if header.get("name").and_then(Value::as_str) != Some(FORMAT_NAME) { + return Err(Error::NotStencilCraft); + } + let Some(version) = header.get("version").and_then(Value::as_u64) else { + return Err(Error::NotStencilCraft); + }; + if version != FORMAT_VERSION { + return Err(Error::Version { found: version, supported: FORMAT_VERSION }); + } + let raw: RawFile = serde_json::from_value(Value::Object(wire.fields)).map_err(|source| Error::Document { source })?; + Ok(Self { document: raw.document }) + } + + /// Writes the current format header and flattened wire fields as indented JSON. + pub fn to_json_pretty(&self) -> Result { + let raw = RawFileRef { header: HeaderRef { name: FORMAT_NAME, version: FORMAT_VERSION }, document: &self.document }; + let value = serde_json::to_value(raw).map_err(Error::Json)?; + serde_json::to_string_pretty(&value).map_err(Error::Json) + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct Header { + name: String, + version: u64, +} + +#[derive(Debug, Deserialize)] +struct RawFile { + #[serde(rename = "stencilcraft")] + _header: Header, + #[serde(flatten)] + document: DocumentFields, +} + +/// A top-level streaming visitor that stores only fields owned by this wire schema. +/// +/// Values are kept as `Value` because consumers own their document models. Unknown fields are +/// skipped, duplicate fields retain serde_json's last-value-wins behavior, and header checks run +/// only after the full JSON input has been parsed. +struct RawWire { + fields: Map, +} + +impl<'de> Deserialize<'de> for RawWire { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct RawWireVisitor; + + impl<'de> Visitor<'de> for RawWireVisitor { + type Value = RawWire; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value containing a StencilCraft document") + } + + fn visit_map(self, mut access: A) -> Result + where + A: MapAccess<'de>, + { + let mut fields = Map::new(); + while let Some(key) = access.next_key::()? { + if is_wire_field(&key) { + fields.insert(key, access.next_value()?); + } else { + let _ = access.next_value::()?; + } + } + Ok(RawWire { fields }) + } + + fn visit_seq(self, mut access: A) -> Result + where + A: SeqAccess<'de>, + { + while access.next_element::()?.is_some() {} + Ok(RawWire { fields: Map::new() }) + } + + fn visit_bool(self, _value: bool) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_i64(self, _value: i64) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_u64(self, _value: u64) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_f64(self, _value: f64) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_str(self, _value: &str) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_string(self, _value: String) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + } + + deserializer.deserialize_any(RawWireVisitor) + } +} + +fn is_wire_field(name: &str) -> bool { + matches!( + name, + HEADER_KEY + | "board" + | "kerf" + | "layers" + | "next-shape-id" + | "images" + | "board-setup" + | "pages" + | "guides" + | "grid" + | "snap" + | "groups" + | "meta" + ) +} + +#[derive(Serialize)] +struct HeaderRef<'a> { + name: &'a str, + version: u64, +} + +#[derive(Serialize)] +struct RawFileRef<'a> { + #[serde(rename = "stencilcraft")] + header: HeaderRef<'a>, + #[serde(flatten)] + document: &'a DocumentFields, +} + +/// A file was malformed, belongs to another format, or uses an unsupported version. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The input is not valid JSON, or the file could not be serialized. + #[error(transparent)] + Json(#[from] serde_json::Error), + /// The input has no valid StencilCraft format header. + #[error("not a stencilcraft file")] + NotStencilCraft, + /// The input names a version other than [`FORMAT_VERSION`]. + #[error("format version {found}, this build reads {supported}")] + Version { found: u64, supported: u64 }, + /// The JSON envelope is valid, but a required or typed wire field is malformed. + #[error("{source}")] + Document { source: serde_json::Error }, +} + +#[cfg(test)] +mod tests { + use std::io::{self, Cursor, Read}; + + use serde_json::{Value, json}; + + use super::{DocumentFields, Error, FORMAT_VERSION, File}; + + fn fields() -> DocumentFields { + DocumentFields { + board: json!({"width_mm": 100.0, "height_mm": 150.0}), + kerf: json!({"width_mm": 0.0, "apply_to_exports": false}), + layers: json!([]), + next_shape_id: 1, + images: Vec::new(), + board_setup: None, + pages: None, + guides: None, + grid: None, + snap: None, + groups: None, + meta: None, + } + } + + #[test] + fn writes_flattened_schema_with_the_current_header_and_reads_it() -> Result<(), Error> { + let file = File::new(fields()); + let text = file.to_json_pretty()?; + assert!(text.starts_with("{\n \"board\":")); + let value: Value = serde_json::from_str(&text)?; + assert_eq!(value["stencilcraft"]["name"], "stencilcraft"); + assert_eq!(value["stencilcraft"]["version"], FORMAT_VERSION); + assert_eq!(value["board"], file.document.board); + assert_eq!(value["layers"], file.document.layers); + assert!(value.get("board-setup").is_none()); + assert_eq!(File::read(&text)?, file); + Ok(()) + } + + #[test] + fn reads_version_one_defaults_and_ignores_unknown_fields() -> Result<(), Error> { + let text = r#"{"stencilcraft":{"name":"stencilcraft","version":1},"board":{},"kerf":{},"layers":[],"future-field":true}"#; + let file = File::read(text)?; + assert_eq!(file.document.next_shape_id, 1); + assert!(file.document.images.is_empty()); + Ok(()) + } + + #[test] + fn refuses_missing_or_mismatched_headers() { + for text in [ + r#"{"board":{},"kerf":{},"layers":[]}"#, + r#"{"stencilcraft":{"name":"other","version":1}}"#, + r#"{"stencilcraft":{"name":"stencilcraft","version":"1"}}"#, + ] { + assert!(matches!(File::read(text), Err(Error::NotStencilCraft))); + } + } + + #[test] + fn scalar_roots_are_not_documents() { + for text in ["false", "-1", "1", "1.5", "\"scalar\"", "null"] { + assert!(matches!(File::read(text), Err(Error::NotStencilCraft))); + } + assert!(matches!(File::read_from(Cursor::new(br#""scalar""#)), Err(Error::NotStencilCraft))); + } + + #[test] + fn refuses_versions_other_than_the_current_version() { + assert!(matches!(File::read(r#"{"stencilcraft":{"name":"stencilcraft","version":2}}"#), Err(Error::Version { found: 2, supported: 1 }))); + } + + #[test] + fn reader_preserves_duplicate_key_and_error_precedence_behavior() -> Result<(), Error> { + let duplicate = r#"{"stencilcraft":{"name":"wrong","version":1},"stencilcraft":{"name":"stencilcraft","version":1},"board":{"first":true},"board":{"width_mm":100,"height_mm":150},"kerf":{},"layers":[]}"#; + let file = File::read_from(Cursor::new(duplicate))?; + assert_eq!(file.document.board, json!({"width_mm":100,"height_mm":150})); + + let malformed_after_bad_header = Cursor::new(br#"{"stencilcraft":{"name":"other","version":1},"board": "#); + assert!(matches!(File::read_from(malformed_after_bad_header), Err(Error::Json(_)))); + assert!(matches!(File::read_from(Cursor::new(b"[]")), Err(Error::NotStencilCraft))); + Ok(()) + } + + #[test] + fn reader_accepts_large_input_in_short_reads() -> Result<(), Error> { + struct ShortReads(R); + impl Read for ShortReads { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let length = buffer.len().min(1024); + self.0.read(&mut buffer[..length]) + } + } + + let text = format!( + r#"{{"stencilcraft":{{"name":"stencilcraft","version":1}},"board":{{}},"kerf":{{}},"layers":[],"future-field":"{}"}}"#, + "x".repeat(8 * 1024 * 1024) + ); + let file = File::read_from(ShortReads(Cursor::new(text.as_bytes())))?; + assert_eq!(file.document.layers, json!([])); + Ok(()) + } + + #[test] + fn separates_json_errors_from_wire_schema_errors() { + assert!(matches!(File::read("{"), Err(Error::Json(_)))); + assert!(matches!(File::read(r#"{"stencilcraft":{"name":"stencilcraft","version":1}}"#), Err(Error::Document { .. }))); + } +} diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index b3cb62488..8cd1848b2 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -117,7 +117,7 @@ impl Renderer<'_, P> { // release outside the window floats. let gone = !has_pointer && !touch_ended; let floats_outside = !self.style.float_on_empty_drop && drag.gesture != GroupGesture::Window; - if !(released && !gone) && !(gone && floats_outside) { + if !(released && !gone || gone && floats_outside) { return; } let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; diff --git a/crates/video/src/hevc/decode/filter.rs b/crates/video/src/hevc/decode/filter.rs index d5b94161d..69f936bc1 100644 --- a/crates/video/src/hevc/decode/filter.rs +++ b/crates/video/src/hevc/decode/filter.rs @@ -522,10 +522,7 @@ fn sao_edge_ctb( return false; } } - if !pic.pps.loop_filter_across_tiles && pic.layout.tile_id[pic.layout.rs_to_ts[nrs] as usize] != pic.layout.tile_id[cur_ts as usize] { - return false; - } - true + pic.pps.loop_filter_across_tiles || pic.layout.tile_id[pic.layout.rs_to_ts[nrs] as usize] == pic.layout.tile_id[cur_ts as usize] }; // avail[dy][dx]: the CTB at (rx + dx - 1, ry + dy - 1) may be used. let (rx, ry) = (rs % pic.wctb, rs / pic.wctb); diff --git a/crates/video/src/vp8/decoder.rs b/crates/video/src/vp8/decoder.rs index 40dcd2fa9..d08b69f3a 100644 --- a/crates/video/src/vp8/decoder.rs +++ b/crates/video/src/vp8/decoder.rs @@ -115,6 +115,7 @@ pub struct Decoder { sign_bias: [bool; 4], color_space: u8, stats: DecodeStats, + max_luma_samples: usize, } impl Default for Decoder { @@ -125,6 +126,14 @@ impl Default for Decoder { impl Decoder { pub fn new() -> Decoder { + Self::with_max_luma_samples(MAX_LUMA_SAMPLES) + } + + /// Set the maximum padded luma samples retained for one frame. + /// + /// The default is 36 million. Consumers that historically supported a larger bound can + /// choose it explicitly while retaining the same checked allocation limit. + pub fn with_max_luma_samples(max_luma_samples: usize) -> Decoder { Decoder { ctx: EntropyContext::default(), seg: Segmentation::default(), @@ -138,6 +147,7 @@ impl Decoder { sign_bias: [false; 4], color_space: 0, stats: DecodeStats::default(), + max_luma_samples, } } @@ -178,7 +188,7 @@ impl Decoder { let (w, h) = (usize::from(tag.width), usize::from(tag.height)); let (mb_w, mb_h) = (w.div_ceil(16), h.div_ceil(16)); let samples = mb_w.checked_mul(16).and_then(|w| w.checked_mul(mb_h)).and_then(|h| h.checked_mul(16)); - ensure!(samples.is_some_and(|n| n <= MAX_LUMA_SAMPLES), "frame size {}x{} exceeds the decoder memory limit", tag.width, tag.height); + ensure!(samples.is_some_and(|n| n <= self.max_luma_samples), "frame size {}x{} exceeds the decoder memory limit", tag.width, tag.height); if w != self.width || h != self.height { self.width = w; self.height = h; @@ -1032,6 +1042,14 @@ mod segment_map_tests { assert_eq!((d.width, d.height), (8192, 4320)); } + #[test] + fn caller_may_preserve_a_larger_existing_frame_limit() { + let mut decoder = Decoder::with_max_luma_samples(1 << 26); + assert!(decoder.begin_key_frame(&key_tag(8192, 8192)).is_ok()); + let mut default = Decoder::new(); + assert!(default.begin_key_frame(&key_tag(8192, 8192)).is_err()); + } + #[test] fn tiny_inter_partition_is_rejected_as_truncated() { let mut d = Decoder::new(); diff --git a/crates/xls/src/read.rs b/crates/xls/src/read.rs index 529a36e11..8fdf5e473 100644 --- a/crates/xls/src/read.rs +++ b/crates/xls/src/read.rs @@ -598,7 +598,7 @@ fn resolve_pending( st.unsupported(TOO_MUCH); continue; } - if !st.formula_at(p.pos).is_some_and(|f| f.text.chars().count() <= MAX_SHARED_TEMPLATE) { + if st.formula_at(p.pos).is_none_or(|f| f.text.chars().count() > MAX_SHARED_TEMPLATE) { failed.insert(p.anchor); } } else { @@ -902,11 +902,8 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report.warn(Warning::ConditionalFormattingSkipped); } }, - 0x01B1 => { - if conditional_format(&mut c, st).is_none() { - report.warn(Warning::ConditionalFormattingSkipped); - } - } + 0x01B1 if conditional_format(&mut c, st).is_none() => report.warn(Warning::ConditionalFormattingSkipped), + 0x01B1 => {} 0x0879 => report.warn(Warning::ConditionalFormatting12), // A short DVal starts no rules. 0x01B2 => st.dv_left = crate::dv::dval(&mut c).unwrap_or(0),