mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
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:
43 files changed
+1695
-176
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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]]
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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(&[]);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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 _;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))];
|
||||
|
||||
@@ -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 }
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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")?)?;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]) });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(®).ends_with(" TB124,4600,3600,0,0"));
|
||||
reg.search = false;
|
||||
assert_eq!(ascii(®), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB55,1 TB23,4600,3600");
|
||||
assert_eq!(ascii(®), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB54,0,0 TB55,1 TB23,4600,3600");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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<()> {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user