Skip to main content

oxitext_sdf/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3//! `oxitext-sdf` — Signed Distance Field glyph atlas generation for OxiText.
4//!
5//! Provides a pure-Rust implementation of the Felzenszwalb-Huttenlocher EDT
6//! for computing signed distance fields from glyph coverage bitmaps, an
7//! atlas packer for producing GPU-ready SDF texture atlases, and a multi-channel
8//! SDF (MSDF) pipeline that generates 3-channel distance fields directly from
9//! glyph outlines for sharper rendering at large magnifications.
10//!
11//! # Quick start (single-channel SDF)
12//!
13//! ```rust
14//! use oxitext_sdf::{compute_sdf, SdfAtlas, SdfTile};
15//!
16//! // A solid 32×32 square (inside everywhere).
17//! let coverage = vec![255u8; 32 * 32];
18//! let sdf = compute_sdf(&coverage, 32, 32, 8.0, 0).expect("compute_sdf");
19//! assert_eq!(sdf.len(), 32 * 32);
20//!
21//! // Pack a single tile into an atlas.
22//! let tile = SdfTile {
23//!     glyph_id: 0,
24//!     width: 32,
25//!     height: 32,
26//!     data: sdf,
27//!     bearing_x: 0,
28//!     bearing_y: 0,
29//!     advance_x: 32.0,
30//! };
31//! let atlas = SdfAtlas::pack(&[tile]);
32//! assert!(atlas.uv_map.contains_key(&0));
33//! ```
34
35pub mod analytic;
36mod atlas;
37pub mod build_helper;
38mod convert;
39mod edt;
40mod gpu;
41pub mod msdf;
42pub mod psdf;
43
44pub use analytic::glyph_to_sdf_tile_analytic;
45pub use atlas::{
46    pack_growing, AtlasOptions, AtlasStats, MsdfAtlas, MultiPageAtlas, PackingAlgorithm, SdfAtlas,
47    SdfTile, UvRect,
48};
49pub use build_helper::{generate_ascii_atlas, generate_atlas_binary};
50pub use convert::bitmap_to_sdf_tile;
51pub use edt::{compute_sdf, SdfError};
52pub use gpu::{AtlasGlyphMetrics, GpuAtlasDescriptor, GpuAtlasFormat, NormalizedUvRect};
53pub use msdf::{
54    color_edges, compute_msdf, compute_mtsdf, extract_glyph_shape, glyph_to_msdf_tile,
55    glyph_to_mtsdf_tile, EdgeColor, GlyphShape, MsdfTile, MtsdfTile,
56};
57pub use psdf::{glyph_to_psdf_tile, PsdfTile};
58
59// ─── Bilinear resampling ──────────────────────────────────────────────────────
60
61/// Sample a float-valued image at fractional coordinates using bilinear interpolation.
62///
63/// `u` and `v` are pixel-space coordinates (not normalised UV).  Values are
64/// clamped to the image bounds.
65fn bilinear_sample(src: &[f32], src_w: usize, src_h: usize, u: f32, v: f32) -> f32 {
66    let x = u.clamp(0.0, (src_w as f32) - 1.0);
67    let y = v.clamp(0.0, (src_h as f32) - 1.0);
68    let x0 = x.floor() as usize;
69    let y0 = y.floor() as usize;
70    let x1 = (x0 + 1).min(src_w - 1);
71    let y1 = (y0 + 1).min(src_h - 1);
72    let fx = x - x.floor();
73    let fy = y - y.floor();
74    let s00 = src[y0 * src_w + x0];
75    let s10 = src[y0 * src_w + x1];
76    let s01 = src[y1 * src_w + x0];
77    let s11 = src[y1 * src_w + x1];
78    s00 * (1.0 - fx) * (1.0 - fy) + s10 * fx * (1.0 - fy) + s01 * (1.0 - fx) * fy + s11 * fx * fy
79}
80
81// ─── Public API ───────────────────────────────────────────────────────────────
82
83/// Generate a signed-distance-field tile for a single glyph.
84///
85/// Input: a grayscale coverage bitmap (fontdue/ab_glyph output,
86/// 0 = outside, 255 = maximum inside coverage).
87///
88/// Output: an SDF bitmap of size `tile_size × tile_size`, where:
89/// - pixel value `< 128` = outside the outline,
90/// - pixel value `≈ 128` = near the outline (the 0.5 isovalue),
91/// - pixel value `> 128` = inside the outline.
92///
93/// The SDF is computed at the source resolution (`src_width × src_height`)
94/// and then the tile is returned at `tile_size × tile_size`. When the source
95/// dimensions match `tile_size`, the result is returned directly. Otherwise
96/// bilinear resampling is applied.
97///
98/// The default spread (maximum SDF distance) is `8.0` pixels.
99///
100/// # Errors
101/// Propagates [`SdfError::InvalidInput`] if the input is malformed.
102pub fn glyph_to_sdf_tile(
103    coverage: &[u8],
104    src_width: usize,
105    src_height: usize,
106    tile_size: u32,
107) -> Result<Vec<u8>, SdfError> {
108    let tile = tile_size as usize;
109
110    // Compute SDF at source resolution (no padding).
111    let sdf_src = compute_sdf(coverage, src_width, src_height, 8.0, 0)?;
112
113    // If dimensions already match, return directly.
114    if src_width == tile && src_height == tile {
115        return Ok(sdf_src);
116    }
117
118    // Convert u8 SDF to f32 for bilinear sampling.
119    let sdf_f32: Vec<f32> = sdf_src.iter().map(|&v| v as f32).collect();
120
121    // Bilinear resample to tile_size × tile_size.
122    let mut out = vec![0u8; tile * tile];
123    for ty in 0..tile {
124        for tx in 0..tile {
125            // Map destination pixel centre to source pixel space.
126            let u = (tx as f32 + 0.5) * src_width as f32 / tile as f32 - 0.5;
127            let v = (ty as f32 + 0.5) * src_height as f32 / tile as f32 - 0.5;
128            let val = bilinear_sample(&sdf_f32, src_width, src_height, u, v);
129            out[ty * tile + tx] = val.clamp(0.0, 255.0).round() as u8;
130        }
131    }
132    Ok(out)
133}
134
135#[cfg(test)]
136mod bench_tests;
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_bilinear_sample_corner_values() {
144        // A 2×2 grid: top-left=0, top-right=255, bottom-left=128, bottom-right=64
145        let grid = [0.0f32, 255.0, 128.0, 64.0];
146        let width = 2usize;
147        let height = 2usize;
148        // Sample exact corners — should return exact values
149        let tl = bilinear_sample(&grid, width, height, 0.0, 0.0);
150        let tr = bilinear_sample(&grid, width, height, 1.0, 0.0);
151        assert!((tl - 0.0).abs() < 1.0, "top-left should be ~0, got {tl}");
152        assert!(
153            (tr - 255.0).abs() < 1.0,
154            "top-right should be ~255, got {tr}"
155        );
156    }
157
158    #[test]
159    fn test_bilinear_sample_midpoint_interpolation() {
160        // Uniform grid should return the same value anywhere
161        let grid = vec![100.0f32; 4];
162        let v = bilinear_sample(&grid, 2, 2, 0.5, 0.5);
163        assert!(
164            (v - 100.0).abs() < 0.01,
165            "uniform grid midpoint should be 100.0, got {v}"
166        );
167    }
168
169    #[test]
170    fn test_bilinear_sample_clamps_out_of_bounds() {
171        // Out-of-bounds coordinates should clamp to nearest edge
172        let grid = [10.0f32, 20.0, 30.0, 40.0];
173        let tl = bilinear_sample(&grid, 2, 2, -1.0, -1.0);
174        assert!(
175            (tl - 10.0).abs() < 0.01,
176            "negative coords should clamp to top-left, got {tl}"
177        );
178        let br = bilinear_sample(&grid, 2, 2, 100.0, 100.0);
179        assert!(
180            (br - 40.0).abs() < 0.01,
181            "large coords should clamp to bottom-right, got {br}"
182        );
183    }
184}