diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index a9aab10..3ed860e 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -19,6 +19,11 @@ on: description: Optional Chocolatey libreoffice-fresh package version. Empty uses latest. required: false type: string + stage_test_build: + description: Stage an owner-only draft test build instead of updating the version release. + required: false + default: false + type: boolean permissions: contents: write @@ -142,9 +147,24 @@ jobs: GH_TOKEN: ${{ github.token }} run: | $version = node -p "require('./package.json').version" - $tag = "v$version" + $testMode = '${{ github.event.inputs.stage_test_build }}' -eq 'true' + $shortSha = $env:GITHUB_SHA.Substring(0, 7) + $tag = if ($testMode) { "test-windows-$version-$shortSha" } else { "v$version" } $notesPath = ".github/release-notes/$tag.md" - if (-not (Test-Path $notesPath)) { throw "Release notes not found at $notesPath" } + if (-not $testMode -and -not (Test-Path $notesPath)) { + throw "Release notes not found at $notesPath" + } + + if ($testMode) { + $notesPath = Join-Path $env:RUNNER_TEMP 'windows-test-build.md' + @" + Temporary unsigned Windows x64 test build for issue #14. + + - Exact source commit: `$env:GITHUB_SHA` + - This is not a release and must not be redistributed. + - Verify the accompanying SHA-256 before testing. + "@ | Set-Content -Path $notesPath -Encoding utf8NoBOM + } $installer = @(Get-ChildItem -Path src-tauri/target/release/bundle/nsis -Filter '*-setup.exe') if ($installer.Count -ne 1) { throw "Expected one NSIS installer, found $($installer.Count)." } @@ -181,11 +201,15 @@ jobs: $isDraft = gh release view $tag --json isDraft --jq '.isDraft' if ($isDraft -ne 'true') { throw "Refusing to modify published release $tag." } } else { - gh release create $tag ` - --verify-tag ` - --draft ` - --title "OffPDF $tag" ` - --notes-file $notesPath + $releaseArgs = @( + 'release', 'create', $tag, + '--verify-tag', + '--draft', + '--title', $(if ($testMode) { "OffPDF Windows test build ($shortSha)" } else { "OffPDF $tag" }), + '--notes-file', $notesPath + ) + if ($testMode) { $releaseArgs += '--prerelease' } + & gh @releaseArgs if ($LASTEXITCODE -ne 0) { throw "Could not create draft release $tag." } } @@ -199,7 +223,8 @@ jobs: gh release upload $tag $installer[0].FullName $checksumPath if ($LASTEXITCODE -ne 0) { throw "Could not upload Windows assets to $tag." } - "Staged unsigned Windows installer in draft release $tag." | Add-Content -Path $env:GITHUB_STEP_SUMMARY + $mode = if ($testMode) { 'owner-only test draft' } else { 'version draft' } + "Staged unsigned Windows installer in $mode $tag." | Add-Content -Path $env:GITHUB_STEP_SUMMARY - name: List bundle outputs shell: pwsh diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a1ba702..e331556 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1720,8 +1720,6 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "heif-rs" version = "26.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1864e5d0f2f9e82db410c2be5bdfd419909d9018ed82702481603a879a33a6d4" dependencies = [ "bindgen", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 74b3e3c..31d4e36 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,7 +42,7 @@ image = { version = "0.25", default-features = false, features = [ ] } # Decode iPhone/iPad HEIC and HEIF photos. libheif is built and linked into the # app so users do not need to install a system codec or upload their photos. -heif-rs = "26.7" +heif-rs = { path = "vendor/heif-rs" } [features] # By default Tauri uses custom protocols. Keep network features off. diff --git a/src-tauri/src/pdf_engine/compress.rs b/src-tauri/src/pdf_engine/compress.rs index 11b1505..f9a60d6 100644 --- a/src-tauri/src/pdf_engine/compress.rs +++ b/src-tauri/src/pdf_engine/compress.rs @@ -355,7 +355,10 @@ fn decode_heif(path: &Path) -> Result { } validate_heif_limits(metadata.len(), width, height, decoded_bytes)?; - let image = image::DynamicImage::from_decoder(decoder.with_threads(1)).map_err(|error| { + // Keep libheif's tile decoding on the calling thread. Its codec backend may + // still manage its own workers, but this removes libheif's background tile + // thread from the FFI path while decoding untrusted input. + let image = image::DynamicImage::from_decoder(decoder.with_threads(0)).map_err(|error| { AppError::new( "INVALID_IMAGE", "Could not decode this HEIC/HEIF image", diff --git a/src-tauri/vendor/heif-rs/.gitignore b/src-tauri/vendor/heif-rs/.gitignore new file mode 100644 index 0000000..e9e2199 --- /dev/null +++ b/src-tauri/vendor/heif-rs/.gitignore @@ -0,0 +1,2 @@ +/target/ +/Cargo.lock diff --git a/src-tauri/vendor/heif-rs/Cargo.toml b/src-tauri/vendor/heif-rs/Cargo.toml new file mode 100644 index 0000000..bac72ce --- /dev/null +++ b/src-tauri/vendor/heif-rs/Cargo.toml @@ -0,0 +1,71 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "heif-rs" +version = "26.7.0" +authors = ["Vinicius Egidio "] +build = "build.rs" +links = "heif" +exclude = [ + ".cargo/", + ".github/", + ".idea/", + ".editorconfig", + ".gitignore", + "assets/", + "examples/", + "scripts/", + "rustfmt.toml", +] +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Encode and decode HEIF/HEIC images with x265 and libde265, via statically-linked libheif." +homepage = "https://github.com/vegidio/heif-rs" +readme = "README.md" +keywords = [ + "heif", + "heic", + "image", + "hevc", + "encoding", +] +categories = [ + "multimedia::images", + "encoding", +] +license = "Apache-2.0" +repository = "https://github.com/vegidio/heif-rs" + +[lib] +name = "heif" +path = "src/lib.rs" + +[dependencies.image] +version = "0.25" + +[dependencies.thiserror] +version = "2" + +[build-dependencies.bindgen] +version = "0.72.1" + +[build-dependencies.ureq] +version = "3" + +[build-dependencies.zip] +version = "8.6.0" +features = ["deflate"] +default-features = false diff --git a/src-tauri/vendor/heif-rs/Cargo.toml.orig b/src-tauri/vendor/heif-rs/Cargo.toml.orig new file mode 100644 index 0000000..f70cc41 --- /dev/null +++ b/src-tauri/vendor/heif-rs/Cargo.toml.orig @@ -0,0 +1,27 @@ +[package] +name = "heif-rs" +version = "26.7.0" +edition = "2024" +links = "heif" +build = "build.rs" +description = "Encode and decode HEIF/HEIC images with x265 and libde265, via statically-linked libheif." +license = "Apache-2.0" +repository = "https://github.com/vegidio/heif-rs" +homepage = "https://github.com/vegidio/heif-rs" +readme = "README.md" +authors = ["Vinicius Egidio "] +keywords = ["heif", "heic", "image", "hevc", "encoding"] +categories = ["multimedia::images", "encoding"] +exclude = [".cargo/", ".github/", ".idea/", ".editorconfig", ".gitignore", "assets/", "examples/", "scripts/", "rustfmt.toml"] + +[lib] +name = "heif" + +[dependencies] +image = "0.25" +thiserror = "2" + +[build-dependencies] +bindgen = "0.72.1" +ureq = "3" +zip = { version = "8.6.0", default-features = false, features = ["deflate"] } diff --git a/src-tauri/vendor/heif-rs/LICENSE b/src-tauri/vendor/heif-rs/LICENSE new file mode 100644 index 0000000..17989b2 --- /dev/null +++ b/src-tauri/vendor/heif-rs/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Vinicius Egidio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/src-tauri/vendor/heif-rs/OFFPDF_PATCHES.md b/src-tauri/vendor/heif-rs/OFFPDF_PATCHES.md new file mode 100644 index 0000000..acb95c6 --- /dev/null +++ b/src-tauri/vendor/heif-rs/OFFPDF_PATCHES.md @@ -0,0 +1,17 @@ +# OffPDF patches + +This directory vendors `heif-rs` 26.7.0 (Apache-2.0) because OffPDF decodes +untrusted HEIC/HEIF files through its native libheif FFI boundary. + +The local decoder patch: + +- rejects null handles and decoded-image pointers returned across FFI; +- uses libheif's `size_t`-based `heif_image_get_plane_readonly2` API instead + of the deprecated `int`-stride variant; +- validates row stride, destination length, and pointer-offset arithmetic + before copying decoded pixels; and +- releases a partially returned image on decode errors. + +Keep the original `LICENSE` file and rebase these changes when upgrading the +vendored crate. Remove the vendored copy once an equivalent upstream release is +available and has passed OffPDF's real-device Windows regression test. diff --git a/src-tauri/vendor/heif-rs/README.md b/src-tauri/vendor/heif-rs/README.md new file mode 100644 index 0000000..214485c --- /dev/null +++ b/src-tauri/vendor/heif-rs/README.md @@ -0,0 +1,102 @@ +# heif-rs + +A Rust library to encode and decode HEIF/HEIC images using x265 and libde265, via statically-linked libheif. + +## ⬇️ Installation + +This library can be installed using Cargo. To do that, run the following command in your project's root directory: + +```bash +cargo add heif-rs +``` + +The crate links as `heif`, so you import it with `use heif;` regardless of the package name. + +> [!NOTE] +> The first build downloads the prebuilt static binaries for your platform, so an internet connection is required (see [Troubleshooting](#-troubleshooting) for offline builds). + +## 🤖 Usage + +Here are some examples of how to encode and decode HEIC images using this library. These snippets don't have any error handling for the sake of simplicity, but you should always check for errors in production code. + +#### Encoding + +```rust +let img = image::open("/path/to/image.png").unwrap(); // an image to be encoded +let bytes = heif::encode(&img).unwrap(); // encode the image with default settings +std::fs::write("/path/to/image.heic", &bytes).unwrap(); // save the HEIC to a file +``` + +#### Encoding with custom settings + +```rust +use heif::{Chroma, HeifEncoder, Preset}; +use image::ImageEncoder; + +let img = image::open("/path/to/image.png").unwrap(); +let mut bytes = Vec::new(); +img.write_with_encoder( + HeifEncoder::new(&mut bytes) + .with_quality(80) // 0–100, higher = better quality + .with_preset(Preset::Slow) // x265 speed preset (slower = better compression) + .with_chroma(Chroma::Yuv420), // 4:2:0 / 4:2:2 / 4:4:4 +).unwrap(); +``` + +#### Decoding + +```rust +let bytes = std::fs::read("/path/to/image.heic").unwrap(); // read the HEIC file +let img = heif::decode(&bytes).unwrap(); // decode it into a DynamicImage +img.save("/path/to/image.png").unwrap(); // save it in another format +``` + +#### Probing (header only) + +Read the image dimensions and bit depth without decoding the pixels — useful for validation or thumbnailing pipelines: + +```rust +let bytes = std::fs::read("/path/to/image.heic").unwrap(); +let info = heif::probe(&bytes).unwrap(); +println!("{}x{} @ {:?}", info.width, info.height, info.bit_depth); +``` + +The public API also exposes [`encode_buffer`] (encode a typed `ImageBuffer` directly), [`HeifEncoder`] / [`HeifDecoder`] for `image`-trait integration, [`EncoderConfig`] / [`DecoderConfig`] for full control, and [`libheif_version`]. + +#### Runnable examples + +The [`examples/`](examples) directory has standalone programs covering each part of the API, runnable out of the box against the bundled assets: + +```bash +cargo run --example encode # encode with defaults +cargo run --example decode # decode a HEIC to PNG +cargo run --example custom_encoder # HeifEncoder builder (quality/preset/chroma) +cargo run --example encode_buffer # encode a typed ImageBuffer +cargo run --example probe # read the header without decoding pixels +cargo run --example roundtrip # encode then decode +cargo run --example high_bit_depth # 10-bit encoding via EncoderConfig +cargo run --example parallel_encode # concurrent encoding +cargo run --example version # print the linked libheif version +``` + +## 💣 Troubleshooting + +### High bit depth (10/12-bit) encoding fails + +libheif supports 8/10/12-bit HEIC for both encode and decode. **Decoding** high-bit-depth HEIC works out of the box (via libde265). **Encoding** 10/12-bit, however, depends on the bundled x265 binary, which is currently an **8-bit build** — requesting 10/12-bit output makes libheif return an encoder error. If you need high-bit-depth encoding, supply your own libheif/x265 binaries built with high-bit-depth support via the `HEIF_BINARIES_DIR` environment variable (see below). + +### My build fails because it can't download the binaries + +The first build fetches the prebuilt static libraries for your platform over the network. For offline or air-gapped builds, download the archive for your target from [binaries-heif](https://github.com/vegidio/binaries-heif/releases), extract it, and point the build at it with the `HEIF_BINARIES_DIR` environment variable: + +``` +$ HEIF_BINARIES_DIR=/path/to/extracted/libs cargo build +``` + +## 📝 License + +**heif-rs** is released under the Apache 2.0 License. See [LICENSE](LICENSE) for details. + +## 👨🏾‍💻 Author + +Vinicius Egidio ([vinicius.io](http://vinicius.io)) diff --git a/src-tauri/vendor/heif-rs/build.rs b/src-tauri/vendor/heif-rs/build.rs new file mode 100644 index 0000000..baa6d10 --- /dev/null +++ b/src-tauri/vendor/heif-rs/build.rs @@ -0,0 +1,228 @@ +//! Build script for `heif-rs`. +//! +//! At build time this script obtains the prebuilt **static** libheif binaries (and the codec/support libraries it +//! depends on) for the current target, links them statically into the crate, and generates the raw FFI bindings from +//! the bundled `heif.h` header. +//! +//! Binaries come from: https://github.com/vegidio/binaries-heif/releases +//! +//! The binaries are normally downloaded from the pinned release and cached under the build's `OUT_DIR`. To build +//! offline (or against a custom build of libheif), set the `HEIF_BINARIES_DIR` environment variable to a directory that +//! contains `include/` and `lib/` subdirectories laid out like the release archives. + +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Version of the `binaries-heif` release to download. +const VERSION: &str = "26.7.0"; + +/// Static archives ship these libraries. Listed dependents-before-dependencies so that GNU ld's single-pass +/// resolution finds every symbol: libheif uses x265 to encode HEVC and libde265 to decode it. These are the +/// macOS/Linux/mingw (`.a`) basenames; the MSVC `.lib` archive names them differently — see `emit_link_directives`. +const STATIC_LIBS: &[&str] = &["heif", "x265", "de265"]; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=HEIF_BINARIES_DIR"); + + let binaries_dir = locate_binaries(); + let lib_dir = binaries_dir.join("lib"); + let include_dir = binaries_dir.join("include"); + + emit_link_directives(&lib_dir); + generate_bindings(&include_dir); +} + +/// Returns the directory containing the `include/` and `lib/` subdirectories, either from the `HEIF_BINARIES_DIR` +/// override or by downloading + extracting the pinned release. +fn locate_binaries() -> PathBuf { + if let Ok(dir) = env::var("HEIF_BINARIES_DIR") { + let dir = PathBuf::from(dir); + assert!( + dir.join("include").is_dir() && dir.join("lib").is_dir(), + "HEIF_BINARIES_DIR ({}) must contain `include/` and `lib/` subdirectories", + dir.display() + ); + return dir; + } + + download_and_extract() +} + +/// Downloads the static archive for the current target into `OUT_DIR` and extracts it. +/// Extraction is skipped if a previous build already populated the cache directory. +fn download_and_extract() -> PathBuf { + let archive = archive_name(); + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set")); + let cache_dir = out_dir.join(format!("binaries-heif-{VERSION}")); + + // Idempotent: a fully extracted cache from a previous build is reused as-is. + if cache_dir.join("lib").is_dir() && cache_dir.join("include").is_dir() { + return cache_dir; + } + + let url = format!("https://github.com/vegidio/binaries-heif/releases/download/{VERSION}/{archive}"); + eprintln!("heif-rs: downloading {url}"); + + let bytes = download(&url); + extract_zip(&bytes, &cache_dir); + cache_dir +} + +/// Maps the Cargo target triple components to the release archive file name, +/// e.g. `static_osx_arm64.zip`. +fn archive_name() -> String { + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); + + let os = match target_os.as_str() { + "linux" => "linux", + "macos" => "osx", + "windows" => "windows", + other => panic!("heif-rs: unsupported target OS `{other}`"), + }; + + let arch = match target_arch.as_str() { + "x86_64" => "x64", + "aarch64" => "arm64", + other => panic!("heif-rs: unsupported target architecture `{other}`"), + }; + + format!("static_{os}_{arch}.zip") +} + +/// Downloads the given URL into memory, following redirects. +fn download(url: &str) -> Vec { + let mut reader = ureq::get(url) + .call() + .unwrap_or_else(|e| panic!("heif-rs: failed to download {url}: {e}")) + .into_body() + .into_reader(); + + let mut bytes = Vec::new(); + io::copy(&mut reader, &mut bytes) + .unwrap_or_else(|e| panic!("heif-rs: failed to read response body from {url}: {e}")); + bytes +} + +/// Extracts a zip archive (held entirely in memory) into `dest`. +fn extract_zip(bytes: &[u8], dest: &Path) { + let reader = io::Cursor::new(bytes); + let mut zip = zip::ZipArchive::new(reader).expect("heif-rs: invalid zip archive"); + + for i in 0..zip.len() { + let mut entry = zip.by_index(i).expect("heif-rs: corrupt zip entry"); + let Some(rel_path) = entry.enclosed_name() else { + continue; // skip unsafe / absolute paths + }; + let out_path = dest.join(rel_path); + + if entry.is_dir() { + fs::create_dir_all(&out_path).unwrap(); + continue; + } + + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut out_file = fs::File::create(&out_path) + .unwrap_or_else(|e| panic!("heif-rs: cannot create {}: {e}", out_path.display())); + io::copy(&mut entry, &mut out_file).expect("heif-rs: failed to extract file"); + } +} + +/// Tells Cargo/rustc where the static libraries live and which ones to link, including the C++ runtime and system +/// libraries the codecs depend on. +fn emit_link_directives(lib_dir: &Path) { + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); + + // The MSVC Windows archive names its static libs `heif.lib`, `x265-static.lib` and + // `libde265.lib`, so the link names differ from the `.a` archives used everywhere else. + let libs: &[&str] = if target_os == "windows" { + &["heif", "x265-static", "libde265"] + } else { + STATIC_LIBS + }; + + for lib in libs { + println!("cargo:rustc-link-lib=static={lib}"); + } + + // libheif, x265, and libde265 are compiled C++, so the C++ runtime and a few system + // libs must be linked. + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); + match target_os.as_str() { + "macos" => { + println!("cargo:rustc-link-lib=dylib=c++"); + // On x86_64, libde265's runtime CPU-feature detection (`__builtin_cpu_supports`) + // references `__cpu_model` / `__cpu_indicator_init`, which live in clang's + // compiler-rt builtins rather than libSystem. rustc links with `-nodefaultlibs`, so + // they must be pulled in explicitly. (arm64 has no such CPUID path.) + if target_arch == "x86_64" { + link_compiler_rt_builtins(); + } + } + "linux" => { + println!("cargo:rustc-link-lib=dylib=stdc++"); + println!("cargo:rustc-link-lib=dylib=m"); + println!("cargo:rustc-link-lib=dylib=pthread"); + println!("cargo:rustc-link-lib=dylib=dl"); + } + "windows" => { + // The release ships MSVC `.lib` archives, which link the C/C++ runtime + // automatically via the objects' default-lib directives. x265's thread pool, however, + // reads the Windows registry (`RegOpenKeyExA` etc.), so its home — advapi32 — must be + // linked explicitly. + println!("cargo:rustc-link-lib=dylib=advapi32"); + } + _ => {} + } +} + +/// Links clang's compiler-rt builtins so the x86 CPU-feature symbols (`__cpu_model` / +/// `__cpu_indicator_init`) that libde265 references resolve. Locates the archive via +/// `clang -print-runtime-dir`; silently does nothing if clang can't be queried, in which case the +/// linker simply reports the missing symbols as before. +fn link_compiler_rt_builtins() { + let cc = env::var("CC").unwrap_or_else(|_| "clang".into()); + let Ok(output) = std::process::Command::new(cc).arg("-print-runtime-dir").output() else { + return; + }; + if !output.status.success() { + return; + } + let dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if dir.is_empty() { + return; + } + // `libclang_rt.osx.a` provides the builtins for every macOS arch. + println!("cargo:rustc-link-search=native={dir}"); + println!("cargo:rustc-link-lib=static=clang_rt.osx"); +} + +/// Generates raw FFI bindings from `include/libheif/heif.h` into `OUT_DIR/bindings.rs`. +fn generate_bindings(include_dir: &Path) { + let header = include_dir.join("libheif").join("heif.h"); + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + + let bindings = bindgen::Builder::default() + .header(header.to_string_lossy()) + .clang_arg(format!("-I{}", include_dir.display())) + // Keep the output focused on the libheif surface. + .allowlist_function("heif_.*") + .allowlist_type("heif_.*") + .allowlist_var("heif_.*") + .allowlist_var("LIBHEIF.*") + .generate_comments(false) + .layout_tests(false) + .generate() + .expect("heif-rs: failed to generate bindings from heif.h"); + + bindings + .write_to_file(out_dir.join("bindings.rs")) + .expect("heif-rs: failed to write bindings.rs"); +} diff --git a/src-tauri/vendor/heif-rs/src/decoder.rs b/src-tauri/vendor/heif-rs/src/decoder.rs new file mode 100644 index 0000000..63ec651 --- /dev/null +++ b/src-tauri/vendor/heif-rs/src/decoder.rs @@ -0,0 +1,364 @@ +//! HEIF decoder, mirroring the `image` crate's per-format decoder convention. +//! +//! [`HeifDecoder`] is generic over a [`Read`] source and implements [`ImageDecoder`], so it slots into +//! `DynamicImage::from_decoder` exactly like the codecs that ship with the `image` crate (e.g. `JpegDecoder`, +//! `PngDecoder`). Decoding uses libde265 (HEVC) under the hood. + +use std::io::Read; +use std::ptr; + +use image::error::{DecodingError, ImageFormatHint}; +use image::{ColorType, ImageDecoder, ImageError, ImageResult}; + +use crate::error::HeifError; +use crate::ffi; +use crate::info::BitDepth; +use crate::sys; + +/// Tunable parameters for the HEVC decoder. +#[derive(Default)] +pub struct DecoderConfig { + /// Worker threads; `None` = auto-detect. + pub threads: Option, +} + +/// HEIF decoder reading from `R`, using libde265. +/// +/// The container header is parsed eagerly in [`new`](HeifDecoder::new) so that +/// [`dimensions`](ImageDecoder::dimensions), [`color_type`](ImageDecoder::color_type), and +/// [`bit_depth`](HeifDecoder::bit_depth) are available before the frame is decoded. +/// +/// # Example +/// ```no_run +/// use heif::HeifDecoder; +/// use image::DynamicImage; +/// use std::io::Cursor; +/// +/// # let bytes: Vec = Vec::new(); +/// let decoder = HeifDecoder::new(Cursor::new(&bytes))?; +/// let img = DynamicImage::from_decoder(decoder)?; +/// # Ok::<(), image::ImageError>(()) +/// ``` +pub struct HeifDecoder { + /// Raw libheif context; freed in `Drop`. + context: *mut sys::heif_context, + /// Primary image handle parsed from the container; released in `Drop`. + handle: *mut sys::heif_image_handle, + /// Owned compressed bytes. libheif's memory IO references this buffer without copying, so it must outlive + /// `context`. Never moved out. + _data: Vec, + config: DecoderConfig, + width: u32, + height: u32, + depth: u32, + alpha_present: bool, + /// Marker to keep the `R` type parameter; the reader is fully drained in `new`. + _reader: std::marker::PhantomData, +} + +impl HeifDecoder { + /// Create a decoder from `r`, reading the container header eagerly so that [`dimensions`](ImageDecoder::dimensions) + /// and [`color_type`](ImageDecoder::color_type) are available before the frame is decoded. + pub fn new(mut r: R) -> ImageResult { + let mut data = Vec::new(); + r.read_to_end(&mut data).map_err(ImageError::IoError)?; + + ffi::init(); + + // SAFETY: pointers are checked; the context/handle are freed on every error path and in `Drop`. `data` outlives + // `context` (stored alongside it below). + unsafe { + let context = sys::heif_context_alloc(); + if context.is_null() { + return Err(to_image_error(HeifError::DecoderInit( + "heif_context_alloc returned null".into(), + ))); + } + + if let Err(m) = ffi::check(sys::heif_context_read_from_memory_without_copy( + context, + data.as_ptr() as *const std::ffi::c_void, + data.len(), + ptr::null(), + )) { + sys::heif_context_free(context); + return Err(to_image_error(HeifError::Decode(m))); + } + + let mut handle: *mut sys::heif_image_handle = ptr::null_mut(); + if let Err(m) = ffi::check(sys::heif_context_get_primary_image_handle( + context, + &mut handle, + )) { + sys::heif_context_free(context); + return Err(to_image_error(HeifError::Decode(m))); + } + + if handle.is_null() { + sys::heif_context_free(context); + return Err(to_image_error(HeifError::Decode( + "heif_context_get_primary_image_handle returned null".into(), + ))); + } + + let width = sys::heif_image_handle_get_width(handle); + let height = sys::heif_image_handle_get_height(handle); + if width <= 0 || height <= 0 { + sys::heif_image_handle_release(handle); + sys::heif_context_free(context); + return Err(to_image_error(HeifError::Decode( + "primary image reported invalid dimensions".into(), + ))); + } + let depth = sys::heif_image_handle_get_luma_bits_per_pixel(handle).max(0) as u32; + let alpha_present = sys::heif_image_handle_has_alpha_channel(handle) != 0; + + Ok(Self { + context, + handle, + width: width as u32, + height: height as u32, + depth, + alpha_present, + _data: data, + config: DecoderConfig::default(), + _reader: std::marker::PhantomData, + }) + } + } + + /// Set the number of decode worker threads (applied when the frame is decoded). + pub fn with_threads(mut self, threads: u32) -> Self { + self.config.threads = Some(threads); + self + } + + /// Bit depth of the image — extra information that [`ColorType`] cannot express (it only distinguishes 8- vs + /// 16-bit). + pub fn bit_depth(&self) -> BitDepth { + match self.depth { + 12 => BitDepth::Twelve, + 10 => BitDepth::Ten, + _ => BitDepth::Eight, + } + } + + /// Channels in the decoded interleaved output (3 without alpha, 4 with). + fn channels(&self) -> usize { + if self.alpha_present { 4 } else { 3 } + } + + /// Bytes per output sample (1 for 8-bit, 2 for >8-bit). + fn sample_bytes(&self) -> usize { + if self.depth > 8 { 2 } else { 1 } + } + + /// libheif interleaved chroma format matching this image's depth and alpha. + fn decode_chroma(&self) -> sys::heif_chroma { + match (self.depth > 8, self.alpha_present) { + (false, false) => sys::heif_chroma_heif_chroma_interleaved_RGB, + (false, true) => sys::heif_chroma_heif_chroma_interleaved_RGBA, + (true, false) => sys::heif_chroma_heif_chroma_interleaved_RRGGBB_LE, + (true, true) => sys::heif_chroma_heif_chroma_interleaved_RRGGBBAA_LE, + } + } +} + +impl ImageDecoder for HeifDecoder { + fn dimensions(&self) -> (u32, u32) { + (self.width, self.height) + } + + fn color_type(&self) -> ColorType { + match (self.depth > 8, self.alpha_present) { + (false, false) => ColorType::Rgb8, + (false, true) => ColorType::Rgba8, + (true, false) => ColorType::Rgb16, + (true, true) => ColorType::Rgba16, + } + } + + fn read_image(self, buf: &mut [u8]) -> ImageResult<()> { + let expected = (self.width as usize) + .checked_mul(self.height as usize) + .and_then(|n| n.checked_mul(self.channels())) + .and_then(|n| n.checked_mul(self.sample_bytes())) + .ok_or_else(|| { + to_image_error(HeifError::Decode( + "decoded image dimensions overflow the output buffer size".into(), + )) + })?; + if buf.len() != expected { + return Err(to_image_error(HeifError::Decode(format!( + "output buffer length {} does not match expected {expected}", + buf.len() + )))); + } + + // SAFETY: `self.context`/`self.handle` are valid handles created and parsed in `new`. + unsafe { + if let Some(threads) = self.config.threads { + let threads = i32::try_from(threads).unwrap_or(i32::MAX); + sys::heif_context_set_max_decoding_threads(self.context, threads); + } + + let mut image: *mut sys::heif_image = ptr::null_mut(); + let decode_result = ffi::check(sys::heif_decode_image( + self.handle, + &mut image, + sys::heif_colorspace_heif_colorspace_RGB, + self.decode_chroma(), + ptr::null(), + )); + if let Err(message) = decode_result { + if !image.is_null() { + sys::heif_image_release(image); + } + return Err(to_image_error(HeifError::Decode(message))); + } + if image.is_null() { + return Err(to_image_error(HeifError::Decode( + "heif_decode_image returned null".into(), + ))); + } + + let result = self.copy_pixels(image, buf); + sys::heif_image_release(image); + result + } + } + + fn read_image_boxed(self: Box, buf: &mut [u8]) -> ImageResult<()> { + self.read_image(buf) + } +} + +impl HeifDecoder { + /// Copies the decoded interleaved plane into `buf` row by row (honoring libheif's stride), scaling >8-bit samples + /// up to `image`'s full-range 16-bit layout. + /// + /// # Safety + /// `image` must be a valid decoded `heif_image` owned by the caller. + unsafe fn copy_pixels(&self, image: *mut sys::heif_image, buf: &mut [u8]) -> ImageResult<()> { + let channels = self.channels(); + let sample_bytes = self.sample_bytes(); + let out_row_bytes = (self.width as usize) + .checked_mul(channels) + .and_then(|n| n.checked_mul(sample_bytes)) + .ok_or_else(|| { + to_image_error(HeifError::Decode( + "decoded row size overflowed the platform address space".into(), + )) + })?; + // Decoded samples occupy `depth` bits; shift up to fill 16 bits for the `image` crate. + let up_shift = if self.depth > 8 { 16 - self.depth } else { 0 }; + + // SAFETY: `image` is valid per contract; the plane spans `width`×`height`. + unsafe { + let mut stride: usize = 0; + let plane = sys::heif_image_get_plane_readonly2( + image, + sys::heif_channel_heif_channel_interleaved, + &mut stride, + ); + if plane.is_null() { + return Err(to_image_error(HeifError::Decode( + "heif_image_get_plane_readonly2 returned null".into(), + ))); + } + validate_plane_layout(stride, out_row_bytes, self.height as usize, buf.len()) + .map_err(|message| to_image_error(HeifError::Decode(message)))?; + + for y in 0..self.height as usize { + let src_row = plane.add(y * stride); + let dst_row = y * out_row_bytes; + + if sample_bytes == 1 { + // 8-bit: a straight row copy. + ptr::copy_nonoverlapping(src_row, buf[dst_row..].as_mut_ptr(), out_row_bytes); + } else { + // >8-bit: read native-endian samples, scale up to 16-bit, write LE. + for i in 0..(self.width as usize * channels) { + let s = src_row.add(i * 2); + let value = u16::from_ne_bytes([*s, *s.add(1)]) as u32; + let scaled = (value << up_shift) as u16; + let bytes = scaled.to_le_bytes(); + let off = dst_row + i * 2; + buf[off] = bytes[0]; + buf[off + 1] = bytes[1]; + } + } + } + } + + Ok(()) + } +} + +fn validate_plane_layout( + stride: usize, + row_bytes: usize, + height: usize, + output_len: usize, +) -> Result<(), String> { + if height == 0 || row_bytes == 0 { + return Err("decoded plane has empty dimensions".into()); + } + if stride < row_bytes { + return Err(format!( + "decoded plane stride {stride} is smaller than row size {row_bytes}", + )); + } + let expected_output = row_bytes + .checked_mul(height) + .ok_or_else(|| "decoded output size overflowed the platform address space".to_string())?; + if output_len != expected_output { + return Err(format!( + "output buffer length {output_len} does not match decoded size {expected_output}", + )); + } + stride + .checked_mul(height - 1) + .and_then(|offset| offset.checked_add(row_bytes)) + .ok_or_else(|| "decoded plane layout overflowed the platform address space".to_string())?; + Ok(()) +} + +impl Drop for HeifDecoder { + fn drop(&mut self) { + // SAFETY: `handle`/`context` were created in `new` and are not freed elsewhere. + unsafe { + if !self.handle.is_null() { + sys::heif_image_handle_release(self.handle); + } + if !self.context.is_null() { + sys::heif_context_free(self.context); + } + } + } +} + +/// Wraps a [`HeifError`] as an `image` decoding error. +fn to_image_error(err: HeifError) -> ImageError { + ImageError::Decoding(DecodingError::new( + ImageFormatHint::Name("HEIF".into()), + err, + )) +} + +#[cfg(test)] +mod tests { + use super::validate_plane_layout; + + #[test] + fn plane_layout_accepts_padding_and_rejects_short_stride() { + assert!(validate_plane_layout(64, 60, 10, 600).is_ok()); + assert!(validate_plane_layout(59, 60, 10, 600).is_err()); + } + + #[test] + fn plane_layout_rejects_output_mismatch_and_offset_overflow() { + assert!(validate_plane_layout(64, 60, 10, 599).is_err()); + assert!(validate_plane_layout(usize::MAX, 1, 2, 2).is_err()); + } +} diff --git a/src-tauri/vendor/heif-rs/src/encoder.rs b/src-tauri/vendor/heif-rs/src/encoder.rs new file mode 100644 index 0000000..dfcc910 --- /dev/null +++ b/src-tauri/vendor/heif-rs/src/encoder.rs @@ -0,0 +1,551 @@ +//! HEIF encoder, mirroring the `image` crate's per-format encoder convention. +//! +//! [`HeifEncoder`] is generic over a [`Write`] sink and implements [`ImageEncoder`], so it slots into +//! `DynamicImage::write_with_encoder` exactly like the codecs that ship with the `image` crate (e.g. `JpegEncoder`, +//! `WebPEncoder`). Encoding uses x265 (HEVC) under the hood. + +use std::ffi::{CString, c_void}; +use std::io::Write; +use std::ptr; + +use image::error::{EncodingError, ImageFormatHint, UnsupportedError, UnsupportedErrorKind}; +use image::{ExtendedColorType, ImageEncoder, ImageError, ImageResult}; + +use crate::error::HeifError; +use crate::ffi; +use crate::info::BitDepth; +use crate::sys; + +/// x265 speed/efficiency preset — the native "speed" control. Faster presets encode quicker at the cost of compression +/// efficiency. Maps 1:1 to libheif's `preset` param. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Preset { + Ultrafast, + Superfast, + Veryfast, + Faster, + Fast, + Medium, + Slow, + Slower, + Veryslow, + Placebo, +} + +impl Preset { + fn as_str(self) -> &'static str { + match self { + Preset::Ultrafast => "ultrafast", + Preset::Superfast => "superfast", + Preset::Veryfast => "veryfast", + Preset::Faster => "faster", + Preset::Fast => "fast", + Preset::Medium => "medium", + Preset::Slow => "slow", + Preset::Slower => "slower", + Preset::Veryslow => "veryslow", + Preset::Placebo => "placebo", + } + } +} + +/// Chroma subsampling of the encoded HEVC stream. Maps to libheif's `chroma` param. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Chroma { + /// 4:2:0 — smallest, the usual choice for photographs. + Yuv420, + /// 4:2:2. + Yuv422, + /// 4:4:4 — no chroma subsampling. + Yuv444, +} + +impl Chroma { + fn as_str(self) -> &'static str { + match self { + Chroma::Yuv420 => "420", + Chroma::Yuv422 => "422", + Chroma::Yuv444 => "444", + } + } +} + +/// Tunable parameters for the x265 encoder. +/// +/// Field names, ranges, and defaults mirror libheif's x265 plugin parameters. +pub struct EncoderConfig { + /// Quality, range 0–100 (higher = better); default 60. Ignored when `lossless` is set. + /// Maps to `heif_encoder_set_lossy_quality`. + pub quality: u8, + /// Lossless encoding; default `false`. When `true`, `quality` is ignored. + /// Maps to `heif_encoder_set_lossless`. + pub lossless: bool, + /// x265 speed preset; default [`Preset::Slow`] (libheif's native default). + pub preset: Preset, + /// Chroma subsampling; default [`Chroma::Yuv420`] (libheif's native default). + pub chroma: Chroma, + /// Output bit depth, default [`BitDepth::Eight`]. + /// + /// libheif supports 8/10/12-bit natively, but the prebuilt x265 in the bundled binaries is an 8-bit build; + /// requesting 10/12-bit there makes libheif return an error, which is surfaced as [`HeifError::Encode`]. + pub bit_depth: BitDepth, +} + +impl Default for EncoderConfig { + fn default() -> Self { + Self { + quality: 60, + lossless: false, + preset: Preset::Slow, + chroma: Chroma::Yuv420, + bit_depth: BitDepth::Eight, + } + } +} + +/// HEIF encoder writing to `W`, using x265. +/// +/// # Example +/// ```no_run +/// use heif::HeifEncoder; +/// use image::ImageEncoder; +/// +/// let img = image::open("photo.png")?; +/// let mut buf = Vec::new(); +/// img.write_with_encoder(HeifEncoder::new(&mut buf))?; +/// # Ok::<(), image::ImageError>(()) +/// ``` +pub struct HeifEncoder { + writer: W, + config: EncoderConfig, +} + +impl HeifEncoder { + /// Create an encoder writing to `w` with default settings. + pub fn new(w: W) -> Self { + Self { + writer: w, + config: EncoderConfig::default(), + } + } + + /// Create an encoder writing to `w` with an explicit configuration. + pub fn new_with_config(w: W, config: EncoderConfig) -> Self { + Self { writer: w, config } + } + + pub fn with_quality(mut self, quality: u8) -> Self { + self.config.quality = quality; + self + } + + pub fn with_lossless(mut self, lossless: bool) -> Self { + self.config.lossless = lossless; + self + } + + pub fn with_preset(mut self, preset: Preset) -> Self { + self.config.preset = preset; + self + } + + pub fn with_chroma(mut self, chroma: Chroma) -> Self { + self.config.chroma = chroma; + self + } + + pub fn with_bit_depth(mut self, bit_depth: BitDepth) -> Self { + self.config.bit_depth = bit_depth; + self + } +} + +/// How an input pixel buffer maps onto the interleaved RGB image libheif consumes. +struct Layout { + /// Channels per pixel in the *input* `buf` (1=L, 2=La, 3=Rgb, 4=Rgba). + src_channels: usize, + /// Bytes per *input* sample (1 for 8-bit, 2 for 16-bit, native-endian). + src_sample_bytes: usize, + /// Channels in the interleaved image handed to libheif (3=RGB, 4=RGBA). + out_channels: usize, + /// Whether the input is grayscale and must be expanded to RGB/RGBA. + gray: bool, + /// Whether the input carries an alpha channel. + alpha: bool, +} + +/// Maps a supported [`ExtendedColorType`] to its [`Layout`], or `None` if unsupported. +fn layout_for(color_type: ExtendedColorType) -> Option { + use ExtendedColorType as E; + + let l = |src_channels, src_sample_bytes, out_channels, gray, alpha| { + Some(Layout { + src_channels, + src_sample_bytes, + out_channels, + gray, + alpha, + }) + }; + + match color_type { + E::L8 => l(1, 1, 3, true, false), + E::La8 => l(2, 1, 4, true, true), + E::Rgb8 => l(3, 1, 3, false, false), + E::Rgba8 => l(4, 1, 4, false, true), + E::L16 => l(1, 2, 3, true, false), + E::La16 => l(2, 2, 4, true, true), + E::Rgb16 => l(3, 2, 3, false, false), + E::Rgba16 => l(4, 2, 4, false, true), + _ => None, + } +} + +fn image_depth(bit_depth: BitDepth) -> u32 { + match bit_depth { + BitDepth::Eight => 8, + BitDepth::Ten => 10, + BitDepth::Twelve => 12, + } +} + +/// Reads one native-endian input sample at byte offset `off`. +#[inline] +fn read_sample(buf: &[u8], off: usize, sample_bytes: usize) -> u32 { + if sample_bytes == 1 { + buf[off] as u32 + } else { + u16::from_ne_bytes([buf[off], buf[off + 1]]) as u32 + } +} + +/// Rescales a sample value from `src_bits` to `dst_bits` (libheif does not auto-scale). +#[inline] +fn scale(value: u32, src_bits: u32, dst_bits: u32) -> u32 { + if dst_bits >= src_bits { + value << (dst_bits - src_bits) + } else { + value >> (src_bits - dst_bits) + } +} + +impl EncoderConfig { + /// Returns the libheif chroma format of the interleaved plane for the given alpha presence and output bit depth. + fn interleaved_chroma(&self, alpha: bool) -> sys::heif_chroma { + match (image_depth(self.bit_depth) > 8, alpha) { + (false, false) => sys::heif_chroma_heif_chroma_interleaved_RGB, + (false, true) => sys::heif_chroma_heif_chroma_interleaved_RGBA, + (true, false) => sys::heif_chroma_heif_chroma_interleaved_RRGGBB_LE, + (true, true) => sys::heif_chroma_heif_chroma_interleaved_RRGGBBAA_LE, + } + } + + /// Runs the full libheif encode pipeline, returning the encoded HEIC bytes. + fn encode( + &self, + buf: &[u8], + width: u32, + height: u32, + color_type: ExtendedColorType, + ) -> Result, EncodeError> { + if width == 0 || height == 0 { + return Err(EncodeError::Heif(HeifError::InvalidDimensions { width, height })); + } + + let layout = layout_for(color_type).ok_or(EncodeError::Unsupported(color_type))?; + + let expected = width as usize * height as usize * layout.src_channels * layout.src_sample_bytes; + if buf.len() != expected { + return Err(EncodeError::Heif(HeifError::Encode(format!( + "buffer length {} does not match {width}x{height} with {} channels of {} byte(s)", + buf.len(), + layout.src_channels, + layout.src_sample_bytes, + )))); + } + + ffi::init(); + + let chroma = self.interleaved_chroma(layout.alpha); + + // SAFETY: every raw pointer below is checked, and the corresponding libheif object is freed on all paths before + // returning. + unsafe { + let mut image: *mut sys::heif_image = ptr::null_mut(); + ffi::check(sys::heif_image_create( + width as i32, + height as i32, + sys::heif_colorspace_heif_colorspace_RGB, + chroma, + &mut image, + )) + .map_err(|m| EncodeError::Heif(HeifError::EncoderInit(m)))?; + + let result = self.encode_with_image(image, buf, &layout, width, height); + sys::heif_image_release(image); + result + } + } + + /// Inner half of [`encode`](Self::encode): assumes `image` is a valid, owned `heif_image` (freed by the caller) and + /// produces the encoded bytes. + /// + /// # Safety + /// `image` must be a non-null pointer from `heif_image_create`, and `buf` must describe `width`×`height` pixels + /// laid out per `layout`. + unsafe fn encode_with_image( + &self, + image: *mut sys::heif_image, + buf: &[u8], + layout: &Layout, + width: u32, + height: u32, + ) -> Result, EncodeError> { + let out_depth = image_depth(self.bit_depth); + let out_sample_bytes = if out_depth > 8 { 2 } else { 1 }; + + // SAFETY: see this function's contract; all libheif handles are checked and freed. + unsafe { + ffi::check(sys::heif_image_add_plane( + image, + sys::heif_channel_heif_channel_interleaved, + width as i32, + height as i32, + out_depth as i32, + )) + .map_err(|m| EncodeError::Heif(HeifError::EncoderInit(m)))?; + + // Fill the interleaved plane row by row, honoring libheif's stride and scaling samples to the output bit + // depth. + let mut stride: i32 = 0; + let plane = sys::heif_image_get_plane(image, sys::heif_channel_heif_channel_interleaved, &mut stride); + if plane.is_null() { + return Err(EncodeError::Heif(HeifError::EncoderInit( + "heif_image_get_plane returned null".into(), + ))); + } + self.fill_plane( + plane, + stride as usize, + buf, + layout, + width, + height, + out_depth, + out_sample_bytes, + ); + + // Context + encoder. + let ctx = sys::heif_context_alloc(); + if ctx.is_null() { + return Err(EncodeError::Heif(HeifError::EncoderInit( + "heif_context_alloc returned null".into(), + ))); + } + + let result = self.encode_into_context(ctx, image); + sys::heif_context_free(ctx); + result + } + } + + /// Acquires the encoder, applies the config, encodes `image`, and writes the result. + /// + /// # Safety + /// `ctx` and `image` must be valid libheif handles owned by the caller. + unsafe fn encode_into_context( + &self, + ctx: *mut sys::heif_context, + image: *mut sys::heif_image, + ) -> Result, EncodeError> { + // SAFETY: see contract; the encoder/handle/options are freed before returning. + unsafe { + let mut encoder: *mut sys::heif_encoder = ptr::null_mut(); + ffi::check(sys::heif_context_get_encoder_for_format( + ctx, + sys::heif_compression_format_heif_compression_HEVC, + &mut encoder, + )) + .map_err(|m| EncodeError::Heif(HeifError::EncoderInit(m)))?; + + let result = self.encode_with_encoder(ctx, image, encoder); + sys::heif_encoder_release(encoder); + result + } + } + + /// Sets parameters on `encoder`, encodes, and serializes to bytes. + /// + /// # Safety + /// `ctx`, `image`, and `encoder` must be valid libheif handles owned by the caller. + unsafe fn encode_with_encoder( + &self, + ctx: *mut sys::heif_context, + image: *mut sys::heif_image, + encoder: *mut sys::heif_encoder, + ) -> Result, EncodeError> { + // SAFETY: see contract. + unsafe { + if self.lossless { + ffi::check(sys::heif_encoder_set_lossless(encoder, 1)) + .map_err(|m| EncodeError::Heif(HeifError::Encode(m)))?; + } else { + ffi::check(sys::heif_encoder_set_lossy_quality(encoder, self.quality as i32)) + .map_err(|m| EncodeError::Heif(HeifError::Encode(m)))?; + } + + set_string_param(encoder, "preset", self.preset.as_str())?; + set_string_param(encoder, "chroma", self.chroma.as_str())?; + + // Encode into a (discarded) image handle. + let options = sys::heif_encoding_options_alloc(); + let mut handle: *mut sys::heif_image_handle = ptr::null_mut(); + let enc_res = ffi::check(sys::heif_context_encode_image( + ctx, + image, + encoder, + options, + &mut handle, + )); + if !options.is_null() { + sys::heif_encoding_options_free(options); + } + if !handle.is_null() { + sys::heif_image_handle_release(handle); + } + enc_res.map_err(|m| EncodeError::Heif(HeifError::Encode(m)))?; + + // Serialize the container to memory via a writer callback. + let mut out: Vec = Vec::new(); + let mut writer = sys::heif_writer { + writer_api_version: 1, + write: Some(write_callback), + }; + ffi::check(sys::heif_context_write( + ctx, + &mut writer, + &mut out as *mut Vec as *mut c_void, + )) + .map_err(|m| EncodeError::Heif(HeifError::Encode(m)))?; + + Ok(out) + } + } + + /// Writes the converted, scaled samples into the interleaved plane row by row. + #[allow(clippy::too_many_arguments)] + fn fill_plane( + &self, + plane: *mut u8, + stride: usize, + buf: &[u8], + layout: &Layout, + width: u32, + height: u32, + out_depth: u32, + out_sample_bytes: usize, + ) { + let src_bits = (layout.src_sample_bytes * 8) as u32; + let in_row_bytes = width as usize * layout.src_channels * layout.src_sample_bytes; + + for y in 0..height as usize { + let in_row = y * in_row_bytes; + let out_row = y * stride; + + for x in 0..width as usize { + let in_pixel = in_row + x * layout.src_channels * layout.src_sample_bytes; + let out_pixel = out_row + x * layout.out_channels * out_sample_bytes; + + for c in 0..layout.out_channels { + // Map the output channel back to an input channel: grayscale replicates luma (channel 0) into R/G/B + // and keeps alpha as the last channel. + let src_channel = if layout.gray { + if layout.alpha && c == 3 { 1 } else { 0 } + } else { + c + }; + + let in_off = in_pixel + src_channel * layout.src_sample_bytes; + let value = scale(read_sample(buf, in_off, layout.src_sample_bytes), src_bits, out_depth); + + let out_off = out_pixel + c * out_sample_bytes; + // SAFETY: `out_off + out_sample_bytes <= stride*height` by construction; the plane was allocated + // for `width`×`height` at `out_channels`. + unsafe { + if out_sample_bytes == 1 { + *plane.add(out_off) = value as u8; + } else { + let bytes = (value as u16).to_le_bytes(); + *plane.add(out_off) = bytes[0]; + *plane.add(out_off + 1) = bytes[1]; + } + } + } + } + } + } +} + +/// Sets a string parameter on the encoder, mapping libheif failures to [`EncodeError`]. +/// +/// # Safety +/// `encoder` must be a valid libheif encoder handle. +unsafe fn set_string_param(encoder: *mut sys::heif_encoder, name: &str, value: &str) -> Result<(), EncodeError> { + let name = CString::new(name).expect("parameter name has no interior NUL"); + let value = CString::new(value).expect("parameter value has no interior NUL"); + // SAFETY: both C strings outlive the call; `encoder` is valid per contract. + unsafe { + ffi::check(sys::heif_encoder_set_parameter_string( + encoder, + name.as_ptr(), + value.as_ptr(), + )) + .map_err(|m| EncodeError::Heif(HeifError::Encode(m))) + } +} + +/// libheif writer callback: appends the encoded bytes to the `Vec` behind `userdata`. +unsafe extern "C" fn write_callback( + _ctx: *mut sys::heif_context, + data: *const c_void, + size: usize, + userdata: *mut c_void, +) -> sys::heif_error { + // SAFETY: `userdata` is the `&mut Vec` we passed to `heif_context_write`, and `data`/`size` describe a valid + // buffer for the duration of the call. + unsafe { + let out = &mut *(userdata as *mut Vec); + if !data.is_null() && size > 0 { + out.extend_from_slice(std::slice::from_raw_parts(data as *const u8, size)); + } + // A zeroed `heif_error` is `heif_error_Ok` with a null (auto-filled) message. + std::mem::zeroed::() + } +} + +/// Internal encode failure, distinguishing unsupported inputs (which become `ImageError::Unsupported`) from +/// libheif/runtime failures. +enum EncodeError { + Unsupported(ExtendedColorType), + Heif(HeifError), +} + +impl From for ImageError { + fn from(err: EncodeError) -> Self { + match err { + EncodeError::Unsupported(color_type) => ImageError::Unsupported(UnsupportedError::from_format_and_kind( + ImageFormatHint::Name("HEIF".into()), + UnsupportedErrorKind::Color(color_type), + )), + EncodeError::Heif(e) => ImageError::Encoding(EncodingError::new(ImageFormatHint::Name("HEIF".into()), e)), + } + } +} + +impl ImageEncoder for HeifEncoder { + fn write_image(mut self, buf: &[u8], width: u32, height: u32, color_type: ExtendedColorType) -> ImageResult<()> { + let encoded = self.config.encode(buf, width, height, color_type)?; + self.writer.write_all(&encoded).map_err(ImageError::IoError)?; + Ok(()) + } +} diff --git a/src-tauri/vendor/heif-rs/src/error.rs b/src-tauri/vendor/heif-rs/src/error.rs new file mode 100644 index 0000000..c9e2346 --- /dev/null +++ b/src-tauri/vendor/heif-rs/src/error.rs @@ -0,0 +1,25 @@ +//! Error and result types for the high-level HEIF API. + +use image::ImageError; + +/// High-level error type covering encode, decode, and conversion failures. +#[derive(Debug, thiserror::Error)] +pub enum HeifError { + #[error("encoder initialization failed: {0}")] + EncoderInit(String), + #[error("decoder initialization failed: {0}")] + DecoderInit(String), + #[error("encode failed: {0}")] + Encode(String), + #[error("decode failed: {0}")] + Decode(String), + #[error("invalid image dimensions: {width}x{height}")] + InvalidDimensions { width: u32, height: u32 }, + #[error(transparent)] + Image(#[from] ImageError), + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// Convenience alias for results produced by this crate. +pub type Result = std::result::Result; diff --git a/src-tauri/vendor/heif-rs/src/ffi.rs b/src-tauri/vendor/heif-rs/src/ffi.rs new file mode 100644 index 0000000..37b1184 --- /dev/null +++ b/src-tauri/vendor/heif-rs/src/ffi.rs @@ -0,0 +1,38 @@ +//! Small helpers shared by the encoder and decoder for talking to libheif's C API. + +use std::ffi::CStr; +use std::ptr; +use std::sync::Once; + +use crate::sys; + +/// Initialize libheif exactly once before first use. libheif's objects are reference counted internally; calling +/// `heif_init` ensures plugin registration and that library memory is set up. We never call `heif_deinit` — the process +/// owns these globals for its whole lifetime, mirroring how the codec libraries expect to be used. +pub(crate) fn init() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + // SAFETY: runs exactly once via `Once`; passing null requests the default params. + unsafe { + sys::heif_init(ptr::null_mut()); + } + }); +} + +/// Convert a [`heif_error`](sys::heif_error) returned by value into a `Result`. Returns `Ok(())` for `heif_error_Ok`; +/// otherwise extracts the human-readable `message`. +pub(crate) fn check(err: sys::heif_error) -> Result<(), String> { + if err.code == sys::heif_error_code_heif_error_Ok { + return Ok(()); + } + + // SAFETY: libheif guarantees `message` is a non-null, NUL-terminated C string for any + // non-OK error, but we guard against null defensively. + let message = if err.message.is_null() { + String::new() + } else { + unsafe { CStr::from_ptr(err.message).to_string_lossy().into_owned() } + }; + + Err(message) +} diff --git a/src-tauri/vendor/heif-rs/src/info.rs b/src-tauri/vendor/heif-rs/src/info.rs new file mode 100644 index 0000000..fe772a4 --- /dev/null +++ b/src-tauri/vendor/heif-rs/src/info.rs @@ -0,0 +1,23 @@ +//! Image metadata types returned by probing and decoding. + +use image::ColorType; + +/// Metadata describing a HEIF image, without (necessarily) decoding its pixels. +#[derive(Debug, Clone)] +pub struct ImageInfo { + pub width: u32, + pub height: u32, + /// Reuse `image`'s enum — no point rolling our own. + pub color_type: ColorType, + /// Bit depth per channel; `image` has no equivalent for this. + pub bit_depth: BitDepth, +} + +/// Bits per channel of a HEIF image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BitDepth { + Eight, + Ten, + Twelve, +} diff --git a/src-tauri/vendor/heif-rs/src/lib.rs b/src-tauri/vendor/heif-rs/src/lib.rs new file mode 100644 index 0000000..14f289e --- /dev/null +++ b/src-tauri/vendor/heif-rs/src/lib.rs @@ -0,0 +1,124 @@ +//! `heif-rs` — encode and decode HEIF/HEIC images via libheif. +//! +//! The libheif C library (plus its codec/support dependencies) is downloaded as a prebuilt **static** library at build +//! time and linked directly into this crate, so consumers do not need libheif installed on the host. See `build.rs`. +//! +//! Encoding uses **x265** and decoding uses **libde265** (HEVC), both statically linked. +//! +//! The API mirrors the `image` crate's codec conventions: +//! * [`HeifEncoder`] / [`HeifDecoder`] implement `image`'s `ImageEncoder` / `ImageDecoder` traits, so they plug into +//! `DynamicImage::write_with_encoder` / `DynamicImage::from_decoder` just like the codecs bundled with `image`. +//! * a thin facade ([`encode`], [`encode_buffer`], [`decode`], [`probe`]) wraps those for one-line convenience. + +mod decoder; +mod encoder; +mod error; +mod ffi; +mod info; +mod sys; + +pub use decoder::{DecoderConfig, HeifDecoder}; +pub use encoder::{Chroma, EncoderConfig, HeifEncoder, Preset}; +pub use error::{HeifError, Result}; +pub use info::{BitDepth, ImageInfo}; + +use std::ffi::CStr; +use std::io::Cursor; +use std::ops::Deref; + +use image::{DynamicImage, EncodableLayout, ImageBuffer, ImageDecoder, PixelWithColorType}; + +/// Returns the version string of the linked libheif library, e.g. `"1.20.2"`. +pub fn libheif_version() -> String { + ffi::init(); + // SAFETY: `heif_get_version` returns a pointer to a static, NUL-terminated C string. + unsafe { + let ptr = sys::heif_get_version(); + CStr::from_ptr(ptr).to_string_lossy().into_owned() + } +} + +/// Encode a [`DynamicImage`] to HEIC bytes using sensible defaults. +/// +/// # Example +/// ```no_run +/// let img = image::open("photo.png")?; +/// let heic_bytes = heif::encode(&img)?; +/// # Ok::<(), heif::HeifError>(()) +/// ``` +pub fn encode(image: &DynamicImage) -> Result> { + let mut buf = Vec::new(); + image.write_with_encoder(HeifEncoder::new(&mut buf))?; + Ok(buf) +} + +/// Encode a typed [`ImageBuffer`] directly, avoiding the runtime dispatch +/// overhead of [`DynamicImage`]. Prefer this when you already know your +/// pixel type at compile time. +/// +/// # Example +/// ```no_run +/// use image::RgbaImage; +/// let img: RgbaImage = image::open("photo.png")?.into_rgba8(); +/// let heic_bytes = heif::encode_buffer(&img)?; +/// # Ok::<(), heif::HeifError>(()) +/// ``` +pub fn encode_buffer(buffer: &ImageBuffer) -> Result> +where + P: PixelWithColorType, + [P::Subpixel]: EncodableLayout, + C: Deref, +{ + let mut buf = Vec::new(); + buffer.write_with_encoder(HeifEncoder::new(&mut buf))?; + Ok(buf) +} + +/// Decode HEIC bytes into a [`DynamicImage`]. +/// +/// # Example +/// ```no_run +/// # let heic_bytes: Vec = Vec::new(); +/// let img = heif::decode(&heic_bytes)?; +/// img.save("output.png")?; +/// # Ok::<(), heif::HeifError>(()) +/// ``` +pub fn decode(data: &[u8]) -> Result { + let decoder = HeifDecoder::new(Cursor::new(data))?; + Ok(DynamicImage::from_decoder(decoder)?) +} + +/// Read only the image header — no pixel decode. +/// Useful for validation or thumbnailing pipelines. +/// +/// # Example +/// ```no_run +/// # let heic_bytes: Vec = Vec::new(); +/// let info = heif::probe(&heic_bytes)?; +/// println!("{}x{} @ {:?}", info.width, info.height, info.bit_depth); +/// # Ok::<(), heif::HeifError>(()) +/// ``` +pub fn probe(data: &[u8]) -> Result { + let decoder = HeifDecoder::new(Cursor::new(data))?; + let (width, height) = decoder.dimensions(); + Ok(ImageInfo { + width, + height, + color_type: decoder.color_type(), + bit_depth: decoder.bit_depth(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Smoke test: calling into libheif proves the static binaries are linked and + /// callable end-to-end. + #[test] + fn reports_libheif_version() { + let version = libheif_version(); + println!("linked libheif version: {version}"); + assert!(!version.is_empty()); + } +} diff --git a/src-tauri/vendor/heif-rs/src/sys.rs b/src-tauri/vendor/heif-rs/src/sys.rs new file mode 100644 index 0000000..f829d87 --- /dev/null +++ b/src-tauri/vendor/heif-rs/src/sys.rs @@ -0,0 +1,5 @@ +//! Raw, unsafe FFI bindings to libheif, generated at build time by `bindgen` from the `heif.h` header bundled with the +//! downloaded static binaries. See `build.rs`. +#![allow(non_upper_case_globals, non_camel_case_types, non_snake_case, dead_code)] + +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); diff --git a/src/lib/paper.test.ts b/src/lib/paper.test.ts new file mode 100644 index 0000000..9f5d48e --- /dev/null +++ b/src/lib/paper.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { describeSize, gridFor, paperPt, PT_PER_MM, tileCount } from "./paper"; + +describe("paperPt", () => { + it("converts A4 millimetres to points in portrait orientation", () => { + const size = paperPt("a4", true); + + expect(size.w).toBeCloseTo(210 * PT_PER_MM); + expect(size.h).toBeCloseTo(297 * PT_PER_MM); + }); + + it("swaps the paper dimensions in landscape orientation", () => { + const portrait = paperPt("a4", true); + const landscape = paperPt("a4", false); + + expect(landscape.w).toBeCloseTo(portrait.h); + expect(landscape.h).toBeCloseTo(portrait.w); + }); +}); + +describe("tileCount", () => { + it("uses one tile for an exact fit", () => { + expect(tileCount(100, 100, 0)).toBe(1); + }); + + it("allows the 0.5 point fit tolerance", () => { + expect(tileCount(100.5, 100, 0)).toBe(1); + }); + + it("adds a tile at the first overflow beyond the tolerance", () => { + expect(tileCount(100.500001, 100, 0)).toBe(2); + }); + + it("counts multiple sheets along one dimension", () => { + expect(tileCount(300, 100, 0)).toBe(3); + }); + + it("increases the sheet count when overlap reduces the usable step", () => { + expect(tileCount(300, 100, 25)).toBe(4); + }); +}); + +describe("gridFor", () => { + it("honours explicit portrait and landscape orientations", () => { + const portrait = gridFor(1_000, 1_000, "a4", "portrait", 0); + const landscape = gridFor(1_000, 1_000, "a4", "landscape", 0); + + expect(portrait.tileW).toBeCloseTo(210 * PT_PER_MM); + expect(portrait.tileH).toBeCloseTo(297 * PT_PER_MM); + expect(landscape.tileW).toBeCloseTo(297 * PT_PER_MM); + expect(landscape.tileH).toBeCloseTo(210 * PT_PER_MM); + }); + + it("reports count as the product of columns and rows", () => { + const grid = gridFor(1_600, 1_600, "a4", "portrait", 0); + + expect(grid.count).toBe(grid.cols * grid.rows); + }); + + it("selects portrait paper for a tall page when it uses fewer sheets", () => { + const grid = gridFor(500, 1_600, "a4", "auto", 0); + + expect(grid.count).toBe(2); + expect(grid.tileW).toBeCloseTo(210 * PT_PER_MM); + expect(grid.tileH).toBeCloseTo(297 * PT_PER_MM); + }); + + it("selects landscape paper for a wide page when it uses fewer sheets", () => { + const grid = gridFor(1_600, 500, "a4", "auto", 0); + + expect(grid.count).toBe(2); + expect(grid.tileW).toBeCloseTo(297 * PT_PER_MM); + expect(grid.tileH).toBeCloseTo(210 * PT_PER_MM); + }); + + it("selects portrait deterministically when orientations tie", () => { + const grid = gridFor(500, 500, "a4", "auto", 0); + + expect(grid.count).toBe(1); + expect(grid.tileW).toBeCloseTo(210 * PT_PER_MM); + expect(grid.tileH).toBeCloseTo(297 * PT_PER_MM); + }); +}); + +describe("describeSize", () => { + it("recognises A-series sizes in either orientation", () => { + expect(describeSize(210 * PT_PER_MM, 297 * PT_PER_MM)).toBe( + "A4 (210 × 297 mm)", + ); + expect(describeSize(420 * PT_PER_MM, 297 * PT_PER_MM)).toBe( + "A3 (420 × 297 mm)", + ); + }); + + it("rounds custom dimensions to whole millimetres", () => { + expect(describeSize(123.4 * PT_PER_MM, 456.6 * PT_PER_MM)).toBe( + "123 × 457 mm", + ); + }); +});