diff --git a/Cargo.toml b/Cargo.toml index 484cf4b..6ecd95f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ categories = ["algorithms"] include = ["/src/**", "/benches/**", "/Cargo.toml", "/LICENSE", "/README.md", "/SECURITY.md", "/CHANGELOG.md", "/llms.txt"] [features] -default = ["animation", "ansi", "array", "bytes", "cache", "ci", "color", "commit", "date", "duration", "env", "fs", "function", "future", "geometry", "hex", "http", "id", "iter", "license", "map", "markdown", "net", "number", "random", "secret", "set", "string", "time", "url", "validate", "version"] +default = ["animation", "ansi", "array", "bytes", "cache", "ci", "color", "commit", "date", "duration", "env", "font", "fs", "function", "future", "geometry", "hex", "http", "id", "iter", "kiwi", "license", "map", "markdown", "net", "number", "random", "secret", "set", "string", "time", "url", "validate", "version"] # One feature per module: `default-features = false, features = ["string"]` # compiles only what is used. animation = [] @@ -29,6 +29,7 @@ date = [] duration = [] env = [] fs = [] +font = [] function = [] future = [] geometry = [] @@ -36,6 +37,8 @@ hex = [] http = [] id = ["random"] iter = [] +# Built on the byte reader. +kiwi = ["bytes"] license = [] map = [] markdown = [] diff --git a/README.md b/README.md index cc4a237..07e0dd4 100644 --- a/README.md +++ b/README.md @@ -43,22 +43,24 @@ One module per category, each behind a Cargo feature of the same name (all enabl | [`animation`](https://helpers4.dev/rust/modules/animation/) | CSS easings parsed and evaluated (`cubic-bezier()`, `steps()`, `linear()`), damped springs, `linear()` output | | [`ansi`](https://helpers4.dev/rust/modules/ansi/) | stripping and building ANSI escape sequences (colors, styles) | | [`array`](https://helpers4.dev/rust/modules/array/) | unique, difference, intersection, grouping, counting, duplicates, interleaving, toggling, upserting | -| [`bytes`](https://helpers4.dev/rust/modules/bytes/) | integers at an offset, a `ByteReader` cursor (LEB128, zigzag, C strings), byte search, XOR, constant-time comparison, `1.5 KiB` sizes | +| [`bytes`](https://helpers4.dev/rust/modules/bytes/) | integers at an offset, a `ByteReader` cursor (LEB128, zigzag, C strings), byte search, XOR, constant-time comparison, `1.5 KiB` sizes, media types | | [`cache`](https://helpers4.dev/rust/modules/cache/) | maps and sets whose entries expire, with the clock passed in | | [`ci`](https://helpers4.dev/rust/modules/ci/) | which CI service is this, is it a pull request, and a Markdown status report | -| [`color`](https://helpers4.dev/rust/modules/color/) | `#rgb`/`rgb()`/names, alpha, CSS output, HSL, blending and WCAG contrast | +| [`color`](https://helpers4.dev/rust/modules/color/) | `#rgb`/`rgb()`/names, alpha, CSS output, HSL, HSB, CSS blend modes, premultiplied alpha and WCAG contrast | | [`commit`](https://helpers4.dev/rust/modules/commit/) | Conventional Commits: parsing, breaking changes and the version bump they call for | | [`date`](https://helpers4.dev/rust/modules/date/) | calendar dates without time zones: ISO 8601, weekdays, day arithmetic | | [`duration`](https://helpers4.dev/rust/modules/duration/) | parsing and formatting durations as `1h30m` | | [`env`](https://helpers4.dev/rust/modules/env/) | reading and editing `.env` files as text | +| [`font`](https://helpers4.dev/rust/modules/font/) | CSS font matching, weight and style names, reading a TTF, OTF or WOFF file's names, weight and style | | [`fs`](https://helpers4.dev/rust/modules/fs/) | atomic writes, missing file as `None`, listing a tree, lexical path checks | | [`function`](https://helpers4.dev/rust/modules/function/) | composition, memoization, retrying with backoff, a token-bucket rate limiter | | [`future`](https://helpers4.dev/rust/modules/future/) | `block_on`, `join`, `join_all` and friends, with no async runtime | -| [`geometry`](https://helpers4.dev/rust/modules/geometry/) | affine matrices in the CSS/SVG layout, rectangles and hit-testing, `object-fit` placement, polyline simplification | +| [`geometry`](https://helpers4.dev/rust/modules/geometry/) | affine matrices in the CSS/SVG layout, rectangles and hit-testing, `object-fit` placement, polyline simplification, SVG path data | | [`hex`](https://helpers4.dev/rust/modules/hex/) | hexadecimal, base64 and base32 encoding/decoding with typed errors | | [`http`](https://helpers4.dev/rust/modules/http/) | extracting a bearer token from an `Authorization` header | | [`id`](https://helpers4.dev/rust/modules/id/) | UUID v4 and v7, ULID, short ids over an alphabet: time and randomness passed in | | [`iter`](https://helpers4.dev/rust/modules/iter/) | chunking, one-pass min/max, first duplicate: for any `Iterator` | +| [`kiwi`](https://helpers4.dev/rust/modules/kiwi/) | decoding of the schema-based Kiwi binary format (Figma `.fig` files) into a dynamic value tree | | [`license`](https://helpers4.dev/rust/modules/license/) | SPDX identifiers and expressions, `MIT OR Apache-2.0` checked against a policy | | [`map`](https://helpers4.dev/rust/modules/map/) | picking, omitting and transforming the entries of a `HashMap` | | [`markdown`](https://helpers4.dev/rust/modules/markdown/) | escaping, links, code blocks, quotes, tables and heading anchors | @@ -67,7 +69,7 @@ One module per category, each behind a Cargo feature of the same name (all enabl | [`random`](https://helpers4.dev/rust/modules/random/) | a seedable generator for shuffling and sampling; keys and tokens from the OS (`random-entropy`) | | [`secret`](https://helpers4.dev/rust/modules/secret/) | a wrapper that never prints its value, redaction, masking, token detection | | [`set`](https://helpers4.dev/rust/modules/set/) | union and intersection of many sets, toggling, sorted output, similarity, power set | -| [`string`](https://helpers4.dev/rust/modules/string/) | case conversion, slugs, truncation, indentation, whitespace, HTML escaping, glob sets, natural order | +| [`string`](https://helpers4.dev/rust/modules/string/) | case conversion, slugs, truncation, indentation, whitespace, HTML escaping, glob sets, `/regex/` or glob patterns, natural order | | [`time`](https://helpers4.dev/rust/modules/time/) | the system clock as unix time, without a silent `0` | | [`url`](https://helpers4.dev/rust/modules/url/) | an RFC 3986 parser with reference resolution, percent-encoding, query strings | | [`validate`](https://helpers4.dev/rust/modules/validate/) | shape checks: email, UUID, slug | diff --git a/llms.txt b/llms.txt index e9942ef..b61265f 100644 --- a/llms.txt +++ b/llms.txt @@ -21,12 +21,14 @@ `key_by`, `duplicates`, `interleave`, `cartesian_product`, `toggle`, `toggle_by`, `replace_or_append` - bytes — byte slices and sizes: `read_u16`, `read_u32`, `read_u64` (with `Endian`), `ByteReader` (cursor: fixed-size values, LEB128 varints, NUL-terminated strings), `zigzag_encode`, `zigzag_decode`, `find`, `xor`, - `constant_time_eq`, `format_size`, `parse_size`, `ParseSizeError` + `constant_time_eq`, `format_size`, `parse_size`, `ParseSizeError`, `media_type_of` (image type from magic bytes), + `media_type_of_path` (type from an extension) - cache — in-memory stores with expiry and an injected clock: `ExpiringMap`, `ExpiringSet` - ci — CI environments and pipeline status (the environment is a lookup function): `detect`, `is_ci`, `is_pull_request`, `Provider`, `Status`, `overall`, `render_report` -- color — colors: `Rgb` (parse `#rgb`, `#rrggbb`, `rgb()`, names; `to_css`), `Rgba` (alpha: `#rrggbbaa`, `rgb(r g b / a)`), `Hsl`, `mix`, `contrast_ratio` (WCAG), - `best_text_color`, `ParseColorError` +- color — colors: `Rgb` (parse `#rgb`, `#rrggbb`, `rgb()`, names; `to_css`), `Rgba` (alpha: `#rrggbbaa`, `rgb(r g b / a)`), `Hsl`, `Hsb`, `mix`, `blend` (all CSS blend modes, `BlendMode`), `premultiply`, + `unpremultiply`, `Rgba::from_unit` / `to_unit` (0-1 float channels), `contrast_ratio` (WCAG), `best_text_color`, + `ParseColorError` - commit — Conventional Commits: `Commit` (parse), `Bump`, `bump_for`, `is_valid`, `emoji_for`, `ParseCommitError` - date — calendar dates without time zones: `Date`, `Weekday`, `DateError`, `is_leap_year`, @@ -34,6 +36,9 @@ - duration — human-readable durations (`1h30m`): `parse`, `format`, `ParseDurationError` - env — dotenv content helpers (pure text, no I/O): `parse`, `get`, `set`, `remove`, `get_bool`, `get_int`, `get_list`, `get_duration` +- font — fonts, no dependency: `pick_closest` (CSS font matching with `FontFace` / `FontRequest`, variable faces cover a weight + range), `weight_of_style`, `is_italic`, `split_postscript`, `same_family`, `read_font` (TrueType, OpenType, collection and + WOFF files into a `FontInfo`; not WOFF2), `FontError` - fs — file-system helpers: `write_atomic`, `read_to_string_opt`, `walk`, `normalize` and `is_within` (lexical path checks, no I/O) - function — functions: `compose`, `pipe`, `Memoize`, `retry`, `backoff`, `TokenBucket` (rate limiter with an @@ -41,7 +46,8 @@ - future — runtime-neutral futures, std only: `block_on`, `now_or_never`, `yield_now`, `join`, `join_all` - geometry — 2D geometry in `f64`: `Matrix` (CSS `matrix(a b c d e f)` layout: `translate`, `scale`, `rotate`, `multiply`, `invert`, `apply`, `bounding_box`, `to_css`), `Point`, `Size`, `Rect` (`union`, `intersection`, - `contains_point`, …), `fit` (CSS `object-fit`, with `FitMode` and `FitAlign`), `simplify_polyline` + `contains_point`, …), `Matrix::rect_to_rect`, `fit` (CSS `object-fit`, with `FitMode` and `FitAlign`), `simplify_polyline`, SVG path data: + `parse_path` (`PathCommand`), `format_path`, `transform_path`, `rounded_rect_path`, `ellipse_path`, `ParsePathError` - hex — byte/text encodings with typed errors: `encode`, `encode_upper`, `decode`, `decode_to_slice`, `decode_array`, `base64_encode`, `base64_decode`, `Base64DecodeError`, `base32_encode`, `base32_decode`, `Base32DecodeError` @@ -50,6 +56,9 @@ `short_id`, and with `random-entropy` `new_short_id`, `Uuid::new_v4`/`new_v7`, `Ulid::new`; `ParseUuidError`, `ParseUlidError`, `AlphabetError` - iter — helpers for any `Iterator`: `chunk`, `min_max`, `first_duplicate` +- kiwi — decoding of the Kiwi binary format (schema-based, used by `.fig` files; feature `kiwi`, needs `bytes`): + `Schema::parse` (a binary schema into `Definition`s, `Kind`, `Field`, `FieldType`), `Schema::decode` (a message into a + dynamic `Value`: `get`, `as_str`, `as_u32`, …), `KiwiError` - license — SPDX licenses: `lookup`, `normalize`, `Expression` (parse and evaluate `MIT OR Apache-2.0`), `Category`, `LicenseInfo`, `header`, `ParseExpressionError` - map — `HashMap` helpers: `pick`, `omit`, `map_values` @@ -66,7 +75,7 @@ - string — string manipulation: `capitalize`, `title_case`, `camel_case`, `pascal_case`, `snake_case`, `kebab_case`, `constant_case`, `slugify`, `truncate`, `excerpt`, `squish`, `indent`, `dedent`, `escape_html`, `unescape_html`, `escape_regex`, `words`, - `levenshtein_distance`, `levenshtein_similarity`, `match_glob`, `GlobSet` (globs with `!` exclusions), + `levenshtein_distance`, `levenshtein_similarity`, `match_glob`, `match_glob_ignore_case`, `parse_pattern` (`/source/flags` or glob, `Pattern`), `GlobSet` (globs with `!` exclusions), `compare_natural` (`"frame 2"` before `"frame 10"`), and (feature `string-diacritics`, which pulls the optional dependency `unicode-normalization`) `remove_diacritics` - time — the system clock as unix time: `unix_now`, `unix_now_millis`, `ClockError` diff --git a/scopes.json b/scopes.json index 297e404..df6d736 100644 --- a/scopes.json +++ b/scopes.json @@ -14,6 +14,7 @@ "deps", "duration", "env", + "font", "fs", "function", "future", @@ -22,6 +23,7 @@ "http", "id", "iter", + "kiwi", "license", "map", "markdown", diff --git a/src/bytes/media_type_of.rs b/src/bytes/media_type_of.rs new file mode 100644 index 0000000..a7022db --- /dev/null +++ b/src/bytes/media_type_of.rs @@ -0,0 +1,94 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +fn starts_with_at(bytes: &[u8], signature: &[u8], offset: usize) -> bool { + bytes + .get(offset..) + .is_some_and(|rest| rest.starts_with(signature)) +} + +/// Skips what may come before the ` &str { + let mut rest = text.strip_prefix('\u{feff}').unwrap_or(text).trim_start(); + loop { + let skipped = [(""), (""), ("")] + .iter() + .find_map(|(open, close)| { + let tail = rest.strip_prefix(open)?; + tail.find(close).map(|end| &tail[end + close.len()..]) + }); + match skipped { + Some(tail) => rest = tail.trim_start(), + None => return rest, + } + } +} + +fn is_svg(bytes: &[u8]) -> bool { + let head = &bytes[..bytes.len().min(2048)]; + // A truncated multi-byte character at the cut is dropped; the prolog is ASCII. + let text = match std::str::from_utf8(head) { + Ok(text) => text, + Err(error) => std::str::from_utf8(&head[..error.valid_up_to()]).unwrap_or(""), + }; + let body = svg_body(text); + body.get(..4) + .is_some_and(|tag| tag.eq_ignore_ascii_case("' || c == '/') +} + +/// Finds the media type of an image from its first bytes (its magic number), whatever its name +/// says: PNG, JPEG, GIF, WebP, AVIF and SVG. SVG has no magic number, so it is recognized as +/// text whose first element is `"), Some("image/svg+xml")); +/// assert_eq!(media_type_of(b"hello"), None); +/// ``` +#[must_use] +pub fn media_type_of(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) { + Some("image/png") + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + Some("image/jpeg") + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif") + } else if bytes.starts_with(b"RIFF") && starts_with_at(bytes, b"WEBP", 8) { + Some("image/webp") + } else if starts_with_at(bytes, b"ftyp", 4) + && (starts_with_at(bytes, b"avif", 8) || starts_with_at(bytes, b"avis", 8)) + { + Some("image/avif") + } else if is_svg(bytes) { + Some("image/svg+xml") + } else { + None + } +} + +#[cfg(test)] +#[path = "media_type_of.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "media_type_of.spec.rs"] +mod spec; diff --git a/src/bytes/media_type_of.spec.rs b/src/bytes/media_type_of.spec.rs new file mode 100644 index 0000000..ad12bab --- /dev/null +++ b/src/bytes/media_type_of.spec.rs @@ -0,0 +1,22 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn never_panics_and_answers_an_image_type_or_nothing(bytes in prop::collection::vec(any::(), 0..64)) { + if let Some(media_type) = media_type_of(&bytes) { + prop_assert!(media_type.starts_with("image/")); + } + } + + #[test] + fn a_known_signature_wins_whatever_follows(tail in prop::collection::vec(any::(), 0..32)) { + let mut png = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + png.extend(tail); + prop_assert_eq!(media_type_of(&png), Some("image/png")); + } +} diff --git a/src/bytes/media_type_of.test.rs b/src/bytes/media_type_of.test.rs new file mode 100644 index 0000000..e42ffab --- /dev/null +++ b/src/bytes/media_type_of.test.rs @@ -0,0 +1,75 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +fn riff(tag: &str) -> Vec { + [b"RIFF\0\0\0\0".as_slice(), tag.as_bytes()].concat() +} + +fn ftyp(brand: &str) -> Vec { + [b"\0\0\0\x1cftyp".as_slice(), brand.as_bytes()].concat() +} + +#[test] +fn recognizes_the_binary_formats() { + assert_eq!( + media_type_of(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0]), + Some("image/png") + ); + assert_eq!(media_type_of(&[0xff, 0xd8, 0xff, 0xe0]), Some("image/jpeg")); + assert_eq!(media_type_of(b"GIF87a..."), Some("image/gif")); + assert_eq!(media_type_of(b"GIF89a..."), Some("image/gif")); + assert_eq!(media_type_of(&riff("WEBPVP8 ")), Some("image/webp")); + assert_eq!(media_type_of(&ftyp("avif")), Some("image/avif")); + assert_eq!(media_type_of(&ftyp("avis")), Some("image/avif")); +} + +#[test] +fn does_not_take_another_riff_or_iso_file_for_an_image() { + assert_eq!(media_type_of(&riff("WAVEfmt ")), None); + assert_eq!(media_type_of(&ftyp("mp42")), None); +} + +#[test] +fn rejects_truncated_signatures() { + assert_eq!(media_type_of(&[0x89, b'P']), None); + assert_eq!(media_type_of(b"RIFF"), None); + assert_eq!(media_type_of(b""), None); +} + +#[test] +fn recognizes_svg_after_a_prolog() { + assert_eq!( + media_type_of(b""), + Some("image/svg+xml") + ); + assert_eq!(media_type_of(b""), Some("image/svg+xml")); + assert_eq!(media_type_of(b""), Some("image/svg+xml")); + assert_eq!( + media_type_of( + "\u{feff} \n\n\n\n" + .as_bytes() + ), + Some("image/svg+xml") + ); +} + +#[test] +fn does_not_take_other_text_or_markup_for_svg() { + assert_eq!(media_type_of(b""), None); + assert_eq!(media_type_of(b""), None); + assert_eq!(media_type_of(b"".to_vec(); + data.resize(2047, b' '); + data.extend("é".as_bytes()); + assert_eq!(media_type_of(&data), Some("image/svg+xml")); +} diff --git a/src/bytes/media_type_of_path.rs b/src/bytes/media_type_of_path.rs new file mode 100644 index 0000000..734c924 --- /dev/null +++ b/src/bytes/media_type_of_path.rs @@ -0,0 +1,76 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +const TYPES: &[(&str, &str)] = &[ + ("png", "image/png"), + ("jpg", "image/jpeg"), + ("jpeg", "image/jpeg"), + ("gif", "image/gif"), + ("webp", "image/webp"), + ("avif", "image/avif"), + ("svg", "image/svg+xml"), + ("ico", "image/x-icon"), + ("bmp", "image/bmp"), + ("woff", "font/woff"), + ("woff2", "font/woff2"), + ("ttf", "font/ttf"), + ("otf", "font/otf"), + ("html", "text/html"), + ("htm", "text/html"), + ("css", "text/css"), + ("js", "text/javascript"), + ("mjs", "text/javascript"), + ("json", "application/json"), + ("txt", "text/plain"), + ("md", "text/markdown"), + ("xml", "application/xml"), + ("wasm", "application/wasm"), + ("pdf", "application/pdf"), + ("zip", "application/zip"), + ("mp3", "audio/mpeg"), + ("wav", "audio/wav"), + ("ogg", "audio/ogg"), + ("mp4", "video/mp4"), + ("webm", "video/webm"), +]; + +/// Finds the media type of a file from the extension of its path or URL: images, fonts, web +/// documents, scripts and styles, data, audio and video. Case is ignored, and a query string or +/// fragment after the path is skipped. Pass it where a server or a blob needs a `Content-Type`. +/// +/// # Arguments +/// +/// - `path` - A file path or URL. +/// +/// # Returns +/// +/// The media type (`"image/webp"`, ...), or `None` for an unknown or missing extension. +/// +/// # Examples +/// +/// ``` +/// use helpers4::bytes::media_type_of_path; +/// +/// assert_eq!(media_type_of_path("layers/a@2x.webp"), Some("image/webp")); +/// assert_eq!(media_type_of_path("/fonts/Inter.WOFF2?v=3"), Some("font/woff2")); +/// assert_eq!(media_type_of_path("LICENSE"), None); +/// ``` +#[must_use] +pub fn media_type_of_path(path: &str) -> Option<&'static str> { + let path = path.split(['?', '#']).next().unwrap_or(""); + let name = path.rsplit(['/', '\\']).next().unwrap_or(""); + let (_, extension) = name.rsplit_once('.')?; + TYPES + .iter() + .find(|(known, _)| extension.eq_ignore_ascii_case(known)) + .map(|(_, media_type)| *media_type) +} + +#[cfg(test)] +#[path = "media_type_of_path.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "media_type_of_path.spec.rs"] +mod spec; diff --git a/src/bytes/media_type_of_path.spec.rs b/src/bytes/media_type_of_path.spec.rs new file mode 100644 index 0000000..6583850 --- /dev/null +++ b/src/bytes/media_type_of_path.spec.rs @@ -0,0 +1,25 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn never_panics(path in "\\PC{0,40}") { + let _ = media_type_of_path(&path); + } + + #[test] + fn ignores_the_folders_the_query_and_the_case_of_a_known_extension( + dir in "[a-z0-9/]{0,12}", + extension in prop::sample::select(vec!["png", "PNG", "Png"]), + query in "[a-z0-9=&]{0,8}", + ) { + prop_assert_eq!( + media_type_of_path(&format!("{dir}/file.{extension}?{query}")), + Some("image/png") + ); + } +} diff --git a/src/bytes/media_type_of_path.test.rs b/src/bytes/media_type_of_path.test.rs new file mode 100644 index 0000000..4669ba9 --- /dev/null +++ b/src/bytes/media_type_of_path.test.rs @@ -0,0 +1,37 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn finds_the_type_from_the_extension() { + assert_eq!(media_type_of_path("layers/a@2x.webp"), Some("image/webp")); + assert_eq!(media_type_of_path("a.svg"), Some("image/svg+xml")); + assert_eq!(media_type_of_path("dir.v2/app.js"), Some("text/javascript")); +} + +#[test] +fn ignores_case_the_query_string_and_the_fragment() { + assert_eq!( + media_type_of_path("/fonts/Inter.WOFF2?v=3"), + Some("font/woff2") + ); + assert_eq!( + media_type_of_path("https://x.dev/a.png#top"), + Some("image/png") + ); +} + +#[test] +fn reads_windows_separators() { + assert_eq!(media_type_of_path("C:\\art\\a.jpeg"), Some("image/jpeg")); +} + +#[test] +fn gives_none_without_a_known_extension() { + assert_eq!(media_type_of_path("LICENSE"), None); + assert_eq!(media_type_of_path("a.unknown"), None); + assert_eq!(media_type_of_path("dir.png/file"), None); + assert_eq!(media_type_of_path(""), None); +} diff --git a/src/bytes/mod.rs b/src/bytes/mod.rs index d946b43..e7299cd 100644 --- a/src/bytes/mod.rs +++ b/src/bytes/mod.rs @@ -6,7 +6,8 @@ //! //! Reading integers at an offset without panicking, a [`ByteReader`] cursor for binary formats //! (fixed-size values, LEB128 varints, zigzag, NUL-terminated strings), a byte-slice search, a -//! constant-time comparison, XOR, and human-readable sizes (`1.5 KiB`) in both directions. +//! constant-time comparison, XOR, human-readable sizes (`1.5 KiB`) in both directions, and the +//! media type of an image from its first bytes or of a file from its extension. mod byte_reader; mod constant_time_eq; @@ -14,6 +15,8 @@ mod endian; mod error; mod find; mod format_size; +mod media_type_of; +mod media_type_of_path; mod parse_size; mod read_u16; mod read_u32; @@ -28,6 +31,8 @@ pub use endian::Endian; pub use error::ParseSizeError; pub use find::find; pub use format_size::format_size; +pub use media_type_of::media_type_of; +pub use media_type_of_path::media_type_of_path; pub use parse_size::parse_size; pub use read_u16::read_u16; pub use read_u32::read_u32; diff --git a/src/color/blend.rs b/src/color/blend.rs new file mode 100644 index 0000000..5496f3e --- /dev/null +++ b/src/color/blend.rs @@ -0,0 +1,232 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::Rgba; + +/// The blend modes of the W3C Compositing and Blending specification, the same ones as CSS +/// `mix-blend-mode`. The last four ([`Hue`](Self::Hue), [`Saturation`](Self::Saturation), +/// [`Color`](Self::Color), [`Luminosity`](Self::Luminosity)) are non-separable: they mix the +/// three channels together instead of one channel at a time. +/// +/// # Examples +/// +/// ``` +/// use helpers4::color::BlendMode; +/// +/// assert_eq!(BlendMode::default(), BlendMode::Normal); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum BlendMode { + /// The source replaces the backdrop (a plain alpha composite). The default. + #[default] + Normal, + /// Multiplies the channels: always darker. + Multiply, + /// The inverse of [`Multiply`](Self::Multiply): always lighter. + Screen, + /// [`Multiply`](Self::Multiply) or [`Screen`](Self::Screen) depending on the backdrop. + Overlay, + /// The darker of the two, per channel. + Darken, + /// The lighter of the two, per channel. + Lighten, + /// Brightens the backdrop to reflect the source. + ColorDodge, + /// Darkens the backdrop to reflect the source. + ColorBurn, + /// [`Multiply`](Self::Multiply) or [`Screen`](Self::Screen) depending on the source. + HardLight, + /// A softer [`HardLight`](Self::HardLight). + SoftLight, + /// The absolute difference of the two. + Difference, + /// Like [`Difference`](Self::Difference), with less contrast. + Exclusion, + /// The hue of the source with the saturation and luminosity of the backdrop. + Hue, + /// The saturation of the source with the hue and luminosity of the backdrop. + Saturation, + /// The hue and saturation of the source with the luminosity of the backdrop. + Color, + /// The luminosity of the source with the hue and saturation of the backdrop. + Luminosity, +} + +type Triplet = [f64; 3]; + +fn hard_light(backdrop: f64, source: f64) -> f64 { + if source <= 0.5 { + backdrop * 2.0 * source + } else { + backdrop + (2.0 * source - 1.0) - backdrop * (2.0 * source - 1.0) + } +} + +fn separable(mode: BlendMode, cb: f64, cs: f64) -> f64 { + match mode { + BlendMode::Multiply => cb * cs, + BlendMode::Screen => cb + cs - cb * cs, + BlendMode::Overlay => hard_light(cs, cb), + BlendMode::Darken => cb.min(cs), + BlendMode::Lighten => cb.max(cs), + BlendMode::ColorDodge => { + if cb == 0.0 { + 0.0 + } else if cs >= 1.0 { + 1.0 + } else { + (cb / (1.0 - cs)).min(1.0) + } + } + BlendMode::ColorBurn => { + if cb >= 1.0 { + 1.0 + } else if cs == 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - cb) / cs).min(1.0) + } + } + BlendMode::HardLight => hard_light(cb, cs), + BlendMode::SoftLight => { + if cs <= 0.5 { + cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb) + } else { + let d = if cb <= 0.25 { + ((16.0 * cb - 12.0) * cb + 4.0) * cb + } else { + cb.sqrt() + }; + cb + (2.0 * cs - 1.0) * (d - cb) + } + } + BlendMode::Difference => (cb - cs).abs(), + BlendMode::Exclusion => cb + cs - 2.0 * cb * cs, + _ => cs, + } +} + +fn lum([r, g, b]: Triplet) -> f64 { + 0.3 * r + 0.59 * g + 0.11 * b +} + +fn extremes(c: Triplet) -> (f64, f64) { + (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])) +} + +fn sat(c: Triplet) -> f64 { + let (min, max) = extremes(c); + max - min +} + +fn clip_color(c: Triplet) -> Triplet { + let l = lum(c); + let (n, x) = extremes(c); + c.map(|v| { + let low = if n < 0.0 { + l + (v - l) * l / (l - n) + } else { + v + }; + if x > 1.0 { + l + (low - l) * (1.0 - l) / (x - l) + } else { + low + } + }) +} + +fn set_lum(c: Triplet, l: f64) -> Triplet { + let d = l - lum(c); + clip_color(c.map(|v| v + d)) +} + +fn set_sat(c: Triplet, s: f64) -> Triplet { + let (min, max) = extremes(c); + if max <= min { + return [0.0; 3]; + } + c.map(|v| (v - min) * s / (max - min)) +} + +fn non_separable(mode: BlendMode, cb: Triplet, cs: Triplet) -> Triplet { + match mode { + BlendMode::Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)), + BlendMode::Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)), + BlendMode::Color => set_lum(cs, lum(cb)), + _ => set_lum(cb, lum(cs)), + } +} + +/// The channels as `0.0..=1.0` intensities. +fn unit(color: Rgba) -> Triplet { + [color.r(), color.g(), color.b()].map(|c| f64::from(c) / 255.0) +} + +/// Composites a source color over a backdrop with a blend mode, as the browser does for +/// `mix-blend-mode` (W3C Compositing and Blending Level 1, source-over). Both colors are straight +/// (non-premultiplied); the maths run in premultiplied space and the result is straight again, +/// each channel and the alpha rounded to the nearest 8-bit value. +/// +/// # Arguments +/// +/// - `backdrop` - The color underneath. +/// - `source` - The color painted over it. +/// - `mode` - The blend mode; [`BlendMode::Normal`] is a plain alpha composite. +/// +/// # Returns +/// +/// The straight result. Transparent black when both colors are fully transparent. +/// +/// # Examples +/// +/// ``` +/// use helpers4::color::{blend, BlendMode, Rgba}; +/// +/// let red = Rgba::new(255, 0, 0, 255); +/// let half_blue = Rgba::new(0, 0, 255, 128); +/// assert_eq!(blend(red, half_blue, BlendMode::Normal), Rgba::new(127, 0, 128, 255)); +/// +/// let orange = Rgba::new(200, 100, 50, 255); +/// let gray = Rgba::new(100, 100, 100, 255); +/// assert_eq!(blend(orange, gray, BlendMode::Multiply), Rgba::new(78, 39, 20, 255)); +/// ``` +#[must_use] +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // clamped to 0..=255 first +pub fn blend(backdrop: Rgba, source: Rgba, mode: BlendMode) -> Rgba { + let ab = backdrop.opacity(); + let a_s = source.opacity(); + let ar = a_s + ab * (1.0 - a_s); + if ar == 0.0 { + return Rgba::new(0, 0, 0, 0); + } + let cb = unit(backdrop); + let cs = unit(source); + let blended: Triplet = match mode { + BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity => { + non_separable(mode, cb, cs) + } + _ => [0, 1, 2].map(|i| separable(mode, cb[i], cs[i])), + }; + let channel = |i: usize| { + let mixed = (1.0 - ab) * cs[i] + ab * blended[i]; + let value = (1.0 - a_s / ar) * cb[i] + (a_s / ar) * mixed; + (value * 255.0).round().clamp(0.0, 255.0) as u8 + }; + Rgba::new( + channel(0), + channel(1), + channel(2), + (ar * 255.0).round().clamp(0.0, 255.0) as u8, + ) +} + +#[cfg(test)] +#[path = "blend.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "blend.spec.rs"] +mod spec; diff --git a/src/color/blend.spec.rs b/src/color/blend.spec.rs new file mode 100644 index 0000000..cc4b524 --- /dev/null +++ b/src/color/blend.spec.rs @@ -0,0 +1,43 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use proptest::prelude::*; + +const MODES: [BlendMode; 16] = [ + BlendMode::Normal, + BlendMode::Multiply, + BlendMode::Screen, + BlendMode::Overlay, + BlendMode::Darken, + BlendMode::Lighten, + BlendMode::ColorDodge, + BlendMode::ColorBurn, + BlendMode::HardLight, + BlendMode::SoftLight, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, +]; + +fn color() -> impl Strategy { + (any::(), any::(), any::(), any::()) + .prop_map(|(r, g, b, a)| Rgba::new(r, g, b, a)) +} + +proptest! { + #[test] + fn never_panics_for_any_colors_and_mode(backdrop in color(), source in color(), mode in prop::sample::select(MODES.to_vec())) { + let _ = blend(backdrop, source, mode); + } + + #[test] + fn an_opaque_source_replaces_the_backdrop_with_normal(backdrop in color(), r in any::(), g in any::(), b in any::()) { + let source = Rgba::new(r, g, b, 255); + prop_assert_eq!(blend(backdrop, source, BlendMode::Normal), source); + } +} diff --git a/src/color/blend.test.rs b/src/color/blend.test.rs new file mode 100644 index 0000000..179b163 --- /dev/null +++ b/src/color/blend.test.rs @@ -0,0 +1,202 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +fn gray(v: u8) -> Rgba { + Rgba::new(v, v, v, 255) +} + +#[test] +fn normal_composites_a_translucent_source_over_the_backdrop() { + assert_eq!( + blend( + Rgba::new(255, 0, 0, 255), + Rgba::new(0, 0, 255, 128), + BlendMode::Normal + ), + Rgba::new(127, 0, 128, 255) + ); +} + +#[test] +fn an_opaque_source_replaces_the_backdrop_with_normal() { + assert_eq!( + blend( + Rgba::new(1, 2, 3, 255), + Rgba::new(9, 8, 7, 255), + BlendMode::Normal + ), + Rgba::new(9, 8, 7, 255) + ); +} + +#[test] +fn a_transparent_source_leaves_the_backdrop_alone() { + assert_eq!( + blend( + Rgba::new(1, 2, 3, 100), + Rgba::new(9, 8, 7, 0), + BlendMode::Multiply + ), + Rgba::new(1, 2, 3, 100) + ); +} + +#[test] +fn a_transparent_backdrop_gives_the_source() { + assert_eq!( + blend( + Rgba::new(1, 2, 3, 0), + Rgba::new(9, 8, 7, 128), + BlendMode::Multiply + ), + Rgba::new(9, 8, 7, 128) + ); +} + +#[test] +fn two_transparent_colors_give_transparent_black() { + assert_eq!( + blend( + Rgba::new(1, 2, 3, 0), + Rgba::new(9, 8, 7, 0), + BlendMode::Normal + ), + Rgba::new(0, 0, 0, 0) + ); +} + +#[test] +fn separable_modes_match_the_specification() { + let cases = [ + (BlendMode::Multiply, 200, 100, 78), + (BlendMode::Screen, 128, 128, 192), + (BlendMode::Overlay, 128, 51, 52), + (BlendMode::Overlay, 128, 204, 204), + (BlendMode::Darken, 10, 200, 10), + (BlendMode::Lighten, 10, 200, 200), + (BlendMode::ColorDodge, 0, 128, 0), + (BlendMode::ColorDodge, 128, 255, 255), + (BlendMode::ColorDodge, 128, 128, 255), + (BlendMode::ColorDodge, 51, 51, 64), + (BlendMode::ColorBurn, 255, 128, 255), + (BlendMode::ColorBurn, 128, 0, 0), + (BlendMode::ColorBurn, 128, 128, 2), + (BlendMode::ColorBurn, 204, 153, 170), + (BlendMode::HardLight, 128, 51, 51), + (BlendMode::HardLight, 128, 204, 204), + (BlendMode::SoftLight, 51, 51, 27), + (BlendMode::SoftLight, 51, 204, 89), + (BlendMode::SoftLight, 128, 204, 160), + (BlendMode::Difference, 200, 50, 150), + (BlendMode::Exclusion, 200, 100, 143), + ]; + for (mode, backdrop, source, expected) in cases { + assert_eq!( + blend(gray(backdrop), gray(source), mode).r(), + expected, + "{mode:?} {backdrop} {source}" + ); + } +} + +#[test] +fn blends_with_a_partly_transparent_backdrop() { + assert_eq!( + blend( + Rgba::new(100, 100, 100, 128), + Rgba::new(200, 200, 200, 128), + BlendMode::Multiply + ), + Rgba::new(126, 126, 126, 192) + ); +} + +fn luminosity(color: Rgba) -> f64 { + let [r, g, b, _] = color.to_unit(); + 0.3 * r + 0.59 * g + 0.11 * b +} + +fn red() -> Rgba { + Rgba::new(255, 0, 0, 255) +} +fn blue() -> Rgba { + Rgba::new(0, 0, 255, 255) +} +fn orange() -> Rgba { + Rgba::new(240, 140, 40, 255) +} +fn teal() -> Rgba { + Rgba::new(30, 150, 160, 255) +} + +#[test] +fn luminosity_keeps_the_backdrop_colors_with_the_source_luminosity() { + let out = blend(red(), blue(), BlendMode::Luminosity); + assert!((luminosity(out) - luminosity(blue())).abs() < 0.01); + assert!(out.r() > out.g()); + let out = blend(orange(), teal(), BlendMode::Luminosity); + assert!((luminosity(out) - luminosity(teal())).abs() < 0.01); +} + +#[test] +fn color_keeps_the_backdrop_luminosity() { + for (backdrop, source) in [(orange(), teal()), (red(), blue())] { + let out = blend(backdrop, source, BlendMode::Color); + assert!((luminosity(out) - luminosity(backdrop)).abs() < 0.01); + } +} + +#[test] +fn color_clips_what_goes_past_white() { + assert_eq!( + blend(gray(255), red(), BlendMode::Color), + Rgba::new(255, 255, 255, 255) + ); +} + +#[test] +fn hue_takes_the_source_hue_with_the_backdrop_saturation_and_luminosity() { + let out = blend(orange(), teal(), BlendMode::Hue); + assert!((luminosity(out) - luminosity(orange())).abs() < 0.01); + let flat = blend(gray(90), red(), BlendMode::Hue); + assert_eq!((flat.r(), flat.g()), (flat.g(), flat.b())); +} + +#[test] +fn hue_with_a_gray_source_and_saturation_with_a_gray_backdrop_have_no_color() { + let flat = blend(orange(), gray(100), BlendMode::Hue); + assert_eq!((flat.r(), flat.g()), (flat.g(), flat.b())); + let flat = blend(gray(100), teal(), BlendMode::Saturation); + assert_eq!(flat.r(), flat.b()); +} + +#[test] +fn saturation_takes_the_source_saturation() { + let out = blend(orange(), teal(), BlendMode::Saturation); + assert!((luminosity(out) - luminosity(orange())).abs() < 0.01); + let flat = blend(orange(), gray(70), BlendMode::Saturation); + assert_eq!(flat.r(), flat.g()); +} + +#[test] +fn a_mode_that_mixes_channels_is_a_no_op_for_identical_colors() { + for mode in [ + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, + ] { + let out = blend(orange(), orange(), mode); + assert!( + out.r().abs_diff(240) <= 1 && out.g().abs_diff(140) <= 1 && out.b().abs_diff(40) <= 1 + ); + } +} + +#[test] +fn the_default_mode_is_normal() { + assert_eq!(BlendMode::default(), BlendMode::Normal); +} diff --git a/src/color/hsb.rs b/src/color/hsb.rs new file mode 100644 index 0000000..03ab872 --- /dev/null +++ b/src/color/hsb.rs @@ -0,0 +1,122 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::Rgb; + +/// A color as hue, saturation and brightness (also called HSV), the model of design tool color +/// pickers. Unlike [`Hsl`](super::Hsl) lightness, brightness `1.0` is the pure color, not white. +/// +/// The hue is in degrees, `0.0..360.0`; saturation and brightness are fractions from `0.0` to +/// `1.0`. [`Hsb::new`] brings any input into those ranges. Get one from [`Rgb::to_hsb`] and go +/// back with [`Hsb::to_rgb`]. +/// +/// # Examples +/// +/// ``` +/// use helpers4::color::{Hsb, Rgb}; +/// +/// assert_eq!(Hsb::new(0.0, 1.0, 1.0).to_rgb(), Rgb::new(255, 0, 0)); +/// assert_eq!(Rgb::new(0, 128, 128).to_hsb(), Hsb::new(180.0, 1.0, 128.0 / 255.0)); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Hsb { + h: f64, + s: f64, + b: f64, +} + +impl Hsb { + /// Builds a color, normalizing the values: the hue wraps around 360 degrees, saturation and + /// brightness are clamped to `0.0..=1.0`, and a `NaN` counts as `0.0`. + /// + /// # Arguments + /// + /// - `h` - The hue in degrees. + /// - `s` - The saturation, `0.0` (grey) to `1.0` (vivid). + /// - `b` - The brightness, `0.0` (black) to `1.0` (the pure color). + /// + /// # Returns + /// + /// The normalized color. + #[must_use] + pub fn new(h: f64, s: f64, b: f64) -> Self { + let clean = |value: f64| if value.is_nan() { 0.0 } else { value }; + Self { + h: clean(h).rem_euclid(360.0), + s: clean(s).clamp(0.0, 1.0), + b: clean(b).clamp(0.0, 1.0), + } + } + + /// The hue in degrees. + /// + /// # Returns + /// + /// A value in `0.0..360.0`. + #[must_use] + pub fn h(self) -> f64 { + self.h + } + + /// The saturation. + /// + /// # Returns + /// + /// A value from `0.0` to `1.0`. + #[must_use] + pub fn s(self) -> f64 { + self.s + } + + /// The brightness. + /// + /// # Returns + /// + /// A value from `0.0` to `1.0`. + #[must_use] + pub fn b(self) -> f64 { + self.b + } + + /// The nearest 8-bit sRGB color. + /// + /// # Returns + /// + /// The [`Rgb`] color, each channel rounded to the nearest integer. + #[must_use] + pub fn to_rgb(self) -> Rgb { + let chroma = self.b * self.s; + let sector = self.h / 60.0; + let second = chroma * (1.0 - (sector % 2.0 - 1.0).abs()); + let (r, g, b) = if sector < 1.0 { + (chroma, second, 0.0) + } else if sector < 2.0 { + (second, chroma, 0.0) + } else if sector < 3.0 { + (0.0, chroma, second) + } else if sector < 4.0 { + (0.0, second, chroma) + } else if sector < 5.0 { + (second, 0.0, chroma) + } else { + (chroma, 0.0, second) + }; + let offset = self.b - chroma; + Rgb::new( + channel(r + offset), + channel(g + offset), + channel(b + offset), + ) + } +} + +/// A `0.0..=1.0` intensity as an 8-bit channel. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // clamped to 0..=255 first +fn channel(intensity: f64) -> u8 { + (intensity * 255.0).round().clamp(0.0, 255.0) as u8 +} + +#[cfg(test)] +#[path = "hsb.test.rs"] +mod tests; diff --git a/src/color/hsb.test.rs b/src/color/hsb.test.rs new file mode 100644 index 0000000..8c8a243 --- /dev/null +++ b/src/color/hsb.test.rs @@ -0,0 +1,68 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn converts_the_primaries() { + assert_eq!(Hsb::new(0.0, 1.0, 1.0).to_rgb(), Rgb::new(255, 0, 0)); + assert_eq!(Hsb::new(60.0, 1.0, 1.0).to_rgb(), Rgb::new(255, 255, 0)); + assert_eq!(Hsb::new(120.0, 1.0, 1.0).to_rgb(), Rgb::new(0, 255, 0)); + assert_eq!(Hsb::new(180.0, 1.0, 1.0).to_rgb(), Rgb::new(0, 255, 255)); + assert_eq!(Hsb::new(240.0, 1.0, 1.0).to_rgb(), Rgb::new(0, 0, 255)); + assert_eq!(Hsb::new(300.0, 1.0, 1.0).to_rgb(), Rgb::new(255, 0, 255)); +} + +#[test] +fn brightness_one_is_the_pure_color_not_white() { + assert_eq!(Hsb::new(0.0, 0.0, 1.0).to_rgb(), Rgb::new(255, 255, 255)); + assert_eq!(Hsb::new(0.0, 1.0, 0.5).to_rgb(), Rgb::new(128, 0, 0)); +} + +#[test] +fn normalizes_its_input() { + assert_eq!(Hsb::new(-90.0, 2.0, -1.0), Hsb::new(270.0, 1.0, 0.0)); + assert_eq!( + Hsb::new(f64::NAN, f64::NAN, f64::NAN), + Hsb::new(0.0, 0.0, 0.0) + ); + let color = Hsb::new(10.0, 0.5, 0.25); + assert_eq!((color.h(), color.s(), color.b()), (10.0, 0.5, 0.25)); +} + +#[test] +fn reads_the_hsb_of_an_rgb_color() { + assert_eq!(Rgb::new(255, 0, 0).to_hsb(), Hsb::new(0.0, 1.0, 1.0)); + assert_eq!( + Rgb::new(0, 128, 128).to_hsb(), + Hsb::new(180.0, 1.0, 128.0 / 255.0) + ); + assert_eq!(Rgb::new(0, 255, 0).to_hsb().h(), 120.0); + assert_eq!(Rgb::new(0, 0, 255).to_hsb().h(), 240.0); + assert_eq!(Rgb::new(255, 0, 128).to_hsb().h().round(), 330.0); +} + +#[test] +fn a_gray_has_no_hue_or_saturation() { + let gray = Rgb::new(51, 51, 51).to_hsb(); + assert_eq!((gray.h(), gray.s()), (0.0, 0.0)); + assert!((gray.b() - 0.2).abs() < 1e-12); + assert_eq!(Rgb::new(0, 0, 0).to_hsb(), Hsb::new(0.0, 0.0, 0.0)); +} + +#[test] +fn round_trips_through_rgb_for_every_gray_and_a_spread_of_colors() { + for v in 0..=255u8 { + assert_eq!(Rgb::new(v, v, v).to_hsb().to_rgb(), Rgb::new(v, v, v)); + } + for (r, g, b) in [ + (255, 0, 0), + (12, 200, 99), + (0, 128, 128), + (250, 250, 3), + (7, 7, 200), + ] { + assert_eq!(Rgb::new(r, g, b).to_hsb().to_rgb(), Rgb::new(r, g, b)); + } +} diff --git a/src/color/mod.rs b/src/color/mod.rs index 02e72e7..7230728 100644 --- a/src/color/mod.rs +++ b/src/color/mod.rs @@ -10,17 +10,26 @@ mod _named; mod best_text_color; +mod blend; mod contrast_ratio; mod error; +mod hsb; mod hsl; mod mix; +mod premultiply; mod rgb; mod rgba; +mod unpremultiply; pub use best_text_color::best_text_color; +pub use blend::BlendMode; +pub use blend::blend; pub use contrast_ratio::contrast_ratio; pub use error::ParseColorError; +pub use hsb::Hsb; pub use hsl::Hsl; pub use mix::mix; +pub use premultiply::premultiply; pub use rgb::Rgb; pub use rgba::Rgba; +pub use unpremultiply::unpremultiply; diff --git a/src/color/premultiply.rs b/src/color/premultiply.rs new file mode 100644 index 0000000..1a82435 --- /dev/null +++ b/src/color/premultiply.rs @@ -0,0 +1,46 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::Rgba; + +/// Multiplies the color channels by the alpha, the form a renderer composites with (a +/// translucent color then adds to what is underneath without a further multiplication). +/// Channels are rounded to 8 bits, so [`unpremultiply`](super::unpremultiply) gives back the +/// original only approximately for translucent colors, and not at all for alpha `0`. +/// +/// # Arguments +/// +/// - `color` - The straight color. +/// +/// # Returns +/// +/// The premultiplied color, with the same alpha. +/// +/// # Examples +/// +/// ``` +/// use helpers4::color::{premultiply, Rgba}; +/// +/// assert_eq!(premultiply(Rgba::new(200, 100, 50, 128)), Rgba::new(100, 50, 25, 128)); +/// assert_eq!(premultiply(Rgba::new(200, 100, 50, 255)), Rgba::new(200, 100, 50, 255)); +/// ``` +#[must_use] +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // at most 255 * 255 / 255 +pub fn premultiply(color: Rgba) -> Rgba { + let scale = |channel: u8| (f64::from(channel) * color.opacity()).round() as u8; + Rgba::new( + scale(color.r()), + scale(color.g()), + scale(color.b()), + color.a(), + ) +} + +#[cfg(test)] +#[path = "premultiply.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "premultiply.spec.rs"] +mod spec; diff --git a/src/color/premultiply.spec.rs b/src/color/premultiply.spec.rs new file mode 100644 index 0000000..998b279 --- /dev/null +++ b/src/color/premultiply.spec.rs @@ -0,0 +1,15 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn never_raises_a_channel_and_keeps_the_alpha(r in any::(), g in any::(), b in any::(), a in any::()) { + let out = premultiply(Rgba::new(r, g, b, a)); + prop_assert!(out.r() <= r && out.g() <= g && out.b() <= b); + prop_assert_eq!(out.a(), a); + } +} diff --git a/src/color/premultiply.test.rs b/src/color/premultiply.test.rs new file mode 100644 index 0000000..10626d1 --- /dev/null +++ b/src/color/premultiply.test.rs @@ -0,0 +1,29 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn scales_the_channels_by_the_alpha() { + assert_eq!( + premultiply(Rgba::new(200, 100, 50, 128)), + Rgba::new(100, 50, 25, 128) + ); +} + +#[test] +fn an_opaque_color_is_unchanged() { + assert_eq!( + premultiply(Rgba::new(200, 100, 50, 255)), + Rgba::new(200, 100, 50, 255) + ); +} + +#[test] +fn a_transparent_color_loses_its_channels() { + assert_eq!( + premultiply(Rgba::new(200, 100, 50, 0)), + Rgba::new(0, 0, 0, 0) + ); +} diff --git a/src/color/rgb.rs b/src/color/rgb.rs index 1daba33..385cb34 100644 --- a/src/color/rgb.rs +++ b/src/color/rgb.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later use super::_named::NAMED; -use super::{Hsl, ParseColorError}; +use super::{Hsb, Hsl, ParseColorError}; use std::fmt; use std::str::FromStr; @@ -208,6 +208,47 @@ impl Rgb { Hsl::new(sector * 60.0, saturation, lightness) } + /// The same color as hue, saturation and brightness (HSB, also called HSV), the model of + /// design tool color pickers. + /// + /// # Returns + /// + /// The [`Hsb`] value. Converting it back with [`Hsb::to_rgb`] gives this color again. + /// + /// # Examples + /// + /// ``` + /// use helpers4::color::{Hsb, Rgb}; + /// + /// assert_eq!(Rgb::new(255, 0, 0).to_hsb(), Hsb::new(0.0, 1.0, 1.0)); + /// assert_eq!(Rgb::new(10, 10, 10).to_hsb().s(), 0.0); + /// ``` + #[must_use] + pub fn to_hsb(self) -> Hsb { + let (max, min) = ( + self.r.max(self.g).max(self.b), + self.r.min(self.g).min(self.b), + ); + let (r, g, b) = ( + f64::from(self.r) / 255.0, + f64::from(self.g) / 255.0, + f64::from(self.b) / 255.0, + ); + let brightness = f64::from(max) / 255.0; + let delta = f64::from(max - min) / 255.0; + if delta <= 0.0 { + return Hsb::new(0.0, 0.0, brightness); + } + let sector = if max == self.r { + ((g - b) / delta).rem_euclid(6.0) + } else if max == self.g { + (b - r) / delta + 2.0 + } else { + (r - g) / delta + 4.0 + }; + Hsb::new(sector * 60.0, delta / brightness, brightness) + } + /// The WCAG relative luminance: `0.0` for black, `1.0` for white. /// /// The channels are linearized from sRGB and weighted by how bright the eye finds each diff --git a/src/color/rgba.rs b/src/color/rgba.rs index 1d65845..460ec18 100644 --- a/src/color/rgba.rs +++ b/src/color/rgba.rs @@ -233,6 +233,61 @@ impl Rgba { f64::from(self.a) / 255.0 } + /// Builds a color from channels given as floats from `0.0` to `1.0`, the way Figma, Sketch + /// and Penpot store them. Each value is clamped to that range (a `NaN` counts as `0.0`) and + /// rounded to the nearest 8-bit value. + /// + /// # Arguments + /// + /// - `r` - Red, `0.0` to `1.0`. + /// - `g` - Green, `0.0` to `1.0`. + /// - `b` - Blue, `0.0` to `1.0`. + /// - `a` - Alpha, `0.0` (transparent) to `1.0` (opaque). + /// + /// # Returns + /// + /// The 8-bit color. + /// + /// # Examples + /// + /// ``` + /// use helpers4::color::Rgba; + /// + /// let orange = Rgba::from_unit(1.0, 0.5, 0.0, 0.5); + /// assert_eq!(orange, Rgba::new(255, 128, 0, 128)); + /// assert_eq!(orange.to_hex(), "#ff800080"); + /// ``` + #[must_use] + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // clamped to 0..=255 first + pub fn from_unit(r: f64, g: f64, b: f64, a: f64) -> Self { + let channel = |value: f64| { + if value.is_nan() { + 0 + } else { + (value.clamp(0.0, 1.0) * 255.0).round() as u8 + } + }; + Self::new(channel(r), channel(g), channel(b), channel(a)) + } + + /// The channels as floats from `0.0` to `1.0`, the way Figma, Sketch and Penpot store them. + /// + /// # Returns + /// + /// `[r, g, b, a]`. + /// + /// # Examples + /// + /// ``` + /// use helpers4::color::Rgba; + /// + /// assert_eq!(Rgba::new(255, 51, 0, 255).to_unit(), [1.0, 0.2, 0.0, 1.0]); + /// ``` + #[must_use] + pub fn to_unit(self) -> [f64; 4] { + [self.r, self.g, self.b, self.a].map(|c| f64::from(c) / 255.0) + } + /// The same color without its alpha. /// /// # Returns diff --git a/src/color/rgba.test.rs b/src/color/rgba.test.rs index b61bffd..42c4ea5 100644 --- a/src/color/rgba.test.rs +++ b/src/color/rgba.test.rs @@ -170,3 +170,31 @@ fn from_str_is_parse() { assert_eq!("#ff880080".parse::(), Ok(Rgba::new(255, 136, 0, 128))); assert!("nope".parse::().is_err()); } + +#[test] +fn from_unit_scales_clamps_and_rounds_the_channels() { + assert_eq!( + Rgba::from_unit(1.0, 0.5, 0.0, 0.5), + Rgba::new(255, 128, 0, 128) + ); + assert_eq!( + Rgba::from_unit(2.0, -1.0, 0.2, 1.0), + Rgba::new(255, 0, 51, 255) + ); + assert_eq!( + Rgba::from_unit(f64::NAN, 0.0, 0.0, f64::NAN), + Rgba::new(0, 0, 0, 0) + ); +} + +#[test] +fn to_unit_gives_the_channels_as_fractions() { + assert_eq!(Rgba::new(255, 51, 0, 255).to_unit(), [1.0, 0.2, 0.0, 1.0]); +} + +#[test] +fn from_unit_and_to_unit_round_trip() { + let color = Rgba::new(12, 200, 99, 7); + let [r, g, b, a] = color.to_unit(); + assert_eq!(Rgba::from_unit(r, g, b, a), color); +} diff --git a/src/color/unpremultiply.rs b/src/color/unpremultiply.rs new file mode 100644 index 0000000..2091b25 --- /dev/null +++ b/src/color/unpremultiply.rs @@ -0,0 +1,52 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::Rgba; + +/// Divides the color channels by the alpha, the inverse of [`premultiply`](super::premultiply). +/// A fully transparent color (alpha `0`) holds no color, so it gives transparent black. Channels +/// are clamped to 255 and rounded to 8 bits. +/// +/// # Arguments +/// +/// - `color` - The premultiplied color. +/// +/// # Returns +/// +/// The straight color, with the same alpha. +/// +/// # Examples +/// +/// ``` +/// use helpers4::color::{unpremultiply, Rgba}; +/// +/// assert_eq!(unpremultiply(Rgba::new(100, 50, 25, 128)), Rgba::new(199, 100, 50, 128)); +/// assert_eq!(unpremultiply(Rgba::new(9, 9, 9, 0)), Rgba::new(0, 0, 0, 0)); +/// ``` +#[must_use] +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // clamped to 0..=255 first +pub fn unpremultiply(color: Rgba) -> Rgba { + if color.a() == 0 { + return Rgba::new(0, 0, 0, 0); + } + let scale = |channel: u8| { + (f64::from(channel) / color.opacity()) + .round() + .clamp(0.0, 255.0) as u8 + }; + Rgba::new( + scale(color.r()), + scale(color.g()), + scale(color.b()), + color.a(), + ) +} + +#[cfg(test)] +#[path = "unpremultiply.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "unpremultiply.spec.rs"] +mod spec; diff --git a/src/color/unpremultiply.spec.rs b/src/color/unpremultiply.spec.rs new file mode 100644 index 0000000..a178109 --- /dev/null +++ b/src/color/unpremultiply.spec.rs @@ -0,0 +1,17 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use crate::color::premultiply; +use proptest::prelude::*; + +proptest! { + #[test] + fn undoes_premultiply_within_the_rounding_of_a_translucent_color(r in any::(), a in 128u8..=255) { + let back = unpremultiply(premultiply(Rgba::new(r, r, r, a))); + // A premultiplied channel keeps `a` levels out of 255, so the error is at most 255 / a. + prop_assert!(back.r().abs_diff(r) <= 2); + prop_assert_eq!(back.a(), a); + } +} diff --git a/src/color/unpremultiply.test.rs b/src/color/unpremultiply.test.rs new file mode 100644 index 0000000..e2e3d87 --- /dev/null +++ b/src/color/unpremultiply.test.rs @@ -0,0 +1,34 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn divides_the_channels_by_the_alpha() { + assert_eq!( + unpremultiply(Rgba::new(100, 50, 25, 128)), + Rgba::new(199, 100, 50, 128) + ); +} + +#[test] +fn an_opaque_color_is_unchanged() { + assert_eq!( + unpremultiply(Rgba::new(200, 100, 50, 255)), + Rgba::new(200, 100, 50, 255) + ); +} + +#[test] +fn a_transparent_color_is_transparent_black() { + assert_eq!(unpremultiply(Rgba::new(9, 9, 9, 0)), Rgba::new(0, 0, 0, 0)); +} + +#[test] +fn clamps_an_invalid_premultiplied_channel() { + assert_eq!( + unpremultiply(Rgba::new(255, 0, 0, 10)), + Rgba::new(255, 0, 0, 10) + ); +} diff --git a/src/font/_inflate.rs b/src/font/_inflate.rs new file mode 100644 index 0000000..c991148 --- /dev/null +++ b/src/font/_inflate.rs @@ -0,0 +1,262 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! zlib decompression (RFC 1950 and 1951) for WOFF tables, a port of the idea of zlib's `puff`. +//! Not re-exported. + +const LENGTH_BASE: [u16; 29] = [ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, + 163, 195, 227, 258, +]; +const LENGTH_EXTRA: [u32; 29] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, +]; +const DISTANCE_BASE: [u16; 30] = [ + 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, + 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577, +]; +const DISTANCE_EXTRA: [u32; 30] = [ + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, + 13, +]; +/// The order the code length code lengths are stored in. +const CODE_LENGTH_ORDER: [usize; 19] = [ + 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15, +]; + +const MAX_BITS: usize = 15; + +struct Bits<'a> { + data: &'a [u8], + position: usize, + buffer: u32, + count: u32, +} + +impl<'a> Bits<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + position: 0, + buffer: 0, + count: 0, + } + } + + /// The next `need` bits (at most 16), least significant first. + fn bits(&mut self, need: u32) -> Option { + let mut value = self.buffer; + while self.count < need { + value |= u32::from(*self.data.get(self.position)?) << self.count; + self.position += 1; + self.count += 8; + } + self.buffer = value >> need; + self.count -= need; + Some(value & ((1 << need) - 1)) + } + + /// Drops the bits left in the current byte and reads `len` whole bytes. + fn aligned_bytes(&mut self, len: usize) -> Option<&'a [u8]> { + self.buffer = 0; + self.count = 0; + let bytes = self + .data + .get(self.position..self.position.saturating_add(len))?; + self.position += len; + Some(bytes) + } +} + +/// A canonical Huffman code: how many codes there are of each length, and the symbols in code +/// order. +struct Huffman { + count: [u16; MAX_BITS + 1], + symbol: Vec, + /// `false` when the lengths over-subscribe the code space: no symbol can be decoded. + valid: bool, +} + +impl Huffman { + /// Builds the code from the length of each symbol's code (`0` for an unused symbol, at most + /// 15). An incomplete code is accepted: a code that is not assigned is an error when it is + /// read. + #[allow(clippy::cast_possible_truncation)] // at most 320 symbols + fn new(lengths: &[u8]) -> Self { + let mut count = [0u16; MAX_BITS + 1]; + for &length in lengths { + count[usize::from(length)] += 1; + } + let mut left: i32 = 1; + for &in_length in &count[1..] { + left = (left << 1) - i32::from(in_length); + } + let mut offsets = [0u16; MAX_BITS + 2]; + for length in 1..=MAX_BITS { + offsets[length + 1] = offsets[length] + count[length]; + } + let mut symbol = vec![0u16; usize::from(offsets[MAX_BITS + 1])]; + for (index, &length) in lengths.iter().enumerate() { + if length != 0 { + let slot = &mut offsets[usize::from(length)]; + symbol[usize::from(*slot)] = index as u16; + *slot += 1; + } + } + // `left` goes negative as soon as a length has more codes than the code space holds, and + // stays negative from then on. + Self { + count, + symbol, + valid: left >= 0, + } + } + + fn decode(&self, bits: &mut Bits<'_>) -> Option { + if !self.valid { + return None; + } + let (mut code, mut first, mut index) = (0u32, 0u32, 0u32); + for length in 1..=MAX_BITS { + code |= bits.bits(1)?; + let count = u32::from(self.count[length]); + if code < first + count { + return self.symbol.get((index + code - first) as usize).copied(); + } + index += count; + first = (first + count) << 1; + code <<= 1; + } + None + } +} + +fn fixed_codes() -> (Huffman, Huffman) { + let mut lengths = [8u8; 288]; + lengths[144..256].fill(9); + lengths[256..280].fill(7); + (Huffman::new(&lengths), Huffman::new(&[5u8; 30])) +} + +#[allow(clippy::cast_possible_truncation)] // code lengths are at most 15 +fn dynamic_codes(bits: &mut Bits<'_>) -> Option<(Huffman, Huffman)> { + // The three counts: 5, 5 and 4 bits. + let counts = bits.bits(14)? as usize; + let literals = (counts & 31) + 257; + let distances = ((counts >> 5) & 31) + 1; + let code_lengths = (counts >> 10) + 4; + if literals > 286 || distances > 30 { + return None; + } + let mut lengths = [0u8; 19]; + for &slot in CODE_LENGTH_ORDER.iter().take(code_lengths) { + lengths[slot] = bits.bits(3)? as u8; + } + let code_code = Huffman::new(&lengths); + + let total = literals + distances; + let mut all = vec![0u8; total]; + let mut index = 0; + while index < total { + let symbol = code_code.decode(bits)?; + let (value, repeat) = match symbol { + 0..=15 => (symbol as u8, 1), + 16 => (all[index.checked_sub(1)?], 3 + bits.bits(2)? as usize), + 17 => (0, 3 + bits.bits(3)? as usize), + _ => (0, 11 + bits.bits(7)? as usize), + }; + if index + repeat > total { + return None; + } + all[index..index + repeat].fill(value); + index += repeat; + } + // Without an end-of-block code the block could never end. + if all[256] == 0 { + return None; + } + Some(( + Huffman::new(&all[..literals]), + Huffman::new(&all[literals..]), + )) +} + +#[allow(clippy::cast_possible_truncation)] // a literal is below 256 +fn codes( + bits: &mut Bits<'_>, + out: &mut Vec, + limit: usize, + literals: &Huffman, + distances: &Huffman, +) -> Option<()> { + loop { + let symbol = usize::from(literals.decode(bits)?); + match symbol { + 0..=255 => { + if out.len() >= limit { + return None; + } + out.push(symbol as u8); + } + 256 => return Some(()), + _ => { + let slot = symbol - 257; + let length = + usize::from(*LENGTH_BASE.get(slot)?) + bits.bits(LENGTH_EXTRA[slot])? as usize; + let slot = usize::from(distances.decode(bits)?); + let distance = + usize::from(DISTANCE_BASE[slot]) + bits.bits(DISTANCE_EXTRA[slot])? as usize; + if distance > out.len() || out.len() + length > limit { + return None; + } + for _ in 0..length { + out.push(out[out.len() - distance]); + } + } + } + } +} + +fn stored(bits: &mut Bits<'_>, out: &mut Vec, limit: usize) -> Option<()> { + let header = bits.aligned_bytes(4)?; + let len = usize::from(u16::from_le_bytes([header[0], header[1]])); + if header[2..4] != [!header[0], !header[1]] || out.len() + len > limit { + return None; + } + out.extend_from_slice(bits.aligned_bytes(len)?); + Some(()) +} + +/// Decompresses a zlib stream to exactly `size` bytes. `None` for anything else: a bad header, +/// invalid or truncated data, more or fewer bytes than announced. The checksum is not verified. +pub(crate) fn zlib_decompress(data: &[u8], size: usize) -> Option> { + let (cmf, flg) = (*data.first()?, *data.get(1)?); + if cmf & 0x0f != 8 || (u16::from(cmf) * 256 + u16::from(flg)) % 31 != 0 || flg & 0x20 != 0 { + return None; + } + let mut bits = Bits::new(&data[2..]); + let mut out = Vec::new(); + loop { + let last = bits.bits(1)?; + match bits.bits(2)? { + 0 => stored(&mut bits, &mut out, size)?, + 1 => { + let (literals, distances) = fixed_codes(); + codes(&mut bits, &mut out, size, &literals, &distances)?; + } + 2 => { + let (literals, distances) = dynamic_codes(&mut bits)?; + codes(&mut bits, &mut out, size, &literals, &distances)?; + } + _ => return None, + } + if last == 1 { + return (out.len() == size).then_some(out); + } + } +} + +#[cfg(test)] +#[path = "_inflate.test.rs"] +mod tests; diff --git a/src/font/_inflate.test.rs b/src/font/_inflate.test.rs new file mode 100644 index 0000000..53f4ab1 --- /dev/null +++ b/src/font/_inflate.test.rs @@ -0,0 +1,288 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::super::_test_fixtures::*; +use super::*; + +/// Writes bits least significant first, like DEFLATE. +#[derive(Default)] +struct BitWriter { + bytes: Vec, + used: u32, +} + +impl BitWriter { + fn put(&mut self, value: u32, count: u32) -> &mut Self { + for i in 0..count { + if self.used == 0 { + self.bytes.push(0); + } + let bit = u8::try_from((value >> i) & 1).unwrap_or(0); + if let Some(last) = self.bytes.last_mut() { + *last |= bit << self.used; + } + self.used = (self.used + 1) % 8; + } + self + } + + /// A Huffman code: its bits go most significant first. + fn code(&mut self, code: u32, length: u32) -> &mut Self { + for i in (0..length).rev() { + self.put((code >> i) & 1, 1); + } + self + } + + /// The bytes, as a zlib stream. + fn zlib(&self) -> Vec { + let mut out = vec![0x78, 0x01]; + out.extend(&self.bytes); + out + } +} + +fn zlib(deflate: &[u8]) -> Vec { + [&[0x78, 0x01][..], deflate].concat() +} + +#[test] +fn inflates_a_stored_block() { + assert_eq!(zlib_decompress(STORED, 11), Some(b"stored data".to_vec())); +} + +#[test] +fn inflates_a_fixed_huffman_block_with_matches() { + assert_eq!( + zlib_decompress(FIXED, 17), + Some(b"hello hello hello".to_vec()) + ); +} + +#[test] +fn inflates_a_dynamic_huffman_block() { + let text = dynamic_text(); + assert_eq!(zlib_decompress(DYNAMIC, text.len()), Some(text)); +} + +#[test] +fn inflates_several_blocks() { + assert_eq!( + zlib_decompress(MULTI, 51), + Some(b"first block, first block. second block second block".to_vec()) + ); +} + +#[test] +fn inflates_a_compressed_name_table() { + let table = zlib_decompress(NAME_TABLE_COMPRESSED, NAME_TABLE_ORIGINAL_SIZE).unwrap(); + assert_eq!(table.len(), NAME_TABLE_ORIGINAL_SIZE); + assert_eq!(&table[..2], [0, 0]); +} + +#[test] +fn a_truncated_stream_never_inflates() { + let text_len = dynamic_text().len(); + for (stream, size) in [ + (STORED, 11), + (FIXED, 17), + (DYNAMIC, text_len), + (MULTI, 51), + (NAME_TABLE_COMPRESSED, NAME_TABLE_ORIGINAL_SIZE), + ] { + for len in 0..stream.len() - 4 { + assert_eq!( + zlib_decompress(&stream[..len], size), + None, + "{len} of {}", + stream.len() + ); + } + } +} + +#[test] +fn the_size_must_be_exact() { + assert_eq!(zlib_decompress(FIXED, 18), None); + assert_eq!(zlib_decompress(FIXED, 16), None); + assert_eq!(zlib_decompress(FIXED, 10), None); + assert_eq!(zlib_decompress(STORED, 5), None); + assert_eq!(zlib_decompress(STORED, 12), None); +} + +#[test] +fn rejects_a_bad_header() { + assert_eq!(zlib_decompress(&[], 0), None); + assert_eq!(zlib_decompress(&[0x78], 0), None); + // Not deflate (compression method 9). + assert_eq!(zlib_decompress(&[0x79, 0x01], 0), None); + // A header checksum that does not divide by 31. + assert_eq!(zlib_decompress(&[0x78, 0x00], 0), None); + // A preset dictionary. + assert_eq!(zlib_decompress(&[0x78, 0xbb], 0), None); +} + +#[test] +fn rejects_the_reserved_block_type() { + assert_eq!(zlib_decompress(&zlib(&[0x07]), 0), None); +} + +#[test] +fn rejects_a_stored_block_with_a_wrong_complement() { + let bad = zlib(&[0x01, 0x01, 0x00, 0x00, 0x00, b'x']); + assert_eq!(zlib_decompress(&bad, 1), None); +} + +#[test] +fn rejects_a_stored_block_longer_than_its_data() { + let short = zlib(&[0x01, 0x05, 0x00, 0xfa, 0xff, b'x']); + assert_eq!(zlib_decompress(&short, 5), None); +} + +#[test] +fn rejects_a_literal_past_the_size() { + // A fixed block of one literal `a`, then the end of block, for a size of zero. + let mut bits = BitWriter::default(); + bits.put(1, 1) + .put(1, 2) + .code(0x30 + u32::from(b'a'), 8) + .code(0, 7); + assert_eq!(zlib_decompress(&bits.zlib(), 1), Some(b"a".to_vec())); + assert_eq!(zlib_decompress(&bits.zlib(), 0), None); +} + +#[test] +fn rejects_a_match_before_the_start() { + // Length 3 (symbol 257), distance 1 (symbol 0), with nothing written yet. + let mut bits = BitWriter::default(); + bits.put(1, 1).put(1, 2).code(1, 7).code(0, 5).code(0, 7); + assert_eq!(zlib_decompress(&bits.zlib(), 3), None); +} + +#[test] +fn rejects_a_length_or_distance_symbol_that_does_not_exist() { + // Literal/length symbol 286 (a fixed 8-bit code 0xc0 + 6). + let mut length = BitWriter::default(); + length.put(1, 1).put(1, 2).code(0xc0 + 6, 8); + assert_eq!(zlib_decompress(&length.zlib(), 3), None); + // Distance code 30, which the fixed code leaves unassigned. + let mut distance = BitWriter::default(); + distance + .put(1, 1) + .put(1, 2) + .code(0x30 + u32::from(b'a'), 8) + .code(1, 7) + .code(30, 5) + .put(0xffff, 16); + assert_eq!(zlib_decompress(&distance.zlib(), 4), None); +} + +#[test] +fn rejects_a_match_past_the_size() { + let mut bits = BitWriter::default(); + bits.put(1, 1) + .put(1, 2) + .code(0x30 + u32::from(b'a'), 8) + .code(1, 7) + .code(0, 5) + .code(0, 7); + assert_eq!(zlib_decompress(&bits.zlib(), 4), Some(b"aaaa".to_vec())); + assert_eq!(zlib_decompress(&bits.zlib(), 3), None); +} + +/// The start of a dynamic block: header counts, then the code length code lengths. +fn dynamic_start(literals: u32, distances: u32, lengths: &[u32]) -> BitWriter { + let mut bits = BitWriter::default(); + bits.put(1, 1).put(2, 2); + bits.put(literals - 257, 5).put(distances - 1, 5); + bits.put(u32::try_from(lengths.len()).unwrap_or(4) - 4, 4); + for &length in lengths { + bits.put(length, 3); + } + bits +} + +#[test] +fn rejects_too_many_literal_or_distance_codes() { + assert_eq!( + zlib_decompress(&dynamic_start(287, 1, &[0; 4]).zlib(), 0), + None + ); + assert_eq!( + zlib_decompress(&dynamic_start(257, 31, &[0; 4]).zlib(), 0), + None + ); +} + +#[test] +fn rejects_an_over_subscribed_code_length_code() { + let mut bits = dynamic_start(257, 1, &[1; 19]); + bits.put(0, 8); + assert_eq!(zlib_decompress(&bits.zlib(), 0), None); +} + +#[test] +fn rejects_a_repeat_with_nothing_to_repeat() { + // Only symbol 16 has a code (`0`), and it comes first. + let mut bits = dynamic_start(257, 1, &[1, 0, 0, 0]); + bits.code(0, 1).put(0, 2); + assert_eq!(zlib_decompress(&bits.zlib(), 0), None); +} + +#[test] +fn reads_a_run_of_zeros_of_the_short_kind() { + // Only symbol 17 has a code: a run of 3 + 7 zeros, then the data ends. + let mut bits = dynamic_start(257, 1, &[0, 1, 0, 0]); + bits.code(0, 1).put(7, 3); + assert_eq!(zlib_decompress(&bits.zlib(), 0), None); +} + +#[test] +fn rejects_a_repeat_whose_count_is_missing() { + // Code lengths: symbol 0 has the code `0`, symbol 16 the code `10`. The data ends exactly + // after the `16`, so its two repeat bits are missing. + let mut bits = dynamic_start(257, 1, &[2, 0, 0, 1]); + bits.code(0, 1).code(0b10, 2); + assert_eq!(bits.bytes.len(), 4); + assert_eq!(zlib_decompress(&bits.zlib(), 0), None); +} + +#[test] +fn rejects_a_block_header_cut_after_its_first_bit() { + // Three non-last empty fixed blocks, the first with a 9-bit literal: 39 bits, so the next + // block's final-block bit is the last bit of the data and its type bits are missing. + let mut bits = BitWriter::default(); + bits.put(0, 1).put(1, 2).code(0x190, 9).code(0, 7); + for _ in 0..2 { + bits.put(0, 1).put(1, 2).code(0, 7); + } + assert_eq!(bits.bytes.len(), 5); + assert_eq!(zlib_decompress(&bits.zlib(), 1), None); +} + +#[test] +fn rejects_repeats_that_overflow_the_lengths() { + // Only symbol 18 has a code: two runs of 138 zeros, for 258 lengths. + let mut bits = dynamic_start(257, 1, &[0, 0, 1, 0]); + bits.code(0, 1).put(127, 7).code(0, 1).put(127, 7); + assert_eq!(zlib_decompress(&bits.zlib(), 0), None); +} + +#[test] +fn rejects_a_block_without_an_end_of_block_code() { + // 138 + 120 zeros: every length is zero, so symbol 256 has no code. + let mut bits = dynamic_start(257, 1, &[0, 0, 1, 0]); + bits.code(0, 1).put(127, 7).code(0, 1).put(109, 7); + assert_eq!(zlib_decompress(&bits.zlib(), 0), None); +} + +#[test] +fn reads_the_runs_of_zeros_and_repeats_of_a_dynamic_block() { + // The fixture uses symbols 16, 17 and 18; inflating it exercises them. + let text = dynamic_text(); + assert_eq!( + zlib_decompress(DYNAMIC, text.len()).map(|t| t.len()), + Some(text.len()) + ); +} diff --git a/src/font/_sfnt.rs b/src/font/_sfnt.rs new file mode 100644 index 0000000..0067a80 --- /dev/null +++ b/src/font/_sfnt.rs @@ -0,0 +1,81 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! The tables of a font file that [`read_font`](super::read_font) reads, found in a TrueType, +//! OpenType or collection file (first font) or in a WOFF file. Not re-exported. + +use super::_woff; +use super::FontError; +use std::borrow::Cow; + +/// The tables [`read_font`](super::read_font) needs: names, weight and style, `wght` axis. +const WANTED: [[u8; 4]; 4] = [*b"name", *b"OS/2", *b"head", *b"fvar"]; + +pub(crate) fn be16(bytes: &[u8], at: usize) -> u16 { + u16::from_be_bytes([bytes[at], bytes[at + 1]]) +} + +pub(crate) fn be32(bytes: &[u8], at: usize) -> u32 { + u32::from_be_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) +} + +pub(crate) fn is_wanted(tag: [u8; 4]) -> bool { + WANTED.contains(&tag) +} + +/// The wanted tables of a font, decompressed when the file was WOFF. +pub(crate) struct Tables<'a> { + pub(crate) entries: Vec<([u8; 4], Cow<'a, [u8]>)>, +} + +impl Tables<'_> { + pub(crate) fn get(&self, tag: [u8; 4]) -> Option<&[u8]> { + self.entries + .iter() + .find(|(known, _)| *known == tag) + .map(|(_, data)| &**data) + } +} + +/// The tables of the font in `bytes`. +pub(crate) fn load(bytes: &[u8]) -> Result, FontError> { + let magic = bytes.get(..4).ok_or(FontError::Truncated)?; + match magic { + [0, 1, 0, 0] | b"OTTO" | b"true" => from_sfnt(bytes, 0), + b"ttcf" => { + let header = bytes.get(..16).ok_or(FontError::Truncated)?; + from_sfnt(bytes, be32(header, 12) as usize) + } + b"wOFF" => _woff::load(bytes), + b"wOF2" => Err(FontError::UnsupportedWoff2), + _ => Err(FontError::UnknownFormat), + } +} + +/// The tables of the sfnt font whose table directory starts at `start`. +fn from_sfnt(bytes: &[u8], start: usize) -> Result, FontError> { + let header = bytes + .get(start..start.saturating_add(12)) + .ok_or(FontError::Truncated)?; + let count = usize::from(be16(header, 4)); + let mut entries = Vec::new(); + for index in 0..count { + let at = start + 12 + 16 * index; + let record = bytes.get(at..at + 16).ok_or(FontError::Truncated)?; + let tag = [record[0], record[1], record[2], record[3]]; + if is_wanted(tag) { + let offset = be32(record, 8) as usize; + let length = be32(record, 12) as usize; + let table = bytes + .get(offset..offset.saturating_add(length)) + .ok_or(FontError::Truncated)?; + entries.push((tag, Cow::Borrowed(table))); + } + } + Ok(Tables { entries }) +} + +#[cfg(test)] +#[path = "_sfnt.test.rs"] +mod tests; diff --git a/src/font/_sfnt.test.rs b/src/font/_sfnt.test.rs new file mode 100644 index 0000000..9270eb6 --- /dev/null +++ b/src/font/_sfnt.test.rs @@ -0,0 +1,51 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::super::_test_support::*; +use super::*; + +#[test] +fn reads_big_endian_numbers() { + assert_eq!(be16(&[0, 9, 1], 1), 0x0901); + assert_eq!(be32(&[9, 0, 1, 2, 3], 1), 0x0001_0203); +} + +#[test] +fn keeps_only_the_wanted_tables() { + let bytes = sfnt(&[ + (*b"glyf", vec![1, 2, 3]), + (*b"name", vec![4, 5]), + (*b"head", vec![6]), + ]); + let tables = load(&bytes).unwrap(); + assert_eq!(tables.get(*b"name"), Some(&[4u8, 5][..])); + assert_eq!(tables.get(*b"head"), Some(&[6u8][..])); + assert_eq!(tables.get(*b"glyf"), None); + assert_eq!(tables.get(*b"fvar"), None); +} + +#[test] +fn finds_the_tables_of_the_first_font_of_a_collection() { + let bytes = collection(&[(*b"name", vec![7, 7])]); + assert_eq!(load(&bytes).unwrap().get(*b"name"), Some(&[7u8, 7][..])); +} + +#[test] +fn recognizes_the_wanted_tags() { + for tag in [b"name", b"head", b"fvar"] { + assert!(is_wanted(*tag)); + } + assert!(is_wanted(*b"OS/2")); + assert!(!is_wanted(*b"glyf")); +} + +#[test] +fn rejects_a_table_that_ends_past_the_file() { + let mut bytes = sfnt(&[(*b"name", vec![1; 8])]); + bytes.truncate(bytes.len() - 2); + assert!(matches!(load(&bytes), Err(FontError::Truncated))); + let mut ignored = sfnt(&[(*b"glyf", vec![1; 8])]); + ignored.truncate(ignored.len() - 2); + assert!(load(&ignored).is_ok()); +} diff --git a/src/font/_test_fixtures.rs b/src/font/_test_fixtures.rs new file mode 100644 index 0000000..096dde5 --- /dev/null +++ b/src/font/_test_fixtures.rs @@ -0,0 +1,57 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! zlib streams made with Python's `zlib` (level 9, default strategy unless said), for the unit +//! tests. Not part of the crate. + +pub(super) const DYNAMIC: &[u8] = &[ + 0x78, 0xda, 0x55, 0x4f, 0x01, 0x0e, 0xc4, 0x20, 0x08, 0xfb, 0x0a, 0x5f, 0x63, 0x9b, 0x51, 0x72, + 0x32, 0xcd, 0xa6, 0xc9, 0x9e, 0x7f, 0x45, 0x34, 0xd9, 0x12, 0x14, 0x2c, 0xa5, 0xc5, 0xa2, 0xb2, + 0x5f, 0xe5, 0xa4, 0x7a, 0x0b, 0xfd, 0xb8, 0x56, 0xa6, 0x8e, 0x32, 0x03, 0x59, 0x59, 0x3b, 0x35, + 0xee, 0x74, 0x84, 0xdc, 0x98, 0xce, 0x4e, 0x01, 0xe9, 0x11, 0x2a, 0x1a, 0xe2, 0x78, 0x23, 0x6e, + 0x89, 0xca, 0x13, 0x71, 0x91, 0xba, 0xd4, 0x8c, 0x5d, 0xa6, 0x47, 0x66, 0xdd, 0x8e, 0xc5, 0x03, + 0x03, 0x61, 0x6d, 0x24, 0x17, 0x98, 0x7d, 0x29, 0x06, 0xa6, 0x85, 0x46, 0x56, 0xdc, 0xb6, 0x1f, + 0x36, 0x41, 0x4c, 0xd6, 0xb3, 0x1c, 0x38, 0xd7, 0xe4, 0x7c, 0x3b, 0x7b, 0x1a, 0xba, 0x2d, 0xbd, + 0x8d, 0x0d, 0xc4, 0xa4, 0x2b, 0xd9, 0x67, 0x7c, 0x72, 0x18, 0x85, 0xef, 0x37, 0xcd, 0xc7, 0x46, + 0x5d, 0xc0, 0x37, 0xd8, 0x50, 0xfe, 0x01, 0xed, 0x58, 0x6a, 0x36, +]; +pub(super) const FIXED: &[u8] = &[ + 0x78, 0x01, 0xcb, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0xc8, 0x40, 0x90, 0x00, 0x3a, 0x2e, 0x06, 0x7d, +]; +pub(super) const STORED: &[u8] = &[ + 0x78, 0x01, 0x01, 0x0b, 0x00, 0xf4, 0xff, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x64, 0x20, 0x64, 0x61, + 0x74, 0x61, 0x1a, 0xb2, 0x04, 0x4c, +]; +pub(super) const MULTI: &[u8] = &[ + 0x78, 0x01, 0x4a, 0xcb, 0x2c, 0x2a, 0x2e, 0x51, 0x48, 0xca, 0xc9, 0x4f, 0xce, 0xd6, 0x51, 0x48, + 0x43, 0x70, 0xf4, 0x14, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x2b, 0x4e, 0x4d, 0xce, 0xcf, 0x4b, + 0x51, 0x48, 0xca, 0xc9, 0x4f, 0xce, 0x56, 0x28, 0x46, 0xe2, 0x00, 0x00, 0xe5, 0x2c, 0x12, 0xaf, +]; +/// A `name` table (family `Neutrif Pro`, style `Bold`) padded with zeros, zlib-compressed. +pub(super) const NAME_TABLE_ORIGINAL_SIZE: usize = 502; +pub(super) const NAME_TABLE_COMPRESSED: &[u8] = &[ + 0x78, 0xda, 0x63, 0x60, 0x60, 0x60, 0x66, 0xd0, 0x02, 0x62, 0x46, 0x16, 0x4e, 0x06, 0x46, 0x06, + 0x31, 0x06, 0x06, 0x28, 0x9b, 0x89, 0x81, 0x03, 0xc8, 0x83, 0xb0, 0xd9, 0x18, 0xe4, 0x80, 0xd0, + 0x8f, 0x21, 0x95, 0xa1, 0x94, 0xa1, 0x84, 0xa1, 0x88, 0x21, 0x93, 0x21, 0x8d, 0x41, 0x81, 0x21, + 0x00, 0xc8, 0xca, 0x67, 0x70, 0x02, 0xe2, 0x1c, 0x86, 0x14, 0x34, 0x59, 0x88, 0x9c, 0x2e, 0x5c, + 0x76, 0x14, 0x0c, 0x2a, 0x00, 0x00, 0xc7, 0x58, 0x0c, 0x45, +]; + +/// The text `DYNAMIC` decompresses to: 60 words of a fixed list, picked by a linear congruential +/// generator. +pub(super) fn dynamic_text() -> Vec { + let words = [ + "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa", + "lambda", "mu", "nu", "xi", "omicron", "pi", "rho", "sigma", "tau", "upsilon", "phi", + "chi", "psi", "omega", + ]; + let mut state: u64 = 1; + let picked: Vec<&str> = (0..60) + .map(|_| { + state = (state * 1_103_515_245 + 12345) % (1 << 31); + words[usize::try_from((state >> 16) % 24).unwrap_or(0)] + }) + .collect(); + picked.join(" ").into_bytes() +} diff --git a/src/font/_test_support.rs b/src/font/_test_support.rs new file mode 100644 index 0000000..d1d2c0b --- /dev/null +++ b/src/font/_test_support.rs @@ -0,0 +1,166 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! Builders of tiny fonts for the unit tests. Not part of the crate. + +#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + +pub(super) type Table = ([u8; 4], Vec); + +/// A `name` record: platform, encoding, language, name id, and its (already encoded) string. +pub(super) type NameRecord = (u16, u16, u16, u16, Vec); + +pub(super) fn utf16(text: &str) -> Vec { + text.encode_utf16().flat_map(u16::to_be_bytes).collect() +} + +/// A Windows, English (US) record. +pub(super) fn windows(id: u16, text: &str) -> NameRecord { + (3, 1, 0x0409, id, utf16(text)) +} + +pub(super) fn name_table(records: &[NameRecord]) -> Vec { + let storage_start = 6 + 12 * records.len(); + let mut out = Vec::new(); + out.extend(0u16.to_be_bytes()); + out.extend((records.len() as u16).to_be_bytes()); + out.extend((storage_start as u16).to_be_bytes()); + let mut storage: Vec = Vec::new(); + for (platform, encoding, language, id, text) in records { + for field in [ + *platform, + *encoding, + *language, + *id, + text.len() as u16, + storage.len() as u16, + ] { + out.extend(field.to_be_bytes()); + } + storage.extend(text); + } + out.extend(storage); + out +} + +pub(super) fn os2_table(weight: u16, fs_selection: u16) -> Vec { + let mut out = vec![0u8; 78]; + out[4..6].copy_from_slice(&weight.to_be_bytes()); + out[62..64].copy_from_slice(&fs_selection.to_be_bytes()); + out +} + +pub(super) fn head_table(mac_style: u16) -> Vec { + let mut out = vec![0u8; 54]; + out[44..46].copy_from_slice(&mac_style.to_be_bytes()); + out +} + +fn fixed(value: f64) -> [u8; 4] { + ((value * 65536.0) as i32).to_be_bytes() +} + +/// An `fvar` table with axes `(tag, min, default, max)`. +pub(super) fn fvar(axes: &[(&[u8; 4], f64, f64, f64)]) -> Vec { + let mut out = Vec::new(); + for field in [1u16, 0, 16, 2, axes.len() as u16, 20, 0, 0] { + out.extend(field.to_be_bytes()); + } + for (tag, min, default, max) in axes { + out.extend(*tag); + out.extend(fixed(*min)); + out.extend(fixed(*default)); + out.extend(fixed(*max)); + out.extend([0u8; 4]); + } + out +} + +/// The tables of a typical font: family, style and PostScript name; weight and style flags. +pub(super) fn typical_tables( + family: &str, + style: &str, + weight: u16, + fs_selection: u16, +) -> Vec { + vec![ + ( + *b"name", + name_table(&[ + windows(1, family), + windows(2, style), + windows( + 6, + &format!("{}-{}", family.replace(' ', ""), style.replace(' ', "")), + ), + ]), + ), + (*b"OS/2", os2_table(weight, fs_selection)), + (*b"head", head_table(0)), + ] +} + +/// An sfnt file (`00 01 00 00`) holding `tables`. +pub(super) fn sfnt(tables: &[Table]) -> Vec { + sfnt_at(tables, 0, [0, 1, 0, 0]) +} + +/// An sfnt whose table directory starts at `start` (the bytes before are zeros). +pub(super) fn sfnt_at(tables: &[Table], start: usize, version: [u8; 4]) -> Vec { + let directory = start + 12 + 16 * tables.len(); + let mut out = vec![0u8; start]; + out.extend(version); + out.extend((tables.len() as u16).to_be_bytes()); + out.extend([0u8; 6]); + let mut data = Vec::new(); + for (tag, bytes) in tables { + out.extend(tag); + out.extend([0u8; 4]); + out.extend(((directory + data.len()) as u32).to_be_bytes()); + out.extend((bytes.len() as u32).to_be_bytes()); + data.extend(bytes); + while data.len() % 4 != 0 { + data.push(0); + } + } + out.extend(data); + out +} + +/// A TrueType collection whose first font holds `tables`. +pub(super) fn collection(tables: &[Table]) -> Vec { + let mut out = Vec::new(); + out.extend(b"ttcf"); + out.extend(0x0001_0000u32.to_be_bytes()); + out.extend(1u32.to_be_bytes()); + out.extend(16u32.to_be_bytes()); + // The offsets in the table records are from the start of the file: build the font at 16. + out.extend(sfnt_at(tables, 16, [0, 1, 0, 0])[16..].iter()); + out +} + +/// A WOFF file whose tables are stored as they are (`compLength == origLength`). +pub(super) fn woff(tables: &[Table]) -> Vec { + let directory = 44 + 20 * tables.len(); + let mut out = Vec::new(); + out.extend(b"wOFF"); + out.extend([0, 1, 0, 0]); + out.extend([0u8; 4]); + out.extend((tables.len() as u16).to_be_bytes()); + out.extend([0u8; 30]); + let mut data = Vec::new(); + for (tag, bytes) in tables { + out.extend(tag); + out.extend(((directory + data.len()) as u32).to_be_bytes()); + out.extend((bytes.len() as u32).to_be_bytes()); + out.extend((bytes.len() as u32).to_be_bytes()); + out.extend([0u8; 4]); + data.extend(bytes); + while data.len() % 4 != 0 { + data.push(0); + } + } + out.extend(data); + out +} diff --git a/src/font/_woff.rs b/src/font/_woff.rs new file mode 100644 index 0000000..87b7ce8 --- /dev/null +++ b/src/font/_woff.rs @@ -0,0 +1,48 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! The tables of a WOFF (1.0) file, each zlib-compressed on its own unless that would not make +//! it smaller. Not re-exported. + +use super::_inflate::zlib_decompress; +use super::_sfnt::{Tables, be16, be32, is_wanted}; +use super::FontError; +use std::borrow::Cow; + +/// A table larger than this is refused instead of inflated: the names, weight and style tables +/// are a few kilobytes. +const MAX_TABLE_SIZE: usize = 16 * 1024 * 1024; + +pub(crate) fn load(bytes: &[u8]) -> Result, FontError> { + let header = bytes.get(..44).ok_or(FontError::Truncated)?; + let count = usize::from(be16(header, 12)); + let mut entries = Vec::new(); + for index in 0..count { + let at = 44 + 20 * index; + let record = bytes.get(at..at + 20).ok_or(FontError::Truncated)?; + let tag = [record[0], record[1], record[2], record[3]]; + if !is_wanted(tag) { + continue; + } + let offset = be32(record, 4) as usize; + let stored = be32(record, 8) as usize; + let size = be32(record, 12) as usize; + let data = bytes + .get(offset..offset.saturating_add(stored)) + .ok_or(FontError::Truncated)?; + let table = if stored == size { + Cow::Borrowed(data) + } else if stored < size && size <= MAX_TABLE_SIZE { + Cow::Owned(zlib_decompress(data, size).ok_or(FontError::InvalidCompressedData)?) + } else { + return Err(FontError::InvalidCompressedData); + }; + entries.push((tag, table)); + } + Ok(Tables { entries }) +} + +#[cfg(test)] +#[path = "_woff.test.rs"] +mod tests; diff --git a/src/font/_woff.test.rs b/src/font/_woff.test.rs new file mode 100644 index 0000000..aa26c28 --- /dev/null +++ b/src/font/_woff.test.rs @@ -0,0 +1,70 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::super::_test_fixtures::*; +use super::super::_test_support::*; +use super::*; + +/// A WOFF with one `name` table stored as given: `stored` bytes in the file, `size` announced. +fn woff_with(stored: &[u8], size: u32) -> Vec { + let mut bytes = woff(&[(*b"name", stored.to_vec())]); + bytes[44 + 12..44 + 16].copy_from_slice(&size.to_be_bytes()); + bytes +} + +#[test] +fn keeps_a_table_stored_as_it_is() { + let bytes = woff(&[(*b"name", vec![1, 2, 3]), (*b"glyf", vec![9; 4])]); + let tables = load(&bytes).unwrap(); + assert_eq!(tables.get(*b"name"), Some(&[1u8, 2, 3][..])); + assert_eq!(tables.get(*b"glyf"), None); +} + +#[test] +fn inflates_a_compressed_table() { + let bytes = woff_with( + NAME_TABLE_COMPRESSED, + u32::try_from(NAME_TABLE_ORIGINAL_SIZE).unwrap(), + ); + let tables = load(&bytes).unwrap(); + assert_eq!( + tables.get(*b"name").map(<[u8]>::len), + Some(NAME_TABLE_ORIGINAL_SIZE) + ); +} + +#[test] +fn rejects_a_table_that_does_not_inflate_to_its_size() { + let wrong_size = woff_with(NAME_TABLE_COMPRESSED, 501); + assert_eq!( + load(&wrong_size).err(), + Some(FontError::InvalidCompressedData) + ); + let garbage = woff_with(&[0xff; 20], 100); + assert_eq!(load(&garbage).err(), Some(FontError::InvalidCompressedData)); +} + +#[test] +fn rejects_a_table_larger_than_its_original_size() { + let bytes = woff_with(&[1; 20], 10); + assert_eq!(load(&bytes).err(), Some(FontError::InvalidCompressedData)); +} + +#[test] +fn rejects_an_absurd_original_size() { + let bytes = woff_with(NAME_TABLE_COMPRESSED, u32::MAX); + assert_eq!(load(&bytes).err(), Some(FontError::InvalidCompressedData)); +} + +#[test] +fn rejects_truncated_files() { + let bytes = woff(&[(*b"name", vec![1; 12])]); + for len in 0..bytes.len() { + assert_eq!( + load(&bytes[..len]).err(), + Some(FontError::Truncated), + "{len}" + ); + } +} diff --git a/src/font/font_error.rs b/src/font/font_error.rs new file mode 100644 index 0000000..16f6a80 --- /dev/null +++ b/src/font/font_error.rs @@ -0,0 +1,42 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use std::fmt; + +/// Why a font file could not be read. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum FontError { + /// The data ends before a table directory, a table or a record it announces. + Truncated, + /// Not a TrueType, OpenType, TrueType collection or WOFF file. + UnknownFormat, + /// A WOFF2 file: its tables are Brotli-compressed, and this crate has no Brotli decoder. + UnsupportedWoff2, + /// A WOFF table whose compressed data is invalid or does not have its announced size. + InvalidCompressedData, + /// The file has no `name` table. + MissingNameTable, + /// The `name` table holds neither a typographic nor a plain family name. + MissingFamily, +} + +impl fmt::Display for FontError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Truncated => write!(f, "font data is truncated"), + Self::UnknownFormat => write!(f, "not a TrueType, OpenType or WOFF font"), + Self::UnsupportedWoff2 => write!(f, "WOFF2 fonts are not supported"), + Self::InvalidCompressedData => write!(f, "invalid compressed WOFF table"), + Self::MissingNameTable => write!(f, "the font has no name table"), + Self::MissingFamily => write!(f, "the font has no family name"), + } + } +} + +impl std::error::Error for FontError {} + +#[cfg(test)] +#[path = "font_error.test.rs"] +mod tests; diff --git a/src/font/font_error.test.rs b/src/font/font_error.test.rs new file mode 100644 index 0000000..261a1f5 --- /dev/null +++ b/src/font/font_error.test.rs @@ -0,0 +1,36 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn display_describes_each_variant() { + assert_eq!(FontError::Truncated.to_string(), "font data is truncated"); + assert_eq!( + FontError::UnknownFormat.to_string(), + "not a TrueType, OpenType or WOFF font" + ); + assert_eq!( + FontError::UnsupportedWoff2.to_string(), + "WOFF2 fonts are not supported" + ); + assert_eq!( + FontError::InvalidCompressedData.to_string(), + "invalid compressed WOFF table" + ); + assert_eq!( + FontError::MissingNameTable.to_string(), + "the font has no name table" + ); + assert_eq!( + FontError::MissingFamily.to_string(), + "the font has no family name" + ); +} + +#[test] +fn is_a_std_error() { + let error: &dyn std::error::Error = &FontError::Truncated; + assert!(error.source().is_none()); +} diff --git a/src/font/font_face.rs b/src/font/font_face.rs new file mode 100644 index 0000000..9f83d51 --- /dev/null +++ b/src/font/font_face.rs @@ -0,0 +1,87 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::FontStyle; + +/// A face of a font family, with the properties CSS font matching compares. A variable face +/// covers a weight range. +/// +/// # Examples +/// +/// ``` +/// use helpers4::font::{FontFace, FontStyle}; +/// +/// let bold = FontFace::new(700.0, FontStyle::Normal); +/// let variable = FontFace::variable(100.0, 900.0, FontStyle::Italic); +/// assert_eq!((bold.weight_min, bold.weight_max), (700.0, 700.0)); +/// assert_eq!((variable.weight_min, variable.weight_max), (100.0, 900.0)); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FontFace { + /// Lightest weight the face covers (CSS `font-weight`, 1–1000). + pub weight_min: f64, + /// Heaviest weight the face covers; equal to `weight_min` for a static face. + pub weight_max: f64, + /// The style of the face. + pub style: FontStyle, + /// CSS `font-stretch` as a percentage (`condensed` is 75, `normal` 100, `expanded` 125). + pub stretch: f64, +} + +impl FontFace { + /// A static face of normal width. + /// + /// # Arguments + /// + /// - `weight` - The weight, 1–1000. + /// - `style` - The style. + /// + /// # Returns + /// + /// The face. + #[must_use] + pub const fn new(weight: f64, style: FontStyle) -> Self { + Self::variable(weight, weight, style) + } + + /// A variable face of normal width covering a weight range. + /// + /// # Arguments + /// + /// - `weight_min` - The lightest weight. + /// - `weight_max` - The heaviest weight. + /// - `style` - The style. + /// + /// # Returns + /// + /// The face. + #[must_use] + pub const fn variable(weight_min: f64, weight_max: f64, style: FontStyle) -> Self { + Self { + weight_min, + weight_max, + style, + stretch: 100.0, + } + } + + /// The same face with another width. + /// + /// # Arguments + /// + /// - `stretch` - The width as a percentage. + /// + /// # Returns + /// + /// The face. + #[must_use] + pub const fn with_stretch(mut self, stretch: f64) -> Self { + self.stretch = stretch; + self + } +} + +#[cfg(test)] +#[path = "font_face.test.rs"] +mod tests; diff --git a/src/font/font_face.test.rs b/src/font/font_face.test.rs new file mode 100644 index 0000000..63fd4e9 --- /dev/null +++ b/src/font/font_face.test.rs @@ -0,0 +1,34 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +#![allow(clippy::float_cmp)] + +use super::*; + +#[test] +fn a_static_face_covers_one_weight() { + let face = FontFace::new(700.0, FontStyle::Italic); + assert_eq!( + face, + FontFace { + weight_min: 700.0, + weight_max: 700.0, + style: FontStyle::Italic, + stretch: 100.0 + } + ); +} + +#[test] +fn a_variable_face_covers_a_range() { + let face = FontFace::variable(100.0, 900.0, FontStyle::Normal); + assert_eq!((face.weight_min, face.weight_max), (100.0, 900.0)); +} + +#[test] +fn with_stretch_changes_only_the_width() { + let face = FontFace::new(400.0, FontStyle::Normal).with_stretch(75.0); + assert_eq!(face.stretch, 75.0); + assert_eq!(face.weight_min, 400.0); +} diff --git a/src/font/font_info.rs b/src/font/font_info.rs new file mode 100644 index 0000000..b89dc42 --- /dev/null +++ b/src/font/font_info.rs @@ -0,0 +1,62 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::{FontFace, FontStyle}; + +/// What a font file says about itself, as [`read_font`](super::read_font) reads it. +/// +/// # Examples +/// +/// ``` +/// use helpers4::font::{FontFace, FontInfo, FontStyle}; +/// +/// let info = FontInfo { +/// family: "Inter".to_string(), +/// style: "Bold Italic".to_string(), +/// postscript_name: Some("Inter-BoldItalic".to_string()), +/// weight: 700.0, +/// weight_range: None, +/// italic: true, +/// }; +/// assert_eq!(info.to_face(), FontFace::new(700.0, FontStyle::Italic)); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct FontInfo { + /// The family name: the typographic family when the font has one, else the plain family. + pub family: String, + /// The style name (`Semi Bold Italic`): typographic first, empty when the font has none. + pub style: String, + /// The PostScript name (`Inter-BoldItalic`). + pub postscript_name: Option, + /// The weight of a static font: the OS/2 weight class, else read from the style name. For a + /// variable font, the default instance. + pub weight: f64, + /// The range of the `wght` axis of a variable font. + pub weight_range: Option<(f64, f64)>, + /// Whether the font is italic or oblique. + pub italic: bool, +} + +impl FontInfo { + /// The face to give to [`pick_closest`](super::pick_closest): a variable font covers its + /// `wght` range, a static one its weight. + /// + /// # Returns + /// + /// The face, italic when the font is. + #[must_use] + pub fn to_face(&self) -> FontFace { + let style = if self.italic { + FontStyle::Italic + } else { + FontStyle::Normal + }; + let (min, max) = self.weight_range.unwrap_or((self.weight, self.weight)); + FontFace::variable(min, max, style) + } +} + +#[cfg(test)] +#[path = "font_info.test.rs"] +mod tests; diff --git a/src/font/font_info.test.rs b/src/font/font_info.test.rs new file mode 100644 index 0000000..1dc8d25 --- /dev/null +++ b/src/font/font_info.test.rs @@ -0,0 +1,42 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +#![allow(clippy::float_cmp)] + +use super::*; + +fn info() -> FontInfo { + FontInfo { + family: "Inter".into(), + style: "Bold".into(), + postscript_name: None, + weight: 700.0, + weight_range: None, + italic: false, + } +} + +#[test] +fn a_static_font_is_a_face_of_one_weight() { + assert_eq!(info().to_face(), FontFace::new(700.0, FontStyle::Normal)); +} + +#[test] +fn an_italic_font_is_an_italic_face() { + let italic = FontInfo { + italic: true, + ..info() + }; + assert_eq!(italic.to_face().style, FontStyle::Italic); +} + +#[test] +fn a_variable_font_covers_its_range() { + let variable = FontInfo { + weight_range: Some((100.0, 900.0)), + ..info() + }; + let face = variable.to_face(); + assert_eq!((face.weight_min, face.weight_max), (100.0, 900.0)); +} diff --git a/src/font/font_request.rs b/src/font/font_request.rs new file mode 100644 index 0000000..1814e43 --- /dev/null +++ b/src/font/font_request.rs @@ -0,0 +1,58 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::FontStyle; + +/// What a text asks of a family: the properties [`pick_closest`](super::pick_closest) matches. +/// The default is CSS's initial value: weight 400, normal style, normal width. +/// +/// # Examples +/// +/// ``` +/// use helpers4::font::{FontRequest, FontStyle}; +/// +/// let request = FontRequest::default(); +/// assert_eq!((request.weight, request.style, request.stretch), (400.0, FontStyle::Normal, 100.0)); +/// assert_eq!(FontRequest::new(600.0, FontStyle::Italic).weight, 600.0); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FontRequest { + /// CSS `font-weight`, 1–1000. + pub weight: f64, + /// CSS `font-style`. + pub style: FontStyle, + /// CSS `font-stretch` as a percentage. + pub stretch: f64, +} + +impl FontRequest { + /// A request of normal width. + /// + /// # Arguments + /// + /// - `weight` - The weight, 1–1000. + /// - `style` - The style. + /// + /// # Returns + /// + /// The request. + #[must_use] + pub const fn new(weight: f64, style: FontStyle) -> Self { + Self { + weight, + style, + stretch: 100.0, + } + } +} + +impl Default for FontRequest { + fn default() -> Self { + Self::new(400.0, FontStyle::Normal) + } +} + +#[cfg(test)] +#[path = "font_request.test.rs"] +mod tests; diff --git a/src/font/font_request.test.rs b/src/font/font_request.test.rs new file mode 100644 index 0000000..c85d3ef --- /dev/null +++ b/src/font/font_request.test.rs @@ -0,0 +1,25 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +#![allow(clippy::float_cmp)] + +use super::*; + +#[test] +fn new_builds_a_request_of_normal_width() { + let request = FontRequest::new(600.0, FontStyle::Italic); + assert_eq!( + (request.weight, request.style, request.stretch), + (600.0, FontStyle::Italic, 100.0) + ); +} + +#[test] +fn the_default_is_the_css_initial_value() { + assert_eq!( + FontRequest::default(), + FontRequest::new(400.0, FontStyle::Normal) + ); + assert_eq!(FontStyle::default(), FontStyle::Normal); +} diff --git a/src/font/font_style.rs b/src/font/font_style.rs new file mode 100644 index 0000000..1e2f69d --- /dev/null +++ b/src/font/font_style.rs @@ -0,0 +1,24 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +/// The `font-style` of a face or a request. Oblique angles are not compared. +/// +/// # Examples +/// +/// ``` +/// use helpers4::font::FontStyle; +/// +/// assert_eq!(FontStyle::default(), FontStyle::Normal); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum FontStyle { + /// Upright. The default. + #[default] + Normal, + /// A true italic. + Italic, + /// A slanted upright. + Oblique, +} diff --git a/src/font/is_italic.rs b/src/font/is_italic.rs new file mode 100644 index 0000000..8a93dd8 --- /dev/null +++ b/src/font/is_italic.rs @@ -0,0 +1,33 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +/// Whether a style name is italic or oblique, as design tools and font files write it +/// (`"Italic"`, `"Semi Bold Italic"`, `"Light Oblique"`). Case is ignored. +/// +/// # Arguments +/// +/// - `style` - The style name. +/// +/// # Returns +/// +/// `true` if the name holds `italic` or `oblique`. +/// +/// # Examples +/// +/// ``` +/// use helpers4::font::is_italic; +/// +/// assert!(is_italic("Semi Bold Italic")); +/// assert!(is_italic("OBLIQUE")); +/// assert!(!is_italic("Bold")); +/// ``` +#[must_use] +pub fn is_italic(style: &str) -> bool { + let lower = style.to_lowercase(); + lower.contains("italic") || lower.contains("oblique") +} + +#[cfg(test)] +#[path = "is_italic.test.rs"] +mod tests; diff --git a/src/font/is_italic.test.rs b/src/font/is_italic.test.rs new file mode 100644 index 0000000..18bc193 --- /dev/null +++ b/src/font/is_italic.test.rs @@ -0,0 +1,20 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn italic_and_oblique_names_are_italic() { + assert!(is_italic("Italic")); + assert!(is_italic("Semi Bold Italic")); + assert!(is_italic("Light Oblique")); + assert!(is_italic("BOLDITALIC")); +} + +#[test] +fn other_names_are_not() { + assert!(!is_italic("Bold")); + assert!(!is_italic("Regular")); + assert!(!is_italic("")); +} diff --git a/src/font/mod.rs b/src/font/mod.rs new file mode 100644 index 0000000..5c46a93 --- /dev/null +++ b/src/font/mod.rs @@ -0,0 +1,43 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! Fonts without a dependency: CSS font matching, the names design tools give to weights and +//! styles, and reading a font file's names, weight and style. +//! +//! [`pick_closest`] is CSS Fonts Level 4 matching (§5.2) among the faces of one family, with +//! variable faces that cover a weight range. [`weight_of_style`], [`is_italic`], +//! [`split_postscript`] and [`same_family`] read the names fonts and design files carry +//! (`"Semi Bold Italic"`, `"NeutrifPro-SemiBold"`). [`read_font`] reads a TrueType, OpenType or +//! WOFF file: family (typographic first), PostScript name, weight or `wght` range, style. + +mod _inflate; +mod _sfnt; +#[cfg(test)] +mod _test_fixtures; +#[cfg(test)] +mod _test_support; +mod _woff; +mod font_error; +mod font_face; +mod font_info; +mod font_request; +mod font_style; +mod is_italic; +mod pick_closest; +mod read_font; +mod same_family; +mod split_postscript; +mod weight_of_style; + +pub use font_error::FontError; +pub use font_face::FontFace; +pub use font_info::FontInfo; +pub use font_request::FontRequest; +pub use font_style::FontStyle; +pub use is_italic::is_italic; +pub use pick_closest::pick_closest; +pub use read_font::read_font; +pub use same_family::same_family; +pub use split_postscript::split_postscript; +pub use weight_of_style::weight_of_style; diff --git a/src/font/pick_closest.rs b/src/font/pick_closest.rs new file mode 100644 index 0000000..401d865 --- /dev/null +++ b/src/font/pick_closest.rs @@ -0,0 +1,156 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::{FontFace, FontRequest, FontStyle}; + +fn ascending(values: &[f64], keep: impl Fn(f64) -> bool) -> Vec { + let mut kept: Vec = values.iter().copied().filter(|v| keep(*v)).collect(); + kept.sort_by(f64::total_cmp); + kept +} + +fn descending(values: &[f64], keep: impl Fn(f64) -> bool) -> Vec { + let mut kept = ascending(values, keep); + kept.reverse(); + kept +} + +/// Stretch values in the order CSS tries them. +fn stretch_order(wanted: f64, values: &[f64]) -> Vec { + if wanted <= 100.0 { + let mut order = descending(values, |v| v <= wanted); + order.extend(ascending(values, |v| v > wanted)); + order + } else { + let mut order = ascending(values, |v| v >= wanted); + order.extend(descending(values, |v| v < wanted)); + order + } +} + +/// Weight values in the order CSS tries them. +fn weight_order(wanted: f64, values: &[f64]) -> Vec { + if (400.0..=500.0).contains(&wanted) { + let mut order = ascending(values, |v| v >= wanted && v <= 500.0); + order.extend(descending(values, |v| v < wanted)); + order.extend(ascending(values, |v| v > 500.0)); + order + } else if wanted < 400.0 { + let mut order = descending(values, |v| v <= wanted); + order.extend(ascending(values, |v| v > wanted)); + order + } else { + let mut order = ascending(values, |v| v >= wanted); + order.extend(descending(values, |v| v < wanted)); + order + } +} + +const fn style_order(wanted: FontStyle) -> [FontStyle; 3] { + match wanted { + FontStyle::Normal => [FontStyle::Normal, FontStyle::Oblique, FontStyle::Italic], + FontStyle::Italic => [FontStyle::Italic, FontStyle::Oblique, FontStyle::Normal], + FontStyle::Oblique => [FontStyle::Oblique, FontStyle::Italic, FontStyle::Normal], + } +} + +/// The faces of `pool` holding the first of `order` that any face has. +fn keep_first( + pool: &[usize], + read: impl Fn(usize) -> T, + order: &[T], + same: impl Fn(T, T) -> bool, +) -> Vec { + let best = order + .iter() + .copied() + .find(|value| pool.iter().any(|&face| same(read(face), *value))); + pool.iter() + .copied() + .filter(|&face| best.is_some_and(|value| same(read(face), value))) + .collect() +} + +/// The weight a face offers for a request: the requested weight when the face's range covers it, +/// otherwise the bound of the range closest to it. +fn offered_weight(face: &FontFace, wanted: f64) -> f64 { + wanted.clamp(face.weight_min, face.weight_max.max(face.weight_min)) +} + +/// Picks the face that CSS would use for a request, among the faces of one family: the CSS Fonts +/// Level 4 matching algorithm (§5.2), as a pure function. The stretch is matched first, then the +/// style, then the weight; each step keeps only the faces closest to what was asked. +/// +/// - Stretch: at or below 100, narrower faces first (nearest), then wider ones; above 100, the +/// other way round. +/// - Style: normal prefers normal, then oblique, then italic; italic prefers italic, then +/// oblique, then normal; oblique prefers oblique, then italic, then normal. +/// - Weight: a variable face whose range covers the weight is an exact match. Otherwise, from 400 +/// to 500, the nearest heavier weight up to 500, then lighter ones, then heavier ones above +/// 500; below 400, lighter first then heavier; above 500, heavier first then lighter. A +/// variable face counts for the bound of its range closest to the weight. +/// +/// # Arguments +/// +/// - `faces` - The faces of one family. +/// - `wanted` - What was asked. +/// +/// # Returns +/// +/// The index of the closest face, the first one when several are equally close, or `None` for no +/// face. +/// +/// # Examples +/// +/// ``` +/// use helpers4::font::{pick_closest, FontFace, FontRequest, FontStyle}; +/// +/// let faces = [ +/// FontFace::new(300.0, FontStyle::Normal), +/// FontFace::new(400.0, FontStyle::Normal), +/// FontFace::new(700.0, FontStyle::Normal), +/// ]; +/// assert_eq!(pick_closest(&faces, FontRequest::new(600.0, FontStyle::Normal)), Some(2)); +/// +/// let variable = [FontFace::variable(100.0, 900.0, FontStyle::Normal), FontFace::new(600.0, FontStyle::Normal)]; +/// assert_eq!(pick_closest(&variable, FontRequest::new(450.0, FontStyle::Normal)), Some(0)); +/// ``` +#[must_use] +pub fn pick_closest(faces: &[FontFace], wanted: FontRequest) -> Option { + let all: Vec = (0..faces.len()).collect(); + let same_number = |a: f64, b: f64| a.total_cmp(&b).is_eq(); + + let stretches: Vec = faces.iter().map(|face| face.stretch).collect(); + let pool = keep_first( + &all, + |i| faces[i].stretch, + &stretch_order(wanted.stretch, &stretches), + same_number, + ); + let pool = keep_first( + &pool, + |i| faces[i].style, + &style_order(wanted.style), + |a, b| a == b, + ); + let weights: Vec = pool + .iter() + .map(|&i| offered_weight(&faces[i], wanted.weight)) + .collect(); + let pool = keep_first( + &pool, + |i| offered_weight(&faces[i], wanted.weight), + &weight_order(wanted.weight, &weights), + same_number, + ); + pool.first().copied() +} + +#[cfg(test)] +#[path = "pick_closest.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "pick_closest.spec.rs"] +mod spec; diff --git a/src/font/pick_closest.spec.rs b/src/font/pick_closest.spec.rs new file mode 100644 index 0000000..bde7dd4 --- /dev/null +++ b/src/font/pick_closest.spec.rs @@ -0,0 +1,46 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use proptest::prelude::*; + +fn style() -> impl Strategy { + prop::sample::select(vec![ + FontStyle::Normal, + FontStyle::Italic, + FontStyle::Oblique, + ]) +} + +fn face() -> impl Strategy { + ( + 1u32..=1000, + 0u32..=300, + style(), + prop::sample::select(vec![50.0, 75.0, 100.0, 125.0]), + ) + .prop_map(|(weight, extra, style, stretch)| { + FontFace::variable(f64::from(weight), f64::from(weight + extra), style) + .with_stretch(stretch) + }) +} + +proptest! { + #[test] + fn picks_a_face_whenever_there_is_one(faces in prop::collection::vec(face(), 0..8), weight in 1u32..=1000, wanted_style in style()) { + let picked = pick_closest(&faces, FontRequest::new(f64::from(weight), wanted_style)); + prop_assert_eq!(picked.is_some(), !faces.is_empty()); + if let Some(index) = picked { + prop_assert!(index < faces.len()); + } + } + + #[test] + fn a_face_that_matches_everything_exactly_is_picked(faces in prop::collection::vec(face(), 0..8), weight in 1u32..=1000, wanted_style in style()) { + let exact = FontFace::new(f64::from(weight), wanted_style); + let mut all = faces; + all.insert(0, exact); + prop_assert_eq!(pick_closest(&all, FontRequest::new(f64::from(weight), wanted_style)), Some(0)); + } +} diff --git a/src/font/pick_closest.test.rs b/src/font/pick_closest.test.rs new file mode 100644 index 0000000..8825b6e --- /dev/null +++ b/src/font/pick_closest.test.rs @@ -0,0 +1,174 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +fn face(weight: f64, style: FontStyle) -> FontFace { + FontFace::new(weight, style) +} + +fn pick(faces: &[FontFace], weight: f64, style: FontStyle) -> Option { + pick_closest(faces, FontRequest::new(weight, style)) +} + +#[test] +fn no_face_gives_none() { + assert_eq!(pick(&[], 400.0, FontStyle::Normal), None); +} + +#[test] +fn an_exact_match_wins() { + let faces = [ + face(300.0, FontStyle::Normal), + face(400.0, FontStyle::Normal), + face(700.0, FontStyle::Normal), + ]; + assert_eq!(pick(&faces, 400.0, FontStyle::Normal), Some(1)); + assert_eq!(pick(&faces, 700.0, FontStyle::Normal), Some(2)); +} + +#[test] +fn above_500_heavier_first_then_lighter() { + let faces = [ + face(300.0, FontStyle::Normal), + face(400.0, FontStyle::Normal), + face(700.0, FontStyle::Normal), + ]; + assert_eq!(pick(&faces, 600.0, FontStyle::Normal), Some(2)); + assert_eq!(pick(&faces, 800.0, FontStyle::Normal), Some(2)); + let light = [ + face(300.0, FontStyle::Normal), + face(400.0, FontStyle::Normal), + ]; + assert_eq!(pick(&light, 800.0, FontStyle::Normal), Some(1)); +} + +#[test] +fn below_400_lighter_first_then_heavier() { + let faces = [ + face(200.0, FontStyle::Normal), + face(300.0, FontStyle::Normal), + face(500.0, FontStyle::Normal), + ]; + assert_eq!(pick(&faces, 250.0, FontStyle::Normal), Some(0)); + assert_eq!(pick(&faces, 100.0, FontStyle::Normal), Some(0)); + let heavy = [ + face(500.0, FontStyle::Normal), + face(700.0, FontStyle::Normal), + ]; + assert_eq!(pick(&heavy, 100.0, FontStyle::Normal), Some(0)); +} + +#[test] +fn between_400_and_500_looks_up_to_500_then_down_then_above() { + let faces = [ + face(300.0, FontStyle::Normal), + face(500.0, FontStyle::Normal), + face(700.0, FontStyle::Normal), + ]; + assert_eq!(pick(&faces, 450.0, FontStyle::Normal), Some(1)); + let no_500 = [ + face(300.0, FontStyle::Normal), + face(700.0, FontStyle::Normal), + ]; + assert_eq!(pick(&no_500, 450.0, FontStyle::Normal), Some(0)); + let only_heavy = [ + face(600.0, FontStyle::Normal), + face(900.0, FontStyle::Normal), + ]; + assert_eq!(pick(&only_heavy, 450.0, FontStyle::Normal), Some(0)); +} + +#[test] +fn style_is_matched_before_weight() { + let faces = [ + face(400.0, FontStyle::Normal), + face(700.0, FontStyle::Italic), + face(400.0, FontStyle::Oblique), + ]; + assert_eq!(pick(&faces, 400.0, FontStyle::Italic), Some(1)); + assert_eq!(pick(&faces, 400.0, FontStyle::Normal), Some(0)); + assert_eq!(pick(&faces, 400.0, FontStyle::Oblique), Some(2)); +} + +#[test] +fn style_falls_back_in_the_css_order() { + let normal_italic = [ + face(400.0, FontStyle::Normal), + face(400.0, FontStyle::Italic), + ]; + assert_eq!(pick(&normal_italic[1..], 400.0, FontStyle::Normal), Some(0)); + let oblique_only = [ + face(400.0, FontStyle::Oblique), + face(400.0, FontStyle::Italic), + ]; + assert_eq!(pick(&oblique_only, 400.0, FontStyle::Normal), Some(0)); + assert_eq!(pick(&oblique_only, 400.0, FontStyle::Italic), Some(1)); + let italic_normal = [ + face(400.0, FontStyle::Normal), + face(400.0, FontStyle::Italic), + ]; + assert_eq!(pick(&italic_normal, 400.0, FontStyle::Oblique), Some(1)); + assert_eq!(pick(&italic_normal[..1], 400.0, FontStyle::Italic), Some(0)); +} + +#[test] +fn stretch_is_matched_first() { + let faces = [ + face(700.0, FontStyle::Normal).with_stretch(100.0), + face(400.0, FontStyle::Normal).with_stretch(75.0), + face(400.0, FontStyle::Normal).with_stretch(125.0), + ]; + let request = |stretch: f64| FontRequest { + stretch, + ..FontRequest::default() + }; + assert_eq!(pick_closest(&faces, request(75.0)), Some(1)); + assert_eq!(pick_closest(&faces, request(100.0)), Some(0)); + assert_eq!(pick_closest(&faces, request(125.0)), Some(2)); + // Narrower than wanted first when at or below normal, wider first above it. + assert_eq!(pick_closest(&faces[1..], request(90.0)), Some(0)); + assert_eq!(pick_closest(&faces[1..2], request(90.0)), Some(0)); + assert_eq!(pick_closest(&faces[..1], request(150.0)), Some(0)); + assert_eq!(pick_closest(&faces[1..], request(110.0)), Some(1)); + assert_eq!(pick_closest(&faces[1..], request(140.0)), Some(1)); + assert_eq!(pick_closest(&faces[1..2], request(140.0)), Some(0)); +} + +#[test] +fn the_first_of_equally_close_faces_wins() { + let faces = [ + face(400.0, FontStyle::Normal), + face(400.0, FontStyle::Normal), + ]; + assert_eq!(pick(&faces, 400.0, FontStyle::Normal), Some(0)); +} + +#[test] +fn a_variable_face_covering_the_weight_is_an_exact_match() { + let faces = [ + face(600.0, FontStyle::Normal), + FontFace::variable(100.0, 900.0, FontStyle::Normal), + ]; + assert_eq!(pick(&faces, 450.0, FontStyle::Normal), Some(1)); + assert_eq!(pick(&faces, 600.0, FontStyle::Normal), Some(0)); +} + +#[test] +fn a_variable_face_outside_the_weight_counts_for_its_nearest_bound() { + let faces = [ + FontFace::variable(100.0, 300.0, FontStyle::Normal), + FontFace::variable(600.0, 900.0, FontStyle::Normal), + ]; + assert_eq!(pick(&faces, 700.0, FontStyle::Normal), Some(1)); + assert_eq!(pick(&faces, 950.0, FontStyle::Normal), Some(1)); + assert_eq!(pick(&faces, 50.0, FontStyle::Normal), Some(0)); + assert_eq!(pick(&faces, 400.0, FontStyle::Normal), Some(0)); +} + +#[test] +fn a_reversed_range_is_read_as_its_lower_bound() { + let faces = [FontFace::variable(500.0, 300.0, FontStyle::Normal)]; + assert_eq!(pick(&faces, 900.0, FontStyle::Normal), Some(0)); +} diff --git a/src/font/read_font.rs b/src/font/read_font.rs new file mode 100644 index 0000000..b72a63b --- /dev/null +++ b/src/font/read_font.rs @@ -0,0 +1,167 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::_sfnt::{self, be16}; +use super::{FontError, FontInfo, is_italic, weight_of_style}; + +/// How well a `name` record fits: Windows English first, then any Unicode or Windows record, then +/// Macintosh. `None` for a platform whose strings are not decoded. +fn rank(platform: u16, language: u16) -> Option { + match (platform, language) { + (3, 0x0409) => Some(0), + (0 | 3, _) => Some(1), + (1, _) => Some(2), + _ => None, + } +} + +/// The string of a `name` record: UTF-16BE on the Unicode and Windows platforms, Macintosh +/// Roman (only its ASCII part) on the Macintosh one. +fn decode_name(platform: u16, data: &[u8]) -> String { + if platform == 1 { + data.iter() + .map(|&b| { + if b.is_ascii() { + char::from(b) + } else { + '\u{fffd}' + } + }) + .collect() + } else { + char::decode_utf16( + data.chunks_exact(2) + .map(|pair| u16::from_be_bytes([pair[0], pair[1]])), + ) + .map(|c| c.unwrap_or('\u{fffd}')) + .collect() + } +} + +/// The best string for a name id, `None` when the table has none. +fn name(table: &[u8], id: u16) -> Option { + let header = table.get(..6)?; + let count = usize::from(be16(header, 2)); + let storage = usize::from(be16(header, 4)); + let mut best: Option<(u8, String)> = None; + for index in 0..count { + let Some(record) = table.get(6 + 12 * index..6 + 12 * (index + 1)) else { + break; + }; + let (platform, language) = (be16(record, 0), be16(record, 4)); + if be16(record, 6) != id { + continue; + } + let Some(rank) = rank(platform, language) else { + continue; + }; + let start = storage + usize::from(be16(record, 10)); + let end = start + usize::from(be16(record, 8)); + let Some(data) = table.get(start..end) else { + continue; + }; + if best.as_ref().is_none_or(|(known, _)| rank < *known) { + best = Some((rank, decode_name(platform, data))); + } + } + best.map(|(_, text)| text) +} + +/// The weight class and whether the style flags say italic or oblique, from the `OS/2` table. +fn os2(table: &[u8]) -> (Option, bool) { + let class = table.get(4..6).map(|b| u16::from_be_bytes([b[0], b[1]])); + let weight = class.filter(|w| (1..=1000).contains(w)).map(f64::from); + let italic = table + .get(62..64) + .is_some_and(|b| u16::from_be_bytes([b[0], b[1]]) & 0x0201 != 0); + (weight, italic) +} + +/// Whether the `head` table's style flags say bold, and italic. +fn mac_style(table: &[u8]) -> (bool, bool) { + table + .get(44..46) + .map_or((false, false), |b| (b[1] & 1 != 0, b[1] & 2 != 0)) +} + +/// The range of the `wght` axis of an `fvar` table. +fn weight_range(table: &[u8]) -> Option<(f64, f64)> { + let header = table.get(..12)?; + let axes = usize::from(be16(header, 4)); + let count = usize::from(be16(header, 8)); + let size = usize::from(be16(header, 10)); + let fixed = |axis: &[u8], at: usize| { + f64::from(i32::from_be_bytes([ + axis[at], + axis[at + 1], + axis[at + 2], + axis[at + 3], + ])) / 65536.0 + }; + (0..count).find_map(|index| { + let start = axes + size * index; + let record = table.get(start..start + 20)?; + (&record[..4] == b"wght").then(|| (fixed(record, 4), fixed(record, 12))) + }) +} + +/// Reads what a font file says about itself: its family (typographic first), style, PostScript +/// name, weight or `wght` range and whether it is italic. Reads TrueType and OpenType files +/// (`.ttf`, `.otf`), the first font of a collection (`.ttc`) and WOFF files; it does not +/// interpret glyphs and does not check checksums. +/// +/// WOFF2 tables are Brotli-compressed, and this crate has no Brotli decoder: a WOFF2 file is a +/// [`FontError::UnsupportedWoff2`]. +/// +/// # Arguments +/// +/// - `bytes` - The font file. +/// +/// # Returns +/// +/// The [`FontInfo`]. +/// +/// # Errors +/// +/// A [`FontError`] for data that is not a supported font, is truncated, or has no `name` table +/// or no family name. +/// +/// # Examples +/// +/// ``` +/// use helpers4::font::{read_font, FontError}; +/// +/// assert_eq!(read_font(b"not a font"), Err(FontError::UnknownFormat)); +/// assert_eq!(read_font(b"wOF2...."), Err(FontError::UnsupportedWoff2)); +/// ``` +pub fn read_font(bytes: &[u8]) -> Result { + let tables = _sfnt::load(bytes)?; + let names = tables.get(*b"name").ok_or(FontError::MissingNameTable)?; + let family = name(names, 16) + .or_else(|| name(names, 1)) + .ok_or(FontError::MissingFamily)?; + let style = name(names, 17) + .or_else(|| name(names, 2)) + .unwrap_or_default(); + + let (os2_weight, os2_italic) = tables.get(*b"OS/2").map_or((None, false), os2); + let (bold, head_italic) = tables.get(*b"head").map_or((false, false), mac_style); + let weight = os2_weight.unwrap_or_else(|| if bold { 700.0 } else { weight_of_style(&style) }); + Ok(FontInfo { + postscript_name: name(names, 6), + weight, + weight_range: tables.get(*b"fvar").and_then(weight_range), + italic: os2_italic || head_italic || is_italic(&style), + family, + style, + }) +} + +#[cfg(test)] +#[path = "read_font.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "read_font.spec.rs"] +mod spec; diff --git a/src/font/read_font.spec.rs b/src/font/read_font.spec.rs new file mode 100644 index 0000000..9e0871b --- /dev/null +++ b/src/font/read_font.spec.rs @@ -0,0 +1,32 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +#![allow(clippy::float_cmp)] + +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn never_panics_on_any_bytes(bytes in prop::collection::vec(any::(), 0..128)) { + let _ = read_font(&bytes); + } + + #[test] + fn never_panics_on_a_damaged_font(position in 0usize..400, value in any::()) { + use super::super::_test_support::*; + let mut bytes = sfnt(&typical_tables("Inter", "Bold", 700, 0)); + let at = position % bytes.len(); + bytes[at] = value; + let _ = read_font(&bytes); + } + + #[test] + fn reads_back_any_family_and_weight(family in "[A-Za-z][A-Za-z ]{0,16}", weight in 1u16..=1000) { + use super::super::_test_support::*; + let info = read_font(&sfnt(&typical_tables(&family, "Regular", weight, 0))).unwrap(); + prop_assert_eq!(info.family, family); + prop_assert_eq!(info.weight, f64::from(weight)); + } +} diff --git a/src/font/read_font.test.rs b/src/font/read_font.test.rs new file mode 100644 index 0000000..3ad4ef8 --- /dev/null +++ b/src/font/read_font.test.rs @@ -0,0 +1,247 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +#![allow(clippy::float_cmp)] + +use super::super::_test_support::*; +use super::*; + +fn read(tables: &[Table]) -> FontInfo { + read_font(&sfnt(tables)).unwrap() +} + +#[test] +fn reads_family_style_postscript_name_weight_and_italic() { + let info = read(&typical_tables( + "Neutrif Pro", + "Semi Bold Italic", + 600, + 0x0001, + )); + assert_eq!(info.family, "Neutrif Pro"); + assert_eq!(info.style, "Semi Bold Italic"); + assert_eq!( + info.postscript_name.as_deref(), + Some("NeutrifPro-SemiBoldItalic") + ); + assert_eq!(info.weight, 600.0); + assert_eq!(info.weight_range, None); + assert!(info.italic); +} + +#[test] +fn a_regular_font_is_not_italic() { + let info = read(&typical_tables("Inter", "Regular", 400, 0x0040)); + assert!(!info.italic); + assert_eq!(info.weight, 400.0); +} + +#[test] +fn the_typographic_names_come_first() { + let name = name_table(&[ + windows(1, "Inter Semi Bold"), + windows(2, "Regular"), + windows(16, "Inter"), + windows(17, "Semi Bold"), + ]); + let info = read(&[(*b"name", name)]); + assert_eq!( + (info.family.as_str(), info.style.as_str()), + ("Inter", "Semi Bold") + ); +} + +#[test] +fn a_font_without_a_style_name_has_an_empty_style() { + let info = read(&[(*b"name", name_table(&[windows(1, "Inter")]))]); + assert_eq!(info.style, ""); + assert_eq!(info.postscript_name, None); + assert_eq!(info.weight, 400.0); + assert!(!info.italic); +} + +#[test] +fn the_weight_comes_from_the_style_name_without_an_os2_table() { + let name = name_table(&[windows(1, "Inter"), windows(2, "Extra Bold Oblique")]); + let info = read(&[(*b"name", name)]); + assert_eq!(info.weight, 800.0); + assert!(info.italic); +} + +#[test] +fn the_head_flags_say_bold_and_italic() { + let name = name_table(&[windows(1, "Inter"), windows(2, "Whatever")]); + let bold = read(&[(*b"name", name.clone()), (*b"head", head_table(1))]); + assert_eq!(bold.weight, 700.0); + assert!(!bold.italic); + let italic = read(&[(*b"name", name), (*b"head", head_table(2))]); + assert!(italic.italic); + assert_eq!(italic.weight, 400.0); +} + +#[test] +fn an_os2_weight_out_of_range_or_a_short_table_is_ignored() { + let name = name_table(&[windows(1, "Inter"), windows(2, "Light")]); + let zero = read(&[(*b"name", name.clone()), (*b"OS/2", os2_table(0, 0))]); + assert_eq!(zero.weight, 300.0); + let huge = read(&[(*b"name", name.clone()), (*b"OS/2", os2_table(1001, 0))]); + assert_eq!(huge.weight, 300.0); + let short = read(&[(*b"name", name.clone()), (*b"OS/2", vec![0; 3])]); + assert_eq!(short.weight, 300.0); + let no_flags = read(&[ + (*b"name", name), + (*b"OS/2", os2_table(500, 0)[..40].to_vec()), + ]); + assert_eq!(no_flags.weight, 500.0); + assert!(!no_flags.italic); +} + +#[test] +fn the_oblique_flag_counts_as_italic() { + let info = read(&typical_tables("Inter", "Regular", 400, 0x0200)); + assert!(info.italic); +} + +#[test] +fn a_short_head_table_has_no_flags() { + let name = name_table(&[windows(1, "Inter"), windows(2, "Regular")]); + let info = read(&[(*b"name", name), (*b"head", vec![0; 10])]); + assert_eq!(info.weight, 400.0); + assert!(!info.italic); +} + +#[test] +fn reads_the_weight_axis_of_a_variable_font() { + let mut tables = typical_tables("Inter", "Regular", 400, 0); + tables.push(( + *b"fvar", + fvar(&[ + (b"opsz", 75.0, 100.0, 125.0), + (b"wght", 100.0, 400.0, 900.0), + ]), + )); + let info = read(&tables); + assert_eq!(info.weight_range, Some((100.0, 900.0))); + assert_eq!(info.to_face().weight_max, 900.0); +} + +#[test] +fn a_variable_font_without_a_weight_axis_has_no_range() { + let mut tables = typical_tables("Inter", "Regular", 400, 0); + tables.push((*b"fvar", fvar(&[(b"opsz", 75.0, 100.0, 125.0)]))); + assert_eq!(read(&tables).weight_range, None); + let mut truncated = typical_tables("Inter", "Regular", 400, 0); + truncated.push(( + *b"fvar", + fvar(&[(b"wght", 100.0, 400.0, 900.0)])[..30].to_vec(), + )); + assert_eq!(read(&truncated).weight_range, None); + let mut short = typical_tables("Inter", "Regular", 400, 0); + short.push((*b"fvar", vec![0; 4])); + assert_eq!(read(&short).weight_range, None); +} + +#[test] +fn a_windows_english_record_beats_the_others() { + let name = name_table(&[ + (1, 0, 0, 1, b"Mac Name".to_vec()), + (3, 1, 0x040c, 1, utf16("Nom Français")), + (0, 3, 0, 1, utf16("Unicode Name")), + windows(1, "English Name"), + (3, 1, 0x0409, 6, utf16("PS")), + ]); + let info = read(&[(*b"name", name)]); + assert_eq!(info.family, "English Name"); + let without_english = name_table(&[ + (1, 0, 0, 1, b"Mac Name".to_vec()), + (0, 3, 0, 1, utf16("Unicode Name")), + ]); + assert_eq!(read(&[(*b"name", without_english)]).family, "Unicode Name"); +} + +#[test] +fn a_macintosh_record_is_decoded_as_ascii() { + let name = name_table(&[(1, 0, 0, 1, b"Bar\x8e".to_vec())]); + assert_eq!(read(&[(*b"name", name)]).family, "Bar\u{fffd}"); +} + +#[test] +fn an_unpaired_surrogate_becomes_a_replacement_character() { + let name = name_table(&[(3, 1, 0x0409, 1, vec![0xd8, 0x00, 0x00, 0x41])]); + assert_eq!(read(&[(*b"name", name)]).family, "\u{fffd}A"); +} + +#[test] +fn skips_records_of_other_platforms_and_out_of_range_strings() { + let mut name = name_table(&[(2, 1, 0, 1, b"Ignored".to_vec()), windows(1, "Kept")]); + // A record whose string lies past the end of the table. + let broken = name_table(&[windows(1, "Kept"), windows(1, "Lost")]); + assert_eq!(read(&[(*b"name", name.clone())]).family, "Kept"); + let mut cut = broken; + cut.truncate(cut.len() - 8); + assert_eq!(read(&[(*b"name", cut)]).family, "Kept"); + // Records announced but missing from the table. + name.truncate(6 + 12); + assert_eq!( + read_font(&sfnt(&[(*b"name", name)])), + Err(FontError::MissingFamily) + ); +} + +#[test] +fn a_font_without_a_name_table_or_a_family_is_an_error() { + assert_eq!( + read_font(&sfnt(&[(*b"head", head_table(0))])), + Err(FontError::MissingNameTable) + ); + assert_eq!( + read_font(&sfnt(&[(*b"name", name_table(&[windows(2, "Regular")]))])), + Err(FontError::MissingFamily) + ); + assert_eq!( + read_font(&sfnt(&[(*b"name", vec![0; 3])])), + Err(FontError::MissingFamily) + ); +} + +#[test] +fn reads_opentype_true_type_and_collection_files() { + let tables = typical_tables("Inter", "Bold", 700, 0); + for version in [*b"OTTO", *b"true"] { + let bytes = sfnt_at(&tables, 0, version); + assert_eq!(read_font(&bytes).unwrap().family, "Inter"); + } + assert_eq!(read_font(&collection(&tables)).unwrap().weight, 700.0); +} + +#[test] +fn reads_woff_files() { + let info = read_font(&woff(&typical_tables("Inter", "Bold", 700, 0))).unwrap(); + assert_eq!((info.family.as_str(), info.weight), ("Inter", 700.0)); +} + +#[test] +fn rejects_other_formats_and_woff2() { + assert_eq!(read_font(b""), Err(FontError::Truncated)); + assert_eq!(read_font(b"abc"), Err(FontError::Truncated)); + assert_eq!(read_font(b"%PDF-1.4"), Err(FontError::UnknownFormat)); + assert_eq!(read_font(b"wOF2abcdefgh"), Err(FontError::UnsupportedWoff2)); +} + +#[test] +fn rejects_truncated_files() { + let bytes = sfnt(&typical_tables("Inter", "Bold", 700, 0)); + // The last table is followed by up to three bytes of padding. + for len in 0..bytes.len() - 3 { + assert!(read_font(&bytes[..len]).is_err(), "{len}"); + } + let collection = collection(&typical_tables("Inter", "Bold", 700, 0)); + for len in 0..collection.len() - 3 { + assert!(read_font(&collection[..len]).is_err(), "{len}"); + } + assert_eq!( + read_font(b"ttcf\0\x01\0\0\0\0\0\x01"), + Err(FontError::Truncated) + ); +} diff --git a/src/font/same_family.rs b/src/font/same_family.rs new file mode 100644 index 0000000..2d95935 --- /dev/null +++ b/src/font/same_family.rs @@ -0,0 +1,42 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +fn squash(name: &str) -> impl Iterator + '_ { + name.chars() + .filter(|c| !c.is_whitespace()) + .flat_map(char::to_lowercase) +} + +/// Whether two family names are the same family: compared without spaces or case, since design +/// files and font files write `"SF Pro"`, `"SFPro"` and `"sf pro"` for one family. +/// +/// # Arguments +/// +/// - `a` - A family name. +/// - `b` - Another one. +/// +/// # Returns +/// +/// `true` if the names are equal once spaces are removed and case is ignored. +/// +/// # Examples +/// +/// ``` +/// use helpers4::font::same_family; +/// +/// assert!(same_family("SF Pro", "sfpro")); +/// assert!(!same_family("SF Pro", "SF Mono")); +/// ``` +#[must_use] +pub fn same_family(a: &str, b: &str) -> bool { + squash(a).eq(squash(b)) +} + +#[cfg(test)] +#[path = "same_family.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "same_family.spec.rs"] +mod spec; diff --git a/src/font/same_family.spec.rs b/src/font/same_family.spec.rs new file mode 100644 index 0000000..d46346c --- /dev/null +++ b/src/font/same_family.spec.rs @@ -0,0 +1,21 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn is_symmetric_and_reflexive(a in "[A-Za-z ]{0,12}", b in "[A-Za-z ]{0,12}") { + prop_assert!(same_family(&a, &a)); + prop_assert_eq!(same_family(&a, &b), same_family(&b, &a)); + } + + #[test] + fn case_and_spaces_never_matter(a in "[A-Za-z]{0,12}") { + prop_assert!(same_family(&a, &a.to_uppercase())); + let padded = format!(" {a} "); + prop_assert!(same_family(&a, &padded)); + } +} diff --git a/src/font/same_family.test.rs b/src/font/same_family.test.rs new file mode 100644 index 0000000..5b395ed --- /dev/null +++ b/src/font/same_family.test.rs @@ -0,0 +1,18 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn ignores_spaces_and_case() { + assert!(same_family("SF Pro", "sfpro")); + assert!(same_family(" Neutrif Pro ", "NEUTRIFPRO")); + assert!(same_family("", " ")); +} + +#[test] +fn different_names_differ() { + assert!(!same_family("SF Pro", "SF Mono")); + assert!(!same_family("Inter", "Inter Tight")); +} diff --git a/src/font/split_postscript.rs b/src/font/split_postscript.rs new file mode 100644 index 0000000..a12eeba --- /dev/null +++ b/src/font/split_postscript.rs @@ -0,0 +1,31 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +/// Splits a PostScript font name into family and style at its first hyphen: +/// `NeutrifPro-SemiBold` is `NeutrifPro` and `SemiBold`. +/// +/// # Arguments +/// +/// - `name` - The PostScript name. +/// +/// # Returns +/// +/// The family and the style, borrowed from `name`. The style is empty when there is no hyphen. +/// +/// # Examples +/// +/// ``` +/// use helpers4::font::split_postscript; +/// +/// assert_eq!(split_postscript("NeutrifPro-SemiBold"), ("NeutrifPro", "SemiBold")); +/// assert_eq!(split_postscript("Helvetica"), ("Helvetica", "")); +/// ``` +#[must_use] +pub fn split_postscript(name: &str) -> (&str, &str) { + name.split_once('-').unwrap_or((name, "")) +} + +#[cfg(test)] +#[path = "split_postscript.test.rs"] +mod tests; diff --git a/src/font/split_postscript.test.rs b/src/font/split_postscript.test.rs new file mode 100644 index 0000000..983da1d --- /dev/null +++ b/src/font/split_postscript.test.rs @@ -0,0 +1,26 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn splits_at_the_first_hyphen() { + assert_eq!( + split_postscript("NeutrifPro-SemiBold"), + ("NeutrifPro", "SemiBold") + ); + assert_eq!(split_postscript("A-B-C"), ("A", "B-C")); +} + +#[test] +fn without_a_hyphen_the_style_is_empty() { + assert_eq!(split_postscript("Helvetica"), ("Helvetica", "")); + assert_eq!(split_postscript(""), ("", "")); +} + +#[test] +fn keeps_empty_sides() { + assert_eq!(split_postscript("-Bold"), ("", "Bold")); + assert_eq!(split_postscript("Name-"), ("Name", "")); +} diff --git a/src/font/weight_of_style.rs b/src/font/weight_of_style.rs new file mode 100644 index 0000000..3631249 --- /dev/null +++ b/src/font/weight_of_style.rs @@ -0,0 +1,72 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +/// Weight names, most specific first: a longer name contains a shorter one (`extrabold` holds +/// `bold`). +const WEIGHTS: &[(&str, f64)] = &[ + ("extrablack", 950.0), + ("ultrablack", 950.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("extralight", 200.0), + ("ultralight", 200.0), + ("semibold", 600.0), + ("demibold", 600.0), + ("hairline", 100.0), + ("thin", 100.0), + ("light", 300.0), + ("medium", 500.0), + ("bold", 700.0), + ("black", 900.0), + ("heavy", 900.0), + // The plain names come last: `Roman Bold` is a bold. + ("regular", 400.0), + ("normal", 400.0), + ("book", 400.0), + ("roman", 400.0), +]; + +/// The CSS weight a style name stands for, as design tools and font files write it: `Thin` 100, +/// `ExtraLight` 200, `Light` 300, `Regular` 400, `Medium` 500, `SemiBold` 600, `Bold` 700, +/// `ExtraBold` 800, `Black` 900, `ExtraBlack` 950. Case, spaces, hyphens and underscores are +/// ignored (`"Semi Bold Italic"`, `"semi-bold"`, `"SEMIBOLD"`), and so are other words such as +/// `Italic` or `Condensed`. +/// +/// # Arguments +/// +/// - `style` - The style name. +/// +/// # Returns +/// +/// The weight, or `400.0` when the name holds no weight word. +/// +/// # Examples +/// +/// ``` +/// use helpers4::font::weight_of_style; +/// +/// assert_eq!(weight_of_style("Semi Bold Italic"), 600.0); +/// assert_eq!(weight_of_style("Black"), 900.0); +/// assert_eq!(weight_of_style("Italic"), 400.0); +/// ``` +#[must_use] +pub fn weight_of_style(style: &str) -> f64 { + let squashed: String = style + .chars() + .filter(|c| !c.is_whitespace() && *c != '-' && *c != '_') + .flat_map(char::to_lowercase) + .collect(); + WEIGHTS + .iter() + .find(|(name, _)| squashed.contains(name)) + .map_or(400.0, |(_, weight)| *weight) +} + +#[cfg(test)] +#[path = "weight_of_style.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "weight_of_style.spec.rs"] +mod spec; diff --git a/src/font/weight_of_style.spec.rs b/src/font/weight_of_style.spec.rs new file mode 100644 index 0000000..9187cb0 --- /dev/null +++ b/src/font/weight_of_style.spec.rs @@ -0,0 +1,22 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn the_weight_is_always_a_css_weight(style in "\\PC{0,24}") { + let weight = weight_of_style(&style); + prop_assert!((100.0..=950.0).contains(&weight)); + } + + #[test] + fn case_and_separators_never_change_the_weight(style in "[A-Za-z ]{0,16}") { + prop_assert_eq!( + weight_of_style(&style).to_bits(), + weight_of_style(&style.to_uppercase().replace(' ', "-")).to_bits() + ); + } +} diff --git a/src/font/weight_of_style.test.rs b/src/font/weight_of_style.test.rs new file mode 100644 index 0000000..17d1da2 --- /dev/null +++ b/src/font/weight_of_style.test.rs @@ -0,0 +1,51 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +#![allow(clippy::float_cmp)] + +use super::*; + +#[test] +fn reads_every_weight_name() { + let cases = [ + ("Thin", 100.0), + ("Hairline", 100.0), + ("ExtraLight", 200.0), + ("Ultra Light", 200.0), + ("Light", 300.0), + ("Regular", 400.0), + ("Normal", 400.0), + ("Book", 400.0), + ("Roman", 400.0), + ("Medium", 500.0), + ("SemiBold", 600.0), + ("Demi Bold", 600.0), + ("Bold", 700.0), + ("Extra Bold", 800.0), + ("UltraBold", 800.0), + ("Black", 900.0), + ("Heavy", 900.0), + ("Extra Black", 950.0), + ("Ultra-Black", 950.0), + ]; + for (style, weight) in cases { + assert_eq!(weight_of_style(style), weight, "{style}"); + } +} + +#[test] +fn ignores_case_separators_and_other_words() { + assert_eq!(weight_of_style("Semi Bold Italic"), 600.0); + assert_eq!(weight_of_style("semi_bold"), 600.0); + assert_eq!(weight_of_style("SEMI-BOLD"), 600.0); + assert_eq!(weight_of_style("Bold Condensed Oblique"), 700.0); + assert_eq!(weight_of_style("Roman Bold"), 700.0); +} + +#[test] +fn a_style_without_a_weight_word_is_regular() { + assert_eq!(weight_of_style("Italic"), 400.0); + assert_eq!(weight_of_style(""), 400.0); + assert_eq!(weight_of_style("Condensed"), 400.0); +} diff --git a/src/geometry/_arc_to_cubics.rs b/src/geometry/_arc_to_cubics.rs new file mode 100644 index 0000000..7c5cd33 --- /dev/null +++ b/src/geometry/_arc_to_cubics.rs @@ -0,0 +1,80 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! Elliptical arcs to cubic Bézier curves, for [`normalize_path`](super::_normalize_path). Not +//! re-exported. + +use super::PathCommand; +use std::f64::consts::PI; + +/// An arc (SVG endpoint form, implementation notes F.6.5) as cubic `C` commands of at most a +/// quarter turn each. Radii that are too small are scaled up so the arc reaches its end point. +/// The caller handles the degenerate cases (a zero radius, equal end points). +#[allow(clippy::too_many_arguments, clippy::similar_names)] +pub(crate) fn arc_to_cubics( + from: (f64, f64), + radii: (f64, f64), + rotation: f64, + large_arc: bool, + sweep: bool, + to: (f64, f64), +) -> Vec { + let (x1, y1) = from; + let (x2, y2) = to; + let (mut rx, mut ry) = (radii.0.abs(), radii.1.abs()); + let phi = rotation.to_radians(); + let (sin, cos) = phi.sin_cos(); + let dx = (x1 - x2) / 2.0; + let dy = (y1 - y2) / 2.0; + let x1p = cos * dx + sin * dy; + let y1p = -sin * dx + cos * dy; + let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); + if lambda > 1.0 { + rx *= lambda.sqrt(); + ry *= lambda.sqrt(); + } + let num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p; + let den = rx * rx * y1p * y1p + ry * ry * x1p * x1p; + let coef = if large_arc == sweep { -1.0 } else { 1.0 } * (num / den).max(0.0).sqrt(); + let cxp = coef * rx * y1p / ry; + let cyp = -coef * ry * x1p / rx; + let cx = cos * cxp - sin * cyp + f64::midpoint(x1, x2); + let cy = sin * cxp + cos * cyp + f64::midpoint(y1, y2); + let theta = ((y1p - cyp) / ry).atan2((x1p - cxp) / rx); + let mut delta = ((-y1p - cyp) / ry).atan2((-x1p - cxp) / rx) - theta; + if !sweep && delta > 0.0 { + delta -= 2.0 * PI; + } + if sweep && delta < 0.0 { + delta += 2.0 * PI; + } + + // At most a quarter turn per curve; `count` is small and positive. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let count = ((delta.abs() / (PI / 2.0) - 1e-9).ceil() as usize).max(1); + #[allow(clippy::cast_precision_loss)] + let step = delta / count as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let map = |ux: f64, uy: f64| { + ( + cx + rx * cos * ux - ry * sin * uy, + cy + rx * sin * ux + ry * cos * uy, + ) + }; + (0..count) + .map(|i| { + #[allow(clippy::cast_precision_loss)] + let a = theta + i as f64 * step; + let b = a + step; + let c1 = map(a.cos() - k * a.sin(), a.sin() + k * a.cos()); + let c2 = map(b.cos() + k * b.sin(), b.sin() - k * b.cos()); + let end = if i == count - 1 { + (x2, y2) + } else { + map(b.cos(), b.sin()) + }; + PathCommand::new('C', vec![c1.0, c1.1, c2.0, c2.1, end.0, end.1]) + }) + .collect() +} diff --git a/src/geometry/_normalize_path.rs b/src/geometry/_normalize_path.rs new file mode 100644 index 0000000..b6a3ce0 --- /dev/null +++ b/src/geometry/_normalize_path.rs @@ -0,0 +1,109 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! Path commands rewritten with absolute `M`, `L`, `C`, `Q` and `Z` only, for +//! [`transform_path`](super::transform_path). Not re-exported. + +use super::_arc_to_cubics::arc_to_cubics; +use super::PathCommand; + +/// The control point mirrored through the current point, or the current point itself without one. +fn reflect(current: (f64, f64), control: Option<(f64, f64)>) -> (f64, f64) { + control.map_or(current, |c| (2.0 * current.0 - c.0, 2.0 * current.1 - c.1)) +} + +/// Relative commands become absolute, `H`/`V` become `L`, `S` becomes `C`, `T` becomes `Q` and +/// arcs become cubic curves. +pub(crate) fn normalize_path(commands: &[PathCommand]) -> Vec { + let mut out = Vec::new(); + let mut current = (0.0, 0.0); + let mut start = (0.0, 0.0); + // The control point the next `S` or `T` reflects, when the previous command was a curve of + // its kind. + let mut last_cubic: Option<(f64, f64)> = None; + let mut last_quad: Option<(f64, f64)> = None; + + for command in commands { + let v = &command.values; + let (ox, oy) = if command.letter.is_ascii_lowercase() { + current + } else { + (0.0, 0.0) + }; + let mut cubic = None; + let mut quad = None; + match command.letter.to_ascii_uppercase() { + 'M' => { + current = (v[0] + ox, v[1] + oy); + start = current; + out.push(PathCommand::new('M', vec![current.0, current.1])); + } + kind @ ('L' | 'H' | 'V') => { + let x = if kind == 'V' { current.0 } else { v[0] + ox }; + let y = match kind { + 'H' => current.1, + 'V' => v[0] + oy, + _ => v[1] + oy, + }; + current = (x, y); + out.push(PathCommand::new('L', vec![x, y])); + } + kind @ ('C' | 'S') => { + let (first, i) = if kind == 'S' { + (reflect(current, last_cubic), 0) + } else { + ((v[0] + ox, v[1] + oy), 2) + }; + let second = (v[i] + ox, v[i + 1] + oy); + current = (v[i + 2] + ox, v[i + 3] + oy); + cubic = Some(second); + out.push(PathCommand::new( + 'C', + vec![first.0, first.1, second.0, second.1, current.0, current.1], + )); + } + kind @ ('Q' | 'T') => { + let (control, i) = if kind == 'T' { + (reflect(current, last_quad), 0) + } else { + ((v[0] + ox, v[1] + oy), 2) + }; + quad = Some(control); + let end = (v[i] + ox, v[i + 1] + oy); + out.push(PathCommand::new( + 'Q', + vec![control.0, control.1, end.0, end.1], + )); + current = end; + } + 'A' => { + let end = (v[5] + ox, v[6] + oy); + if v[0] == 0.0 || v[1] == 0.0 { + out.push(PathCommand::new('L', vec![end.0, end.1])); + } else if end != current { + out.extend(arc_to_cubics( + current, + (v[0], v[1]), + v[2], + v[3] != 0.0, + v[4] != 0.0, + end, + )); + } + current = end; + } + _ => { + out.push(PathCommand::new('Z', Vec::new())); + current = start; + } + } + last_cubic = cubic; + last_quad = quad; + } + out +} + +#[cfg(test)] +#[path = "_normalize_path.test.rs"] +mod tests; diff --git a/src/geometry/_normalize_path.test.rs b/src/geometry/_normalize_path.test.rs new file mode 100644 index 0000000..e75c80a --- /dev/null +++ b/src/geometry/_normalize_path.test.rs @@ -0,0 +1,124 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use crate::geometry::parse_path; + +fn normalized(d: &str) -> String { + crate::geometry::format_path(&normalize_path(&parse_path(d).unwrap()), 6) +} + +#[test] +fn reads_relative_commands_from_the_current_point() { + assert_eq!( + normalized("m1 1 l2 0 c1 0 1 1 0 2 q1 1 2 0"), + "M1 1 L3 1 C4 1 4 2 3 3 Q4 4 5 3" + ); +} + +#[test] +fn expands_h_and_v() { + assert_eq!(normalized("M1 1 H5 V7 h1 v-1"), "M1 1 L5 1 L5 7 L6 7 L6 6"); +} + +#[test] +fn reflects_the_previous_control_point_for_s_and_t() { + assert_eq!( + normalized("M0 0 C0 1 1 2 2 2 S4 3 4 2"), + "M0 0 C0 1 1 2 2 2 C3 2 4 3 4 2" + ); + assert_eq!(normalized("M0 0 Q1 1 2 0 T4 0"), "M0 0 Q1 1 2 0 Q3 -1 4 0"); +} + +#[test] +fn starts_s_and_t_from_the_current_point_when_no_curve_precedes() { + assert_eq!(normalized("M1 1 S2 2 3 3"), "M1 1 C1 1 2 2 3 3"); + assert_eq!(normalized("M1 1 T3 3"), "M1 1 Q1 1 3 3"); + assert_eq!( + normalized("M0 0 C0 1 1 2 2 2 L3 3 S4 4 5 5"), + "M0 0 C0 1 1 2 2 2 L3 3 C3 3 4 4 5 5" + ); + assert_eq!( + normalized("M0 0 Q1 1 2 2 L3 3 T4 4"), + "M0 0 Q1 1 2 2 L3 3 Q3 3 4 4" + ); +} + +#[test] +fn goes_back_to_the_start_of_the_subpath_after_a_close() { + assert_eq!(normalized("M1 1 L3 1 z l1 1"), "M1 1 L3 1 Z L2 2"); +} + +#[test] +fn turns_a_zero_radius_arc_into_a_line_and_skips_an_arc_to_the_same_point() { + assert_eq!(normalized("M0 0 A0 5 0 0 1 5 5"), "M0 0 L5 5"); + assert_eq!(normalized("M0 0 A5 0 0 0 1 5 5"), "M0 0 L5 5"); + assert_eq!(normalized("M1 1 A5 5 0 0 1 1 1"), "M1 1"); +} + +#[test] +fn turns_an_arc_into_cubics_that_reach_its_end_point() { + let commands = normalize_path(&parse_path("M0 0 A5 5 0 0 1 10 0").unwrap()); + assert_eq!(commands.len(), 3); // a move and two quarter turns + let last = commands.last().unwrap(); + assert_eq!( + (last.letter, last.values[4], last.values[5]), + ('C', 10.0, 0.0) + ); + // The middle of a half circle on the circle, above the chord. + let mid = &commands[1].values; + assert!((((mid[4] - 5.0).hypot(mid[5])) - 5.0).abs() < 1e-9); + assert!(mid[5] < 0.0); +} + +#[test] +fn follows_the_sweep_and_large_arc_flags() { + let count = |flags: &str| { + normalize_path(&parse_path(&format!("M0 0 A5 5 0 {flags} 6 0")).unwrap()).len() + }; + assert!(count("0 0") < count("1 1")); + let top = |flags: &str| { + normalize_path(&parse_path(&format!("M0 0 A5 5 0 {flags} 6 0")).unwrap()) + .iter() + .flat_map(|c| { + c.values + .iter() + .skip(1) + .step_by(2) + .copied() + .collect::>() + }) + .fold(f64::INFINITY, f64::min) + }; + assert!(top("0 1") < top("0 0")); +} + +#[test] +fn scales_up_radii_that_are_too_small() { + let commands = normalize_path(&parse_path("M0 0 A1 1 0 0 1 10 0").unwrap()); + let last = commands.last().unwrap(); + assert_eq!((last.values[4], last.values[5]), (10.0, 0.0)); + let mid = &commands[1].values; + assert!((((mid[4] - 5.0).hypot(mid[5])) - 5.0).abs() < 1e-9); +} + +#[test] +fn handles_a_rotated_ellipse_and_every_direction_of_the_chord() { + let ends_at = |d: &str, end: (f64, f64)| { + let commands = normalize_path(&parse_path(d).unwrap()); + let last = commands.last().unwrap(); + assert!((last.values[4] - end.0).abs() < 1e-9 && (last.values[5] - end.1).abs() < 1e-9); + }; + ends_at("M0 0 A10 5 30 0 1 8 4", (8.0, 4.0)); + for (large, sweep) in [("0", "0"), ("0", "1"), ("1", "0"), ("1", "1")] { + for degrees in (10..360).step_by(35) { + let radians = f64::from(degrees).to_radians(); + let end = (5.0 * radians.cos(), 5.0 * radians.sin()); + ends_at( + &format!("M5 0 A5 5 0 {large} {sweep} {} {}", end.0, end.1), + end, + ); + } + } +} diff --git a/src/geometry/ellipse_path.rs b/src/geometry/ellipse_path.rs new file mode 100644 index 0000000..10c5b96 --- /dev/null +++ b/src/geometry/ellipse_path.rs @@ -0,0 +1,45 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::{PathCommand, Rect, format_path}; + +/// The path of the ellipse that fills a box, as SVG path data: two half-turn arcs. A square box +/// gives a circle. +/// +/// # Arguments +/// +/// - `bounds` - The bounding box of the ellipse. +/// +/// # Returns +/// +/// The path data, closed. +/// +/// # Examples +/// +/// ``` +/// use helpers4::geometry::{ellipse_path, Rect}; +/// +/// assert_eq!( +/// ellipse_path(Rect::new(0.0, 0.0, 10.0, 6.0)), +/// "M0 3 A5 3 0 0 1 10 3 A5 3 0 0 1 0 3 Z" +/// ); +/// ``` +#[must_use] +pub fn ellipse_path(bounds: Rect) -> String { + let (rx, ry) = (bounds.width / 2.0, bounds.height / 2.0); + let cy = bounds.y + ry; + format_path( + &[ + PathCommand::new('M', vec![bounds.x, cy]), + PathCommand::new('A', vec![rx, ry, 0.0, 0.0, 1.0, bounds.right(), cy]), + PathCommand::new('A', vec![rx, ry, 0.0, 0.0, 1.0, bounds.x, cy]), + PathCommand::new('Z', Vec::new()), + ], + 6, + ) +} + +#[cfg(test)] +#[path = "ellipse_path.test.rs"] +mod tests; diff --git a/src/geometry/ellipse_path.test.rs b/src/geometry/ellipse_path.test.rs new file mode 100644 index 0000000..23f1a94 --- /dev/null +++ b/src/geometry/ellipse_path.test.rs @@ -0,0 +1,21 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn draws_the_ellipse_in_its_box_with_two_arcs() { + assert_eq!( + ellipse_path(Rect::new(0.0, 0.0, 10.0, 6.0)), + "M0 3 A5 3 0 0 1 10 3 A5 3 0 0 1 0 3 Z" + ); +} + +#[test] +fn draws_a_circle_in_a_square_box_offset() { + assert_eq!( + ellipse_path(Rect::new(10.0, 20.0, 4.0, 4.0)), + "M10 22 A2 2 0 0 1 14 22 A2 2 0 0 1 10 22 Z" + ); +} diff --git a/src/geometry/format_path.rs b/src/geometry/format_path.rs new file mode 100644 index 0000000..f5f4048 --- /dev/null +++ b/src/geometry/format_path.rs @@ -0,0 +1,63 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::PathCommand; + +fn write_number(n: f64, precision: usize) -> String { + let mut text = format!("{n:.precision$}"); + if text.contains('.') { + text.truncate(text.trim_end_matches('0').trim_end_matches('.').len()); + } + if text == "-0" { + text = "0".to_string(); + } + text +} + +/// Writes path commands back as SVG path data: letters glued to their first number, single +/// spaces elsewhere, numbers rounded to `precision` decimals with the trailing zeros dropped +/// (`-0` is written `0`). +/// +/// # Arguments +/// +/// - `commands` - The commands, as [`parse_path`](super::parse_path) returns them. +/// - `precision` - Decimals kept per number; `6` is a good default. +/// +/// # Returns +/// +/// The path data. +/// +/// # Examples +/// +/// ``` +/// use helpers4::geometry::{format_path, PathCommand}; +/// +/// let d = format_path( +/// &[ +/// PathCommand::new('M', vec![0.0, 0.5]), +/// PathCommand::new('L', vec![10.0, 1.0 / 3.0]), +/// ], +/// 6, +/// ); +/// assert_eq!(d, "M0 0.5 L10 0.333333"); +/// ``` +#[must_use] +pub fn format_path(commands: &[PathCommand], precision: usize) -> String { + commands + .iter() + .map(|command| { + let numbers: Vec = command + .values + .iter() + .map(|n| write_number(*n, precision)) + .collect(); + format!("{}{}", command.letter, numbers.join(" ")) + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +#[path = "format_path.test.rs"] +mod tests; diff --git a/src/geometry/format_path.test.rs b/src/geometry/format_path.test.rs new file mode 100644 index 0000000..a19d712 --- /dev/null +++ b/src/geometry/format_path.test.rs @@ -0,0 +1,51 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use crate::geometry::parse_path; + +fn cmd(letter: char, values: &[f64]) -> PathCommand { + PathCommand::new(letter, values.to_vec()) +} + +#[test] +fn writes_letters_glued_to_their_first_number_and_a_space_elsewhere() { + let d = format_path( + &[ + cmd('M', &[0.0, 0.5]), + cmd('L', &[10.0, -2.0]), + cmd('Z', &[]), + ], + 6, + ); + assert_eq!(d, "M0 0.5 L10 -2 Z"); +} + +#[test] +fn rounds_to_the_precision_and_drops_trailing_zeros_and_negative_zero() { + assert_eq!( + format_path(&[cmd('L', &[1.0 / 3.0, -0.000_000_1, 2.5])], 6), + "L0.333333 0 2.5" + ); + assert_eq!( + format_path(&[cmd('L', &[1.0 / 3.0, 1.23456])], 2), + "L0.33 1.23" + ); +} + +#[test] +fn keeps_the_zeros_of_a_whole_number_without_decimals() { + assert_eq!(format_path(&[cmd('L', &[100.0, 20.0])], 0), "L100 20"); +} + +#[test] +fn writes_nothing_for_no_command() { + assert_eq!(format_path(&[], 6), ""); +} + +#[test] +fn round_trips_parsed_data() { + let d = "M0 0 L10 0 A5 5 0 0 1 10 10 Z"; + assert_eq!(format_path(&parse_path(d).unwrap(), 6), d); +} diff --git a/src/geometry/matrix.rs b/src/geometry/matrix.rs index 49d2ee6..f68b334 100644 --- a/src/geometry/matrix.rs +++ b/src/geometry/matrix.rs @@ -220,6 +220,42 @@ impl Matrix { .then_some(inverse) } + /// The translation and scale that put one rectangle exactly on another, for animating or + /// fitting between two boxes. An axis where `from` has no extent is only translated (scale + /// `1`). + /// + /// # Arguments + /// + /// - `from` - The rectangle to move. + /// - `to` - The rectangle to reach. + /// + /// # Returns + /// + /// The matrix that maps `from` onto `to`. + /// + /// # Examples + /// + /// ``` + /// use helpers4::geometry::{Matrix, Rect}; + /// + /// let m = Matrix::rect_to_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(5.0, 5.0, 20.0, 10.0)); + /// assert_eq!(m, Matrix::new(2.0, 0.0, 0.0, 1.0, 5.0, 5.0)); + /// ``` + #[must_use] + pub fn rect_to_rect(from: Rect, to: Rect) -> Self { + let a = if from.width == 0.0 { + 1.0 + } else { + to.width / from.width + }; + let d = if from.height == 0.0 { + 1.0 + } else { + to.height / from.height + }; + Self::new(a, 0.0, 0.0, d, to.x - from.x * a, to.y - from.y * d) + } + /// The CSS and SVG `transform` value, `matrix(a, b, c, d, e, f)`. /// /// Entries are written as Rust prints them, without rounding (and `0` for `-0`); a diff --git a/src/geometry/matrix.test.rs b/src/geometry/matrix.test.rs index 62a8750..5c4eca3 100644 --- a/src/geometry/matrix.test.rs +++ b/src/geometry/matrix.test.rs @@ -165,3 +165,30 @@ fn to_css_writes_the_six_entries_in_order_without_negative_zero() { ); assert_eq!(Matrix::IDENTITY.to_css(), "matrix(1, 0, 0, 1, 0, 0)"); } + +#[test] +fn rect_to_rect_translates_and_scales_one_rectangle_onto_another() { + let from = Rect::new(0.0, 0.0, 10.0, 10.0); + let to = Rect::new(5.0, 5.0, 20.0, 10.0); + let m = Matrix::rect_to_rect(from, to); + assert_eq!(m, Matrix::new(2.0, 0.0, 0.0, 1.0, 5.0, 5.0)); + assert_eq!(m.bounding_box(from), to); +} + +#[test] +fn rect_to_rect_is_the_identity_from_a_rectangle_to_itself() { + let r = Rect::new(3.0, 4.0, 5.0, 6.0); + assert_eq!(Matrix::rect_to_rect(r, r), Matrix::IDENTITY); +} + +#[test] +fn rect_to_rect_only_translates_an_axis_without_extent() { + assert_eq!( + Matrix::rect_to_rect(Rect::new(1.0, 1.0, 0.0, 2.0), Rect::new(4.0, 3.0, 9.0, 4.0)), + Matrix::new(1.0, 0.0, 0.0, 2.0, 3.0, 1.0) + ); + assert_eq!( + Matrix::rect_to_rect(Rect::new(1.0, 1.0, 2.0, 0.0), Rect::new(4.0, 3.0, 4.0, 9.0)), + Matrix::new(2.0, 0.0, 0.0, 1.0, 2.0, 2.0) + ); +} diff --git a/src/geometry/mod.rs b/src/geometry/mod.rs index fbe1d44..d582b2d 100644 --- a/src/geometry/mod.rs +++ b/src/geometry/mod.rs @@ -7,20 +7,38 @@ //! //! Enough to map between coordinate spaces: build and multiply transforms, invert one to bring a //! point back, take the bounding box of a transformed rectangle (hit areas, gradient bounds), place -//! content in a container like CSS `object-fit` ([`fit`]), and simplify a polyline. +//! content in a container like CSS `object-fit` ([`fit`]), simplify a polyline, and work with SVG +//! path data: parse it ([`parse_path`]), transform it by a matrix ([`transform_path`]) and write +//! rectangles and ellipses as paths ([`rounded_rect_path`], [`ellipse_path`]). +mod _arc_to_cubics; +mod _normalize_path; +mod ellipse_path; mod fit; +mod format_path; mod matrix; +mod parse_path; +mod path_command; +mod path_error; mod point; mod rect; +mod rounded_rect_path; mod simplify_polyline; mod size; +mod transform_path; +pub use ellipse_path::ellipse_path; pub use fit::FitAlign; pub use fit::FitMode; pub use fit::fit; +pub use format_path::format_path; pub use matrix::Matrix; +pub use parse_path::parse_path; +pub use path_command::PathCommand; +pub use path_error::ParsePathError; pub use point::Point; pub use rect::Rect; +pub use rounded_rect_path::rounded_rect_path; pub use simplify_polyline::simplify_polyline; pub use size::Size; +pub use transform_path::transform_path; diff --git a/src/geometry/parse_path.rs b/src/geometry/parse_path.rs new file mode 100644 index 0000000..781bb7d --- /dev/null +++ b/src/geometry/parse_path.rs @@ -0,0 +1,150 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::{ParsePathError, PathCommand}; + +/// How many numbers a command takes, `None` for a letter that is not a command. +fn arity(letter: char) -> Option { + match letter.to_ascii_lowercase() { + 'm' | 'l' | 't' => Some(2), + 'h' | 'v' => Some(1), + 'c' => Some(6), + 's' | 'q' => Some(4), + 'a' => Some(7), + 'z' => Some(0), + _ => None, + } +} + +fn skip_separators(bytes: &[u8], mut i: usize) -> usize { + while i < bytes.len() && (bytes[i].is_ascii_whitespace() || bytes[i] == b',') { + i += 1; + } + i +} + +fn digits(bytes: &[u8], mut i: usize) -> usize { + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + i +} + +/// Reads a number at `i`, returning it with the offset after it. +fn read_number(text: &str, i: usize) -> Option<(f64, usize)> { + let bytes = text.as_bytes(); + let mut end = i; + if end < bytes.len() && (bytes[end] == b'+' || bytes[end] == b'-') { + end += 1; + } + let int_end = digits(bytes, end); + let mut seen = int_end > end; + end = int_end; + if end < bytes.len() && bytes[end] == b'.' { + let frac_end = digits(bytes, end + 1); + seen |= frac_end > end + 1; + end = frac_end; + } + if !seen { + return None; + } + if end < bytes.len() && (bytes[end] == b'e' || bytes[end] == b'E') { + let mut exp = end + 1; + if exp < bytes.len() && (bytes[exp] == b'+' || bytes[exp] == b'-') { + exp += 1; + } + let exp_end = digits(bytes, exp); + if exp_end > exp { + end = exp_end; + } + } + text[i..end].parse().ok().map(|n| (n, end)) +} + +/// Parses SVG path data (the `d` attribute) into commands, as written: the letters keep their +/// case (absolute or relative), `H`/`V`/`S`/`T`/`A` stay what they are, and a command repeated +/// without its letter (`L 1 2 3 4`) becomes one command per set of numbers (`M` repeats as `L`, +/// `m` as `l`). +/// +/// It follows the SVG grammar: numbers may be glued (`1-2`, `.5.5`, `1e-3`), separators are +/// optional, and the two arc flags may be written without any separator (`a1 1 0 011 1`). +/// +/// # Arguments +/// +/// - `d` - The path data. +/// +/// # Returns +/// +/// The commands, in order. Empty for an empty or blank string. +/// +/// # Errors +/// +/// A [`ParsePathError`] if the data does not start with a move, has an unknown letter, or misses +/// numbers or flags. +/// +/// # Examples +/// +/// ``` +/// use helpers4::geometry::{parse_path, PathCommand}; +/// +/// let commands = parse_path("M0 0h10v10z")?; +/// assert_eq!(commands[0], PathCommand::new('M', vec![0.0, 0.0])); +/// assert_eq!(commands[1], PathCommand::new('h', vec![10.0])); +/// assert_eq!(commands.len(), 4); +/// # Ok::<(), helpers4::geometry::ParsePathError>(()) +/// ``` +pub fn parse_path(d: &str) -> Result, ParsePathError> { + let bytes = d.as_bytes(); + let mut commands: Vec = Vec::new(); + let mut i = skip_separators(bytes, 0); + while i < bytes.len() { + let letter = char::from(bytes[i]); + let Some(count) = arity(letter) else { + return Err(ParsePathError::UnknownCommand { index: i }); + }; + if commands.is_empty() && letter != 'M' && letter != 'm' { + return Err(ParsePathError::MissingMove); + } + i += 1; + let mut current = letter; + let mut first = true; + loop { + let mut values = Vec::with_capacity(count); + for k in 0..count { + i = skip_separators(bytes, i); + if letter.eq_ignore_ascii_case(&'a') && (k == 3 || k == 4) { + match bytes.get(i) { + Some(b'0') => values.push(0.0), + Some(b'1') => values.push(1.0), + _ => return Err(ParsePathError::ExpectedFlag { index: i }), + } + i += 1; + } else { + let (n, end) = + read_number(d, i).ok_or(ParsePathError::ExpectedNumber { index: i })?; + values.push(n); + i = end; + } + } + commands.push(PathCommand::new(current, values)); + if first && (letter == 'M' || letter == 'm') { + current = if letter == 'M' { 'L' } else { 'l' }; + } + first = false; + i = skip_separators(bytes, i); + if count == 0 || i >= bytes.len() || bytes[i].is_ascii_alphabetic() { + break; + } + } + } + Ok(commands) +} + +#[cfg(test)] +#[path = "parse_path.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "parse_path.spec.rs"] +mod spec; diff --git a/src/geometry/parse_path.spec.rs b/src/geometry/parse_path.spec.rs new file mode 100644 index 0000000..4e31662 --- /dev/null +++ b/src/geometry/parse_path.spec.rs @@ -0,0 +1,26 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use crate::geometry::format_path; +use proptest::prelude::*; + +proptest! { + #[test] + fn never_panics(d in "\\PC{0,40}") { + let _ = parse_path(&d); + } + + #[test] + fn reads_back_what_format_path_wrote( + points in prop::collection::vec((-1000i32..1000, -1000i32..1000), 1..8) + ) { + let mut commands = vec![PathCommand::new('M', vec![f64::from(points[0].0), f64::from(points[0].1)])]; + for (x, y) in &points[1..] { + commands.push(PathCommand::new('L', vec![f64::from(*x), f64::from(*y)])); + } + commands.push(PathCommand::new('Z', vec![])); + prop_assert_eq!(parse_path(&format_path(&commands, 6)), Ok(commands)); + } +} diff --git a/src/geometry/parse_path.test.rs b/src/geometry/parse_path.test.rs new file mode 100644 index 0000000..251d2fb --- /dev/null +++ b/src/geometry/parse_path.test.rs @@ -0,0 +1,123 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +fn cmd(letter: char, values: &[f64]) -> PathCommand { + PathCommand::new(letter, values.to_vec()) +} + +#[test] +fn blank_data_has_no_command() { + assert_eq!(parse_path(""), Ok(vec![])); + assert_eq!(parse_path(" ,\n"), Ok(vec![])); +} + +#[test] +fn keeps_the_letters_and_their_case() { + assert_eq!( + parse_path("M0 0h10v10z"), + Ok(vec![ + cmd('M', &[0.0, 0.0]), + cmd('h', &[10.0]), + cmd('v', &[10.0]), + cmd('z', &[]), + ]) + ); +} + +#[test] +fn splits_a_repeated_command_a_repeated_move_being_a_line() { + assert_eq!( + parse_path("M0 0 1 1 2 2L3 3 4 4"), + Ok(vec![ + cmd('M', &[0.0, 0.0]), + cmd('L', &[1.0, 1.0]), + cmd('L', &[2.0, 2.0]), + cmd('L', &[3.0, 3.0]), + cmd('L', &[4.0, 4.0]), + ]) + ); + let letters: Vec = parse_path("m0 0 1 1 2 2") + .unwrap() + .iter() + .map(|c| c.letter) + .collect(); + assert_eq!(letters, ['m', 'l', 'l']); +} + +#[test] +fn reads_glued_numbers_exponents_and_any_separator() { + let values: Vec> = parse_path("M1-2.5.5.5L1e-1,+2E2") + .unwrap() + .into_iter() + .map(|c| c.values) + .collect(); + assert_eq!(values, [vec![1.0, -2.5], vec![0.5, 0.5], vec![0.1, 200.0]]); +} + +#[test] +fn reads_arc_flags_glued_to_the_next_number() { + assert_eq!( + parse_path("M0 0a1 1 0 011 1"), + Ok(vec![ + cmd('M', &[0.0, 0.0]), + cmd('a', &[1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]) + ]) + ); +} + +#[test] +fn accepts_numbers_like_3_dot_and_dot_5() { + assert_eq!(parse_path("M3. .5"), Ok(vec![cmd('M', &[3.0, 0.5])])); +} + +#[test] +fn an_exponent_without_digits_is_not_part_of_the_number() { + assert_eq!( + parse_path("M1e 2"), + Err(ParsePathError::ExpectedNumber { index: 2 }) + ); + assert_eq!( + parse_path("M1e+ 2"), + Err(ParsePathError::ExpectedNumber { index: 2 }) + ); +} + +#[test] +fn rejects_data_that_does_not_start_with_a_move() { + assert_eq!(parse_path("L0 0"), Err(ParsePathError::MissingMove)); +} + +#[test] +fn rejects_an_unknown_letter() { + assert_eq!( + parse_path("M0 0 X"), + Err(ParsePathError::UnknownCommand { index: 5 }) + ); +} + +#[test] +fn rejects_missing_numbers_and_bad_flags() { + assert_eq!( + parse_path("M0"), + Err(ParsePathError::ExpectedNumber { index: 2 }) + ); + assert_eq!( + parse_path("M0 -"), + Err(ParsePathError::ExpectedNumber { index: 3 }) + ); + assert_eq!( + parse_path("M0 ."), + Err(ParsePathError::ExpectedNumber { index: 3 }) + ); + assert_eq!( + parse_path("M0 0 A1 1 0 2 1 1 1"), + Err(ParsePathError::ExpectedFlag { index: 12 }) + ); + assert_eq!( + parse_path("M0 0 A1 1 0"), + Err(ParsePathError::ExpectedFlag { index: 11 }) + ); +} diff --git a/src/geometry/path_command.rs b/src/geometry/path_command.rs new file mode 100644 index 0000000..63c6fca --- /dev/null +++ b/src/geometry/path_command.rs @@ -0,0 +1,49 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +/// One command of SVG path data: its letter (uppercase is absolute, lowercase relative to the +/// current point) and its numbers, as written in the `d` attribute. +/// +/// | Letter | Command | Numbers | +/// | --- | --- | --- | +/// | `M` `L` `T` | move, line, smooth quadratic | `x y` | +/// | `H` `V` | horizontal, vertical line | `x` or `y` | +/// | `C` | cubic Bézier | `x1 y1 x2 y2 x y` | +/// | `S` `Q` | smooth cubic, quadratic | `x2 y2 x y` or `x1 y1 x y` | +/// | `A` | elliptical arc | `rx ry rotation large-arc sweep x y` | +/// | `Z` | close the subpath | none | +/// +/// # Examples +/// +/// ``` +/// use helpers4::geometry::PathCommand; +/// +/// let line = PathCommand::new('L', vec![10.0, 5.0]); +/// assert_eq!(line.letter, 'L'); +/// assert_eq!(line.values, [10.0, 5.0]); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct PathCommand { + /// The command letter. + pub letter: char, + /// The numbers that follow it. + pub values: Vec, +} + +impl PathCommand { + /// Builds a command. + /// + /// # Arguments + /// + /// - `letter` - The command letter. + /// - `values` - Its numbers. + /// + /// # Returns + /// + /// The command. + #[must_use] + pub const fn new(letter: char, values: Vec) -> Self { + Self { letter, values } + } +} diff --git a/src/geometry/path_error.rs b/src/geometry/path_error.rs new file mode 100644 index 0000000..7350209 --- /dev/null +++ b/src/geometry/path_error.rs @@ -0,0 +1,45 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use std::fmt; + +/// Why SVG path data could not be parsed. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum ParsePathError { + /// The data does not start with a move (`M` or `m`). + MissingMove, + /// A letter that is not a path command. + UnknownCommand { + /// Byte offset of the letter in the input. + index: usize, + }, + /// A command is missing a number, or the text is not a number. + ExpectedNumber { + /// Byte offset where a number was expected. + index: usize, + }, + /// An arc flag is not `0` or `1`. + ExpectedFlag { + /// Byte offset where a flag was expected. + index: usize, + }, +} + +impl fmt::Display for ParsePathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingMove => write!(f, "path data must start with a move"), + Self::UnknownCommand { index } => write!(f, "unknown path command at byte {index}"), + Self::ExpectedNumber { index } => write!(f, "expected a number at byte {index}"), + Self::ExpectedFlag { index } => write!(f, "expected an arc flag at byte {index}"), + } + } +} + +impl std::error::Error for ParsePathError {} + +#[cfg(test)] +#[path = "path_error.test.rs"] +mod tests; diff --git a/src/geometry/path_error.test.rs b/src/geometry/path_error.test.rs new file mode 100644 index 0000000..bac4238 --- /dev/null +++ b/src/geometry/path_error.test.rs @@ -0,0 +1,31 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn display_describes_each_variant() { + assert_eq!( + ParsePathError::MissingMove.to_string(), + "path data must start with a move" + ); + assert_eq!( + ParsePathError::UnknownCommand { index: 3 }.to_string(), + "unknown path command at byte 3" + ); + assert_eq!( + ParsePathError::ExpectedNumber { index: 4 }.to_string(), + "expected a number at byte 4" + ); + assert_eq!( + ParsePathError::ExpectedFlag { index: 5 }.to_string(), + "expected an arc flag at byte 5" + ); +} + +#[test] +fn is_a_std_error() { + let error: &dyn std::error::Error = &ParsePathError::MissingMove; + assert!(error.source().is_none()); +} diff --git a/src/geometry/rounded_rect_path.rs b/src/geometry/rounded_rect_path.rs new file mode 100644 index 0000000..fc5b5db --- /dev/null +++ b/src/geometry/rounded_rect_path.rs @@ -0,0 +1,64 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::{PathCommand, Rect, format_path}; + +/// The path of a rectangle with a radius per corner, as SVG path data. Each radius is clamped to +/// half the smaller side, and a radius of `0` gives a square corner (no arc is written). +/// +/// # Arguments +/// +/// - `rect` - The rectangle. +/// - `radii` - `[top_left, top_right, bottom_right, bottom_left]`, as in CSS `border-radius`; +/// pass `[r; 4]` for one radius. Negative radii count as `0`. +/// +/// # Returns +/// +/// The path data, closed. +/// +/// # Examples +/// +/// ``` +/// use helpers4::geometry::{rounded_rect_path, Rect}; +/// +/// let square = Rect::new(0.0, 0.0, 10.0, 10.0); +/// assert_eq!(rounded_rect_path(square, [0.0; 4]), "M0 0 L10 0 L10 10 L0 10 L0 0 Z"); +/// assert_eq!( +/// rounded_rect_path(square, [5.0, 0.0, 0.0, 0.0]), +/// "M5 0 L10 0 L10 10 L0 10 L0 5 A5 5 0 0 1 5 0 Z" +/// ); +/// ``` +#[must_use] +pub fn rounded_rect_path(rect: Rect, radii: [f64; 4]) -> String { + let limit = rect.width.min(rect.height) / 2.0; + let [tl, tr, br, bl] = radii.map(|r| r.max(0.0).min(limit)); + let (x, y) = (rect.x, rect.y); + let (right, bottom) = (rect.right(), rect.bottom()); + let mut commands = vec![PathCommand::new('M', vec![x + tl, y])]; + let arc = |commands: &mut Vec, r: f64, end: (f64, f64)| { + if r > 0.0 { + commands.push(PathCommand::new( + 'A', + vec![r, r, 0.0, 0.0, 1.0, end.0, end.1], + )); + } + }; + let line = |commands: &mut Vec, to: (f64, f64)| { + commands.push(PathCommand::new('L', vec![to.0, to.1])); + }; + line(&mut commands, (right - tr, y)); + arc(&mut commands, tr, (right, y + tr)); + line(&mut commands, (right, bottom - br)); + arc(&mut commands, br, (right - br, bottom)); + line(&mut commands, (x + bl, bottom)); + arc(&mut commands, bl, (x, bottom - bl)); + line(&mut commands, (x, y + tl)); + arc(&mut commands, tl, (x + tl, y)); + commands.push(PathCommand::new('Z', Vec::new())); + format_path(&commands, 6) +} + +#[cfg(test)] +#[path = "rounded_rect_path.test.rs"] +mod tests; diff --git a/src/geometry/rounded_rect_path.test.rs b/src/geometry/rounded_rect_path.test.rs new file mode 100644 index 0000000..a083b1f --- /dev/null +++ b/src/geometry/rounded_rect_path.test.rs @@ -0,0 +1,60 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +const BOX: Rect = Rect::new(0.0, 0.0, 10.0, 10.0); + +#[test] +fn draws_a_plain_rectangle_without_radius() { + assert_eq!( + rounded_rect_path(BOX, [0.0; 4]), + "M0 0 L10 0 L10 10 L0 10 L0 0 Z" + ); +} + +#[test] +fn rounds_the_four_corners_with_one_radius() { + assert_eq!( + rounded_rect_path(Rect::new(1.0, 2.0, 10.0, 20.0), [2.0; 4]), + "M3 2 L9 2 A2 2 0 0 1 11 4 L11 20 A2 2 0 0 1 9 22 L3 22 A2 2 0 0 1 1 20 L1 4 A2 2 0 0 1 3 2 Z" + ); +} + +#[test] +fn takes_a_radius_per_corner_in_css_order() { + assert_eq!( + rounded_rect_path(BOX, [5.0, 0.0, 0.0, 0.0]), + "M5 0 L10 0 L10 10 L0 10 L0 5 A5 5 0 0 1 5 0 Z" + ); + assert_eq!( + rounded_rect_path(BOX, [0.0, 4.0, 0.0, 0.0]), + "M0 0 L6 0 A4 4 0 0 1 10 4 L10 10 L0 10 L0 0 Z" + ); + assert_eq!( + rounded_rect_path(BOX, [0.0, 0.0, 3.0, 0.0]), + "M0 0 L10 0 L10 7 A3 3 0 0 1 7 10 L0 10 L0 0 Z" + ); + assert_eq!( + rounded_rect_path(BOX, [0.0, 0.0, 0.0, 2.0]), + "M0 0 L10 0 L10 10 L2 10 A2 2 0 0 1 0 8 L0 0 Z" + ); +} + +#[test] +fn clamps_radii_to_half_the_smaller_side() { + assert_eq!( + rounded_rect_path(BOX, [100.0; 4]), + "M5 0 L5 0 A5 5 0 0 1 10 5 L10 5 A5 5 0 0 1 5 10 L5 10 A5 5 0 0 1 0 5 L0 5 A5 5 0 0 1 5 0 Z" + ); + assert!(rounded_rect_path(Rect::new(0.0, 0.0, 20.0, 4.0), [9.0; 4]).contains("A2 2")); +} + +#[test] +fn counts_a_negative_radius_as_zero() { + assert_eq!( + rounded_rect_path(BOX, [-3.0; 4]), + rounded_rect_path(BOX, [0.0; 4]) + ); +} diff --git a/src/geometry/transform_path.rs b/src/geometry/transform_path.rs new file mode 100644 index 0000000..d8a8aad --- /dev/null +++ b/src/geometry/transform_path.rs @@ -0,0 +1,53 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::_normalize_path::normalize_path; +use super::{Matrix, ParsePathError, Point, format_path, parse_path}; + +/// Transforms SVG path data by a matrix. The result is written with absolute `M`, `L`, `C`, `Q` +/// and `Z` only: relative commands are made absolute, `H`/`V`/`S`/`T` are expanded, and arcs +/// become cubic curves (so any matrix works, skews included). Moving a path is the common case. +/// +/// # Arguments +/// +/// - `d` - The path data. +/// - `matrix` - The matrix, in the CSS/SVG layout. +/// - `precision` - Decimals kept per number; `6` is a good default. +/// +/// # Returns +/// +/// The transformed path data. +/// +/// # Errors +/// +/// A [`ParsePathError`] if `d` is not valid path data. +/// +/// # Examples +/// +/// ``` +/// use helpers4::geometry::{transform_path, Matrix}; +/// +/// let moved = transform_path("M0 0 h10 v10 z", Matrix::translate(5.0, 5.0), 6)?; +/// assert_eq!(moved, "M5 5 L15 5 L15 15 Z"); +/// # Ok::<(), helpers4::geometry::ParsePathError>(()) +/// ``` +pub fn transform_path(d: &str, matrix: Matrix, precision: usize) -> Result { + let mut commands = normalize_path(&parse_path(d)?); + for command in &mut commands { + for pair in command.values.chunks_exact_mut(2) { + let moved = matrix.apply(Point::new(pair[0], pair[1])); + pair[0] = moved.x; + pair[1] = moved.y; + } + } + Ok(format_path(&commands, precision)) +} + +#[cfg(test)] +#[path = "transform_path.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "transform_path.spec.rs"] +mod spec; diff --git a/src/geometry/transform_path.spec.rs b/src/geometry/transform_path.spec.rs new file mode 100644 index 0000000..7c1893d --- /dev/null +++ b/src/geometry/transform_path.spec.rs @@ -0,0 +1,29 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn moving_in_two_steps_equals_moving_at_once( + dx in -1000i32..1000, + dy in -1000i32..1000, + points in prop::collection::vec((-1000i32..1000, -1000i32..1000), 1..8) + ) { + let steps: Vec = points[1..].iter().map(|(x, y)| format!(" l{x} {y}")).collect(); + let rest = steps.join(""); + let d = format!("M{} {}{rest}z", points[0].0, points[0].1); + let (dx, dy) = (f64::from(dx), f64::from(dy)); + let once = transform_path(&d, Matrix::translate(dx, dy), 6).unwrap(); + let first = transform_path(&d, Matrix::translate(dx, 0.0), 6).unwrap(); + let twice = transform_path(&first, Matrix::translate(0.0, dy), 6).unwrap(); + prop_assert_eq!(once, twice); + } + + #[test] + fn never_panics_on_any_text(d in "\\PC{0,40}") { + let _ = transform_path(&d, Matrix::IDENTITY, 6); + } +} diff --git a/src/geometry/transform_path.test.rs b/src/geometry/transform_path.test.rs new file mode 100644 index 0000000..b5f9e7e --- /dev/null +++ b/src/geometry/transform_path.test.rs @@ -0,0 +1,67 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +const IDENTITY: Matrix = Matrix::IDENTITY; + +#[test] +fn moves_a_path_writing_it_absolute() { + assert_eq!( + transform_path("M0 0 h10 v10 z", Matrix::translate(5.0, 5.0), 6), + Ok("M5 5 L15 5 L15 15 Z".to_string()) + ); +} + +#[test] +fn transforms_with_a_rotation_and_a_scale() { + let quarter = Matrix::new(0.0, 2.0, -2.0, 0.0, 0.0, 0.0); + assert_eq!( + transform_path("M1 0 L0 1", quarter, 6), + Ok("M0 2 L-2 0".to_string()) + ); + assert_eq!( + transform_path("M1 0 L0 1", Matrix::rotate(std::f64::consts::FRAC_PI_2), 3), + Ok("M0 1 L-1 0".to_string()) + ); + assert_eq!( + transform_path("M1 1", Matrix::scale(2.0, 3.0), 6), + Ok("M2 3".to_string()) + ); +} + +#[test] +fn transforms_curve_control_points_too() { + assert_eq!( + transform_path("M0 0 C1 1 2 2 3 3 Q1 1 2 2", Matrix::translate(1.0, 0.0), 6), + Ok("M1 0 C2 1 3 2 4 3 Q2 1 3 2".to_string()) + ); +} + +#[test] +fn agrees_with_apply_on_every_end_point() { + let m = Matrix::new(1.5, 0.2, -0.3, 0.8, 7.0, -2.0); + let moved = parse_path(&transform_path("M1 2 L3 4", m, 9).unwrap()).unwrap(); + for (command, (x, y)) in moved.iter().zip([(1.0, 2.0), (3.0, 4.0)]) { + let expected = m.apply(Point::new(x, y)); + assert!((command.values[0] - expected.x).abs() < 1e-9); + assert!((command.values[1] - expected.y).abs() < 1e-9); + } +} + +#[test] +fn leaves_a_path_alone_under_the_identity() { + assert_eq!( + transform_path("M1 1 L2 2", IDENTITY, 6), + Ok("M1 1 L2 2".to_string()) + ); +} + +#[test] +fn rejects_invalid_path_data() { + assert_eq!( + transform_path("L1 1", IDENTITY, 6), + Err(ParsePathError::MissingMove) + ); +} diff --git a/src/kiwi/_reader.rs b/src/kiwi/_reader.rs new file mode 100644 index 0000000..dbef5ed --- /dev/null +++ b/src/kiwi/_reader.rs @@ -0,0 +1,92 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! A cursor over Kiwi bytes: LEB128 varints, zigzag integers, Kiwi floats and NUL-terminated +//! strings, on `ByteReader`. Kiwi's 64-bit varints and floats have their own encoding, read +//! here. Not re-exported. + +use super::KiwiError; +use crate::bytes::ByteReader; + +pub(crate) struct Reader<'a> { + bytes: ByteReader<'a>, +} + +impl<'a> Reader<'a> { + pub(crate) fn new(data: &'a [u8]) -> Self { + Self { + bytes: ByteReader::new(data), + } + } + + pub(crate) fn remaining(&self) -> usize { + self.bytes.remaining() + } + + /// The end of the data, where a read fell short. + fn eof(&self) -> KiwiError { + KiwiError::UnexpectedEof { + index: self.bytes.position(), + } + } + + pub(crate) fn byte(&mut self) -> Result { + self.bytes.read_u8().ok_or_else(|| self.eof()) + } + + pub(crate) fn take(&mut self, len: usize) -> Result<&'a [u8], KiwiError> { + self.bytes.read_bytes(len).ok_or_else(|| self.eof()) + } + + pub(crate) fn var_uint(&mut self) -> Result { + self.bytes.read_var_u32().ok_or_else(|| self.eof()) + } + + pub(crate) fn var_int(&mut self) -> Result { + self.bytes.read_var_i32().ok_or_else(|| self.eof()) + } + + /// Kiwi's 64-bit varints are not LEB128: after eight groups of 7 bits, the ninth byte holds + /// 8 full bits. + pub(crate) fn var_uint64(&mut self) -> Result { + let mut value = 0u64; + for shift in (0..56).step_by(7) { + let byte = self.byte()?; + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Ok(value | (u64::from(self.byte()?) << 56)) + } + + #[allow(clippy::cast_possible_wrap)] // zigzag: the decoded bits are reinterpreted + pub(crate) fn var_int64(&mut self) -> Result { + let v = self.var_uint64()?; + Ok(((v >> 1) as i64) ^ -((v & 1) as i64)) + } + + /// Kiwi stores floats with the exponent moved to the low bits, and a single `0` byte for zero. + pub(crate) fn var_float(&mut self) -> Result { + let first = self.byte()?; + if first == 0 { + return Ok(0.0); + } + let rest = self.take(3)?; + let bits = u32::from_le_bytes([first, rest[0], rest[1], rest[2]]); + Ok(f32::from_bits(bits.rotate_left(23))) + } + + pub(crate) fn string(&mut self) -> Result { + let start = self.bytes.position(); + self.bytes + .read_cstr() + .map(str::to_owned) + .ok_or(KiwiError::InvalidString { index: start }) + } +} + +#[cfg(test)] +#[path = "_reader.test.rs"] +mod tests; diff --git a/src/kiwi/_reader.test.rs b/src/kiwi/_reader.test.rs new file mode 100644 index 0000000..b616420 --- /dev/null +++ b/src/kiwi/_reader.test.rs @@ -0,0 +1,109 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn var_uint_reads_leb128() { + assert_eq!(Reader::new(&[0x00]).var_uint(), Ok(0)); + assert_eq!(Reader::new(&[0x7f]).var_uint(), Ok(127)); + assert_eq!(Reader::new(&[0x80, 0x01]).var_uint(), Ok(128)); + assert_eq!( + Reader::new(&[0xff, 0xff, 0xff, 0xff, 0x0f]).var_uint(), + Ok(u32::MAX) + ); +} + +#[test] +fn var_int_reads_zigzag() { + assert_eq!(Reader::new(&[0x00]).var_int(), Ok(0)); + assert_eq!(Reader::new(&[0x01]).var_int(), Ok(-1)); + assert_eq!(Reader::new(&[0x02]).var_int(), Ok(1)); + assert_eq!(Reader::new(&[0x03]).var_int(), Ok(-2)); +} + +#[test] +fn var_uint64_reads_seven_bit_groups_then_a_full_ninth_byte() { + assert_eq!(Reader::new(&[0x00]).var_uint64(), Ok(0)); + assert_eq!(Reader::new(&[0xac, 0x02]).var_uint64(), Ok(300)); + let mut max = vec![0xff; 8]; + max.push(0xff); + assert_eq!(Reader::new(&max).var_uint64(), Ok(u64::MAX)); + // The ninth byte is not a continuation byte: its high bit is data. + let mut ninth = vec![0x80; 8]; + ninth.push(0x80); + assert_eq!(Reader::new(&ninth).var_uint64(), Ok(0x80 << 56)); +} + +#[test] +fn var_int64_reads_zigzag() { + assert_eq!(Reader::new(&[0x00]).var_int64(), Ok(0)); + assert_eq!(Reader::new(&[0x01]).var_int64(), Ok(-1)); + assert_eq!(Reader::new(&[0x02]).var_int64(), Ok(1)); + let mut min = vec![0xff; 8]; + min.push(0xff); + assert_eq!(Reader::new(&min).var_int64(), Ok(i64::MIN)); +} + +#[test] +fn var_float_reads_kiwi_floats() { + assert_eq!(Reader::new(&[0x00]).var_float().map(f32::to_bits), Ok(0)); + for value in [1.0f32, -2.5, 3.5, 1e-20] { + let bytes = value.to_bits().rotate_right(23).to_le_bytes(); + assert_eq!(Reader::new(&bytes).var_float(), Ok(value)); + } +} + +#[test] +fn string_reads_null_terminated_utf8() { + let mut r = Reader::new(b"na\xc3\xafve\0next\0"); + assert_eq!(r.string().as_deref(), Ok("naïve")); + assert_eq!(r.string().as_deref(), Ok("next")); +} + +#[test] +fn reports_where_a_read_fell_short() { + let mut r = Reader::new(&[0x01]); + assert_eq!(r.byte(), Ok(1)); + assert_eq!(r.byte(), Err(KiwiError::UnexpectedEof { index: 1 })); + assert_eq!( + Reader::new(&[0x80]).var_uint(), + Err(KiwiError::UnexpectedEof { index: 0 }) + ); + assert_eq!( + Reader::new(&[0x80]).var_int(), + Err(KiwiError::UnexpectedEof { index: 0 }) + ); + assert_eq!( + Reader::new(&[0x80, 0x80]).var_uint64(), + Err(KiwiError::UnexpectedEof { index: 2 }) + ); + assert_eq!( + Reader::new(&[0x01, 0x02]).var_float(), + Err(KiwiError::UnexpectedEof { index: 1 }) + ); + assert_eq!( + Reader::new(&[1, 2]).take(3), + Err(KiwiError::UnexpectedEof { index: 0 }) + ); +} + +#[test] +fn reports_a_bad_string_where_it_starts() { + let mut r = Reader::new(&[0x00, 0xff, 0x00]); + assert_eq!(r.string().as_deref(), Ok("")); + assert_eq!(r.string(), Err(KiwiError::InvalidString { index: 1 })); + assert_eq!( + Reader::new(b"no end").string(), + Err(KiwiError::InvalidString { index: 0 }) + ); +} + +#[test] +fn counts_the_bytes_left() { + let mut r = Reader::new(&[1, 2, 3]); + assert_eq!(r.remaining(), 3); + assert_eq!(r.take(2), Ok(&[1u8, 2][..])); + assert_eq!(r.remaining(), 1); +} diff --git a/src/kiwi/_test_support.rs b/src/kiwi/_test_support.rs new file mode 100644 index 0000000..05d4b43 --- /dev/null +++ b/src/kiwi/_test_support.rs @@ -0,0 +1,126 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! Encoders for the unit tests, shared by `schema.test.rs` and `schema.spec.rs`. + +#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + +pub(super) fn var_uint(mut value: u64, out: &mut Vec) { + while value >= 0x80 { + out.push((value & 0x7f) as u8 | 0x80); + value >>= 7; + } + out.push(value as u8); +} + +pub(super) fn var_int(value: i32) -> Vec { + let mut out = Vec::new(); + var_uint(u64::from(((value << 1) ^ (value >> 31)) as u32), &mut out); + out +} + +/// Kiwi's 64-bit varint: seven bits per byte, a full eighth bit in the ninth. +pub(super) fn var_uint64(value: u64) -> Vec { + let mut out = Vec::new(); + let mut rest = value; + for _ in 0..8 { + if rest < 0x80 { + out.push(rest as u8); + return out; + } + out.push((rest & 0x7f) as u8 | 0x80); + rest >>= 7; + } + out.push(rest as u8); + out +} + +pub(super) fn var_int64(value: i64) -> Vec { + var_uint64(((value << 1) ^ (value >> 63)) as u64) +} + +pub(super) fn var_float(value: f32) -> Vec { + if value == 0.0 { + vec![0] + } else { + value.to_bits().rotate_right(23).to_le_bytes().to_vec() + } +} + +pub(super) fn string(text: &str) -> Vec { + let mut out = text.as_bytes().to_vec(); + out.push(0); + out +} + +pub(super) fn field(name: &str, type_id: i32, is_array: bool, value: u32) -> Vec { + let mut out = string(name); + out.extend(var_int(type_id)); + out.push(u8::from(is_array)); + var_uint(u64::from(value), &mut out); + out +} + +pub(super) fn definition(name: &str, kind: u8, fields: &[Vec]) -> Vec { + let mut out = string(name); + out.push(kind); + var_uint(fields.len() as u64, &mut out); + for field in fields { + out.extend(field); + } + out +} + +pub(super) fn schema(definitions: &[Vec]) -> Vec { + let mut out = Vec::new(); + var_uint(definitions.len() as u64, &mut out); + for definition in definitions { + out.extend(definition); + } + out +} + +pub(super) const BOOL: i32 = -1; +pub(super) const BYTE: i32 = -2; +pub(super) const INT: i32 = -3; +pub(super) const UINT: i32 = -4; +pub(super) const FLOAT: i32 = -5; +pub(super) const STRING: i32 = -6; +pub(super) const INT64: i32 = -7; +pub(super) const UINT64: i32 = -8; + +/// `Color` (enum, 0), `Point` (struct, 1) and `Msg` (message, 2). +pub(super) fn sample_schema() -> Vec { + schema(&[ + definition( + "Color", + 0, + &[field("RED", 0, false, 1), field("GREEN", 0, false, 2)], + ), + definition( + "Point", + 1, + &[field("x", FLOAT, false, 1), field("y", FLOAT, false, 2)], + ), + definition( + "Msg", + 2, + &[ + field("id", UINT, false, 1), + field("name", STRING, false, 2), + field("tags", STRING, true, 3), + field("color", 0, false, 4), + field("origin", 1, false, 5), + field("data", BYTE, true, 6), + field("big", INT64, false, 7), + field("flag", BOOL, false, 8), + field("n", INT, false, 9), + field("u", UINT64, false, 10), + field("b", BYTE, false, 11), + field("points", 1, true, 12), + field("nested", 2, false, 13), + ], + ), + ]) +} diff --git a/src/kiwi/definition.rs b/src/kiwi/definition.rs new file mode 100644 index 0000000..f51fe45 --- /dev/null +++ b/src/kiwi/definition.rs @@ -0,0 +1,101 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use std::sync::Arc; + +/// What a [`Definition`] is: an enum, a struct (all its fields, in order) or a message (any of +/// its fields, each with an id, ended by a `0`). +/// +/// # Examples +/// +/// ``` +/// use helpers4::kiwi::Kind; +/// +/// assert_ne!(Kind::Enum, Kind::Message); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Kind { + /// A set of named numbers. + Enum, + /// A fixed list of fields, always all present. + Struct, + /// A list of optional fields, each written with its id. + Message, +} + +/// The type of a [`Field`]: one of Kiwi's built-in types, or another definition of the schema. +/// +/// # Examples +/// +/// ``` +/// use helpers4::kiwi::FieldType; +/// +/// assert_eq!(FieldType::Definition(2), FieldType::Definition(2)); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum FieldType { + /// One byte, `0` is false. + Bool, + /// An unsigned byte. + Byte, + /// A signed 32-bit integer. + Int, + /// An unsigned 32-bit integer. + Uint, + /// A 32-bit float. + Float, + /// A UTF-8 string. + String, + /// A signed 64-bit integer. + Int64, + /// An unsigned 64-bit integer. + Uint64, + /// Another definition, by its index in [`Schema::definitions`](super::Schema::definitions). + Definition(usize), +} + +/// One field of a [`Definition`]. On an enum, a field is a member: only its name and `value` +/// matter. +/// +/// # Examples +/// +/// ``` +/// use helpers4::kiwi::{Field, FieldType}; +/// +/// let field = Field { name: "id".into(), field_type: Some(FieldType::Uint), is_array: false, value: 1 }; +/// assert_eq!(&*field.name, "id"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Field { + /// The field name. + pub name: Arc, + /// The type, `None` on an enum member. + pub field_type: Option, + /// Whether the field holds a list of values. + pub is_array: bool, + /// The field id of a message field, or the number of an enum member. + pub value: u32, +} + +/// A named enum, struct or message of a Kiwi schema. +/// +/// # Examples +/// +/// ``` +/// use helpers4::kiwi::{Definition, Kind}; +/// +/// let empty = Definition { name: "Empty".into(), kind: Kind::Message, fields: vec![] }; +/// assert_eq!(&*empty.name, "Empty"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Definition { + /// The definition name. + pub name: Arc, + /// Whether it is an enum, a struct or a message. + pub kind: Kind, + /// Its fields (or enum members), in schema order. + pub fields: Vec, +} diff --git a/src/kiwi/error.rs b/src/kiwi/error.rs new file mode 100644 index 0000000..ea9ca72 --- /dev/null +++ b/src/kiwi/error.rs @@ -0,0 +1,68 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use std::fmt; + +/// Why a Kiwi schema or message could not be decoded. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum KiwiError { + /// The data ends in the middle of a value. + UnexpectedEof { + /// Byte offset where the read that fell short started. + index: usize, + }, + /// A string that is not valid UTF-8, or has no terminating `0`. + InvalidString { + /// Byte offset where the string starts. + index: usize, + }, + /// A definition whose kind byte is not enum, struct or message. + UnknownKind { + /// The kind byte. + kind: u8, + }, + /// A field whose type is neither a built-in type nor a definition of the schema. + UnknownType { + /// The raw type number. + type_id: i32, + }, + /// No definition has the requested name. + UnknownDefinition { + /// The name that was asked for. + name: String, + }, + /// A message holds a field id its definition does not list. + UnknownField { + /// The message definition. + message: String, + /// The field id. + id: u32, + }, + /// The data nests deeper than the decoder allows (a malformed or hostile schema can recurse + /// forever). + TooDeep, +} + +impl fmt::Display for KiwiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnexpectedEof { index } => write!(f, "unexpected end of data at byte {index}"), + Self::InvalidString { index } => write!(f, "invalid string at byte {index}"), + Self::UnknownKind { kind } => write!(f, "unknown definition kind {kind}"), + Self::UnknownType { type_id } => write!(f, "unknown type {type_id}"), + Self::UnknownDefinition { name } => write!(f, "no definition named {name:?}"), + Self::UnknownField { message, id } => { + write!(f, "field id {id} does not exist in message {message:?}") + } + Self::TooDeep => write!(f, "data nested too deeply"), + } + } +} + +impl std::error::Error for KiwiError {} + +#[cfg(test)] +#[path = "error.test.rs"] +mod tests; diff --git a/src/kiwi/error.test.rs b/src/kiwi/error.test.rs new file mode 100644 index 0000000..80eeaa0 --- /dev/null +++ b/src/kiwi/error.test.rs @@ -0,0 +1,44 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn display_describes_each_variant() { + assert_eq!( + KiwiError::UnexpectedEof { index: 3 }.to_string(), + "unexpected end of data at byte 3" + ); + assert_eq!( + KiwiError::InvalidString { index: 4 }.to_string(), + "invalid string at byte 4" + ); + assert_eq!( + KiwiError::UnknownKind { kind: 9 }.to_string(), + "unknown definition kind 9" + ); + assert_eq!( + KiwiError::UnknownType { type_id: -9 }.to_string(), + "unknown type -9" + ); + assert_eq!( + KiwiError::UnknownDefinition { name: "X".into() }.to_string(), + "no definition named \"X\"" + ); + assert_eq!( + KiwiError::UnknownField { + message: "M".into(), + id: 7 + } + .to_string(), + "field id 7 does not exist in message \"M\"" + ); + assert_eq!(KiwiError::TooDeep.to_string(), "data nested too deeply"); +} + +#[test] +fn is_a_std_error() { + let error: &dyn std::error::Error = &KiwiError::TooDeep; + assert!(error.source().is_none()); +} diff --git a/src/kiwi/mod.rs b/src/kiwi/mod.rs new file mode 100644 index 0000000..ff1b286 --- /dev/null +++ b/src/kiwi/mod.rs @@ -0,0 +1,31 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +//! Decoding of the [Kiwi](https://github.com/evanw/kiwi) binary format, Evan Wallace's +//! schema-based serialization (used by Figma's `.fig` files, but generic). +//! +//! A Kiwi message is meaningless without its schema. [`Schema::parse`] reads a *binary* schema +//! (the compiled form Kiwi writes, which `.fig` files embed), then [`Schema::decode`] decodes a +//! message against one of its definitions into a dynamic [`Value`] tree: objects, arrays, enums +//! by name and the usual scalars. Every failure on truncated or invalid data is a precise +//! [`KiwiError`]. +//! +//! Built on [`bytes::ByteReader`](crate::bytes::ByteReader), plus Kiwi's own 64-bit varints and +//! floats, which are not LEB128 and IEEE 754 on the wire. + +mod _reader; +#[cfg(test)] +mod _test_support; +mod definition; +mod error; +mod schema; +mod value; + +pub use definition::Definition; +pub use definition::Field; +pub use definition::FieldType; +pub use definition::Kind; +pub use error::KiwiError; +pub use schema::Schema; +pub use value::Value; diff --git a/src/kiwi/schema.rs b/src/kiwi/schema.rs new file mode 100644 index 0000000..a12c72a --- /dev/null +++ b/src/kiwi/schema.rs @@ -0,0 +1,304 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::_reader::Reader; +use super::{Definition, Field, FieldType, Kind, KiwiError, Value}; +use std::sync::Arc; + +/// How deep nested values may go before decoding stops with [`KiwiError::TooDeep`]. +const MAX_DEPTH: usize = 64; + +/// A Kiwi schema: the definitions (enums, structs and messages) that give meaning to the bytes +/// of a message. +/// +/// Read one from its binary form with [`Schema::parse`], then decode messages with +/// [`Schema::decode`]. +/// +/// # Examples +/// +/// ``` +/// use helpers4::kiwi::Schema; +/// +/// // A schema with one message, `Ping`, holding a `uint` field `id` (field number 1). +/// let schema = Schema::parse(b"\x01Ping\0\x02\x01id\0\x07\x00\x01")?; +/// let ping = schema.decode(&[1, 42, 0], "Ping")?; +/// assert_eq!(ping.get("id").and_then(|id| id.as_u32()), Some(42)); +/// # Ok::<(), helpers4::kiwi::KiwiError>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Schema { + /// The definitions, in schema order: [`FieldType::Definition`] refers to one by index. + pub definitions: Vec, +} + +impl Schema { + /// Reads a binary Kiwi schema (what Kiwi's `compileSchema` / `encodeBinarySchema` write; in + /// a `.fig` file, the first decompressed chunk). + /// + /// # Arguments + /// + /// - `bytes` - The binary schema. Bytes after the last definition are ignored. + /// + /// # Returns + /// + /// The schema. + /// + /// # Errors + /// + /// A [`KiwiError`] on truncated data, an invalid string, an unknown definition kind, or a + /// field whose type is not a built-in type nor a definition of the schema. + /// + /// # Examples + /// + /// ``` + /// use helpers4::kiwi::{Kind, Schema}; + /// + /// let schema = Schema::parse(b"\x01Ping\0\x02\x01id\0\x07\x00\x01")?; + /// assert_eq!(&*schema.definitions[0].name, "Ping"); + /// assert_eq!(schema.definitions[0].kind, Kind::Message); + /// # Ok::<(), helpers4::kiwi::KiwiError>(()) + /// ``` + pub fn parse(bytes: &[u8]) -> Result { + let mut reader = Reader::new(bytes); + let count = reader.var_uint()? as usize; + // Every definition takes at least three bytes: do not trust a huge count. + let mut raw = Vec::with_capacity(count.min(reader.remaining())); + for _ in 0..count { + raw.push(read_definition(&mut reader)?); + } + let known = raw.len(); + let definitions = raw + .into_iter() + .map(|(name, kind, fields)| { + let fields = fields + .into_iter() + .map(|(name, raw_type, is_array, value)| { + let field_type = raw_type + .map(|type_id| resolve_type(type_id, known)) + .transpose()?; + Ok(Field { + name, + field_type, + is_array, + value, + }) + }) + .collect::, KiwiError>>()?; + Ok(Definition { name, kind, fields }) + }) + .collect::, KiwiError>>()?; + Ok(Self { definitions }) + } + + /// The index of the definition with this name. + /// + /// # Arguments + /// + /// - `name` - The definition name. + /// + /// # Returns + /// + /// The index in [`definitions`](Self::definitions), or `None`. + /// + /// # Examples + /// + /// ``` + /// use helpers4::kiwi::Schema; + /// + /// let schema = Schema::parse(b"\x01Ping\0\x02\x00")?; + /// assert_eq!(schema.find("Ping"), Some(0)); + /// assert_eq!(schema.find("Pong"), None); + /// # Ok::<(), helpers4::kiwi::KiwiError>(()) + /// ``` + #[must_use] + pub fn find(&self, name: &str) -> Option { + self.definitions.iter().position(|d| &*d.name == name) + } + + /// Decodes a message as an instance of one definition (usually a message, the root of the + /// file). Bytes after the value are left alone. + /// + /// # Arguments + /// + /// - `bytes` - The encoded value. + /// - `root` - The name of the definition it is an instance of. + /// + /// # Returns + /// + /// The value: an [`Value::Object`] for a message or a struct, with enums by name and a + /// `byte[]` as [`Value::Bytes`]. + /// + /// # Errors + /// + /// A [`KiwiError`] when `root` is unknown, the data is truncated or invalid (a message with a + /// field id its definition lacks, a bad string), or it nests deeper than 64 levels. + /// + /// # Examples + /// + /// ``` + /// use helpers4::kiwi::{KiwiError, Schema}; + /// + /// let schema = Schema::parse(b"\x01Ping\0\x02\x01id\0\x07\x00\x01")?; + /// assert_eq!(schema.decode(&[1], "Ping"), Err(KiwiError::UnexpectedEof { index: 1 })); + /// assert!(schema.decode(&[0], "Pong").is_err()); + /// # Ok::<(), KiwiError>(()) + /// ``` + pub fn decode(&self, bytes: &[u8], root: &str) -> Result { + let index = self + .find(root) + .ok_or_else(|| KiwiError::UnknownDefinition { + name: root.to_string(), + })?; + self.decode_definition(index, &mut Reader::new(bytes), 0) + } + + fn decode_definition( + &self, + index: usize, + reader: &mut Reader<'_>, + depth: usize, + ) -> Result { + if depth > MAX_DEPTH { + return Err(KiwiError::TooDeep); + } + let Some(definition) = self.definitions.get(index) else { + return Err(KiwiError::UnknownType { + type_id: i32::try_from(index).unwrap_or(i32::MAX), + }); + }; + match definition.kind { + Kind::Enum => { + let raw = reader.var_uint()?; + Ok(definition + .fields + .iter() + .find(|member| member.value == raw) + .map_or(Value::UnknownEnum(raw), |member| { + Value::Enum(member.name.clone()) + })) + } + Kind::Struct => { + let mut fields = Vec::with_capacity(definition.fields.len()); + for field in &definition.fields { + fields.push((field.name.clone(), self.decode_field(field, reader, depth)?)); + } + Ok(Value::Object(fields)) + } + Kind::Message => { + let mut fields = Vec::new(); + loop { + let id = reader.var_uint()?; + if id == 0 { + return Ok(Value::Object(fields)); + } + let field = definition + .fields + .iter() + .find(|field| field.value == id) + .ok_or_else(|| KiwiError::UnknownField { + message: definition.name.to_string(), + id, + })?; + fields.push((field.name.clone(), self.decode_field(field, reader, depth)?)); + } + } + } + } + + fn decode_field( + &self, + field: &Field, + reader: &mut Reader<'_>, + depth: usize, + ) -> Result { + // Enum members have no type; a field built by hand without one reads as a `uint`. + let field_type = field.field_type.unwrap_or(FieldType::Uint); + if !field.is_array { + return self.decode_value(field_type, reader, depth); + } + let length = reader.var_uint()? as usize; + if field_type == FieldType::Byte { + return Ok(Value::Bytes(reader.take(length)?.to_vec())); + } + // Every item takes at least one byte: do not trust a huge length. + let mut items = Vec::with_capacity(length.min(reader.remaining())); + for _ in 0..length { + items.push(self.decode_value(field_type, reader, depth)?); + } + Ok(Value::Array(items)) + } + + fn decode_value( + &self, + field_type: FieldType, + reader: &mut Reader<'_>, + depth: usize, + ) -> Result { + Ok(match field_type { + FieldType::Bool => Value::Bool(reader.byte()? != 0), + FieldType::Byte => Value::Byte(reader.byte()?), + FieldType::Int => Value::Int(reader.var_int()?), + FieldType::Uint => Value::Uint(reader.var_uint()?), + FieldType::Float => Value::Float(reader.var_float()?), + FieldType::String => Value::String(reader.string()?), + FieldType::Int64 => Value::Int64(reader.var_int64()?), + FieldType::Uint64 => Value::Uint64(reader.var_uint64()?), + FieldType::Definition(index) => self.decode_definition(index, reader, depth + 1)?, + }) + } +} + +type RawField = (Arc, Option, bool, u32); + +fn read_definition(reader: &mut Reader<'_>) -> Result<(Arc, Kind, Vec), KiwiError> { + let name: Arc = reader.string()?.into(); + let kind = match reader.byte()? { + 0 => Kind::Enum, + 1 => Kind::Struct, + 2 => Kind::Message, + kind => return Err(KiwiError::UnknownKind { kind }), + }; + let count = reader.var_uint()? as usize; + // Every field takes at least four bytes: do not trust a huge count. + let mut fields = Vec::with_capacity(count.min(reader.remaining())); + for _ in 0..count { + let field_name: Arc = reader.string()?.into(); + let raw_type = reader.var_int()?; + let is_array = reader.byte()? & 1 == 1; + let value = reader.var_uint()?; + // An enum member carries a type number too, but it means nothing. + fields.push(( + field_name, + (kind != Kind::Enum).then_some(raw_type), + is_array, + value, + )); + } + Ok((name, kind, fields)) +} + +fn resolve_type(type_id: i32, definition_count: usize) -> Result { + Ok(match type_id { + -1 => FieldType::Bool, + -2 => FieldType::Byte, + -3 => FieldType::Int, + -4 => FieldType::Uint, + -5 => FieldType::Float, + -6 => FieldType::String, + -7 => FieldType::Int64, + -8 => FieldType::Uint64, + _ => match usize::try_from(type_id) { + Ok(index) if index < definition_count => FieldType::Definition(index), + _ => return Err(KiwiError::UnknownType { type_id }), + }, + }) +} + +#[cfg(test)] +#[path = "schema.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "schema.spec.rs"] +mod spec; diff --git a/src/kiwi/schema.spec.rs b/src/kiwi/schema.spec.rs new file mode 100644 index 0000000..3fb1d88 --- /dev/null +++ b/src/kiwi/schema.spec.rs @@ -0,0 +1,49 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::super::_test_support::*; +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn parse_and_decode_never_panic_on_any_bytes(bytes in prop::collection::vec(any::(), 0..64)) { + let _ = Schema::parse(&bytes); + let schema = Schema::parse(&sample_schema()).unwrap(); + let _ = schema.decode(&bytes, "Msg"); + let _ = schema.decode(&bytes, "Point"); + } + + #[test] + fn a_message_of_scalars_decodes_to_what_was_encoded( + id in any::(), + n in any::(), + big in any::(), + u in any::(), + flag in any::(), + name in "[a-zA-Z0-9 ]{0,12}", + ) { + let mut data = vec![1]; + var_uint(u64::from(id), &mut data); + data.push(2); + data.extend(string(&name)); + data.push(7); + data.extend(var_int64(big)); + data.push(8); + data.push(u8::from(flag)); + data.push(9); + data.extend(var_int(n)); + data.push(10); + data.extend(var_uint64(u)); + data.push(0); + let schema = Schema::parse(&sample_schema()).unwrap(); + let value = schema.decode(&data, "Msg").unwrap(); + prop_assert_eq!(value.get("id").and_then(Value::as_u32), Some(id)); + prop_assert_eq!(value.get("name").and_then(Value::as_str), Some(name.as_str())); + prop_assert_eq!(value.get("big").and_then(Value::as_i64), Some(big)); + prop_assert_eq!(value.get("flag").and_then(Value::as_bool), Some(flag)); + prop_assert_eq!(value.get("n").and_then(Value::as_i32), Some(n)); + prop_assert_eq!(value.get("u").and_then(Value::as_u64), Some(u)); + } +} diff --git a/src/kiwi/schema.test.rs b/src/kiwi/schema.test.rs new file mode 100644 index 0000000..850414f --- /dev/null +++ b/src/kiwi/schema.test.rs @@ -0,0 +1,321 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::super::_test_support::*; +use super::*; + +fn sample() -> Schema { + Schema::parse(&sample_schema()).unwrap() +} + +#[test] +fn parses_definitions_kinds_and_field_types() { + let schema = sample(); + assert_eq!(schema.definitions.len(), 3); + let color = &schema.definitions[0]; + assert_eq!((&*color.name, color.kind), ("Color", Kind::Enum)); + assert_eq!(color.fields[0].field_type, None); + assert_eq!(color.fields[1].value, 2); + let point = &schema.definitions[1]; + assert_eq!(point.kind, Kind::Struct); + assert_eq!(point.fields[0].field_type, Some(FieldType::Float)); + let msg = &schema.definitions[2]; + assert_eq!(msg.kind, Kind::Message); + assert_eq!(msg.fields[2].field_type, Some(FieldType::String)); + assert!(msg.fields[2].is_array); + assert_eq!(msg.fields[3].field_type, Some(FieldType::Definition(0))); + assert_eq!(msg.fields[10].value, 11); +} + +#[test] +fn resolves_every_builtin_type() { + let types = [ + (BOOL, FieldType::Bool), + (BYTE, FieldType::Byte), + (INT, FieldType::Int), + (UINT, FieldType::Uint), + (FLOAT, FieldType::Float), + (STRING, FieldType::String), + (INT64, FieldType::Int64), + (UINT64, FieldType::Uint64), + ]; + for (type_id, expected) in types { + let bytes = schema(&[definition("S", 1, &[field("f", type_id, false, 1)])]); + let parsed = Schema::parse(&bytes).unwrap(); + assert_eq!(parsed.definitions[0].fields[0].field_type, Some(expected)); + } +} + +#[test] +fn finds_a_definition_by_name() { + let schema = sample(); + assert_eq!(schema.find("Color"), Some(0)); + assert_eq!(schema.find("Msg"), Some(2)); + assert_eq!(schema.find("Missing"), None); +} + +#[test] +fn rejects_an_unknown_kind() { + let bytes = schema(&[definition("S", 7, &[])]); + assert_eq!( + Schema::parse(&bytes), + Err(KiwiError::UnknownKind { kind: 7 }) + ); +} + +#[test] +fn rejects_an_unknown_type() { + let past_the_end = schema(&[definition("S", 1, &[field("f", 5, false, 1)])]); + assert_eq!( + Schema::parse(&past_the_end), + Err(KiwiError::UnknownType { type_id: 5 }) + ); + let below_builtins = schema(&[definition("S", 1, &[field("f", -9, false, 1)])]); + assert_eq!( + Schema::parse(&below_builtins), + Err(KiwiError::UnknownType { type_id: -9 }) + ); +} + +#[test] +fn an_enum_member_type_is_not_checked() { + let bytes = schema(&[definition("E", 0, &[field("A", 99, false, 0)])]); + assert!(Schema::parse(&bytes).is_ok()); +} + +#[test] +fn rejects_truncated_and_invalid_schemas() { + let bytes = sample_schema(); + for len in 0..bytes.len() { + assert!(Schema::parse(&bytes[..len]).is_err(), "{len}"); + } + assert_eq!( + Schema::parse(&[1, 0xff, 0]), + Err(KiwiError::InvalidString { index: 1 }) + ); +} + +#[test] +fn a_huge_definition_or_field_count_is_an_error_not_an_allocation() { + assert!(Schema::parse(&[0xff, 0xff, 0xff, 0xff, 0x0f]).is_err()); + let mut bytes = string("S"); + bytes.push(2); + bytes.extend([0xff, 0xff, 0xff, 0xff, 0x0f]); + let mut all = vec![1]; + all.extend(bytes); + assert!(Schema::parse(&all).is_err()); +} + +fn encode_msg() -> Vec { + let mut out = Vec::new(); + out.push(1); // id + var_uint(300, &mut out); + out.push(2); // name + out.extend(string("Frame 1")); + out.push(3); // tags + out.push(2); + out.extend(string("a")); + out.extend(string("b")); + out.push(4); // color + out.push(2); + out.push(5); // origin + out.extend(var_float(1.5)); + out.extend(var_float(0.0)); + out.push(6); // data + out.extend([3, 9, 8, 7]); + out.push(7); // big + out.extend(var_int64(-5_000_000_000)); + out.push(8); // flag + out.push(1); + out.push(9); // n + out.extend(var_int(-7)); + out.push(10); // u + out.extend(var_uint64(u64::MAX)); + out.push(11); // b + out.push(200); + out.push(12); // points + out.push(2); + for (x, y) in [(1.0, 2.0), (-3.0, 0.5)] { + out.extend(var_float(x)); + out.extend(var_float(y)); + } + out.push(0); + out +} + +#[test] +fn decodes_every_kind_of_value() { + let value = sample().decode(&encode_msg(), "Msg").unwrap(); + assert_eq!(value.get("id").and_then(Value::as_u32), Some(300)); + assert_eq!(value.get("name").and_then(Value::as_str), Some("Frame 1")); + assert_eq!( + value.get("tags"), + Some(&Value::Array(vec![ + Value::String("a".into()), + Value::String("b".into()) + ])) + ); + assert_eq!(value.get("color").and_then(Value::as_str), Some("GREEN")); + let origin = value.get("origin").unwrap(); + assert_eq!(origin.get("x").and_then(Value::as_f32), Some(1.5)); + assert_eq!(origin.get("y").and_then(Value::as_f32), Some(0.0)); + assert_eq!( + value.get("data").and_then(Value::as_bytes), + Some(&[9u8, 8, 7][..]) + ); + assert_eq!( + value.get("big").and_then(Value::as_i64), + Some(-5_000_000_000) + ); + assert_eq!(value.get("flag").and_then(Value::as_bool), Some(true)); + assert_eq!(value.get("n").and_then(Value::as_i32), Some(-7)); + assert_eq!(value.get("u").and_then(Value::as_u64), Some(u64::MAX)); + assert_eq!(value.get("b"), Some(&Value::Byte(200))); + let points = value.get("points").unwrap().as_array(); + assert_eq!(points.len(), 2); + assert_eq!(points[1].get("x").and_then(Value::as_f32), Some(-3.0)); + assert_eq!(value.get("nested"), None); +} + +#[test] +fn an_empty_message_has_no_field() { + assert_eq!(sample().decode(&[0], "Msg"), Ok(Value::Object(vec![]))); +} + +#[test] +fn keeps_the_field_order_of_the_data() { + let mut data = vec![2]; + data.extend(string("x")); + data.push(1); + data.push(5); + data.push(0); + let Value::Object(fields) = sample().decode(&data, "Msg").unwrap() else { + panic!("expected an object"); + }; + let names: Vec<&str> = fields.iter().map(|(name, _)| &**name).collect(); + assert_eq!(names, ["name", "id"]); +} + +#[test] +fn an_enum_number_missing_from_the_schema_is_kept() { + let data = [4, 9, 0]; + assert_eq!( + sample().decode(&data, "Msg").unwrap().get("color"), + Some(&Value::UnknownEnum(9)) + ); +} + +#[test] +fn can_decode_a_struct_or_an_enum_as_the_root() { + let mut point = var_float(2.0); + point.extend(var_float(4.0)); + let value = sample().decode(&point, "Point").unwrap(); + assert_eq!(value.get("y").and_then(Value::as_f32), Some(4.0)); + assert_eq!( + sample().decode(&[1], "Color"), + Ok(Value::Enum("RED".into())) + ); +} + +#[test] +fn leaves_trailing_bytes_alone() { + assert!(sample().decode(&[0, 1, 2, 3], "Msg").is_ok()); +} + +#[test] +fn rejects_an_unknown_root() { + assert_eq!( + sample().decode(&[0], "Nope"), + Err(KiwiError::UnknownDefinition { + name: "Nope".into() + }) + ); +} + +#[test] +fn rejects_a_field_id_the_message_does_not_have() { + assert_eq!( + sample().decode(&[99, 0], "Msg"), + Err(KiwiError::UnknownField { + message: "Msg".into(), + id: 99 + }) + ); +} + +#[test] +fn reports_truncated_data_with_its_offset() { + let data = encode_msg(); + let schema = sample(); + for len in 0..data.len() { + assert!(schema.decode(&data[..len], "Msg").is_err(), "{len}"); + } + assert_eq!( + schema.decode(&[1], "Msg"), + Err(KiwiError::UnexpectedEof { index: 1 }) + ); +} + +#[test] +fn a_huge_array_length_is_an_error_not_an_allocation() { + let data = [3, 0xff, 0xff, 0xff, 0xff, 0x0f]; + assert!(sample().decode(&data, "Msg").is_err()); + let bytes = [6, 0xff, 0xff, 0xff, 0xff, 0x0f]; + assert_eq!( + sample().decode(&bytes, "Msg"), + Err(KiwiError::UnexpectedEof { index: 6 }) + ); +} + +#[test] +fn stops_at_the_nesting_limit() { + // `nested` is a Msg inside a Msg: field 13, 100 levels deep. + let data = [13; 100]; + assert_eq!(sample().decode(&data, "Msg"), Err(KiwiError::TooDeep)); + // A few levels are fine. + let mut shallow = vec![13, 13, 13, 0, 0, 0, 0]; + assert!(sample().decode(&shallow, "Msg").is_ok()); + shallow.truncate(2); + assert!(sample().decode(&shallow, "Msg").is_err()); +} + +#[test] +fn a_definition_index_out_of_range_is_an_unknown_type() { + let schema = Schema { + definitions: vec![Definition { + name: "S".into(), + kind: Kind::Struct, + fields: vec![Field { + name: "f".into(), + field_type: Some(FieldType::Definition(9)), + is_array: false, + value: 1, + }], + }], + }; + assert_eq!( + schema.decode(&[], "S"), + Err(KiwiError::UnknownType { type_id: 9 }) + ); +} + +#[test] +fn a_hand_built_field_without_a_type_reads_as_a_uint() { + let schema = Schema { + definitions: vec![Definition { + name: "S".into(), + kind: Kind::Struct, + fields: vec![Field { + name: "f".into(), + field_type: None, + is_array: false, + value: 1, + }], + }], + }; + assert_eq!( + schema.decode(&[7], "S").unwrap().get("f"), + Some(&Value::Uint(7)) + ); +} diff --git a/src/kiwi/value.rs b/src/kiwi/value.rs new file mode 100644 index 0000000..991f5c2 --- /dev/null +++ b/src/kiwi/value.rs @@ -0,0 +1,197 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use std::sync::Arc; + +/// A decoded Kiwi value. Messages and structs are [`Object`](Self::Object)s that keep the field +/// order of the data. +/// +/// The `as_*` accessors return `None` when the value is of another type, so a chain of `get` +/// and `as_*` reads like a path. +/// +/// # Examples +/// +/// ``` +/// use helpers4::kiwi::Value; +/// +/// let node = Value::Object(vec![ +/// ("name".into(), Value::String("Frame 1".to_string())), +/// ("size".into(), Value::Float(10.0)), +/// ]); +/// assert_eq!(node.get("name").and_then(Value::as_str), Some("Frame 1")); +/// assert_eq!(node.get("size").and_then(Value::as_f32), Some(10.0)); +/// assert_eq!(node.get("missing"), None); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Value { + /// A `bool`. + Bool(bool), + /// A `byte`. + Byte(u8), + /// An `int`. + Int(i32), + /// A `uint`. + Uint(u32), + /// A `float`. + Float(f32), + /// A `string`. + String(String), + /// An `int64`. + Int64(i64), + /// A `uint64`. + Uint64(u64), + /// A `byte[]`, kept as bytes. + Bytes(Vec), + /// An enum member, by name. + Enum(Arc), + /// An enum number the schema does not list. + UnknownEnum(u32), + /// A list of values. + Array(Vec), + /// A struct or message: its fields by name, in data order. + Object(Vec<(Arc, Value)>), +} + +impl Value { + /// A field of an object. + /// + /// # Arguments + /// + /// - `name` - The field name. + /// + /// # Returns + /// + /// The field, or `None` when this is not an object or has no such field (a message omits its + /// absent fields). + #[must_use] + pub fn get(&self, name: &str) -> Option<&Self> { + match self { + Self::Object(fields) => fields.iter().find(|(n, _)| &**n == name).map(|(_, v)| v), + _ => None, + } + } + + /// The text of a string or of an enum member name. + /// + /// # Returns + /// + /// The text, or `None` for any other value. + #[must_use] + pub fn as_str(&self) -> Option<&str> { + match self { + Self::String(text) => Some(text), + Self::Enum(name) => Some(name), + _ => None, + } + } + + /// The value of a `bool`. + /// + /// # Returns + /// + /// The boolean, or `None` for any other value. + #[must_use] + pub fn as_bool(&self) -> Option { + match self { + Self::Bool(value) => Some(*value), + _ => None, + } + } + + /// The value of an `int`. + /// + /// # Returns + /// + /// The integer, or `None` for any other value. + #[must_use] + pub fn as_i32(&self) -> Option { + match self { + Self::Int(value) => Some(*value), + _ => None, + } + } + + /// The value of a `uint`. + /// + /// # Returns + /// + /// The integer, or `None` for any other value. + #[must_use] + pub fn as_u32(&self) -> Option { + match self { + Self::Uint(value) => Some(*value), + _ => None, + } + } + + /// The value of an `int64`. + /// + /// # Returns + /// + /// The integer, or `None` for any other value. + #[must_use] + pub fn as_i64(&self) -> Option { + match self { + Self::Int64(value) => Some(*value), + _ => None, + } + } + + /// The value of a `uint64`. + /// + /// # Returns + /// + /// The integer, or `None` for any other value. + #[must_use] + pub fn as_u64(&self) -> Option { + match self { + Self::Uint64(value) => Some(*value), + _ => None, + } + } + + /// The value of a `float`. + /// + /// # Returns + /// + /// The float, or `None` for any other value. + #[must_use] + pub fn as_f32(&self) -> Option { + match self { + Self::Float(value) => Some(*value), + _ => None, + } + } + + /// The bytes of a `byte[]`. + /// + /// # Returns + /// + /// The bytes, or `None` for any other value. + #[must_use] + pub fn as_bytes(&self) -> Option<&[u8]> { + match self { + Self::Bytes(bytes) => Some(bytes), + _ => None, + } + } + + /// The items of an array. + /// + /// # Returns + /// + /// The items, or an empty slice for any other value, so a missing list reads as an empty one. + #[must_use] + pub fn as_array(&self) -> &[Self] { + match self { + Self::Array(items) => items, + _ => &[], + } + } +} + +#[cfg(test)] +#[path = "value.test.rs"] +mod tests; diff --git a/src/kiwi/value.test.rs b/src/kiwi/value.test.rs new file mode 100644 index 0000000..ed5f994 --- /dev/null +++ b/src/kiwi/value.test.rs @@ -0,0 +1,61 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +fn object() -> Value { + Value::Object(vec![ + ("a".into(), Value::Uint(1)), + ("b".into(), Value::String("text".into())), + ]) +} + +#[test] +fn get_finds_a_field_of_an_object() { + assert_eq!(object().get("a"), Some(&Value::Uint(1))); + assert_eq!(object().get("b").and_then(Value::as_str), Some("text")); + assert_eq!(object().get("c"), None); +} + +#[test] +fn get_on_anything_but_an_object_is_none() { + assert_eq!(Value::Uint(1).get("a"), None); + assert_eq!(Value::Array(vec![]).get("a"), None); +} + +#[test] +fn as_str_reads_strings_and_enum_names() { + assert_eq!(Value::String("s".into()).as_str(), Some("s")); + assert_eq!(Value::Enum("RED".into()).as_str(), Some("RED")); + assert_eq!(Value::Uint(1).as_str(), None); +} + +#[test] +fn the_scalar_accessors_only_read_their_own_type() { + assert_eq!(Value::Bool(true).as_bool(), Some(true)); + assert_eq!(Value::Int(-1).as_i32(), Some(-1)); + assert_eq!(Value::Uint(1).as_u32(), Some(1)); + assert_eq!(Value::Int64(-2).as_i64(), Some(-2)); + assert_eq!(Value::Uint64(2).as_u64(), Some(2)); + assert_eq!(Value::Float(0.5).as_f32(), Some(0.5)); + assert_eq!(Value::Bytes(vec![1, 2]).as_bytes(), Some(&[1u8, 2][..])); + + let other = Value::Byte(0); + assert_eq!(other.as_bool(), None); + assert_eq!(other.as_i32(), None); + assert_eq!(other.as_u32(), None); + assert_eq!(other.as_i64(), None); + assert_eq!(other.as_u64(), None); + assert_eq!(other.as_f32(), None); + assert_eq!(other.as_bytes(), None); +} + +#[test] +fn as_array_is_empty_for_anything_but_an_array() { + assert_eq!( + Value::Array(vec![Value::Uint(1)]).as_array(), + [Value::Uint(1)] + ); + assert_eq!(Value::Uint(1).as_array(), []); +} diff --git a/src/lib.rs b/src/lib.rs index e1bfeb9..e52f90b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,6 +53,9 @@ pub mod duration; #[cfg(feature = "env")] pub mod env; +#[cfg(feature = "font")] +pub mod font; + #[cfg(feature = "fs")] pub mod fs; @@ -77,6 +80,9 @@ pub mod id; #[cfg(feature = "iter")] pub mod iter; +#[cfg(feature = "kiwi")] +pub mod kiwi; + #[cfg(feature = "license")] pub mod license; diff --git a/src/string/match_glob_ignore_case.rs b/src/string/match_glob_ignore_case.rs new file mode 100644 index 0000000..d72d29d --- /dev/null +++ b/src/string/match_glob_ignore_case.rs @@ -0,0 +1,39 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::match_glob; + +/// Like [`match_glob`], ignoring case: both sides are compared in Unicode lower case. A character +/// that lower-cases to several (`İ`) counts as several for `?`. +/// +/// # Arguments +/// +/// - `pattern` - The glob pattern. +/// - `text` - The string to test. +/// +/// # Returns +/// +/// `true` if `text` matches `pattern` in full. +/// +/// # Examples +/// +/// ``` +/// use helpers4::string::match_glob_ignore_case; +/// +/// assert!(match_glob_ignore_case("screen *", "Screen 2")); +/// assert!(match_glob_ignore_case("É*", "école")); +/// assert!(!match_glob_ignore_case("screen *", "Home")); +/// ``` +#[must_use] +pub fn match_glob_ignore_case(pattern: &str, text: &str) -> bool { + match_glob(&pattern.to_lowercase(), &text.to_lowercase()) +} + +#[cfg(test)] +#[path = "match_glob_ignore_case.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "match_glob_ignore_case.spec.rs"] +mod spec; diff --git a/src/string/match_glob_ignore_case.spec.rs b/src/string/match_glob_ignore_case.spec.rs new file mode 100644 index 0000000..8425626 --- /dev/null +++ b/src/string/match_glob_ignore_case.spec.rs @@ -0,0 +1,21 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn agrees_with_match_glob_on_lower_case_input(pattern in "[a-z*?]{0,8}", text in "[a-z]{0,8}") { + prop_assert_eq!(match_glob_ignore_case(&pattern, &text), match_glob(&pattern, &text)); + } + + #[test] + fn the_case_of_the_text_never_matters(pattern in "[a-z*?]{0,8}", text in "[a-z]{0,8}") { + prop_assert_eq!( + match_glob_ignore_case(&pattern, &text), + match_glob_ignore_case(&pattern, &text.to_uppercase()) + ); + } +} diff --git a/src/string/match_glob_ignore_case.test.rs b/src/string/match_glob_ignore_case.test.rs new file mode 100644 index 0000000..03704f4 --- /dev/null +++ b/src/string/match_glob_ignore_case.test.rs @@ -0,0 +1,19 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +#[test] +fn ignores_case_on_both_sides() { + assert!(match_glob_ignore_case("screen *", "Screen 2")); + assert!(match_glob_ignore_case("SCREEN *", "screen 2")); + assert!(match_glob_ignore_case("É*", "école")); +} + +#[test] +fn still_matches_in_full() { + assert!(!match_glob_ignore_case("screen", "Screen 2")); + assert!(!match_glob_ignore_case("screen *", "Home")); + assert!(match_glob_ignore_case("frame-?", "FRAME-1")); +} diff --git a/src/string/mod.rs b/src/string/mod.rs index 4d5a1ae..39aec24 100644 --- a/src/string/mod.rs +++ b/src/string/mod.rs @@ -19,6 +19,8 @@ mod kebab_case; mod levenshtein_distance; mod levenshtein_similarity; mod match_glob; +mod match_glob_ignore_case; +mod parse_pattern; mod pascal_case; #[cfg(feature = "string-diacritics")] mod remove_diacritics; @@ -44,6 +46,9 @@ pub use kebab_case::kebab_case; pub use levenshtein_distance::levenshtein_distance; pub use levenshtein_similarity::levenshtein_similarity; pub use match_glob::match_glob; +pub use match_glob_ignore_case::match_glob_ignore_case; +pub use parse_pattern::Pattern; +pub use parse_pattern::parse_pattern; pub use pascal_case::pascal_case; #[cfg(feature = "string-diacritics")] pub use remove_diacritics::remove_diacritics; diff --git a/src/string/parse_pattern.rs b/src/string/parse_pattern.rs new file mode 100644 index 0000000..da6bc67 --- /dev/null +++ b/src/string/parse_pattern.rs @@ -0,0 +1,80 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +/// A pattern read by [`parse_pattern`]: a glob, or a regular expression kept as text for the +/// regex engine of your choice (this crate has none). +/// +/// # Examples +/// +/// ``` +/// use helpers4::string::{parse_pattern, Pattern}; +/// +/// assert_eq!(parse_pattern("SF Pro*"), Pattern::Glob("SF Pro*".to_string())); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Pattern { + /// A glob, to use with [`match_glob`](super::match_glob). + Glob(String), + /// A regular expression written `/source/flags`. + RegExp { + /// The source, without the slashes. + source: String, + /// The flags (any of `d`, `g`, `i`, `m`, `s`, `u`, `v`, `y`), possibly empty. + flags: String, + }, +} + +/// Reads a setting that is either a regular expression or a glob: `/source/flags` is a regular +/// expression when every flag is valid (`d`, `g`, `i`, `m`, `s`, `u`, `v`, `y`), anything else is +/// a glob. For font substitution rules, ignore lists, filters. Note that `/usr/lib/img` is the +/// regular expression `usr/lib` with the flags `img`: for paths, build a [`Pattern::Glob`] +/// yourself. +/// +/// The regular expression is returned as text, since this crate has no regex engine; the source +/// is not validated. +/// +/// # Arguments +/// +/// - `pattern` - A `/source/flags` regular expression or a glob. +/// +/// # Returns +/// +/// The [`Pattern`]. +/// +/// # Examples +/// +/// ``` +/// use helpers4::string::{parse_pattern, Pattern}; +/// +/// assert_eq!( +/// parse_pattern("/^SF Pro/i"), +/// Pattern::RegExp { source: "^SF Pro".to_string(), flags: "i".to_string() } +/// ); +/// assert_eq!(parse_pattern("/src/*.ts/x"), Pattern::Glob("/src/*.ts/x".to_string())); +/// ``` +#[must_use] +pub fn parse_pattern(pattern: &str) -> Pattern { + pattern + .strip_prefix('/') + .and_then(|rest| rest.rsplit_once('/')) + .filter(|(source, flags)| { + !source.is_empty() && flags.chars().all(|c| "dgimsuvy".contains(c)) + }) + .map_or_else( + || Pattern::Glob(pattern.to_string()), + |(source, flags)| Pattern::RegExp { + source: source.to_string(), + flags: flags.to_string(), + }, + ) +} + +#[cfg(test)] +#[path = "parse_pattern.test.rs"] +mod tests; + +#[cfg(test)] +#[path = "parse_pattern.spec.rs"] +mod spec; diff --git a/src/string/parse_pattern.spec.rs b/src/string/parse_pattern.spec.rs new file mode 100644 index 0000000..13ac7d7 --- /dev/null +++ b/src/string/parse_pattern.spec.rs @@ -0,0 +1,16 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn never_panics_and_a_regular_expression_reassembles(pattern in "\\PC{0,20}") { + match parse_pattern(&pattern) { + Pattern::RegExp { source, flags } => prop_assert_eq!(format!("/{source}/{flags}"), pattern), + Pattern::Glob(glob) => prop_assert_eq!(glob, pattern), + } + } +} diff --git a/src/string/parse_pattern.test.rs b/src/string/parse_pattern.test.rs new file mode 100644 index 0000000..5b650a2 --- /dev/null +++ b/src/string/parse_pattern.test.rs @@ -0,0 +1,40 @@ +// This file is part of helpers4. +// Copyright (C) 2025 baxyz +// SPDX-License-Identifier: LGPL-3.0-or-later + +use super::*; + +fn regexp(source: &str, flags: &str) -> Pattern { + Pattern::RegExp { + source: source.to_string(), + flags: flags.to_string(), + } +} + +#[test] +fn reads_source_and_flags_as_a_regular_expression() { + assert_eq!(parse_pattern("/^SF Pro/i"), regexp("^SF Pro", "i")); + assert_eq!(parse_pattern("/a.c/"), regexp("a.c", "")); + assert_eq!(parse_pattern("/a/b/gi"), regexp("a/b", "gi")); +} + +#[test] +fn reads_anything_else_as_a_glob() { + assert_eq!( + parse_pattern("SF Pro*"), + Pattern::Glob("SF Pro*".to_string()) + ); + assert_eq!(parse_pattern(""), Pattern::Glob(String::new())); + assert_eq!( + parse_pattern("/src/*.ts"), + Pattern::Glob("/src/*.ts".to_string()) + ); + assert_eq!(parse_pattern("/a/x"), Pattern::Glob("/a/x".to_string())); +} + +#[test] +fn needs_a_source_between_the_slashes() { + assert_eq!(parse_pattern("//"), Pattern::Glob("//".to_string())); + assert_eq!(parse_pattern("/"), Pattern::Glob("/".to_string())); + assert_eq!(parse_pattern("//i"), Pattern::Glob("//i".to_string())); +}