From 16501f23313daf5a313a0197521ab2b098f02a2f Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 9 Aug 2026 03:42:18 +0000 Subject: [PATCH] Implement optional JPEG2000 codec adapter (#40) --- .gitea/workflows/jpeg2000.yml | 53 ++ .gitignore | 2 + Cargo.lock | 21 + Cargo.toml | 1 + README.md | 9 + RUSTREWRITE.md | 2 +- api/SHIM-COVERAGE.md | 2 +- crates/libremetaverse-imaging/Cargo.toml | 5 + crates/libremetaverse-imaging/src/codec.rs | 298 +++++++ crates/libremetaverse-imaging/src/jpeg2000.rs | 707 ++++++++++++++++ crates/libremetaverse-imaging/src/lib.rs | 13 +- crates/libremetaverse-openjpeg/Cargo.toml | 22 + crates/libremetaverse-openjpeg/README.md | 38 + crates/libremetaverse-openjpeg/build.rs | 16 + crates/libremetaverse-openjpeg/src/ffi.rs | 279 +++++++ crates/libremetaverse-openjpeg/src/lib.rs | 770 ++++++++++++++++++ .../src/model.rs | 2 +- crates/libremetaverse-types/src/compat.rs | 101 ++- crates/libremetaverse/Cargo.toml | 1 + crates/libremetaverse/src/generated.rs | 47 +- crates/libremetaverse/src/j2k.rs | 435 ++++++++++ crates/libremetaverse/src/lib.rs | 1 + tests/red-suite-baseline.json | 2 +- tools/generate_api_shims.py | 2 + 24 files changed, 2776 insertions(+), 53 deletions(-) create mode 100644 .gitea/workflows/jpeg2000.yml create mode 100644 crates/libremetaverse-imaging/src/codec.rs create mode 100644 crates/libremetaverse-imaging/src/jpeg2000.rs create mode 100644 crates/libremetaverse-openjpeg/Cargo.toml create mode 100644 crates/libremetaverse-openjpeg/README.md create mode 100644 crates/libremetaverse-openjpeg/build.rs create mode 100644 crates/libremetaverse-openjpeg/src/ffi.rs create mode 100644 crates/libremetaverse-openjpeg/src/lib.rs create mode 100644 crates/libremetaverse/src/j2k.rs diff --git a/.gitea/workflows/jpeg2000.yml b/.gitea/workflows/jpeg2000.yml new file mode 100644 index 0000000..b7625d2 --- /dev/null +++ b/.gitea/workflows/jpeg2000.yml @@ -0,0 +1,53 @@ +name: JPEG 2000 feature + +on: + push: + pull_request: + +jobs: + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install OpenJPEG 2.5.4 + run: | + sudo apt-get update + sudo apt-get install --yes build-essential cmake pkg-config + git clone --branch v2.5.4 --depth 1 https://github.com/uclouvain/openjpeg.git /tmp/openjpeg-2.5.4 + cmake -S /tmp/openjpeg-2.5.4 -B /tmp/openjpeg-build -DBUILD_CODEC=OFF -DBUILD_SHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release + cmake --build /tmp/openjpeg-build --parallel + sudo cmake --install /tmp/openjpeg-build + sudo ldconfig + - name: Build and test optional feature + run: | + cargo test -p libremetaverse-imaging --features jpeg2000 + cargo check -p libremetaverse --features jpeg2000 + + macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install OpenJPEG + run: brew install openjpeg pkg-config + - name: Build and test optional feature + run: | + cargo test -p libremetaverse-imaging --features jpeg2000 + cargo check -p libremetaverse --features jpeg2000 + + windows: + runs-on: windows-latest + env: + VCPKGRS_DYNAMIC: "1" + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install OpenJPEG + shell: pwsh + run: vcpkg install openjpeg:x64-windows + - name: Build and test optional feature + shell: pwsh + run: | + cargo test -p libremetaverse-imaging --features jpeg2000 + cargo check -p libremetaverse --features jpeg2000 diff --git a/.gitignore b/.gitignore index 6b5612e..999936e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /target/ +/crates/libremetaverse-openjpeg/target/ +/crates/libremetaverse-openjpeg/Cargo.lock /tests/api-compile/target/ .env .env.* diff --git a/Cargo.lock b/Cargo.lock index fbc1161..c19b87a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,7 @@ dependencies = [ name = "libremetaverse-imaging" version = "0.0.1" dependencies = [ + "libremetaverse-openjpeg", "libremetaverse-types", ] @@ -236,6 +237,14 @@ dependencies = [ "libremetaverse-types", ] +[[package]] +name = "libremetaverse-openjpeg" +version = "0.0.1" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "libremetaverse-prim-mesher" version = "0.0.1" @@ -359,6 +368,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -510,6 +525,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index a5a1204..afaf8a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] resolver = "3" +exclude = ["crates/libremetaverse-openjpeg"] members = [ "crates/libremetaverse-types", "crates/libremetaverse-structured-data", diff --git a/README.md b/README.md index 0814078..7133a35 100644 --- a/README.md +++ b/README.md @@ -141,5 +141,14 @@ Legacy and DX10 BC1 through BC5 decoding is built in. The default `dds-bc67` feature adds BC6H/BC7 decoding through the safe, pure-Rust `bcdec_rs` crate; disabling default features removes that optional dependency and makes those two formats return a typed unsupported-format error. +JPEG 2000 is available through the opt-in `jpeg2000` feature. It provides raw +J2K and JP2 lossless/lossy encoding and decoding, preserves one through five +component order, precision, signedness, and alpha metadata until explicit byte +conversion, and bounds encoded input, output, dimensions, and decoded samples. +The compatibility encoder reproduces CoreJ2K's four-plane RGB/alpha view, +including its alpha-only and opaque-alpha substitutions. See +[`crates/libremetaverse-openjpeg/README.md`](crates/libremetaverse-openjpeg/README.md) +for OpenJPEG prerequisites, licensing, and deployment details. Default builds +do not discover or link OpenJPEG. The controlled audit aggregates every expected failure by standardized C# member ID and rejects unrelated fixture, assertion, compile, or symbol errors. diff --git a/RUSTREWRITE.md b/RUSTREWRITE.md index c087e0a..3799c23 100644 --- a/RUSTREWRITE.md +++ b/RUSTREWRITE.md @@ -413,7 +413,7 @@ MSRV, features, and licenses at adoption time. | rate limiting | [`governor` 0.10.4](https://crates.io/crates/governor/0.10.4) | Candidate for caps categories. First reproduce burst/refill/cancellation behavior with deterministic clock tests. | | compression and tar archives | [`flate2` 1.1.9](https://crates.io/crates/flate2/1.1.9), [`tar` 0.4.46](https://crates.io/crates/tar/0.4.46) | Enforce decompressed-size, path traversal, and entry-count limits on untrusted OAR data. | | XML and URL | [`quick-xml` 0.41.0](https://crates.io/crates/quick-xml/0.41.0), [`url` 2.5.8](https://crates.io/crates/url/2.5.8), [`base64` 0.23.1](https://crates.io/crates/base64/0.23.1) | Streaming XML for LLSD/login; retain exact URL escaping behavior with fixtures. | -| CoreJ2K 2.3.3.91 | [`jpeg2k` 0.10.1](https://crates.io/crates/jpeg2k/0.10.1) | Defaults to OpenJPEG/native bindings. Keep behind a codec trait and feature; test channel order, alpha, dimensions, discard levels, and malformed input. | +| CoreJ2K 2.3.3.91 | system [OpenJPEG](https://www.openjpeg.org/) 2.5.4+ through the private `libremetaverse-openjpeg` adapter | Opt-in `jpeg2000` feature; checked-in minimal bindings avoid a libclang build dependency. Bounded memory streams and header validation isolate native code. Golden tests cover channel order, alpha, 16-bit precision, dimensions, discard levels, lossless/lossy modes, and malformed input. | | SkiaSharp 4.150.1 | [`skia-safe` 0.99.0](https://crates.io/crates/skia-safe/0.99.0) | MSRV 1.85 and native/binary-cache build. Optional adapter only; core image APIs must not leak Skia types. | | Pfim 0.11.4 | [`image` 0.25.10](https://crates.io/crates/image/0.25.10), [`ddsfile` 0.6.0](https://crates.io/crates/ddsfile/0.6.0) | `image` covers TGA and common DDS decoding; `ddsfile` exposes DDS container details. Golden files decide whether both are needed. | | OggVorbisEncoder 1.2.2 | [`vorbis_rs` 0.5.6](https://crates.io/crates/vorbis_rs/0.5.6) | BSD-3-Clause, MSRV 1.82, backed by C libraries. Feature-gate native audio encoding. | diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index 3bbbcbc..e3bbfae 100644 --- a/api/SHIM-COVERAGE.md +++ b/api/SHIM-COVERAGE.md @@ -4,7 +4,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand. | Assembly | Types | Members | Status | |---|---:|---:|---| -| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 1 type / 3 members; remaining surface is callable failure-only shims | +| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 3 types / 7 members; remaining surface is callable failure-only shims | | `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain | | `LibreMetaverse.Imaging.Skia` | 1 | 3 | callable failure-only shim | | `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim | diff --git a/crates/libremetaverse-imaging/Cargo.toml b/crates/libremetaverse-imaging/Cargo.toml index 5a00365..b11d743 100644 --- a/crates/libremetaverse-imaging/Cargo.toml +++ b/crates/libremetaverse-imaging/Cargo.toml @@ -7,8 +7,13 @@ license.workspace = true repository.workspace = true description = "Texture codec abstractions for the MetaCrate LibreMetaverse rewrite" +[features] +default = [] +jpeg2000 = ["dep:libremetaverse-openjpeg"] + [dependencies] libremetaverse-types = { path = "../libremetaverse-types" } +libremetaverse-openjpeg = { path = "../libremetaverse-openjpeg", optional = true } [lints] workspace = true diff --git a/crates/libremetaverse-imaging/src/codec.rs b/crates/libremetaverse-imaging/src/codec.rs new file mode 100644 index 0000000..4592f9b --- /dev/null +++ b/crates/libremetaverse-imaging/src/codec.rs @@ -0,0 +1,298 @@ +//! Project-owned boundaries for JPEG 2000 codec types exposed by the C# API. + +use crate::{DEFAULT_MAX_PIXELS, Error, ManagedImage}; +use std::any::Any; +use std::fmt::Debug; +use std::marker::PhantomData; + +/// A type-erased decoded image returned by an image creator. +pub trait IImage: Any + Debug + Send + Sync { + /// Returns the concrete image wrapper for checked downcasting. + fn as_any(&self) -> &dyn Any; + + /// Returns the already-created managed image when this is a compatible + /// decode target. + fn as_managed_image(&self) -> Option<&ManagedImage> { + None + } +} + +/// Marker corresponding to `CoreJ2K`'s image-creator boundary. +pub trait IImageCreator: Send + Sync {} + +/// Typed marker corresponding to `CoreJ2K`'s generic image creator. +#[derive(Clone, Copy, Debug, Default)] +pub struct ImageCreator(pub PhantomData); + +/// One decoded JPEG 2000 component at its original integer precision. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterleavedComponent { + precision: u8, + signed: bool, + alpha: bool, + samples: Vec, +} + +impl InterleavedComponent { + /// Creates a checked component without reducing its sample precision. + /// + /// # Errors + /// + /// Returns [`Error::Argument`] for precision outside 1 through 31 bits or + /// for samples outside the declared signed or unsigned range. + pub fn new(precision: u8, signed: bool, alpha: bool, samples: Vec) -> Result { + if !(1..=31).contains(&precision) { + return Err(Error::Argument); + } + let (minimum, maximum) = component_range(precision, signed); + if samples + .iter() + .any(|sample| i64::from(*sample) < minimum || i64::from(*sample) > maximum) + { + return Err(Error::Argument); + } + Ok(Self { + precision, + signed, + alpha, + samples, + }) + } + + /// Declared component precision in bits. + #[must_use] + pub const fn precision(&self) -> u8 { + self.precision + } + + /// Whether component samples use signed representation. + #[must_use] + pub const fn is_signed(&self) -> bool { + self.signed + } + + /// Whether the container marks this component as alpha. + #[must_use] + pub const fn is_alpha(&self) -> bool { + self.alpha + } + + /// Original integer samples, in top-left row-major order. + #[must_use] + pub fn samples(&self) -> &[i32] { + &self.samples + } +} + +/// A bounded decoded image retaining component precision and order. +/// +/// The historical external type calls this representation interleaved, while +/// its public conversion API is component-oriented. Keeping the components in +/// separate owned planes avoids a second full-image allocation and retains the +/// original JPEG 2000 integer samples until byte conversion is requested. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterleavedImage { + width: i32, + height: i32, + components: Vec, +} + +impl InterleavedImage { + /// Creates a checked decoded component image. + /// + /// # Errors + /// + /// Returns [`Error::Argument`] for invalid/over-limit dimensions, zero or + /// more than five components, or a plane whose size differs from + /// `width * height`. + pub fn new( + width: i32, + height: i32, + components: Vec, + ) -> Result { + let pixels = checked_pixels(width, height)?; + if components.is_empty() + || components.len() > 5 + || components + .iter() + .any(|component| component.samples.len() != pixels) + { + return Err(Error::Argument); + } + pixels + .checked_mul(components.len()) + .filter(|samples| *samples <= DEFAULT_MAX_PIXELS * 5) + .ok_or(Error::Argument)?; + Ok(Self { + width, + height, + components, + }) + } + + /// Decoded width after any discard-level reduction. + #[must_use] + pub const fn width(&self) -> i32 { + self.width + } + + /// Decoded height after any discard-level reduction. + #[must_use] + pub const fn height(&self) -> i32 { + self.height + } + + /// Number of decoded components in codestream order. + #[must_use] + pub fn number_of_components(&self) -> usize { + self.components.len() + } + + /// Component metadata and original samples. + #[must_use] + pub fn components(&self) -> &[InterleavedComponent] { + &self.components + } + + /// Scales one component into an 8-bit plane using CoreJ2K-compatible full + /// range conversion. + /// + /// # Errors + /// + /// Returns [`Error::IndexOutOfRange`] for an invalid component index and + /// [`Error::Argument`] when `destination` is not exactly one image plane. + pub fn to_component_bytes( + &self, + component_index: usize, + destination: &mut [u8], + ) -> Result<(), Error> { + let component = self + .components + .get(component_index) + .ok_or(Error::IndexOutOfRange)?; + if destination.len() != component.samples.len() { + return Err(Error::Argument); + } + for (destination, sample) in destination.iter_mut().zip(&component.samples) { + *destination = scale_sample(*sample, component.precision, component.signed); + } + Ok(()) + } +} + +/// Encode-side sample source corresponding to `CoreJ2K`'s block image source. +pub trait BlkImgDataSrc: Debug + Send + Sync { + /// Image width. + fn width(&self) -> i32; + /// Image height. + fn height(&self) -> i32; + /// Number of presented components. + fn number_of_components(&self) -> usize; + /// Nominal range bits for one component. + /// + /// # Errors + /// + /// Returns [`Error::IndexOutOfRange`] for an invalid component index. + fn nominal_range_bits(&self, component_index: usize) -> Result; + /// Fixed-point fractional bits for one component. + /// + /// # Errors + /// + /// Returns [`Error::IndexOutOfRange`] for an invalid component index. + fn fixed_point(&self, component_index: usize) -> Result; + /// Whether one presented component is signed at the source. + /// + /// # Errors + /// + /// Returns [`Error::IndexOutOfRange`] for an invalid component index. + fn is_original_signed(&self, component_index: usize) -> Result; + /// Returns a checked top-left row-major rectangle with the source DC offset + /// applied, exactly as `CoreJ2K` expects. + /// + /// # Errors + /// + /// Returns a typed index or argument error for an invalid component or + /// rectangle. + fn component_block( + &self, + component_index: usize, + x: i32, + y: i32, + width: i32, + height: i32, + ) -> Result, Error>; +} + +fn checked_pixels(width: i32, height: i32) -> Result { + let width = usize::try_from(width).map_err(|_| Error::Argument)?; + let height = usize::try_from(height).map_err(|_| Error::Argument)?; + if width == 0 || height == 0 { + return Err(Error::Argument); + } + width + .checked_mul(height) + .filter(|pixels| *pixels <= DEFAULT_MAX_PIXELS) + .ok_or(Error::Argument) +} + +fn component_range(precision: u8, signed: bool) -> (i64, i64) { + if signed { + let magnitude = 1_i64 << (precision - 1); + (-magnitude, magnitude - 1) + } else { + (0, (1_i64 << precision) - 1) + } +} + +fn scale_sample(sample: i32, precision: u8, signed: bool) -> u8 { + if signed { + let old_max = 1_i64 << (precision - 1); + let scaled = (i64::from(sample) * 128) / old_max + 128; + u8::try_from(scaled.clamp(0, 255)).unwrap_or_default() + } else { + let old_max = (1_u64 << precision) - 1; + let scaled = (u64::try_from(sample).unwrap_or_default() * 255) / old_max; + u8::try_from(scaled).unwrap_or(u8::MAX) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn component_conversion_preserves_precision_and_signed_range() { + let unsigned = InterleavedComponent::new(16, false, false, vec![0, 32_768, 65_535]) + .expect("16-bit component"); + let signed = InterleavedComponent::new(8, true, false, vec![-128, 0, 127]) + .expect("signed component"); + let unsigned = InterleavedImage::new(3, 1, vec![unsigned]).expect("unsigned image"); + let signed = InterleavedImage::new(3, 1, vec![signed]).expect("signed image"); + let mut bytes = [0; 3]; + unsigned + .to_component_bytes(0, &mut bytes) + .expect("unsigned bytes"); + assert_eq!(bytes, [0, 127, 255]); + signed + .to_component_bytes(0, &mut bytes) + .expect("signed bytes"); + assert_eq!(bytes, [0, 128, 255]); + } + + #[test] + fn interleaved_image_rejects_bad_layouts_and_ranges() { + assert_eq!( + InterleavedComponent::new(0, false, false, vec![]), + Err(Error::Argument) + ); + assert_eq!( + InterleavedComponent::new(8, false, false, vec![256]), + Err(Error::Argument) + ); + let component = InterleavedComponent::new(8, false, false, vec![0]).unwrap(); + assert_eq!( + InterleavedImage::new(2, 1, vec![component]), + Err(Error::Argument) + ); + } +} diff --git a/crates/libremetaverse-imaging/src/jpeg2000.rs b/crates/libremetaverse-imaging/src/jpeg2000.rs new file mode 100644 index 0000000..f9dddb3 --- /dev/null +++ b/crates/libremetaverse-imaging/src/jpeg2000.rs @@ -0,0 +1,707 @@ +//! Optional JPEG 2000 codec backed by a bounded system `OpenJPEG` adapter. + +use crate::codec::{InterleavedComponent, InterleavedImage}; +use crate::{ + DEFAULT_MAX_ENCODED_BYTES, DEFAULT_MAX_PIXELS, Error, ITextureCodec, ManagedImage, + ManagedImageImageChannels, +}; +use libremetaverse_openjpeg as openjpeg; +use libremetaverse_types::compat::ReadWrite; +use std::io::Read; + +/// JPEG 2000 container selection. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub enum J2kFormat { + /// Raw JPEG 2000 codestream used by Second Life texture assets. + #[default] + Codestream, + /// JP2 file-format container. + Jp2, +} + +/// JPEG 2000 wavelet and rate-control mode. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum J2kCompression { + /// Reversible 5/3 wavelet with exact sample reconstruction. + #[default] + Lossless, + /// Irreversible 9/7 wavelet targeting the given compression ratio. + Lossy { + /// Uncompressed bytes divided by target codestream bytes. Must be at + /// least 1.0 and finite. + compression_ratio: f32, + }, +} + +/// Bounded decode configuration. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct J2kDecodeOptions { + discard_levels: u32, + quality_layers: u32, + strict: bool, + max_encoded_bytes: usize, + max_pixels: usize, +} + +impl Default for J2kDecodeOptions { + fn default() -> Self { + Self { + discard_levels: 0, + quality_layers: 0, + strict: true, + max_encoded_bytes: DEFAULT_MAX_ENCODED_BYTES, + max_pixels: DEFAULT_MAX_PIXELS, + } + } +} + +impl J2kDecodeOptions { + /// Sets the number of highest-resolution levels to discard. + #[must_use] + pub const fn with_discard_levels(mut self, discard_levels: u32) -> Self { + self.discard_levels = discard_levels; + self + } + + /// Limits decoding to the first `quality_layers` progressive layers. Zero + /// decodes every available layer. + #[must_use] + pub const fn with_quality_layers(mut self, quality_layers: u32) -> Self { + self.quality_layers = quality_layers; + self + } + + /// Selects whether truncated codestreams are rejected. + #[must_use] + pub const fn with_strict_mode(mut self, strict: bool) -> Self { + self.strict = strict; + self + } + + /// Replaces the encoded-byte and decoded-pixel limits. + /// + /// Zero limits are invalid and cause decode to return [`Error::Argument`]. + #[must_use] + pub const fn with_limits(mut self, max_encoded_bytes: usize, max_pixels: usize) -> Self { + self.max_encoded_bytes = max_encoded_bytes; + self.max_pixels = max_pixels; + self + } + + /// Configured discard level. + #[must_use] + pub const fn discard_levels(self) -> u32 { + self.discard_levels + } + + /// Configured quality-layer limit. + #[must_use] + pub const fn quality_layers(self) -> u32 { + self.quality_layers + } +} + +/// Encode configuration. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct J2kEncodeOptions { + format: J2kFormat, + compression: J2kCompression, + max_encoded_bytes: usize, +} + +impl Default for J2kEncodeOptions { + fn default() -> Self { + Self { + format: J2kFormat::Codestream, + compression: J2kCompression::Lossless, + max_encoded_bytes: DEFAULT_MAX_ENCODED_BYTES, + } + } +} + +impl J2kEncodeOptions { + /// Selects raw codestream or JP2 output. + #[must_use] + pub const fn with_format(mut self, format: J2kFormat) -> Self { + self.format = format; + self + } + + /// Selects reversible lossless or irreversible lossy compression. + #[must_use] + pub const fn with_compression(mut self, compression: J2kCompression) -> Self { + self.compression = compression; + self + } + + /// Caps the produced codestream size. + #[must_use] + pub const fn with_max_encoded_bytes(mut self, max_encoded_bytes: usize) -> Self { + self.max_encoded_bytes = max_encoded_bytes; + self + } +} + +/// Cross-platform JPEG 2000 adapter. +/// +/// The optional adapter links to BSD-2-Clause `OpenJPEG` 2.5.4 or newer. Linux +/// and macOS builds discover it with `pkg-config`; Windows MSVC builds use +/// vcpkg. Checked-in minimal bindings and all unsafe FFI are isolated in the private +/// adapter crate and no native codec type crosses this boundary. +#[derive(Clone, Copy, Debug, Default)] +pub struct J2kCodec { + decode_options: J2kDecodeOptions, +} + +impl J2kCodec { + /// Creates a codec with explicit bounded decode options. + #[must_use] + pub const fn new(decode_options: J2kDecodeOptions) -> Self { + Self { decode_options } + } + + /// Decodes a raw J2K codestream or JP2 container while retaining component + /// precision, signedness, alpha metadata, and order. + /// + /// The encoded-byte limit is checked before buffering to a codec stream. + /// `OpenJPEG` header parsing does not allocate sample planes; dimensions and + /// component counts are validated before `decode` may allocate them. + /// + /// # Errors + /// + /// Returns a typed argument or parse failure for invalid limits, oversized + /// input/dimensions, unsupported component layouts, or malformed data. + pub fn decode_interleaved( + encoded: &[u8], + options: J2kDecodeOptions, + ) -> Result { + if options.max_encoded_bytes == 0 + || options.max_pixels == 0 + || encoded.is_empty() + || encoded.len() > options.max_encoded_bytes + { + return Err(Error::Argument); + } + let format = detect_format(encoded)?; + let decoded = openjpeg::decode( + encoded, + backend_format(format), + openjpeg::DecodeOptions { + discard_levels: options.discard_levels, + quality_layers: options.quality_layers, + strict: options.strict, + max_pixels: options.max_pixels.min(DEFAULT_MAX_PIXELS), + }, + ) + .map_err(map_decode_error)?; + backend_image_to_interleaved(decoded, options.max_pixels) + } + + /// Decodes into the C#-compatible planar byte representation. + /// + /// # Errors + /// + /// Returns the failures documented by [`Self::decode_interleaved`] or a + /// typed error for a component conversion/allocation failure. + pub fn decode_bytes(encoded: &[u8], options: J2kDecodeOptions) -> Result { + interleaved_to_managed(&Self::decode_interleaved(encoded, options)?) + } + + /// Encodes the four-component compatibility view used by `CoreJ2K`. + /// + /// Color images preserve RGB and optional alpha. Alpha-only images repeat + /// alpha into RGB and encode an opaque alpha plane. Images without alpha + /// receive opaque alpha. Bump is not a JPEG 2000 output component, matching + /// the reference adapter. + /// + /// # Errors + /// + /// Returns a typed validation/operation failure for invalid image layouts, + /// lossy settings, allocation/codec errors, or oversized output. + pub fn encode(image: &ManagedImage, options: J2kEncodeOptions) -> Result, Error> { + image.validate()?; + if options.max_encoded_bytes == 0 { + return Err(Error::Argument); + } + if let J2kCompression::Lossy { compression_ratio } = options.compression { + if !compression_ratio.is_finite() || compression_ratio < 1.0 { + return Err(Error::Argument); + } + } + encode_with_openjpeg(image, options) + } +} + +impl ITextureCodec for J2kCodec { + fn decode(&self, mut stream: Box) -> Result { + if self.decode_options.max_encoded_bytes == 0 { + return Err(Error::Argument); + } + let limit = self + .decode_options + .max_encoded_bytes + .checked_add(1) + .ok_or(Error::Argument)?; + let mut encoded = Vec::new(); + Read::by_ref(&mut stream) + .take(u64::try_from(limit).map_err(|_| Error::Argument)?) + .read_to_end(&mut encoded) + .map_err(|_| parse("JPEG 2000 input stream"))?; + if encoded.len() > self.decode_options.max_encoded_bytes { + return Err(Error::Argument); + } + Self::decode_bytes(&encoded, self.decode_options) + } +} + +fn backend_image_to_interleaved( + image: openjpeg::Image, + max_pixels: usize, +) -> Result { + let (width, height) = checked_dimensions(image.width, image.height, max_pixels)?; + let mut decoded = Vec::new(); + decoded + .try_reserve_exact(image.components.len()) + .map_err(|_| Error::InvalidOperation)?; + for component in image.components { + if component.width != image.width || component.height != image.height { + return Err(parse("subsampled JPEG 2000 components")); + } + decoded.push(InterleavedComponent::new( + component.precision, + component.signed, + component.alpha, + component.samples, + )?); + } + InterleavedImage::new(width, height, decoded) +} + +fn interleaved_to_managed(image: &InterleavedImage) -> Result { + let channels = channels_for_components(image.number_of_components())?; + let pixels = usize::try_from(image.width()) + .ok() + .and_then(|width| { + usize::try_from(image.height()) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .ok_or(Error::Argument)?; + let mut bytes = Vec::new(); + let length = pixels + .checked_mul(image.number_of_components()) + .ok_or(Error::Argument)?; + bytes + .try_reserve_exact(length) + .map_err(|_| Error::InvalidOperation)?; + bytes.resize(length, 0); + let mut plane = vec![0; pixels]; + for component in 0..image.number_of_components() { + image.to_component_bytes(component, &mut plane)?; + for (pixel, sample) in plane.iter().enumerate() { + bytes[pixel * image.number_of_components() + component] = *sample; + } + } + managed_from_reference_interleaved(image.width(), image.height(), channels, &bytes) +} + +fn encode_with_openjpeg(image: &ManagedImage, options: J2kEncodeOptions) -> Result, Error> { + let width = u32::try_from(image.width).map_err(|_| Error::Argument)?; + let height = u32::try_from(image.height).map_err(|_| Error::Argument)?; + let planes = reference_encode_planes(image)?; + let components = [ + openjpeg::ComponentRef { + precision: 8, + signed: false, + alpha: false, + samples: &planes[0], + }, + openjpeg::ComponentRef { + precision: 8, + signed: false, + alpha: false, + samples: &planes[1], + }, + openjpeg::ComponentRef { + precision: 8, + signed: false, + alpha: false, + samples: &planes[2], + }, + openjpeg::ComponentRef { + precision: 8, + signed: false, + alpha: true, + samples: &planes[3], + }, + ]; + let compression = match options.compression { + J2kCompression::Lossless => openjpeg::Compression::Lossless, + J2kCompression::Lossy { compression_ratio } => { + openjpeg::Compression::Lossy { compression_ratio } + } + }; + openjpeg::encode( + width, + height, + &components, + backend_format(options.format), + compression, + options.max_encoded_bytes, + ) + .map_err(map_encode_error) +} + +fn reference_encode_planes(image: &ManagedImage) -> Result<[Vec; 4], Error> { + let pixels = usize::try_from(image.width) + .ok() + .and_then(|width| { + usize::try_from(image.height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .ok_or(Error::Argument)?; + let mut planes = [Vec::new(), Vec::new(), Vec::new(), Vec::new()]; + for plane in &mut planes { + plane + .try_reserve_exact(pixels) + .map_err(|_| Error::InvalidOperation)?; + plane.resize(pixels, 0); + } + let has_color = image.channels.contains(ManagedImageImageChannels::COLOR); + let has_alpha = image.channels.contains(ManagedImageImageChannels::ALPHA); + + if has_alpha && !has_color { + for (pixel, sample) in image.alpha.iter().copied().enumerate() { + let sample = i32::from(sample); + planes[0][pixel] = sample; + planes[1][pixel] = sample; + planes[2][pixel] = sample; + planes[3][pixel] = 255; + } + return Ok(planes); + } + if !has_color { + return Err(Error::InvalidOperation); + } + for (pixel, red) in image.red.iter().copied().enumerate() { + planes[0][pixel] = i32::from(red); + planes[1][pixel] = i32::from(image.green[pixel]); + planes[2][pixel] = i32::from(image.blue[pixel]); + planes[3][pixel] = if has_alpha { + i32::from(image.alpha[pixel]) + } else { + 255 + }; + } + Ok(planes) +} + +fn managed_from_reference_interleaved( + width: i32, + height: i32, + channels: ManagedImageImageChannels, + bytes: &[u8], +) -> Result { + let components = component_count(channels); + let pixels = usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .ok_or(Error::Argument)?; + if bytes.len() != pixels.checked_mul(components).ok_or(Error::Argument)? { + return Err(Error::Argument); + } + let mut image = ManagedImage::new(width, height, channels)?; + for pixel in 0..pixels { + let source = pixel * components; + match components { + 1 => image.red[pixel] = bytes[source], + 2 => { + image.red[pixel] = bytes[source]; + image.alpha[pixel] = bytes[source + 1]; + } + 3 => { + image.red[pixel] = bytes[source]; + image.green[pixel] = bytes[source + 1]; + image.blue[pixel] = bytes[source + 2]; + } + 4 => { + image.red[pixel] = bytes[source]; + image.green[pixel] = bytes[source + 1]; + image.blue[pixel] = bytes[source + 2]; + image.alpha[pixel] = bytes[source + 3]; + } + 5 => { + image.red[pixel] = bytes[source]; + image.green[pixel] = bytes[source + 1]; + image.blue[pixel] = bytes[source + 2]; + image.bump[pixel] = bytes[source + 3]; + image.alpha[pixel] = bytes[source + 4]; + } + _ => return Err(Error::Argument), + } + } + Ok(image) +} + +fn channels_for_components(components: usize) -> Result { + Ok(match components { + 1 => ManagedImageImageChannels::GRAY, + 2 => ManagedImageImageChannels::GRAY | ManagedImageImageChannels::ALPHA, + 3 => ManagedImageImageChannels::COLOR, + 4 => ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA, + 5 => { + ManagedImageImageChannels::COLOR + | ManagedImageImageChannels::BUMP + | ManagedImageImageChannels::ALPHA + } + _ => return Err(Error::Argument), + }) +} + +fn component_count(channels: ManagedImageImageChannels) -> usize { + if channels.contains(ManagedImageImageChannels::BUMP) { + 5 + } else if channels.contains(ManagedImageImageChannels::COLOR) { + 3 + usize::from(channels.contains(ManagedImageImageChannels::ALPHA)) + } else if channels.contains(ManagedImageImageChannels::GRAY) { + 1 + usize::from(channels.contains(ManagedImageImageChannels::ALPHA)) + } else { + usize::from(channels.contains(ManagedImageImageChannels::ALPHA)) + } +} + +fn detect_format(encoded: &[u8]) -> Result { + const JP2_MAGIC: &[u8] = &[ + 0x00, 0x00, 0x00, 0x0c, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a, + ]; + const J2K_MAGIC: &[u8] = &[0xff, 0x4f, 0xff, 0x51]; + if encoded.starts_with(JP2_MAGIC) { + Ok(J2kFormat::Jp2) + } else if encoded.starts_with(J2K_MAGIC) { + Ok(J2kFormat::Codestream) + } else { + Err(parse("JPEG 2000 magic")) + } +} + +const fn backend_format(format: J2kFormat) -> openjpeg::Format { + match format { + J2kFormat::Codestream => openjpeg::Format::J2k, + J2kFormat::Jp2 => openjpeg::Format::Jp2, + } +} + +const fn map_decode_error(error: openjpeg::Error) -> Error { + match error { + openjpeg::Error::LimitExceeded => Error::Argument, + openjpeg::Error::Allocation => Error::InvalidOperation, + openjpeg::Error::InvalidInput | openjpeg::Error::Codec => parse("JPEG 2000 codestream"), + } +} + +const fn map_encode_error(error: openjpeg::Error) -> Error { + match error { + openjpeg::Error::InvalidInput | openjpeg::Error::LimitExceeded => Error::Argument, + openjpeg::Error::Allocation | openjpeg::Error::Codec => Error::InvalidOperation, + } +} + +fn checked_dimensions(width: u32, height: u32, max_pixels: usize) -> Result<(i32, i32), Error> { + let width_usize = usize::try_from(width).map_err(|_| Error::Argument)?; + let height_usize = usize::try_from(height).map_err(|_| Error::Argument)?; + let pixels = width_usize + .checked_mul(height_usize) + .ok_or(Error::Argument)?; + if width == 0 || height == 0 || pixels > max_pixels || pixels > DEFAULT_MAX_PIXELS { + return Err(Error::Argument); + } + Ok(( + i32::try_from(width).map_err(|_| Error::Argument)?, + i32::try_from(height).map_err(|_| Error::Argument)?, + )) +} + +const fn parse(context: &'static str) -> Error { + Error::Parse { + position: 0, + context, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn rgba(width: i32, height: i32) -> ManagedImage { + let mut image = ManagedImage::new( + width, + height, + ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA, + ) + .unwrap(); + for pixel in 0..image.red.len() { + image.red[pixel] = u8::try_from((pixel * 17) & 255).unwrap(); + image.green[pixel] = u8::try_from((pixel * 29 + 3) & 255).unwrap(); + image.blue[pixel] = u8::try_from((pixel * 43 + 7) & 255).unwrap(); + image.alpha[pixel] = u8::try_from((pixel * 11 + 101) & 255).unwrap(); + } + image + } + + #[test] + fn lossless_codestream_and_jp2_round_trip_all_channels() { + let source = rgba(17, 9); + for format in [J2kFormat::Codestream, J2kFormat::Jp2] { + let encoded = + J2kCodec::encode(&source, J2kEncodeOptions::default().with_format(format)) + .expect("encode"); + let decoded = + J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default()).expect("decode"); + assert_eq!(decoded, source); + } + } + + #[test] + fn alpha_only_substitution_matches_managed_image_creator() { + let mut source = ManagedImage::new(2, 1, ManagedImageImageChannels::ALPHA).unwrap(); + source.alpha.copy_from_slice(&[17, 231]); + let encoded = J2kCodec::encode(&source, J2kEncodeOptions::default()).unwrap(); + let decoded = J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default()).unwrap(); + assert_eq!(decoded.red, [17, 231]); + assert_eq!(decoded.green, [17, 231]); + assert_eq!(decoded.blue, [17, 231]); + assert_eq!(decoded.alpha, [255, 255]); + } + + #[test] + fn discard_levels_reduce_dimensions_and_stream_boundary_is_bounded() { + let source = rgba(64, 32); + let encoded = J2kCodec::encode(&source, J2kEncodeOptions::default()).unwrap(); + let reduced = + J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default().with_discard_levels(1)) + .unwrap(); + assert_eq!((reduced.width, reduced.height), (32, 16)); + + let codec = J2kCodec::new(J2kDecodeOptions::default().with_limits(8, 64)); + assert_eq!( + codec.decode(Box::new(Cursor::new(encoded))), + Err(Error::Argument) + ); + + let encoded = J2kCodec::encode(&source, J2kEncodeOptions::default()).unwrap(); + assert_eq!( + J2kCodec::decode_bytes( + &encoded, + J2kDecodeOptions::default().with_limits(encoded.len(), 128), + ), + Err(Error::Argument) + ); + } + + #[test] + fn invalid_data_and_lossy_settings_fail_without_panicking() { + assert!(matches!( + J2kCodec::decode_bytes(b"not jpeg2000", J2kDecodeOptions::default()), + Err(Error::Parse { .. }) + )); + let source = rgba(2, 2); + assert_eq!( + J2kCodec::encode( + &source, + J2kEncodeOptions::default().with_compression(J2kCompression::Lossy { + compression_ratio: f32::NAN, + }), + ), + Err(Error::Argument) + ); + assert!(matches!( + J2kCodec::decode_bytes( + b"\0\0\0\x0cjP \r\n\x87\n\0\0\0", + J2kDecodeOptions::default(), + ), + Err(Error::Parse { .. }) + )); + assert_eq!( + J2kCodec::encode( + &source, + J2kEncodeOptions::default().with_max_encoded_bytes(32), + ), + Err(Error::Argument) + ); + } + + #[test] + fn lossy_mode_preserves_layout_with_bounded_sample_error() { + let source = rgba(64, 64); + let encoded = J2kCodec::encode( + &source, + J2kEncodeOptions::default() + .with_format(J2kFormat::Jp2) + .with_compression(J2kCompression::Lossy { + compression_ratio: 8.0, + }), + ) + .expect("lossy encode"); + let decoded = + J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default().with_quality_layers(1)) + .expect("lossy decode"); + assert_eq!( + (decoded.width, decoded.height, decoded.channels), + (source.width, source.height, source.channels) + ); + + let total_error: u64 = source + .red + .iter() + .chain(&source.green) + .chain(&source.blue) + .chain(&source.alpha) + .zip( + decoded + .red + .iter() + .chain(&decoded.green) + .chain(&decoded.blue) + .chain(&decoded.alpha), + ) + .map(|(expected, actual)| u64::from(expected.abs_diff(*actual))) + .sum(); + let samples = u64::try_from(source.red.len() * 4).unwrap(); + assert!( + total_error > 0, + "lossy mode unexpectedly reconstructed exactly" + ); + assert!(total_error / samples < 32, "mean sample error is too high"); + } + + #[test] + fn openjpeg_2_5_4_golden_retains_sixteen_bit_samples() { + // Generated by OpenJPEG 2.5.4 `opj_compress` from the deterministic + // 64x64 unsigned 16-bit gradient asserted below. + const GOLDEN_HEX: &str = "ff4fff5100290000000000400000004000000000000000000000004000000040000000000000000000010f0101ff52000c00000001000504040001ff5c00134080888890888890888890888890888890ff640025000143726561746564206279204f70656e4a5045472076657273696f6e20322e352e34ff90000a00000000011c0001ff93dff890500c58e2753ee5f7dd26b3c7fe1811000fa8120007ce080d020629b05f7dbf0c199c5f0c44c3ff0306c001f3858000f90180221a085d6dcb5f4686076b4a7f00d09a4b7f01627fc1ff4005e000f9c440007c80c036a199ae63a3628986621949a89ff8bdd889db3a01813f439e44760735fc3f3e871dc0ff0278001f20f80007c2385fa7c788a00db0790644473e98d48fd92b5faff3c71e10932fde0e8f81be8b5bafccacfdd7cf9f9eab0ab177092bc3e079cc1037c0679d9aecfa3be703c07f2670001f09700007438b6201aba8dfc3791d615480235541001a680dce59014e5d9a88d45e9373f9519239613060c182ef7f7816874a3e947ceeaf7fe19cdb5b3899ff0636ff7fe18c4fffd9"; + let encoded = GOLDEN_HEX + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let pair = std::str::from_utf8(pair).unwrap(); + u8::from_str_radix(pair, 16).unwrap() + }) + .collect::>(); + let decoded = J2kCodec::decode_interleaved(&encoded, J2kDecodeOptions::default()) + .expect("decode OpenJPEG golden"); + assert_eq!((decoded.width(), decoded.height()), (64, 64)); + assert_eq!(decoded.number_of_components(), 1); + let component = &decoded.components()[0]; + assert_eq!((component.precision(), component.is_signed()), (16, false)); + let expected = (0..64) + .flat_map(|y| (0..64).map(move |x| (x * 65_535 / 64) ^ ((y * 3) & 0xffff))) + .collect::>(); + assert_eq!(component.samples(), expected); + } +} diff --git a/crates/libremetaverse-imaging/src/lib.rs b/crates/libremetaverse-imaging/src/lib.rs index ef742a0..67a4985 100644 --- a/crates/libremetaverse-imaging/src/lib.rs +++ b/crates/libremetaverse-imaging/src/lib.rs @@ -2,17 +2,16 @@ extern crate self as libremetaverse_imaging; -pub mod codec { - pub trait IImage {} - pub trait IImageCreator {} - pub struct ImageCreator(pub std::marker::PhantomData); - pub struct InterleavedImage; - pub trait BlkImgDataSrc {} -} +pub mod codec; + +#[cfg(feature = "jpeg2000")] +mod jpeg2000; mod generated; mod managed_image; pub use generated::*; +#[cfg(feature = "jpeg2000")] +pub use jpeg2000::{J2kCodec, J2kCompression, J2kDecodeOptions, J2kEncodeOptions, J2kFormat}; pub use libremetaverse_types::Error; pub use managed_image::{DEFAULT_MAX_ENCODED_BYTES, DEFAULT_MAX_PIXELS}; diff --git a/crates/libremetaverse-openjpeg/Cargo.toml b/crates/libremetaverse-openjpeg/Cargo.toml new file mode 100644 index 0000000..5067398 --- /dev/null +++ b/crates/libremetaverse-openjpeg/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "libremetaverse-openjpeg" +version = "0.0.1" +edition = "2024" +rust-version = "1.85" +license = "BSD-3-Clause" +publish = false +description = "Safe, bounded OpenJPEG 2.5.4 adapter for MetaCrate" +build = "build.rs" + +[build-dependencies] +pkg-config = "0.3" +vcpkg = "0.2" + +# Unsafe code is permitted only in this workspace member: it is the reviewed +# native ABI boundary. All callers use its safe, owned Rust API. +[lints.rust] +unsafe_code = "allow" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } diff --git a/crates/libremetaverse-openjpeg/README.md b/crates/libremetaverse-openjpeg/README.md new file mode 100644 index 0000000..56e8002 --- /dev/null +++ b/crates/libremetaverse-openjpeg/README.md @@ -0,0 +1,38 @@ +# MetaCrate OpenJPEG adapter + +This private crate is the unsafe implementation boundary for MetaCrate's +optional `jpeg2000` feature. Its public surface is safe Rust: native handles, +callbacks, and OpenJPEG image structures never cross into the imaging API. +The adapter uses checked arithmetic and bounded memory streams, validates +header dimensions and component counts before decoded sample-plane allocation, +and copies decoded samples into owned Rust vectors. + +## Native prerequisite + +OpenJPEG **2.5.4 or newer** is required. Versions 2.5.1 through 2.5.3 are not +accepted because they are affected by CVE-2025-54874. No bindgen or libclang +installation is needed; the crate contains the minimal public ABI declarations +used by the adapter. + +- Linux and other Unix targets: install OpenJPEG development headers/libraries + and `pkg-config`; `pkg-config --modversion libopenjp2` must report 2.5.4 or + newer. +- macOS: install `openjpeg` and `pkg-config` (for example with Homebrew). This + is the same system-library path used on Linux; no Apple-only API is used. +- Windows MSVC: install `openjpeg` with vcpkg and expose `VCPKG_ROOT` to Cargo. + Use the triplet matching the Rust target and deploy the resulting OpenJPEG + DLL beside the application or on its normal DLL search path when using a + dynamic triplet. + +Enable the adapter with `--features jpeg2000` on either +`libremetaverse-imaging` or the umbrella `libremetaverse` crate. Without that +feature, neither the build script nor native library discovery runs. + +## License and redistribution + +OpenJPEG is BSD-2-Clause licensed. MetaCrate discovers an installation supplied +by the builder and does not redistribute OpenJPEG source or binaries. A product +that redistributes an OpenJPEG shared or static library must also satisfy the +OpenJPEG license's copyright and notice requirements. The ABI declarations in +this crate follow OpenJPEG's public `openjpeg.h`; MetaCrate's adapter code is +licensed under the repository's BSD-3-Clause license. diff --git a/crates/libremetaverse-openjpeg/build.rs b/crates/libremetaverse-openjpeg/build.rs new file mode 100644 index 0000000..0b0a54f --- /dev/null +++ b/crates/libremetaverse-openjpeg/build.rs @@ -0,0 +1,16 @@ +use std::env; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + let target = env::var("TARGET").expect("Cargo provides TARGET"); + if target.contains("msvc") { + vcpkg::Config::new() + .find_package("openjpeg") + .expect("jpeg2000 requires OpenJPEG 2.5.4 or newer from vcpkg"); + } else { + pkg_config::Config::new() + .atleast_version("2.5.4") + .probe("libopenjp2") + .expect("jpeg2000 requires pkg-config and OpenJPEG 2.5.4 or newer"); + } +} diff --git a/crates/libremetaverse-openjpeg/src/ffi.rs b/crates/libremetaverse-openjpeg/src/ffi.rs new file mode 100644 index 0000000..d0f52b3 --- /dev/null +++ b/crates/libremetaverse-openjpeg/src/ffi.rs @@ -0,0 +1,279 @@ +//! Minimal `OpenJPEG` 2.5 public ABI used by this crate. +//! +//! These declarations follow `openjpeg.h` from `OpenJPEG` 2.5.4. Keeping the +//! bindings here avoids requiring libclang merely to enable the codec. + +use std::ffi::{c_char, c_int, c_uint, c_void}; + +pub type OPJ_BOOL = c_int; +pub type OPJ_CHAR = c_char; +pub type OPJ_UINT16 = u16; +pub type OPJ_INT32 = i32; +pub type OPJ_UINT32 = u32; +pub type OPJ_UINT64 = u64; +pub type OPJ_OFF_T = i64; +pub type OPJ_SIZE_T = usize; + +pub type OPJ_RSIZ_CAPABILITIES = u32; +pub type OPJ_CINEMA_MODE = u32; +pub type OPJ_PROG_ORDER = i32; + +#[repr(transparent)] +#[derive(Debug, Copy, Clone)] +pub struct OPJ_COLOR_SPACE(i32); + +impl OPJ_COLOR_SPACE { + pub const OPJ_CLRSPC_SRGB: Self = Self(1); + pub const OPJ_CLRSPC_GRAY: Self = Self(2); +} + +#[repr(transparent)] +#[derive(Debug, Copy, Clone)] +pub struct OPJ_CODEC_FORMAT(i32); + +impl OPJ_CODEC_FORMAT { + pub const OPJ_CODEC_J2K: Self = Self(0); + pub const OPJ_CODEC_JP2: Self = Self(2); +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct opj_poc_t { + pub resno0: OPJ_UINT32, + pub compno0: OPJ_UINT32, + pub layno1: OPJ_UINT32, + pub resno1: OPJ_UINT32, + pub compno1: OPJ_UINT32, + pub layno0: OPJ_UINT32, + pub precno0: OPJ_UINT32, + pub precno1: OPJ_UINT32, + pub prg1: OPJ_PROG_ORDER, + pub prg: OPJ_PROG_ORDER, + pub progorder: [OPJ_CHAR; 5], + pub tile: OPJ_UINT32, + pub tx0: OPJ_INT32, + pub tx1: OPJ_INT32, + pub ty0: OPJ_INT32, + pub ty1: OPJ_INT32, + pub layS: OPJ_UINT32, + pub resS: OPJ_UINT32, + pub compS: OPJ_UINT32, + pub prcS: OPJ_UINT32, + pub layE: OPJ_UINT32, + pub resE: OPJ_UINT32, + pub compE: OPJ_UINT32, + pub prcE: OPJ_UINT32, + pub txS: OPJ_UINT32, + pub txE: OPJ_UINT32, + pub tyS: OPJ_UINT32, + pub tyE: OPJ_UINT32, + pub dx: OPJ_UINT32, + pub dy: OPJ_UINT32, + pub lay_t: OPJ_UINT32, + pub res_t: OPJ_UINT32, + pub comp_t: OPJ_UINT32, + pub prc_t: OPJ_UINT32, + pub tx0_t: OPJ_UINT32, + pub ty0_t: OPJ_UINT32, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct opj_cparameters_t { + pub tile_size_on: OPJ_BOOL, + pub cp_tx0: c_int, + pub cp_ty0: c_int, + pub cp_tdx: c_int, + pub cp_tdy: c_int, + pub cp_disto_alloc: c_int, + pub cp_fixed_alloc: c_int, + pub cp_fixed_quality: c_int, + pub cp_matrice: *mut c_int, + pub cp_comment: *mut c_char, + pub csty: c_int, + pub prog_order: OPJ_PROG_ORDER, + pub POC: [opj_poc_t; 32], + pub numpocs: OPJ_UINT32, + pub tcp_numlayers: c_int, + pub tcp_rates: [f32; 100], + pub tcp_distoratio: [f32; 100], + pub numresolution: c_int, + pub cblockw_init: c_int, + pub cblockh_init: c_int, + pub mode: c_int, + pub irreversible: c_int, + pub roi_compno: c_int, + pub roi_shift: c_int, + pub res_spec: c_int, + pub prcw_init: [c_int; 33], + pub prch_init: [c_int; 33], + pub infile: [c_char; 4096], + pub outfile: [c_char; 4096], + pub index_on: c_int, + pub index: [c_char; 4096], + pub image_offset_x0: c_int, + pub image_offset_y0: c_int, + pub subsampling_dx: c_int, + pub subsampling_dy: c_int, + pub decod_format: c_int, + pub cod_format: c_int, + pub jpwl_epc_on: OPJ_BOOL, + pub jpwl_hprot_MH: c_int, + pub jpwl_hprot_TPH_tileno: [c_int; 16], + pub jpwl_hprot_TPH: [c_int; 16], + pub jpwl_pprot_tileno: [c_int; 16], + pub jpwl_pprot_packno: [c_int; 16], + pub jpwl_pprot: [c_int; 16], + pub jpwl_sens_size: c_int, + pub jpwl_sens_addr: c_int, + pub jpwl_sens_range: c_int, + pub jpwl_sens_MH: c_int, + pub jpwl_sens_TPH_tileno: [c_int; 16], + pub jpwl_sens_TPH: [c_int; 16], + pub cp_cinema: OPJ_CINEMA_MODE, + pub max_comp_size: c_int, + pub cp_rsiz: OPJ_RSIZ_CAPABILITIES, + pub tp_on: c_char, + pub tp_flag: c_char, + pub tcp_mct: c_char, + pub jpip_on: OPJ_BOOL, + pub mct_data: *mut c_void, + pub max_cs_size: c_int, + pub rsiz: OPJ_UINT16, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct opj_dparameters_t { + pub cp_reduce: OPJ_UINT32, + pub cp_layer: OPJ_UINT32, + pub infile: [c_char; 4096], + pub outfile: [c_char; 4096], + pub decod_format: c_int, + pub cod_format: c_int, + pub DA_x0: OPJ_UINT32, + pub DA_x1: OPJ_UINT32, + pub DA_y0: OPJ_UINT32, + pub DA_y1: OPJ_UINT32, + pub m_verbose: OPJ_BOOL, + pub tile_index: OPJ_UINT32, + pub nb_tile_to_decode: OPJ_UINT32, + pub jpwl_correct: OPJ_BOOL, + pub jpwl_exp_comps: c_int, + pub jpwl_max_tiles: c_int, + pub flags: c_uint, +} + +pub type opj_codec_t = *mut c_void; +pub type opj_stream_t = *mut c_void; + +pub type opj_stream_read_fn = + Option OPJ_SIZE_T>; +pub type opj_stream_write_fn = + Option OPJ_SIZE_T>; +pub type opj_stream_skip_fn = Option OPJ_OFF_T>; +pub type opj_stream_seek_fn = Option OPJ_BOOL>; +pub type opj_stream_free_user_data_fn = Option; + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct opj_image_comp_t { + pub dx: OPJ_UINT32, + pub dy: OPJ_UINT32, + pub w: OPJ_UINT32, + pub h: OPJ_UINT32, + pub x0: OPJ_UINT32, + pub y0: OPJ_UINT32, + pub prec: OPJ_UINT32, + pub bpp: OPJ_UINT32, + pub sgnd: OPJ_UINT32, + pub resno_decoded: OPJ_UINT32, + pub factor: OPJ_UINT32, + pub data: *mut OPJ_INT32, + pub alpha: OPJ_UINT16, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct opj_image_t { + pub x0: OPJ_UINT32, + pub y0: OPJ_UINT32, + pub x1: OPJ_UINT32, + pub y1: OPJ_UINT32, + pub numcomps: OPJ_UINT32, + pub color_space: OPJ_COLOR_SPACE, + pub comps: *mut opj_image_comp_t, + pub icc_profile_buf: *mut u8, + pub icc_profile_len: OPJ_UINT32, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct opj_image_cmptparm_t { + pub dx: OPJ_UINT32, + pub dy: OPJ_UINT32, + pub w: OPJ_UINT32, + pub h: OPJ_UINT32, + pub x0: OPJ_UINT32, + pub y0: OPJ_UINT32, + pub prec: OPJ_UINT32, + pub bpp: OPJ_UINT32, + pub sgnd: OPJ_UINT32, +} + +unsafe extern "C" { + pub fn opj_version() -> *const c_char; + pub fn opj_stream_default_create(is_input: OPJ_BOOL) -> *mut opj_stream_t; + pub fn opj_stream_destroy(stream: *mut opj_stream_t); + pub fn opj_stream_set_read_function(stream: *mut opj_stream_t, function: opj_stream_read_fn); + pub fn opj_stream_set_write_function(stream: *mut opj_stream_t, function: opj_stream_write_fn); + pub fn opj_stream_set_skip_function(stream: *mut opj_stream_t, function: opj_stream_skip_fn); + pub fn opj_stream_set_seek_function(stream: *mut opj_stream_t, function: opj_stream_seek_fn); + pub fn opj_stream_set_user_data( + stream: *mut opj_stream_t, + data: *mut c_void, + free_function: opj_stream_free_user_data_fn, + ); + pub fn opj_stream_set_user_data_length(stream: *mut opj_stream_t, length: OPJ_UINT64); + + pub fn opj_create_decompress(format: OPJ_CODEC_FORMAT) -> *mut opj_codec_t; + pub fn opj_create_compress(format: OPJ_CODEC_FORMAT) -> *mut opj_codec_t; + pub fn opj_destroy_codec(codec: *mut opj_codec_t); + pub fn opj_set_default_decoder_parameters(parameters: *mut opj_dparameters_t); + pub fn opj_setup_decoder( + codec: *mut opj_codec_t, + parameters: *mut opj_dparameters_t, + ) -> OPJ_BOOL; + pub fn opj_decoder_set_strict_mode(codec: *mut opj_codec_t, strict: OPJ_BOOL) -> OPJ_BOOL; + pub fn opj_read_header( + stream: *mut opj_stream_t, + codec: *mut opj_codec_t, + image: *mut *mut opj_image_t, + ) -> OPJ_BOOL; + pub fn opj_decode( + codec: *mut opj_codec_t, + stream: *mut opj_stream_t, + image: *mut opj_image_t, + ) -> OPJ_BOOL; + pub fn opj_end_decompress(codec: *mut opj_codec_t, stream: *mut opj_stream_t) -> OPJ_BOOL; + + pub fn opj_image_create( + component_count: OPJ_UINT32, + component_parameters: *mut opj_image_cmptparm_t, + color_space: OPJ_COLOR_SPACE, + ) -> *mut opj_image_t; + pub fn opj_image_destroy(image: *mut opj_image_t); + pub fn opj_set_default_encoder_parameters(parameters: *mut opj_cparameters_t); + pub fn opj_setup_encoder( + codec: *mut opj_codec_t, + parameters: *mut opj_cparameters_t, + image: *mut opj_image_t, + ) -> OPJ_BOOL; + pub fn opj_start_compress( + codec: *mut opj_codec_t, + image: *mut opj_image_t, + stream: *mut opj_stream_t, + ) -> OPJ_BOOL; + pub fn opj_encode(codec: *mut opj_codec_t, stream: *mut opj_stream_t) -> OPJ_BOOL; + pub fn opj_end_compress(codec: *mut opj_codec_t, stream: *mut opj_stream_t) -> OPJ_BOOL; +} diff --git a/crates/libremetaverse-openjpeg/src/lib.rs b/crates/libremetaverse-openjpeg/src/lib.rs new file mode 100644 index 0000000..c292bad --- /dev/null +++ b/crates/libremetaverse-openjpeg/src/lib.rs @@ -0,0 +1,770 @@ +//! Safe, bounded access to the system `OpenJPEG` codec. +//! +//! All foreign calls and callback pointer handling are contained here. The +//! build requires `OpenJPEG` 2.5.4 or newer, excluding releases affected by +//! CVE-2025-54874 and earlier fixed decoder defects. + +#![allow(unsafe_code)] + +use std::ffi::{CStr, c_void}; +use std::marker::PhantomData; +use std::mem::MaybeUninit; +use std::ptr::NonNull; + +const MAX_PIXELS: usize = 4096 * 4096; + +#[allow( + dead_code, + non_camel_case_types, + non_snake_case, + non_upper_case_globals, + rustdoc::bare_urls, + rustdoc::broken_intra_doc_links +)] +mod ffi; + +/// Codec/container selection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Format { + J2k, + Jp2, +} + +/// `OpenJPEG` rate-control selection. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Compression { + Lossless, + Lossy { compression_ratio: f32 }, +} + +/// Decoder controls passed directly to `OpenJPEG`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DecodeOptions { + pub discard_levels: u32, + pub quality_layers: u32, + pub strict: bool, + pub max_pixels: usize, +} + +/// One decoded component with original integer precision. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Component { + pub width: u32, + pub height: u32, + pub precision: u8, + pub signed: bool, + pub alpha: bool, + pub samples: Vec, +} + +/// Decoded `OpenJPEG` image. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Image { + pub width: u32, + pub height: u32, + pub components: Vec, +} + +/// Input image component for encoding. +#[derive(Clone, Copy, Debug)] +pub struct ComponentRef<'a> { + pub precision: u8, + pub signed: bool, + pub alpha: bool, + pub samples: &'a [i32], +} + +/// Stable error categories; `OpenJPEG` diagnostics never cross the safe API. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Error { + InvalidInput, + LimitExceeded, + Allocation, + Codec, +} + +/// Decodes J2K or JP2 after validating header dimensions and components and +/// before allowing `OpenJPEG` to allocate decoded sample planes. +/// +/// # Errors +/// +/// Returns a stable validation, limit, allocation, or codec error without +/// exposing native handles or diagnostics. +pub fn decode(bytes: &[u8], format: Format, options: DecodeOptions) -> Result { + ensure_supported_version()?; + if bytes.is_empty() || options.max_pixels == 0 { + return Err(Error::InvalidInput); + } + validate_encoded_header(bytes, format, options.max_pixels)?; + let stream = InputStream::new(bytes)?; + let codec = Codec::decoder(format)?; + let mut parameters = MaybeUninit::::zeroed(); + unsafe { ffi::opj_set_default_decoder_parameters(parameters.as_mut_ptr()) }; + let mut parameters = unsafe { parameters.assume_init() }; + parameters.cp_reduce = options.discard_levels; + parameters.cp_layer = options.quality_layers; + if unsafe { ffi::opj_setup_decoder(codec.raw.as_ptr(), &raw mut parameters) } != 1 + || unsafe { + ffi::opj_decoder_set_strict_mode(codec.raw.as_ptr(), i32::from(options.strict)) + } != 1 + { + return Err(Error::Codec); + } + + // Initializing this pointer is security-significant: OpenJPEG <=2.5.3 had + // an out-of-bounds write on short JP2 input when it was uninitialized. + let mut raw_image = std::ptr::null_mut(); + if unsafe { ffi::opj_read_header(stream.raw.as_ptr(), codec.raw.as_ptr(), &raw mut raw_image) } + != 1 + { + return Err(Error::InvalidInput); + } + let mut image = RawImage::new(raw_image)?; + validate_header(image.raw.as_ptr(), options.max_pixels)?; + if unsafe { ffi::opj_decode(codec.raw.as_ptr(), stream.raw.as_ptr(), image.raw.as_ptr()) } != 1 + || unsafe { ffi::opj_end_decompress(codec.raw.as_ptr(), stream.raw.as_ptr()) } != 1 + { + return Err(Error::InvalidInput); + } + image.copy(options.max_pixels) +} + +/// Encodes one through five equal-sized component planes to memory. +/// +/// # Errors +/// +/// Returns a stable validation, limit, allocation, or codec error. Dimensions, +/// sample ranges, and output capacity are checked before native encoding. +pub fn encode( + width: u32, + height: u32, + components: &[ComponentRef<'_>], + format: Format, + compression: Compression, + max_encoded_bytes: usize, +) -> Result, Error> { + ensure_supported_version()?; + let pixels = checked_pixels(width, height, MAX_PIXELS)?; + if components.is_empty() + || components.len() > 5 + || max_encoded_bytes == 0 + || components.iter().any(|component| { + !(1..=31).contains(&component.precision) || component.samples.len() != pixels + }) + { + return Err(Error::InvalidInput); + } + if let Compression::Lossy { compression_ratio } = compression { + if !compression_ratio.is_finite() || compression_ratio < 1.0 { + return Err(Error::InvalidInput); + } + } + + let image = RawImage::from_components(width, height, components)?; + let codec = Codec::encoder(format)?; + let stream = OutputStream::new(max_encoded_bytes)?; + let mut parameters = MaybeUninit::::zeroed(); + unsafe { ffi::opj_set_default_encoder_parameters(parameters.as_mut_ptr()) }; + let mut parameters = unsafe { parameters.assume_init() }; + parameters.tcp_numlayers = 1; + parameters.cp_disto_alloc = 1; + parameters.tcp_mct = (components.len() >= 3).into(); + parameters.numresolution = maximum_resolution_levels(width, height); + parameters.max_cs_size = i32::try_from(max_encoded_bytes).unwrap_or(i32::MAX); + match compression { + Compression::Lossless => { + parameters.tcp_rates[0] = 0.0; + parameters.irreversible = 0; + } + Compression::Lossy { compression_ratio } => { + parameters.tcp_rates[0] = compression_ratio; + parameters.irreversible = 1; + } + } + if unsafe { + ffi::opj_setup_encoder(codec.raw.as_ptr(), &raw mut parameters, image.raw.as_ptr()) + } != 1 + || unsafe { + ffi::opj_start_compress(codec.raw.as_ptr(), image.raw.as_ptr(), stream.raw.as_ptr()) + } != 1 + || unsafe { ffi::opj_encode(codec.raw.as_ptr(), stream.raw.as_ptr()) } != 1 + || unsafe { ffi::opj_end_compress(codec.raw.as_ptr(), stream.raw.as_ptr()) } != 1 + { + return Err(if stream.state.limit_exceeded { + Error::LimitExceeded + } else { + Error::Codec + }); + } + stream.into_bytes() +} + +struct Codec { + raw: NonNull, +} + +impl Codec { + fn decoder(format: Format) -> Result { + let raw = unsafe { ffi::opj_create_decompress(codec_format(format)) }; + NonNull::new(raw) + .map(|raw| Self { raw }) + .ok_or(Error::Codec) + } + + fn encoder(format: Format) -> Result { + let raw = unsafe { ffi::opj_create_compress(codec_format(format)) }; + NonNull::new(raw) + .map(|raw| Self { raw }) + .ok_or(Error::Codec) + } +} + +impl Drop for Codec { + fn drop(&mut self) { + unsafe { ffi::opj_destroy_codec(self.raw.as_ptr()) }; + } +} + +struct RawImage { + raw: NonNull, +} + +impl RawImage { + fn new(raw: *mut ffi::opj_image_t) -> Result { + NonNull::new(raw) + .map(|raw| Self { raw }) + .ok_or(Error::Codec) + } + + fn from_components( + width: u32, + height: u32, + components: &[ComponentRef<'_>], + ) -> Result { + let mut parameters: Vec<_> = components + .iter() + .map(|component| ffi::opj_image_cmptparm_t { + dx: 1, + dy: 1, + w: width, + h: height, + x0: 0, + y0: 0, + prec: u32::from(component.precision), + bpp: u32::from(component.precision), + sgnd: u32::from(component.signed), + }) + .collect(); + let color_space = if components.len() >= 3 { + ffi::OPJ_COLOR_SPACE::OPJ_CLRSPC_SRGB + } else { + ffi::OPJ_COLOR_SPACE::OPJ_CLRSPC_GRAY + }; + let raw = unsafe { + ffi::opj_image_create( + u32::try_from(parameters.len()).map_err(|_| Error::InvalidInput)?, + parameters.as_mut_ptr(), + color_space, + ) + }; + let mut image = Self::new(raw)?; + unsafe { + let raw = image.raw.as_mut(); + raw.x0 = 0; + raw.y0 = 0; + raw.x1 = width; + raw.y1 = height; + let output = std::slice::from_raw_parts_mut(raw.comps, raw.numcomps as usize); + for (output, input) in output.iter_mut().zip(components) { + if output.data.is_null() { + return Err(Error::Allocation); + } + output.alpha = u16::from(input.alpha); + let destination = std::slice::from_raw_parts_mut(output.data, input.samples.len()); + destination.copy_from_slice(input.samples); + } + } + Ok(image) + } + + fn copy(&mut self, max_pixels: usize) -> Result { + let raw = unsafe { self.raw.as_ref() }; + let width = raw.x1.checked_sub(raw.x0).ok_or(Error::InvalidInput)?; + let height = raw.y1.checked_sub(raw.y0).ok_or(Error::InvalidInput)?; + checked_pixels(width, height, max_pixels)?; + let components = unsafe { std::slice::from_raw_parts(raw.comps, raw.numcomps as usize) }; + if !(1..=5).contains(&components.len()) { + return Err(Error::InvalidInput); + } + let first = components.first().ok_or(Error::InvalidInput)?; + let mut copied = Vec::new(); + copied + .try_reserve_exact(components.len()) + .map_err(|_| Error::Allocation)?; + for component in components { + if component.w != first.w + || component.h != first.h + || component.data.is_null() + || !(1..=31).contains(&component.prec) + { + return Err(Error::InvalidInput); + } + checked_pixels(component.w, component.h, max_pixels)?; + let length = checked_pixels(component.w, component.h, max_pixels)?; + let source = unsafe { std::slice::from_raw_parts(component.data, length) }; + let mut samples = Vec::new(); + samples + .try_reserve_exact(length) + .map_err(|_| Error::Allocation)?; + samples.extend_from_slice(source); + copied.push(Component { + width: component.w, + height: component.h, + precision: u8::try_from(component.prec).map_err(|_| Error::InvalidInput)?, + signed: component.sgnd != 0, + alpha: component.alpha != 0, + samples, + }); + } + Ok(Image { + width: first.w, + height: first.h, + components: copied, + }) + } +} + +impl Drop for RawImage { + fn drop(&mut self) { + unsafe { ffi::opj_image_destroy(self.raw.as_ptr()) }; + } +} + +struct InputState<'a> { + bytes: &'a [u8], + position: usize, +} + +struct InputStream<'a> { + raw: NonNull, + state: Box>, + _lifetime: PhantomData<&'a [u8]>, +} + +impl<'a> InputStream<'a> { + fn new(bytes: &'a [u8]) -> Result { + let raw = unsafe { ffi::opj_stream_default_create(1) }; + let raw = NonNull::new(raw).ok_or(Error::Allocation)?; + let mut state = Box::new(InputState { bytes, position: 0 }); + unsafe { + ffi::opj_stream_set_read_function(raw.as_ptr(), Some(input_read)); + ffi::opj_stream_set_skip_function(raw.as_ptr(), Some(input_skip)); + ffi::opj_stream_set_seek_function(raw.as_ptr(), Some(input_seek)); + ffi::opj_stream_set_user_data( + raw.as_ptr(), + std::ptr::from_mut(state.as_mut()).cast(), + None, + ); + ffi::opj_stream_set_user_data_length(raw.as_ptr(), bytes.len() as u64); + } + Ok(Self { + raw, + state, + _lifetime: PhantomData, + }) + } +} + +impl Drop for InputStream<'_> { + fn drop(&mut self) { + unsafe { ffi::opj_stream_destroy(self.raw.as_ptr()) }; + // Keep the callback state observably alive until after stream destroy. + let _ = &self.state; + } +} + +struct OutputState { + bytes: Vec, + position: usize, + limit: usize, + limit_exceeded: bool, +} + +struct OutputStream { + raw: NonNull, + state: Box, +} + +impl OutputStream { + fn new(limit: usize) -> Result { + let raw = unsafe { ffi::opj_stream_default_create(0) }; + let raw = NonNull::new(raw).ok_or(Error::Allocation)?; + let mut state = Box::new(OutputState { + bytes: Vec::new(), + position: 0, + limit, + limit_exceeded: false, + }); + unsafe { + ffi::opj_stream_set_write_function(raw.as_ptr(), Some(output_write)); + ffi::opj_stream_set_skip_function(raw.as_ptr(), Some(output_skip)); + ffi::opj_stream_set_seek_function(raw.as_ptr(), Some(output_seek)); + ffi::opj_stream_set_user_data( + raw.as_ptr(), + std::ptr::from_mut(state.as_mut()).cast(), + None, + ); + } + Ok(Self { raw, state }) + } + + fn into_bytes(mut self) -> Result, Error> { + unsafe { ffi::opj_stream_destroy(self.raw.as_ptr()) }; + self.raw = NonNull::dangling(); + if self.state.limit_exceeded || self.state.bytes.is_empty() { + return Err(if self.state.limit_exceeded { + Error::LimitExceeded + } else { + Error::Codec + }); + } + Ok(std::mem::take(&mut self.state.bytes)) + } +} + +impl Drop for OutputStream { + fn drop(&mut self) { + if self.raw != NonNull::dangling() { + unsafe { ffi::opj_stream_destroy(self.raw.as_ptr()) }; + } + } +} + +unsafe extern "C" fn input_read( + output: *mut c_void, + length: usize, + user_data: *mut c_void, +) -> usize { + if output.is_null() || user_data.is_null() || length == 0 { + return usize::MAX; + } + let state = unsafe { &mut *user_data.cast::>() }; + let remaining = state.bytes.len().saturating_sub(state.position); + if remaining == 0 { + return usize::MAX; + } + let count = remaining.min(length); + unsafe { + std::ptr::copy_nonoverlapping( + state.bytes.as_ptr().add(state.position), + output.cast(), + count, + ); + } + state.position += count; + count +} + +unsafe extern "C" fn input_skip(offset: i64, user_data: *mut c_void) -> i64 { + if user_data.is_null() || offset < 0 { + return -1; + } + let state = unsafe { &mut *user_data.cast::>() }; + let Ok(offset) = usize::try_from(offset) else { + return -1; + }; + let Some(position) = state.position.checked_add(offset) else { + return -1; + }; + if position > state.bytes.len() { + return -1; + } + state.position = position; + i64::try_from(offset).unwrap_or(-1) +} + +unsafe extern "C" fn input_seek(position: i64, user_data: *mut c_void) -> i32 { + if user_data.is_null() || position < 0 { + return 0; + } + let state = unsafe { &mut *user_data.cast::>() }; + let Ok(position) = usize::try_from(position) else { + return 0; + }; + if position > state.bytes.len() { + return 0; + } + state.position = position; + 1 +} + +unsafe extern "C" fn output_write( + input: *mut c_void, + length: usize, + user_data: *mut c_void, +) -> usize { + if input.is_null() || user_data.is_null() { + return usize::MAX; + } + let state = unsafe { &mut *user_data.cast::() }; + let Some(end) = state.position.checked_add(length) else { + state.limit_exceeded = true; + return usize::MAX; + }; + if end > state.limit { + state.limit_exceeded = true; + return usize::MAX; + } + if end > state.bytes.len() { + if state.bytes.try_reserve(end - state.bytes.len()).is_err() { + return usize::MAX; + } + state.bytes.resize(end, 0); + } + let source = unsafe { std::slice::from_raw_parts(input.cast::(), length) }; + state.bytes[state.position..end].copy_from_slice(source); + state.position = end; + length +} + +unsafe extern "C" fn output_skip(offset: i64, user_data: *mut c_void) -> i64 { + if user_data.is_null() { + return -1; + } + let state = unsafe { &mut *user_data.cast::() }; + let Ok(offset_isize) = isize::try_from(offset) else { + return -1; + }; + let Some(position) = state.position.checked_add_signed(offset_isize) else { + return -1; + }; + if position > state.limit { + state.limit_exceeded = true; + return -1; + } + state.position = position; + offset +} + +unsafe extern "C" fn output_seek(position: i64, user_data: *mut c_void) -> i32 { + if user_data.is_null() || position < 0 { + return 0; + } + let state = unsafe { &mut *user_data.cast::() }; + let Ok(position) = usize::try_from(position) else { + return 0; + }; + if position > state.limit { + state.limit_exceeded = true; + return 0; + } + state.position = position; + 1 +} + +fn validate_header(raw: *mut ffi::opj_image_t, max_pixels: usize) -> Result<(), Error> { + let raw = unsafe { raw.as_ref() }.ok_or(Error::InvalidInput)?; + checked_pixels( + raw.x1.checked_sub(raw.x0).ok_or(Error::InvalidInput)?, + raw.y1.checked_sub(raw.y0).ok_or(Error::InvalidInput)?, + max_pixels, + )?; + let count = usize::try_from(raw.numcomps).map_err(|_| Error::InvalidInput)?; + if !(1..=5).contains(&count) || raw.comps.is_null() { + return Err(Error::InvalidInput); + } + let components = unsafe { std::slice::from_raw_parts(raw.comps, count) }; + for component in components { + checked_pixels(component.w, component.h, max_pixels)?; + if !(1..=31).contains(&component.prec) { + return Err(Error::InvalidInput); + } + } + Ok(()) +} + +fn checked_pixels(width: u32, height: u32, maximum: usize) -> Result { + if width == 0 || height == 0 { + return Err(Error::InvalidInput); + } + usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .filter(|pixels| *pixels <= maximum) + .ok_or(Error::LimitExceeded) +} + +fn validate_encoded_header(bytes: &[u8], format: Format, max_pixels: usize) -> Result<(), Error> { + match format { + Format::J2k => validate_siz_marker(bytes, max_pixels), + Format::Jp2 => { + let mut position = 0_usize; + while bytes.len().saturating_sub(position) >= 8 { + let box_length = read_u32(bytes, position)?; + let box_type = bytes + .get(position + 4..position + 8) + .ok_or(Error::InvalidInput)?; + let (header_length, length) = if box_length == 1 { + let extended = usize::try_from(read_u64(bytes, position + 8)?) + .map_err(|_| Error::LimitExceeded)?; + (16_usize, extended) + } else if box_length == 0 { + (8_usize, bytes.len() - position) + } else { + ( + 8_usize, + usize::try_from(box_length).map_err(|_| Error::LimitExceeded)?, + ) + }; + if length < header_length { + return Err(Error::InvalidInput); + } + let body = position + .checked_add(header_length) + .ok_or(Error::LimitExceeded)?; + let end = position.checked_add(length).ok_or(Error::LimitExceeded)?; + if end > bytes.len() { + return Err(Error::InvalidInput); + } + if box_type == b"jp2c" { + return validate_siz_marker(&bytes[body..end], max_pixels); + } + position = end; + } + Err(Error::InvalidInput) + } + } +} + +fn validate_siz_marker(codestream: &[u8], max_pixels: usize) -> Result<(), Error> { + if codestream.get(..4) != Some(&[0xff, 0x4f, 0xff, 0x51]) { + return Err(Error::InvalidInput); + } + let length = usize::from(read_u16(codestream, 4)?); + let end = 4_usize.checked_add(length).ok_or(Error::LimitExceeded)?; + if length < 38 || end > codestream.len() { + return Err(Error::InvalidInput); + } + let x1 = read_u32(codestream, 8)?; + let y1 = read_u32(codestream, 12)?; + let x0 = read_u32(codestream, 16)?; + let y0 = read_u32(codestream, 20)?; + if read_u32(codestream, 24)? == 0 || read_u32(codestream, 28)? == 0 { + return Err(Error::InvalidInput); + } + let width = x1.checked_sub(x0).ok_or(Error::InvalidInput)?; + let height = y1.checked_sub(y0).ok_or(Error::InvalidInput)?; + checked_pixels(width, height, max_pixels)?; + + let component_count = usize::from(read_u16(codestream, 40)?); + if !(1..=5).contains(&component_count) + || length != 38 + component_count.checked_mul(3).ok_or(Error::LimitExceeded)? + { + return Err(Error::InvalidInput); + } + for component in 0..component_count { + let offset = 42 + component * 3; + let precision = codestream.get(offset).copied().ok_or(Error::InvalidInput)? & 0x7f; + let dx = u32::from( + codestream + .get(offset + 1) + .copied() + .ok_or(Error::InvalidInput)?, + ); + let dy = u32::from( + codestream + .get(offset + 2) + .copied() + .ok_or(Error::InvalidInput)?, + ); + if precision >= 31 || dx == 0 || dy == 0 { + return Err(Error::InvalidInput); + } + let component_width = ceiling_div(x1, dx)? + .checked_sub(ceiling_div(x0, dx)?) + .ok_or(Error::InvalidInput)?; + let component_height = ceiling_div(y1, dy)? + .checked_sub(ceiling_div(y0, dy)?) + .ok_or(Error::InvalidInput)?; + checked_pixels(component_width, component_height, max_pixels)?; + } + Ok(()) +} + +fn ceiling_div(value: u32, divisor: u32) -> Result { + value + .checked_add(divisor - 1) + .map(|value| value / divisor) + .ok_or(Error::LimitExceeded) +} + +fn read_u16(bytes: &[u8], position: usize) -> Result { + let bytes: [u8; 2] = bytes + .get(position..position.checked_add(2).ok_or(Error::LimitExceeded)?) + .ok_or(Error::InvalidInput)? + .try_into() + .map_err(|_| Error::InvalidInput)?; + Ok(u16::from_be_bytes(bytes)) +} + +fn read_u32(bytes: &[u8], position: usize) -> Result { + let bytes: [u8; 4] = bytes + .get(position..position.checked_add(4).ok_or(Error::LimitExceeded)?) + .ok_or(Error::InvalidInput)? + .try_into() + .map_err(|_| Error::InvalidInput)?; + Ok(u32::from_be_bytes(bytes)) +} + +fn read_u64(bytes: &[u8], position: usize) -> Result { + let bytes: [u8; 8] = bytes + .get(position..position.checked_add(8).ok_or(Error::LimitExceeded)?) + .ok_or(Error::InvalidInput)? + .try_into() + .map_err(|_| Error::InvalidInput)?; + Ok(u64::from_be_bytes(bytes)) +} + +fn maximum_resolution_levels(width: u32, height: u32) -> i32 { + let minimum = width.min(height); + let levels = u32::BITS - minimum.leading_zeros(); + i32::try_from(levels.min(6)).unwrap_or(1).max(1) +} + +const fn codec_format(format: Format) -> ffi::OPJ_CODEC_FORMAT { + match format { + Format::J2k => ffi::OPJ_CODEC_FORMAT::OPJ_CODEC_J2K, + Format::Jp2 => ffi::OPJ_CODEC_FORMAT::OPJ_CODEC_JP2, + } +} + +fn ensure_supported_version() -> Result<(), Error> { + let raw = unsafe { ffi::opj_version() }; + if raw.is_null() { + return Err(Error::Codec); + } + let version = unsafe { CStr::from_ptr(raw) } + .to_str() + .map_err(|_| Error::Codec)?; + let mut components = version.split('.').map(|component| { + component + .bytes() + .take_while(u8::is_ascii_digit) + .try_fold(0_u32, |value, digit| { + value.checked_mul(10)?.checked_add(u32::from(digit - b'0')) + }) + }); + let parsed = ( + components.next().flatten().ok_or(Error::Codec)?, + components.next().flatten().ok_or(Error::Codec)?, + components.next().flatten().ok_or(Error::Codec)?, + ); + if parsed < (2, 5, 4) { + return Err(Error::Codec); + } + Ok(()) +} diff --git a/crates/libremetaverse-structured-data/src/model.rs b/crates/libremetaverse-structured-data/src/model.rs index ebb1b0d..fe884fa 100644 --- a/crates/libremetaverse-structured-data/src/model.rs +++ b/crates/libremetaverse-structured-data/src/model.rs @@ -1694,7 +1694,7 @@ fn object_to_osd(value: Object) -> Result { Object::Vector4(value) => OSD::from_vector4(value)?, Object::Quaternion(value) => OSD::from_quaternion(value)?, Object::Color4(value) => OSD::from_color4(value)?, - Object::Matrix4(_) => OSD::Undefined, + Object::Matrix4(_) | Object::Opaque(_) => OSD::Undefined, }) } diff --git a/crates/libremetaverse-types/src/compat.rs b/crates/libremetaverse-types/src/compat.rs index 3f21477..31f6d23 100644 --- a/crates/libremetaverse-types/src/compat.rs +++ b/crates/libremetaverse-types/src/compat.rs @@ -4,7 +4,9 @@ //! surfaces. Their behavior is implemented only when the owning API slice is //! ported. +use std::any::Any; use std::collections::BTreeMap; +use std::fmt; use std::future::Future; use std::hash::Hash; use std::marker::PhantomData; @@ -20,6 +22,54 @@ pub trait ReadWrite: std::io::Read + std::io::Write + std::io::Seek {} impl ReadWrite for T {} +trait OpaqueValue: Any + fmt::Debug + Send + Sync { + fn as_any(&self) -> &dyn Any; + fn into_any(self: Arc) -> Arc; +} + +impl OpaqueValue for T { + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Arc) -> Arc { + self + } +} + +/// A type-erased reference value used when a mapped `System.Object` carries a +/// project type rather than one of the protocol scalar variants. +/// +/// Equality and hashing use reference identity, matching the default behavior +/// of arbitrary CLR reference objects. The contained value can be recovered +/// with [`Object::downcast_ref`]. +#[derive(Clone)] +pub struct OpaqueObject(Arc); + +impl fmt::Debug for OpaqueObject { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("OpaqueObject") + .field(&self.0) + .finish() + } +} + +impl PartialEq for OpaqueObject { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for OpaqueObject {} + +impl Hash for OpaqueObject { + fn hash(&self, state: &mut H) { + let pointer = Arc::as_ptr(&self.0).cast::<()>(); + pointer.hash(state); + } +} + #[derive(Clone, Debug)] pub enum Object { Undefined, @@ -43,6 +93,34 @@ pub enum Object { Vector3(crate::Vector3), Vector3d(crate::Vector3d), Vector4(crate::Vector4), + /// A project-owned value passed through a mapped `System.Object` boundary. + Opaque(OpaqueObject), +} + +impl Object { + /// Boxes a project-owned value for a mapped `System.Object` parameter. + #[must_use] + pub fn opaque(value: T) -> Self { + Self::Opaque(OpaqueObject(Arc::new(value))) + } + + /// Borrows an opaque value when its concrete type is `T`. + #[must_use] + pub fn downcast_ref(&self) -> Option<&T> { + match self { + Self::Opaque(value) => value.0.as_ref().as_any().downcast_ref(), + _ => None, + } + } + + /// Clones the opaque reference and recovers its concrete shared value. + #[must_use] + pub fn downcast_arc(&self) -> Option> { + match self { + Self::Opaque(value) => value.0.clone().into_any().downcast().ok(), + _ => None, + } + } } impl PartialEq for Object { @@ -69,6 +147,7 @@ impl PartialEq for Object { (Self::Vector3(lhs), Self::Vector3(rhs)) => crate::Vector3::eq(*lhs, *rhs), (Self::Vector3d(lhs), Self::Vector3d(rhs)) => crate::Vector3d::eq(*lhs, *rhs), (Self::Vector4(lhs), Self::Vector4(rhs)) => crate::Vector4::eq(*lhs, *rhs), + (Self::Opaque(lhs), Self::Opaque(rhs)) => lhs == rhs, _ => false, } } @@ -105,6 +184,7 @@ impl std::hash::Hash for Object { Self::Vector3(value) => value.get_hash_code().hash(state), Self::Vector3d(value) => value.get_hash_code().hash(state), Self::Vector4(value) => value.get_hash_code().hash(state), + Self::Opaque(value) => value.hash(state), } } } @@ -379,9 +459,10 @@ pub struct SocketException; #[cfg(test)] mod tests { - use super::{CancellationToken, HttpMessageHandler, HttpRequest, HttpResponse, Uri}; + use super::{CancellationToken, HttpMessageHandler, HttpRequest, HttpResponse, Object, Uri}; use std::collections::BTreeMap; use std::future::Future; + use std::sync::Arc; use std::task::{Context, Poll, Waker}; #[test] @@ -427,4 +508,22 @@ mod tests { .is_success_status_code() ); } + + #[test] + fn opaque_objects_downcast_and_preserve_reference_identity() { + let object = Object::opaque(String::from("managed image boundary")); + assert_eq!( + object.downcast_ref::().map(String::as_str), + Some("managed image boundary") + ); + assert!(object.downcast_ref::>().is_none()); + let first = object.downcast_arc::().expect("shared string"); + let second = object.downcast_arc::().expect("shared string"); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(object, object.clone()); + assert_ne!( + object, + Object::opaque(String::from("managed image boundary")) + ); + } } diff --git a/crates/libremetaverse/Cargo.toml b/crates/libremetaverse/Cargo.toml index e164d73..4f6bd2d 100644 --- a/crates/libremetaverse/Cargo.toml +++ b/crates/libremetaverse/Cargo.toml @@ -10,6 +10,7 @@ description = "Rust rewrite shell for the LibreMetaverse client library" [features] default = ["dds-bc67"] dds-bc67 = ["dep:bcdec_rs"] +jpeg2000 = ["libremetaverse-imaging/jpeg2000"] [dependencies] bcdec_rs = { version = "0.2.0", optional = true } diff --git a/crates/libremetaverse/src/generated.rs b/crates/libremetaverse/src/generated.rs index a4f5430..c317056 100644 --- a/crates/libremetaverse/src/generated.rs +++ b/crates/libremetaverse/src/generated.rs @@ -34036,49 +34036,14 @@ pub mod imaging { } /// C# type: `T:LibreMetaverse.Imaging.ManagedImageCreator`. - pub struct ManagedImageCreator; - impl ManagedImageCreator { - /// C# member: `M:LibreMetaverse.Imaging.ManagedImageCreator.#ctor`. - pub fn new() -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Imaging.ManagedImageCreator.#ctor", - ) - } - /// C# member: `M:LibreMetaverse.Imaging.ManagedImageCreator.Create(System.Int32,System.Int32,System.Int32,System.Byte[])`. - pub fn create( - &self, - width: i32, - height: i32, - num_components: i32, - bytes: Vec, - ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Imaging.ManagedImageCreator.Create(System.Int32,System.Int32,System.Int32,System.Byte[])", - ) - } - /// C# member: `M:LibreMetaverse.Imaging.ManagedImageCreator.ToPortableImageSource(System.Object)`. - pub fn to_portable_image_source( - &self, - image_object: libremetaverse_types::compat::Object, - ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Imaging.ManagedImageCreator.ToPortableImageSource(System.Object)", - ) - } - } + /// C# member: `M:LibreMetaverse.Imaging.ManagedImageCreator.#ctor`. + /// C# member: `M:LibreMetaverse.Imaging.ManagedImageCreator.Create(System.Int32,System.Int32,System.Int32,System.Byte[])`. + /// C# member: `M:LibreMetaverse.Imaging.ManagedImageCreator.ToPortableImageSource(System.Object)`. + pub use crate::j2k::ManagedImageCreator; /// C# type: `T:LibreMetaverse.Imaging.ManagedImageInterleavedExtensions`. - pub struct ManagedImageInterleavedExtensions; - impl ManagedImageInterleavedExtensions { - /// C# member: `M:LibreMetaverse.Imaging.ManagedImageInterleavedExtensions.ToManagedImage(CoreJ2K.Util.InterleavedImage)`. - pub fn to_managed_image( - image: libremetaverse_imaging::codec::InterleavedImage, - ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Imaging.ManagedImageInterleavedExtensions.ToManagedImage(CoreJ2K.Util.InterleavedImage)", - ) - } - } + /// C# member: `M:LibreMetaverse.Imaging.ManagedImageInterleavedExtensions.ToManagedImage(CoreJ2K.Util.InterleavedImage)`. + pub use crate::j2k::ManagedImageInterleavedExtensions; /// C# type: `T:LibreMetaverse.Imaging.Targa`. /// C# member: `M:LibreMetaverse.Imaging.Targa.DecodeToManagedImage(System.IO.Stream)`. diff --git a/crates/libremetaverse/src/j2k.rs b/crates/libremetaverse/src/j2k.rs new file mode 100644 index 0000000..97bf0ba --- /dev/null +++ b/crates/libremetaverse/src/j2k.rs @@ -0,0 +1,435 @@ +//! LibreMetaverse-specific adapters around the format-neutral imaging boundary. + +use libremetaverse_imaging::codec::{BlkImgDataSrc, IImage, IImageCreator, InterleavedImage}; +use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels}; +use libremetaverse_types::Error; +use libremetaverse_types::compat::Object; +use std::any::Any; +use std::sync::Arc; + +const DC_OFFSET: i32 = 128; + +/// CoreJ2K-compatible creator that writes directly into managed image planes. +#[derive(Clone, Copy, Debug, Default)] +pub struct ManagedImageCreator; + +impl ManagedImageCreator { + /// Creates a creator. + /// + /// # Errors + /// + /// This fixed compatibility signature cannot fail. + pub const fn new() -> Result { + Ok(Self) + } + + /// Builds a managed image from already byte-scaled interleaved samples. + /// + /// Component order follows the reference exactly: gray; gray/alpha; RGB; + /// RGBA; or RGB/bump/alpha. + /// + /// # Errors + /// + /// Returns [`Error::Argument`] for invalid dimensions, component counts, + /// or byte lengths and [`Error::InvalidOperation`] on bounded allocation + /// failure. + #[allow(clippy::needless_pass_by_value)] // Fixed mapped `byte[]` ownership signature. + pub fn create( + &self, + width: i32, + height: i32, + num_components: i32, + bytes: Vec, + ) -> Result, Error> { + let components = usize::try_from(num_components).map_err(|_| Error::Argument)?; + let channels = channels_for_components(components)?; + let image = managed_from_interleaved(width, height, channels, components, &bytes)?; + Ok(Box::new(ManagedImageJ2kImage(image))) + } + + /// Adapts a boxed managed image passed through the mapped `System.Object` + /// boundary into the four-component encode source used by `CoreJ2K`. + /// + /// # Errors + /// + /// Returns [`Error::ArgumentNull`] for `Object::Undefined`, + /// [`Error::Argument`] for another object type, or a validation/allocation + /// failure for an inconsistent image. + #[allow(clippy::needless_pass_by_value)] // Fixed mapped `System.Object` ownership signature. + pub fn to_portable_image_source( + &self, + image_object: Object, + ) -> Result, Error> { + if image_object == Object::Undefined { + return Err(Error::ArgumentNull); + } + let image = image_object + .downcast_arc::() + .ok_or(Error::Argument)?; + image.validate()?; + Ok(Box::new(ManagedImageBlockSource { image })) + } + + /// Rust-native strongly typed form of [`Self::to_portable_image_source`]. + /// + /// # Errors + /// + /// Returns [`Error::InvalidOperation`] for an inconsistent public image + /// layout. + pub fn to_portable_image_source_from_managed( + &self, + image: ManagedImage, + ) -> Result, Error> { + image.validate()?; + Ok(Box::new(ManagedImageBlockSource { + image: Arc::new(image), + })) + } +} + +impl IImageCreator for ManagedImageCreator {} + +#[derive(Debug)] +struct ManagedImageJ2kImage(ManagedImage); + +impl IImage for ManagedImageJ2kImage { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_managed_image(&self) -> Option<&ManagedImage> { + Some(&self.0) + } +} + +/// Conversion helpers for the external `CoreJ2K` interleaved-image boundary. +#[derive(Clone, Copy, Debug, Default)] +pub struct ManagedImageInterleavedExtensions; + +impl ManagedImageInterleavedExtensions { + /// Converts original-precision component planes to managed 8-bit planes. + /// + /// # Errors + /// + /// Returns a typed component/layout/allocation error. Component order is + /// gray; gray/alpha; RGB; RGBA; or RGB/bump/alpha. + #[allow(clippy::needless_pass_by_value)] // Fixed mapped external-type ownership signature. + pub fn to_managed_image(image: InterleavedImage) -> Result { + let components = image.number_of_components(); + let channels = channels_for_components(components)?; + let width = image.width(); + let height = image.height(); + let pixels = checked_pixels(width, height)?; + let length = pixels.checked_mul(components).ok_or(Error::Argument)?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(length) + .map_err(|_| Error::InvalidOperation)?; + bytes.resize(length, 0); + let mut plane = vec![0; pixels]; + for component in 0..components { + image.to_component_bytes(component, &mut plane)?; + for (pixel, sample) in plane.iter().enumerate() { + bytes[pixel * components + component] = *sample; + } + } + managed_from_interleaved(width, height, channels, components, &bytes) + } +} + +#[derive(Debug)] +struct ManagedImageBlockSource { + image: Arc, +} + +impl BlkImgDataSrc for ManagedImageBlockSource { + fn width(&self) -> i32 { + self.image.width + } + + fn height(&self) -> i32 { + self.image.height + } + + fn number_of_components(&self) -> usize { + 4 + } + + fn nominal_range_bits(&self, component_index: usize) -> Result { + validate_component_index(component_index)?; + Ok(8) + } + + fn fixed_point(&self, component_index: usize) -> Result { + validate_component_index(component_index)?; + Ok(0) + } + + fn is_original_signed(&self, component_index: usize) -> Result { + validate_component_index(component_index)?; + Ok(false) + } + + fn component_block( + &self, + component_index: usize, + x: i32, + y: i32, + width: i32, + height: i32, + ) -> Result, Error> { + validate_component_index(component_index)?; + self.image.validate()?; + let x = usize::try_from(x).map_err(|_| Error::Argument)?; + let y = usize::try_from(y).map_err(|_| Error::Argument)?; + let width = usize::try_from(width).map_err(|_| Error::Argument)?; + let height = usize::try_from(height).map_err(|_| Error::Argument)?; + let image_width = usize::try_from(self.image.width).map_err(|_| Error::Argument)?; + let image_height = usize::try_from(self.image.height).map_err(|_| Error::Argument)?; + let Some(x_end) = x.checked_add(width) else { + return Err(Error::Argument); + }; + let Some(y_end) = y.checked_add(height) else { + return Err(Error::Argument); + }; + if width == 0 || height == 0 || x_end > image_width || y_end > image_height { + return Err(Error::Argument); + } + let sample_count = width.checked_mul(height).ok_or(Error::Argument)?; + let mut samples = Vec::new(); + samples + .try_reserve_exact(sample_count) + .map_err(|_| Error::InvalidOperation)?; + for row in y..y + height { + for column in x..x + width { + let pixel = row * image_width + column; + samples.push(self.sample(component_index, pixel)? - DC_OFFSET); + } + } + Ok(samples) + } +} + +impl ManagedImageBlockSource { + fn sample(&self, component: usize, pixel: usize) -> Result { + let has_color = self + .image + .channels + .contains(ManagedImageImageChannels::COLOR); + let has_alpha = self + .image + .channels + .contains(ManagedImageImageChannels::ALPHA); + let value = if has_alpha { + if has_color { + match component { + 0 => self.image.red[pixel], + 1 => self.image.green[pixel], + 2 => self.image.blue[pixel], + 3 => self.image.alpha[pixel], + _ => return Err(Error::IndexOutOfRange), + } + } else if component == 3 { + u8::MAX + } else { + self.image.alpha[pixel] + } + } else if has_color { + match component { + 0 => self.image.red[pixel], + 1 => self.image.green[pixel], + 2 => self.image.blue[pixel], + 3 => u8::MAX, + _ => return Err(Error::IndexOutOfRange), + } + } else { + return Err(Error::InvalidOperation); + }; + Ok(i32::from(value)) + } +} + +fn managed_from_interleaved( + width: i32, + height: i32, + channels: ManagedImageImageChannels, + components: usize, + bytes: &[u8], +) -> Result { + let pixels = checked_pixels(width, height)?; + if bytes.len() != pixels.checked_mul(components).ok_or(Error::Argument)? { + return Err(Error::Argument); + } + let mut image = ManagedImage::new(width, height, channels)?; + for pixel in 0..pixels { + let source = pixel * components; + match components { + 1 => image.red[pixel] = bytes[source], + 2 => { + image.red[pixel] = bytes[source]; + image.alpha[pixel] = bytes[source + 1]; + } + 3 => { + image.red[pixel] = bytes[source]; + image.green[pixel] = bytes[source + 1]; + image.blue[pixel] = bytes[source + 2]; + } + 4 => { + image.red[pixel] = bytes[source]; + image.green[pixel] = bytes[source + 1]; + image.blue[pixel] = bytes[source + 2]; + image.alpha[pixel] = bytes[source + 3]; + } + 5 => { + image.red[pixel] = bytes[source]; + image.green[pixel] = bytes[source + 1]; + image.blue[pixel] = bytes[source + 2]; + image.bump[pixel] = bytes[source + 3]; + image.alpha[pixel] = bytes[source + 4]; + } + _ => return Err(Error::Argument), + } + } + Ok(image) +} + +fn checked_pixels(width: i32, height: i32) -> Result { + let width = usize::try_from(width).map_err(|_| Error::Argument)?; + let height = usize::try_from(height).map_err(|_| Error::Argument)?; + if width == 0 || height == 0 { + return Err(Error::Argument); + } + width + .checked_mul(height) + .filter(|pixels| *pixels <= libremetaverse_imaging::DEFAULT_MAX_PIXELS) + .ok_or(Error::Argument) +} + +fn channels_for_components(components: usize) -> Result { + Ok(match components { + 1 => ManagedImageImageChannels::GRAY, + 2 => ManagedImageImageChannels::GRAY | ManagedImageImageChannels::ALPHA, + 3 => ManagedImageImageChannels::COLOR, + 4 => ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA, + 5 => { + ManagedImageImageChannels::COLOR + | ManagedImageImageChannels::BUMP + | ManagedImageImageChannels::ALPHA + } + _ => return Err(Error::Argument), + }) +} + +fn validate_component_index(component_index: usize) -> Result<(), Error> { + if component_index < 4 { + Ok(()) + } else { + Err(Error::IndexOutOfRange) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use libremetaverse_imaging::codec::InterleavedComponent; + + #[test] + fn creator_maps_all_reference_component_orders() { + let creator = ManagedImageCreator::new().unwrap(); + let cases = [ + (1, vec![1], ManagedImageImageChannels::GRAY), + ( + 2, + vec![1, 2], + ManagedImageImageChannels::GRAY | ManagedImageImageChannels::ALPHA, + ), + (3, vec![1, 2, 3], ManagedImageImageChannels::COLOR), + ( + 4, + vec![1, 2, 3, 4], + ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA, + ), + ( + 5, + vec![1, 2, 3, 4, 5], + ManagedImageImageChannels::COLOR + | ManagedImageImageChannels::BUMP + | ManagedImageImageChannels::ALPHA, + ), + ]; + for (components, bytes, channels) in cases { + let wrapper = creator.create(1, 1, components, bytes).unwrap(); + let image = wrapper.as_managed_image().expect("managed image"); + assert_eq!(image.channels, channels); + assert_eq!(image.red, [1]); + if components >= 3 { + assert_eq!(image.green, [2]); + assert_eq!(image.blue, [3]); + } + if components == 2 || components == 4 { + assert_eq!(image.alpha[0], u8::try_from(components).unwrap()); + } + if components == 5 { + assert_eq!(image.bump, [4]); + assert_eq!(image.alpha, [5]); + } + } + assert!(matches!( + creator.create(1, 1, 4, vec![0; 3]), + Err(Error::Argument) + )); + } + + #[test] + fn interleaved_extension_scales_samples_and_keeps_bump_before_alpha() { + let components = vec![ + InterleavedComponent::new(16, false, false, vec![65_535]).unwrap(), + InterleavedComponent::new(8, false, false, vec![2]).unwrap(), + InterleavedComponent::new(8, false, false, vec![3]).unwrap(), + InterleavedComponent::new(8, false, false, vec![4]).unwrap(), + InterleavedComponent::new(8, false, true, vec![5]).unwrap(), + ]; + let image = InterleavedImage::new(1, 1, components).unwrap(); + let image = ManagedImageInterleavedExtensions::to_managed_image(image).unwrap(); + assert_eq!(image.red, [255]); + assert_eq!(image.green, [2]); + assert_eq!(image.blue, [3]); + assert_eq!(image.bump, [4]); + assert_eq!(image.alpha, [5]); + } + + #[test] + fn portable_source_matches_dc_offset_and_channel_substitution() { + let creator = ManagedImageCreator::new().unwrap(); + let mut image = ManagedImage::new( + 2, + 1, + ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA, + ) + .unwrap(); + image.red.copy_from_slice(&[0, 255]); + image.green.copy_from_slice(&[1, 2]); + image.blue.copy_from_slice(&[3, 4]); + image.alpha.copy_from_slice(&[5, 6]); + let source = creator + .to_portable_image_source(Object::opaque(image)) + .unwrap(); + assert_eq!(source.component_block(0, 0, 0, 2, 1).unwrap(), [-128, 127]); + assert_eq!(source.component_block(3, 0, 0, 2, 1).unwrap(), [-123, -122]); + assert_eq!(source.nominal_range_bits(0), Ok(8)); + assert_eq!(source.fixed_point(0), Ok(0)); + assert_eq!(source.is_original_signed(0), Ok(false)); + assert_eq!( + source.component_block(4, 0, 0, 1, 1), + Err(Error::IndexOutOfRange) + ); + + let mut alpha = ManagedImage::new(1, 1, ManagedImageImageChannels::ALPHA).unwrap(); + alpha.alpha[0] = 42; + let source = creator + .to_portable_image_source(Object::opaque(alpha)) + .unwrap(); + assert_eq!(source.component_block(0, 0, 0, 1, 1).unwrap(), [-86]); + assert_eq!(source.component_block(3, 0, 0, 1, 1).unwrap(), [127]); + } +} diff --git a/crates/libremetaverse/src/lib.rs b/crates/libremetaverse/src/lib.rs index e1f65df..d0ad152 100644 --- a/crates/libremetaverse/src/lib.rs +++ b/crates/libremetaverse/src/lib.rs @@ -3,6 +3,7 @@ extern crate self as libremetaverse; mod generated; +mod j2k; mod targa; #[cfg(test)] diff --git a/tests/red-suite-baseline.json b/tests/red-suite-baseline.json index da49481..c2ee820 100644 --- a/tests/red-suite-baseline.json +++ b/tests/red-suite-baseline.json @@ -101,5 +101,5 @@ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeUUID::test", "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeUndef::test" ], - "support_passes": 109 + "support_passes": 115 } diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index 5e4d720..a32df96 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -38,6 +38,8 @@ TARGETS = { # implementations. The generated module keeps catalog markers and re-exports # the hand-written type so coverage remains deterministic. NATIVE_TYPES = { + "T:LibreMetaverse.Imaging.ManagedImageCreator": "crate::j2k::ManagedImageCreator", + "T:LibreMetaverse.Imaging.ManagedImageInterleavedExtensions": "crate::j2k::ManagedImageInterleavedExtensions", "T:LibreMetaverse.Imaging.Targa": "crate::targa::Targa", "T:LibreMetaverse.Imaging.ITextureCodec": "crate::managed_image::ITextureCodec", "T:LibreMetaverse.Imaging.ManagedImage": "crate::managed_image::ManagedImage",