Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -29,13 +29,16 @@ date = []
duration = []
env = []
fs = []
font = []
function = []
future = []
geometry = []
hex = []
http = []
id = ["random"]
iter = []
# Built on the byte reader.
kiwi = ["bytes"]
license = []
map = []
markdown = []
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
19 changes: 14 additions & 5 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,33 @@
`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`,
`days_in_month`
- 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
injected clock)
- 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`
Expand All @@ -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`
Expand All @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions scopes.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"deps",
"duration",
"env",
"font",
"fs",
"function",
"future",
Expand All @@ -22,6 +23,7 @@
"http",
"id",
"iter",
"kiwi",
"license",
"map",
"markdown",
Expand Down
94 changes: 94 additions & 0 deletions src/bytes/media_type_of.rs
Original file line number Diff line number Diff line change
@@ -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 `<svg` element: a byte order mark, whitespace, an XML
/// declaration, comments and a doctype.
fn svg_body(text: &str) -> &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("<svg"))
&& body[4..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_whitespace() || c == '>' || 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 `<svg`, possibly after a byte order mark, whitespace, an XML
/// declaration, comments or a doctype.
///
/// # Arguments
///
/// - `bytes` - The start of the file: 512 bytes are enough for every type but a long SVG prolog.
///
/// # Returns
///
/// The media type (`"image/png"`, ...), or `None` when it is none of these.
///
/// # Examples
///
/// ```
/// use helpers4::bytes::media_type_of;
///
/// assert_eq!(media_type_of(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]), Some("image/png"));
/// assert_eq!(media_type_of(b"<svg xmlns='http://www.w3.org/2000/svg'/>"), 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;
22 changes: 22 additions & 0 deletions src/bytes/media_type_of.spec.rs
Original file line number Diff line number Diff line change
@@ -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::<u8>(), 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::<u8>(), 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"));
}
}
75 changes: 75 additions & 0 deletions src/bytes/media_type_of.test.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
[b"RIFF\0\0\0\0".as_slice(), tag.as_bytes()].concat()
}

fn ftyp(brand: &str) -> Vec<u8> {
[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"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"),
Some("image/svg+xml")
);
assert_eq!(media_type_of(b"<svg>"), Some("image/svg+xml"));
assert_eq!(media_type_of(b"<svg/>"), Some("image/svg+xml"));
assert_eq!(
media_type_of(
"\u{feff} \n<?xml version=\"1.0\"?>\n<!-- made by hand -->\n<!DOCTYPE svg PUBLIC \"x\" \"y\">\n<SVG/>"
.as_bytes()
),
Some("image/svg+xml")
);
}

#[test]
fn does_not_take_other_text_or_markup_for_svg() {
assert_eq!(media_type_of(b"<html><svg/></html>"), None);
assert_eq!(media_type_of(b"<svgx/>"), None);
assert_eq!(media_type_of(b"<svg"), None);
assert_eq!(media_type_of(b"<?xml never closed"), None);
assert_eq!(media_type_of(b"hello"), None);
assert_eq!(media_type_of(&[0xff, 0xfe, b'<']), None);
}

#[test]
fn survives_a_multi_byte_character_cut_at_the_limit() {
let mut data = b"<svg>".to_vec();
data.resize(2047, b' ');
data.extend("é".as_bytes());
assert_eq!(media_type_of(&data), Some("image/svg+xml"));
}
Loading
Loading