Merge remote-tracking branch 'upstream/main' into HEAD

# Conflicts:
#	Cargo.lock
#	crates/raster-codecs/Cargo.toml
#	crates/raster-codecs/src/common.rs
This commit is contained in:
Dylan M. Taylor committed 2026-11-13 03:30:22 -05:00
commit 4d709b5e0e
43 files changed
+1695 -176

No files matched your search

+14
View File
@@ -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
+60 -58
View File
@@ -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
Generated
+27 -9
View File
@@ -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]]
+2
View File
@@ -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" }
+1
View File
@@ -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
+1 -1
View File
@@ -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);
+4 -4
View File
@@ -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;
}
}
+1 -1
View File
@@ -443,7 +443,7 @@ fn read_axes<'a>(c: &mut Chart, pa: &'a El, primary_val: &Option<String>) -> (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")));
+3 -3
View File
@@ -768,7 +768,7 @@ fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
/// 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<u8> {
}
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(&[]);
}
+2 -3
View File
@@ -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<usize> = 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]
-3
View File
@@ -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:
//! <https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-3F0380A5-1C15-464D-BC66-2C5F094BCFB9.htm>
//! <https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-FC1C3C69-DBC2-49E4-893A-000D6538C0FE.htm>
@@ -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] {
+1 -1
View File
@@ -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 _;
+1 -1
View File
@@ -471,7 +471,7 @@ impl Loaded {
/// notes why not otherwise.
fn add(&mut self, name: &str, overrides: Option<Overrides>) {
let shown = || name.chars().take(MAX_SET_NAME).collect::<String>();
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() });
+35 -8
View File
@@ -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<Node> {
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::<Result<_>>()?;
}
Ok(out)
}
/// A node's own attributes, without its children.
fn node_fields(el: &El, is_mask: bool) -> Result<Node> {
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<Node> {
let read = matches!(node_type.as_str(), "paintlayer" | "grouplayer" | "transparencymask");
let lenient = |v: Result<f64>, 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<Node> {
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::<Result<_>>()?;
}
Ok(out)
}
/// The `<layers>` of `el`. Recursion is bounded by [`MAX_XML_DEPTH`].
/// The `<layers>` 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<Vec<Node>> {
struct Frame<'a> {
node: Node,
rest: std::vec::IntoIter<&'a El>,
}
let mut root = children(el, "layers", "layer").collect::<Vec<_>>().into_iter();
let mut stack: Vec<Frame> = 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::<Result<_>>()?;
stack.push(Frame { node: built, rest: children(child, "layers", "layer").collect::<Vec<_>>().into_iter() });
}
Ok(out)
}
+6 -6
View File
@@ -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))];
+15
View File
@@ -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 }
+593
View File
@@ -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 `<materialx>`.
#[error("the XML root element is not <materialx>")]
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<f32>, colorspace: Option<String> },
/// 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<Value>,
/// First valid image tiling value, or `[1, 1]`.
pub uv_tiling: [f32; 2],
/// Unsupported or ignored constructs encountered while resolving values.
pub warnings: Vec<String>,
}
#[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<Self, Error> {
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 `<materialx>` element.
pub fn from_root(root: El) -> Result<Self, Error> {
if root.name.rsplit(':').next() != Some("materialx") {
return Err(Error::Root);
}
Ok(Document { root })
}
/// The root `<materialx>` element.
pub fn root(&self) -> &El {
&self.root
}
/// The root `<materialx>` 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<Item = &El> {
self.root.children("nodegraph")
}
/// Outputs in a named nodegraph, in document order.
pub fn outputs(&self, graph: &str) -> impl Iterator<Item = &El> {
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<Material, Error> {
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<f32> {
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<Vec<f32>> {
let nums: Option<Vec<f32>> = text.split(',').map(|part| part.trim().parse::<f32>().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<String, &El>,
outputs: &[(String, String)],
hops: usize,
warnings: &mut Vec<String>,
) -> Option<Found> {
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#"<materialx version="1.39"><surfacematerial name="mat"/></materialx>"#).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#"<materialx><nodegraph name="g"><output name="second" nodename="b"/><output name="first" nodename="a"/></nodegraph></materialx>"#,
)
.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#"<materialx><nodegraph name="g"><output name="o" nodename="first"/><output name="o" nodename="last"/></nodegraph></materialx>"#,
)
.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"<root/>").err(), Some(Error::Root));
assert!(Document::parse(b"<!DOCTYPE materialx [<!ENTITY x 'y'>]><materialx/>").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#"<materialx fileprefix="tex/">
<open_pbr_surface name="S" type="surfaceshader">
<input name="base_color" type="color3" nodename="C"/>
<input name="geometry_normal" type="vector3" nodename="N"/>
<input name="emission_color" type="color3" value="1,0.5,0.25"/>
<input name="emission_luminance" type="float" value="1"/>
</open_pbr_surface>
<tiledimage name="C" type="color3"><input name="file" value="albedo.png"/><input name="uvtiling" value="2,4"/></tiledimage>
<normalmap name="N" type="vector3"><input name="in" nodegraph="g"/></normalmap>
<nodegraph name="g"><image name="normal" type="vector3"><input name="file" value="normal.png"/></image><image name="other" type="vector3"><input name="file" value="wrong.png"/></image><output name="first" nodename="normal"/><output name="second" nodename="other"/></nodegraph>
<displacement name="D" type="displacementshader"><input name="displacement" value="0.25"/></displacement>
<surfacematerial name="M" type="material"><input name="surfaceshader" nodename="S"/><input name="displacementshader" nodename="D"/></surfacematerial>
</materialx>"#).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\""));
}
}
+13
View File
@@ -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<TextureRef> {
let v = v?;
let texture = arr(j, "textures").get(idx(v, "index")?)?;
+6
View File
@@ -621,6 +621,9 @@ pub struct Scene {
pub meshes: Vec<Mesh>,
pub materials: Vec<Material>,
pub images: Vec<Image>,
/// 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<TextureRef>,
/// Every format fills this: a format without a hierarchy gets one root node per mesh.
pub nodes: Vec<Node>,
/// 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 =
+5
View File
@@ -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());
+24
View File
@@ -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");
+32 -5
View File
@@ -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<Vec<u8>> {
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<Vec<u8>> {
hatch_tile_inner(style, fg, bg, true)
}
fn hatch_tile_inner(style: u32, fg: [u8; 4], bg: Option<[u8; 4]>, gdi_plus: bool) -> Option<Vec<u8>> {
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]) });
}
+5 -3
View File
@@ -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);
+27 -2
View File
@@ -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]
+2 -1
View File
@@ -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();
+48 -13
View File
@@ -189,28 +189,63 @@ fn props(el: &El) -> Result<Props> {
})
}
/// 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<Vec<Node>> {
struct Frame<'a> {
props: Props,
isolation: Option<String>,
children: Vec<Node>,
rest: std::vec::IntoIter<&'a El>,
}
let mut root = el.elements().collect::<Vec<_>>().into_iter();
let mut frames: Vec<Frame> = 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::<Vec<_>>().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)
}
+3 -3
View File
@@ -8,10 +8,10 @@ fn scenes() -> Vec<Scene> {
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 {
+2 -2
View File
@@ -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::<Vec<_>>().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(&reg).ends_with(" TB124,4600,3600,0,0"));
reg.search = false;
assert_eq!(ascii(&reg), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB55,1 TB23,4600,3600");
assert_eq!(ascii(&reg), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB54,0,0 TB55,1 TB23,4600,3600");
}
#[test]
+11 -5
View File
@@ -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.
+1 -5
View File
@@ -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)]
+1 -1
View File
@@ -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 {
+118 -4
View File
@@ -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<Self> {
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::<Result<Vec<_>>>().unwrap();
assert_eq!(borrowed.iter().map(|r| (*r).into_owned()).collect::<Vec<_>>(), 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::<Result<Vec<_>>>().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() {
+2 -2
View File
@@ -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;
+120 -13
View File
@@ -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<u8>,
}
/// 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<Self> {
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<ImageResourceView<'a>>;
fn next(&mut self) -> Option<Self::Item> {
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<usize>) {
// 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<Self> {
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<u8>) -> Result<()> {
+1
View File
@@ -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 }
+31 -7
View File
@@ -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<Vec<u8>, 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<String, CodecError> {
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<RgbaImage, CodecError> {
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<Vec<u8>, 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<std::path::Path>, pixels: &RgbaImage) -> Result<Vec<u8>, 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"))),
}
}
+16
View File
@@ -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 }
+23
View File
@@ -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 = "<commit>" }
```
`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.
+414
View File
@@ -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<Value>,
/// Optional canvas page setup, omitted when absent.
#[serde(rename = "board-setup", default, skip_serializing_if = "Option::is_none")]
pub board_setup: Option<Value>,
/// Optional pages, omitted when absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pages: Option<Value>,
/// Optional guides, omitted when absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guides: Option<Value>,
/// Optional grid settings, omitted when absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grid: Option<Value>,
/// Optional snap settings, omitted when absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snap: Option<Value>,
/// Optional groups, omitted when absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub groups: Option<Value>,
/// Optional user metadata, omitted when absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Value>,
}
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<Self, Error> {
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<Self, Error> {
let wire: RawWire = serde_json::from_reader(reader).map_err(Error::Json)?;
Self::from_wire(wire)
}
fn from_wire(wire: RawWire) -> Result<Self, Error> {
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<String, Error> {
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<String, Value>,
}
impl<'de> Deserialize<'de> for RawWire {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<A>(self, mut access: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut fields = Map::new();
while let Some(key) = access.next_key::<String>()? {
if is_wire_field(&key) {
fields.insert(key, access.next_value()?);
} else {
let _ = access.next_value::<IgnoredAny>()?;
}
}
Ok(RawWire { fields })
}
fn visit_seq<A>(self, mut access: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
while access.next_element::<IgnoredAny>()?.is_some() {}
Ok(RawWire { fields: Map::new() })
}
fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(RawWire { fields: Map::new() })
}
fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(RawWire { fields: Map::new() })
}
fn visit_u64<E>(self, _value: u64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(RawWire { fields: Map::new() })
}
fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(RawWire { fields: Map::new() })
}
fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(RawWire { fields: Map::new() })
}
fn visit_string<E>(self, _value: String) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(RawWire { fields: Map::new() })
}
fn visit_unit<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(RawWire { fields: Map::new() })
}
fn visit_none<E>(self) -> Result<Self::Value, E>
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>(R);
impl<R: Read> Read for ShortReads<R> {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
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 { .. })));
}
}
+1 -1
View File
@@ -117,7 +117,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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 };
+1 -4
View File
@@ -522,10 +522,7 @@ fn sao_edge_ctb<S: Sample>(
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);
+19 -1
View File
@@ -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();
+3 -6
View File
@@ -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),