diff --git a/packages/preview/keyless/0.1.0/LICENSE b/packages/preview/keyless/0.1.0/LICENSE new file mode 100644 index 0000000000..aa8a6e0d93 --- /dev/null +++ b/packages/preview/keyless/0.1.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 mkpoli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/keyless/0.1.0/README.md b/packages/preview/keyless/0.1.0/README.md new file mode 100644 index 0000000000..1217e741d4 --- /dev/null +++ b/packages/preview/keyless/0.1.0/README.md @@ -0,0 +1,246 @@ +# Keyless + +Keyless is a Typst package for keying colors out of raster images and converting them into transparent pixels. + +It provides a simple interface for common tasks such as removing a white background, cutting out a green screen, or making a scanned image blend cleanly into the page. Keyless is designed for reproducible documents: every threshold, softness value, color space, and output option can be written directly in Typst source. + +![Keyless green-screen removal demo](docs/keyless-demo.png) + +The demo image is rendered from `tests/visual-chan.typ`, using the same local package entrypoint and fixtures as the visual test suite. + +## Usage + +For the local Tyler install: + +```typst +#import "@local/keyless:0.1.0": key-out, key-out-bytes +``` + +After publishing to Typst Universe: + +```typst +#import "@preview/keyless:0.1.0": key-out, key-out-bytes +``` + +Remove a white background and place the result like a normal Typst image: + +```typst +#import "@local/keyless:0.1.0": key-out + +#let logo = read("logo.png", encoding: none) + +#key-out( + logo, + color: white, + tolerance: 10%, + softness: 2%, + width: 5cm, + alt: "Logo with white background removed", +) +``` + +Use `key-out-bytes` when you want the transformed PNG bytes instead of image content: + +```typst +#let keyed = key-out-bytes( + logo, + color: rgb("#ffffff"), + tolerance: 12%, + softness: 3%, +) + +#image(keyed, width: 80%) +``` + +## Examples + +Place a logo with a white background on a colored panel: + +```typst +#import "@local/keyless:0.1.0": key-out + +#set page(fill: rgb("#20242a")) + +#let logo = read("logo.png", encoding: none) + +#box( + fill: rgb("#f5c542"), + inset: 12pt, + radius: 6pt, + key-out( + logo, + color: white, + tolerance: 8%, + softness: 2%, + width: 42mm, + alt: "Logo with transparent background", + ), +) +``` + +Cut out a green-screen image and keep a soft edge around the subject: + +```typst +#import "@local/keyless:0.1.0": key-out + +#let portrait = read("portrait.webp", encoding: none) + +#key-out( + portrait, + color: rgb("#00ff00"), + tolerance: 18%, + softness: 5%, + width: 70%, + alt: "Portrait with green screen removed", +) +``` + +Make a scanned signature blend into the page while preserving dark ink: + +```typst +#import "@local/keyless:0.1.0": key-out + +#let signature = read("signature.jpg", encoding: none) + +#align(right)[ + #key-out( + signature, + color: white, + tolerance: 14%, + softness: 4%, + width: 35mm, + alt: "Handwritten signature", + ) +] +``` + +Generate keyed PNG bytes for reuse in metadata, export pipelines, or later image placement: + +```typst +#import "@local/keyless:0.1.0": key-out-bytes + +#let source = read("badge.png", encoding: none) +#let keyed = key-out-bytes( + source, + color: rgb("#ffffff"), + tolerance: 10%, + softness: 2%, +) + +#metadata(range(keyed.len()).map(i => keyed.at(i))) +#image(keyed, width: 30mm) +``` + +## API + +```typst +#key-out( + source, + color: white, + tolerance: 0%, + softness: 0%, + space: "srgb", + premultiply: false, + format: auto, + ..args, +) +``` + +`source` is image bytes, usually from `read("image.png", encoding: none)`. Extra arguments are forwarded to Typst's built-in `image`, including `width`, `height`, `alt`, `fit`, and `scaling`. + +`key-out-bytes` accepts the same image-processing parameters and returns PNG bytes. + +## Behavior + +Keyless currently computes normalized Euclidean distance in sRGB: + +```text +distance <= tolerance + alpha = 0 + +distance >= tolerance + softness + alpha = original alpha + +otherwise + alpha fades smoothly between 0 and original alpha +``` + +The first release supports PNG, JPEG, GIF, and WebP input and always emits PNG output with an alpha channel. + +## Compatibility + +Keyless requires Typst 0.8.0 or newer. This is the first Typst release with Wasm plugin support. + +## Building + +```sh +rustup target add wasm32-unknown-unknown +cargo build --release --target wasm32-unknown-unknown +cp target/wasm32-unknown-unknown/release/keyless_plugin.wasm src/plugin.wasm +``` + +## Testing + +Run the full compiler compatibility matrix with Nix: + +```sh +nix run .#test-matrix +``` + +This builds the same checks and prints one line per compiler plus an `x/y passed` summary. + +To quickly inspect the compiled results for the latest compiler version, build the latest artifact bundle: + +```sh +nix build .#artifacts-latest +``` + +To manually inspect the compiled results for every compiler version, build the full artifact bundle: + +```sh +nix build .#artifacts --max-jobs auto +``` + +The full bundle builds one derivation per compiler version. `--max-jobs auto` lets Nix build independent versions in parallel; without it, your Nix configuration may build them one at a time. + +The `result` symlink contains one directory per Typst version: + +```text +result/ + typst-0.8.0/ + compat.pdf + visual-kun.pdf + visual-chan.pdf + keyed.png + keyed.json + typst-0.9.0/ + ... + report.txt +``` + +Open each `visual-kun.pdf` and `visual-chan.pdf` to compare the real-world `typst-kun.png` and `typst-chan.png` examples. Each visual check shows the source image beside its keyed result on a high-contrast checkerboard so remaining background, halos, and accidentally removed foreground details are easier to spot. Open each `keyed.png` to inspect the tiny deterministic pixel fixture used by the analyzer. + +The matrix compiles the rendering regression test with every Typst release from 0.8.0 through 0.14.2. Older releases are not included because 0.8.0 introduced WASM plugin support. + +Each matrix check also queries the PNG bytes produced by `key-out-bytes` and analyzes the decoded pixels. The fixture contains one white pixel and one black pixel; the analyzer verifies that the white pixel becomes transparent RGBA `[255, 255, 255, 0]` and the black pixel stays opaque RGBA `[0, 0, 0, 255]`. + +To test one compiler version while iterating, build its check directly: + +```sh +nix build .#checks.x86_64-linux.0_12_0 +``` + +Replace `0_12_0` with any check name shown by: + +```sh +nix flake show +``` + +The compatibility test lives in `tests/compat.typ`. It imports the local package, verifies that `key-out-bytes` returns PNG bytes, exposes those bytes as queryable metadata for pixel analysis, and renders `key-out` so the bytes-to-image path is tested on each compiler version. The visual examples live in `tests/visual-kun.typ` and `tests/visual-chan.typ`, with image fixtures in `tests/fixtures/`, and the pixel analyzer lives in `tests/analyze-keyed-png.py`. + +You can also run the test with your local Typst compiler after generating the fixture image: + +```sh +base64 -d tests/fixtures/white-black.png.b64 > tests/fixtures/white-black.png +typst compile --root . tests/compat.typ /tmp/keyless-compat.pdf +``` diff --git a/packages/preview/keyless/0.1.0/docs/keyless-demo.png b/packages/preview/keyless/0.1.0/docs/keyless-demo.png new file mode 100644 index 0000000000..499d067073 Binary files /dev/null and b/packages/preview/keyless/0.1.0/docs/keyless-demo.png differ diff --git a/packages/preview/keyless/0.1.0/internal.typ b/packages/preview/keyless/0.1.0/internal.typ new file mode 100644 index 0000000000..af50d93aff --- /dev/null +++ b/packages/preview/keyless/0.1.0/internal.typ @@ -0,0 +1,53 @@ +#let plugin = plugin("plugin.wasm") + +#let as-bytes(value) = bytes(repr(value)) + +#let color-bytes(color) = bytes(color.to-hex()) + +#let image-from-bytes(data, ..args) = { + if sys.version >= version(0, 13, 0) { + image(data, ..args) + } else { + image.decode(data, format: "png", ..args) + } +} + +#let key-out-bytes( + source, + color: white, + tolerance: 0%, + softness: 0%, + space: "srgb", + premultiply: false, + format: auto, +) = plugin.key_out( + source, + color-bytes(color), + as-bytes(tolerance), + as-bytes(softness), + bytes(space), + as-bytes(premultiply), + as-bytes(format), +) + +#let key-out( + source, + color: white, + tolerance: 0%, + softness: 0%, + space: "srgb", + premultiply: false, + format: auto, + ..args, +) = image-from-bytes( + key-out-bytes( + source, + color: color, + tolerance: tolerance, + softness: softness, + space: space, + premultiply: premultiply, + format: format, + ), + ..args, +) diff --git a/packages/preview/keyless/0.1.0/lib.rs b/packages/preview/keyless/0.1.0/lib.rs new file mode 100644 index 0000000000..07222c74e9 --- /dev/null +++ b/packages/preview/keyless/0.1.0/lib.rs @@ -0,0 +1,194 @@ +use std::io::Cursor; + +use image::{DynamicImage, ImageFormat, ImageReader, RgbaImage}; +#[cfg(target_arch = "wasm32")] +wasm_minimal_protocol::initiate_protocol!(); + +#[cfg_attr(target_arch = "wasm32", wasm_minimal_protocol::wasm_func)] +pub fn key_out( + source: &[u8], + color: &[u8], + tolerance: &[u8], + softness: &[u8], + space: &[u8], + premultiply: &[u8], + format: &[u8], +) -> Result, String> { + let key = parse_color(color)?; + let tolerance = parse_ratio(tolerance, "tolerance")?; + let softness = parse_ratio(softness, "softness")?; + let space = parse_text(space, "space")?; + let premultiply = parse_bool(premultiply, "premultiply")?; + let format = parse_text(format, "format")?; + + if space != "srgb" { + return Err(format!( + "unsupported color space `{space}`; expected `srgb`" + )); + } + + let mut img = decode_image(source, format)?.to_rgba8(); + apply_key(&mut img, key, tolerance, softness, premultiply); + encode_png(&img) +} + +fn decode_image(source: &[u8], format: &str) -> Result { + let cursor = Cursor::new(source); + + if format == "auto" { + return ImageReader::new(cursor) + .with_guessed_format() + .map_err(|err| format!("could not guess image format: {err}"))? + .decode() + .map_err(|err| format!("could not decode image: {err}")); + } + + let image_format = match format { + "png" => ImageFormat::Png, + "jpg" | "jpeg" => ImageFormat::Jpeg, + "gif" => ImageFormat::Gif, + "webp" => ImageFormat::WebP, + other => return Err(format!("unsupported image format `{other}`")), + }; + + image::load(cursor, image_format).map_err(|err| format!("could not decode image: {err}")) +} + +fn apply_key(img: &mut RgbaImage, key: [f32; 3], tolerance: f32, softness: f32, premultiply: bool) { + let max_distance = 3.0_f32.sqrt(); + + for pixel in img.pixels_mut() { + let rgb = [ + f32::from(pixel[0]) / 255.0, + f32::from(pixel[1]) / 255.0, + f32::from(pixel[2]) / 255.0, + ]; + let distance = + (((rgb[0] - key[0]).powi(2) + (rgb[1] - key[1]).powi(2) + (rgb[2] - key[2]).powi(2)) + .sqrt() + / max_distance) + .clamp(0.0, 1.0); + + let keep = if distance <= tolerance { + 0.0 + } else if softness <= f32::EPSILON || distance >= tolerance + softness { + 1.0 + } else { + smoothstep((distance - tolerance) / softness) + }; + + let alpha = (f32::from(pixel[3]) * keep).round().clamp(0.0, 255.0) as u8; + pixel[3] = alpha; + + if premultiply { + let scale = f32::from(alpha) / 255.0; + pixel[0] = (f32::from(pixel[0]) * scale).round().clamp(0.0, 255.0) as u8; + pixel[1] = (f32::from(pixel[1]) * scale).round().clamp(0.0, 255.0) as u8; + pixel[2] = (f32::from(pixel[2]) * scale).round().clamp(0.0, 255.0) as u8; + } + } +} + +fn smoothstep(value: f32) -> f32 { + let value = value.clamp(0.0, 1.0); + value * value * (3.0 - 2.0 * value) +} + +fn encode_png(img: &RgbaImage) -> Result, String> { + let mut output = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(img.clone()) + .write_to(&mut output, ImageFormat::Png) + .map_err(|err| format!("could not encode png: {err}"))?; + Ok(output.into_inner()) +} + +fn parse_color(input: &[u8]) -> Result<[f32; 3], String> { + let text = parse_text(input, "color")?; + let hex = text.trim().trim_start_matches('#'); + let hex = match hex.len() { + 6 | 8 => hex.to_string(), + 3 | 4 => hex.chars().flat_map(|ch| [ch, ch]).collect(), + _ => return Err(format!("invalid color `{text}`; expected hex RGB or RGBA")), + }; + + let r = parse_hex_pair(&hex[0..2], &text)?; + let g = parse_hex_pair(&hex[2..4], &text)?; + let b = parse_hex_pair(&hex[4..6], &text)?; + Ok([ + f32::from(r) / 255.0, + f32::from(g) / 255.0, + f32::from(b) / 255.0, + ]) +} + +fn parse_hex_pair(pair: &str, original: &str) -> Result { + u8::from_str_radix(pair, 16).map_err(|_| format!("invalid color `{original}`")) +} + +fn parse_ratio(input: &[u8], name: &str) -> Result { + let text = parse_text(input, name)?; + let trimmed = text.trim(); + let value = if let Some(percent) = trimmed.strip_suffix('%') { + percent + .trim() + .parse::() + .map_err(|_| format!("invalid {name} `{text}`"))? + / 100.0 + } else { + trimmed + .parse::() + .map_err(|_| format!("invalid {name} `{text}`"))? + }; + + if !(0.0..=1.0).contains(&value) { + return Err(format!("{name} must be between 0% and 100%")); + } + + Ok(value) +} + +fn parse_bool(input: &[u8], name: &str) -> Result { + match parse_text(input, name)? { + "true" => Ok(true), + "false" => Ok(false), + other => Err(format!("invalid {name} `{other}`; expected true or false")), + } +} + +fn parse_text<'a>(input: &'a [u8], name: &str) -> Result<&'a str, String> { + std::str::from_utf8(input).map_err(|_| format!("{name} must be valid UTF-8")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_exact_white_to_transparent_png() { + let mut input = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(RgbaImage::from_fn(2, 1, |x, _| { + if x == 0 { + image::Rgba([255, 255, 255, 255]) + } else { + image::Rgba([0, 0, 0, 255]) + } + })) + .write_to(&mut input, ImageFormat::Png) + .unwrap(); + + let output = key_out( + input.get_ref(), + b"ffffff", + b"0%", + b"0%", + b"srgb", + b"false", + b"auto", + ) + .unwrap(); + + let keyed = image::load_from_memory(&output).unwrap().to_rgba8(); + assert_eq!(keyed.get_pixel(0, 0).0, [255, 255, 255, 0]); + assert_eq!(keyed.get_pixel(1, 0).0, [0, 0, 0, 255]); + } +} diff --git a/packages/preview/keyless/0.1.0/lib.typ b/packages/preview/keyless/0.1.0/lib.typ new file mode 100644 index 0000000000..7fdcf67ce8 --- /dev/null +++ b/packages/preview/keyless/0.1.0/lib.typ @@ -0,0 +1 @@ +#import "internal.typ": key-out, key-out-bytes diff --git a/packages/preview/keyless/0.1.0/plugin.wasm b/packages/preview/keyless/0.1.0/plugin.wasm new file mode 100644 index 0000000000..d51e5a3656 Binary files /dev/null and b/packages/preview/keyless/0.1.0/plugin.wasm differ diff --git a/packages/preview/keyless/0.1.0/typst.toml b/packages/preview/keyless/0.1.0/typst.toml new file mode 100644 index 0000000000..05458db4eb --- /dev/null +++ b/packages/preview/keyless/0.1.0/typst.toml @@ -0,0 +1,25 @@ +[package] +name = "keyless" +version = "0.1.0" +entrypoint = "lib.typ" +authors = ["mkpoli"] +license = "MIT" +description = "Key out selected colors from raster images and turn them transparent." +repository = "https://github.com/mkpoli/typst-keyless" +keywords = [ + "image", + "transparency", + "alpha", + "chroma key", + "color key", + "background removal", + "removal", + "remove", + "deletion", + "delete", + "mask", + "wasm", +] +categories = ["utility"] +compiler = "0.8.0" +exclude = ["docs/**"]