Skip to content

Commit eeff6de

Browse files
committed
Add bezier-curve SDF generation via ttf-parser (research branch for #16)
Add an alternative vector-based SDF generation path that computes signed distance fields directly from font bezier curve outlines, rather than from rasterized bitmaps. This improves rendering quality for complex scripts (Indic, Khmer, Burmese) where rasterization loses information. New modules in sdf_glyph_renderer: - outline.rs: Glyph outline data structures with bezier curve flattening - outline_sdf.rs: SDF algorithm using R-tree spatial indexing + winding number - ttf.rs: ttf-parser integration via OutlineBuilder trait - types.rs: Shared SdfGlyph/GlyphMetrics types (extracted from ft.rs) New in pbf_font_tools: - ttf_generate.rs: ttf-parser glyph generation pipeline (parallel to ft_generate.rs) CLI changes: - build_pbf_glyphs --backend bitmap|bezier flag to select SDF generation method New dependencies: ttf-parser (pure Rust font parser), rstar (R-tree spatial index) Both gated behind the "ttf-parser" feature flag. https://claude.ai/code/session_01MZQnJnULX397TRkQ6vpJms
1 parent 4aac362 commit eeff6de

16 files changed

Lines changed: 1019 additions & 61 deletions

File tree

Cargo.lock

Lines changed: 63 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ prost = "0.14.3"
1818
prost-build = "0.14.3"
1919
protobuf-src = "2.1.1"
2020
protoc-bin-vendored = "3.2.0"
21+
rstar = "0.12"
2122
sdf_glyph_renderer = { path = "sdf_glyph_renderer" }
2223
serde_json = "1.0.149"
2324
spmc = "0.3.0"
2425
thiserror = "2.0.18"
2526
tokio = { version = "1.50.0", features = ["rt", "fs", "io-util"] }
27+
ttf-parser = "0.25"

build_pbf_glyphs/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ protoc-vendored = ["pbf_font_tools/protoc-vendored"]
1818

1919
[dependencies]
2020
clap.workspace = true
21-
pbf_font_tools = { workspace = true, features = ["freetype"] }
21+
pbf_font_tools = { workspace = true, features = ["freetype", "ttf-parser"] }
2222
prost.workspace = true
2323
serde_json.workspace = true
2424
spmc.workspace = true

build_pbf_glyphs/src/main.rs

Lines changed: 117 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
//! [sdf_glyph_renderer](https://github.com/stadiamaps/sdf_glyph_renderer) for more technical
1111
//! details on how this works.
1212
//!
13+
//! NOTE: The default `bitmap` backend uses FreeType (bundled from source by default).
14+
//! The `bezier` backend uses a pure Rust font parser and does not require FreeType.
1315
//! ## Usage
1416
//!
1517
//! This tool will create `out_dir` if necessary, and will put each range (of 256 glyphs, for
@@ -18,6 +20,7 @@
1820
//!
1921
//! ```
2022
//! $ build_pbf_glyphs /path/to/font_dir /path/to/out_dir
23+
//! $ build_pbf_glyphs --backend bezier /path/to/font_dir /path/to/out_dir
2124
//! ```
2225
2326
use std::collections::HashMap;
@@ -28,8 +31,9 @@ use std::thread;
2831
use std::time::Instant;
2932
use tokio::fs::{create_dir_all, File};
3033

31-
use clap::Parser;
34+
use clap::{Parser, ValueEnum};
3235
use pbf_font_tools::freetype::{Face, Library};
36+
use pbf_font_tools::ttf_parser;
3337
use pbf_font_tools::{get_named_font_stack, glyph_range_for_face, Glyphs};
3438
use prost::Message;
3539
use spmc::{channel, Receiver};
@@ -38,6 +42,14 @@ use tokio::task::spawn_blocking;
3842

3943
static TOTAL_GLYPHS_RENDERED: AtomicUsize = AtomicUsize::new(0);
4044

45+
#[derive(Copy, Clone, Debug, ValueEnum)]
46+
enum Backend {
47+
/// Bitmap-based SDF using FreeType rasterization (default, fast).
48+
Bitmap,
49+
/// Vector-based SDF using bezier curves from font outlines (higher quality for complex scripts).
50+
Bezier,
51+
}
52+
4153
#[derive(Parser, Debug)]
4254
#[command(version, author, about)]
4355
struct Args {
@@ -51,6 +63,9 @@ struct Args {
5163
/// Overwrites existing glyphs. By default, glyph generation will be skipped for any range with a matching file in the output directory. Note that the contents of the file are not inspected; only the name.
5264
#[arg(long)]
5365
overwrite: bool,
66+
/// SDF generation backend to use.
67+
#[arg(long, value_enum, default_value_t = Backend::Bitmap)]
68+
backend: Backend,
5469
}
5570

5671
/// Combines glyphs for all fonts listed in `font_names` in `font_path` into a single stack
@@ -95,12 +110,8 @@ async fn combine_glyphs(font_path: &Path, font_names: &[&str], stack_name: Strin
95110
);
96111
}
97112

98-
/// A worker function that converts a font to a set of SDF glyphs.
99-
///
100-
/// The glyphs are output as a set of files in a directory where each file contains
101-
/// exactly 256 glyphs and is named like so: `<base_out_dir>/<font name>/<start>-<end>.pbf`
102-
/// where the start and end numbers represent the Unicode code point.
103-
fn render_worker(
113+
/// A worker function that converts a font to a set of SDF glyphs using FreeType (bitmap backend).
114+
fn render_worker_bitmap(
104115
base_out_dir: &Path,
105116
overwrite: bool,
106117
radius: usize,
@@ -115,10 +126,6 @@ fn render_worker(
115126

116127
println!("Processing {}", path.display());
117128

118-
// Load the font once to save useless I/O
119-
// FIXME: lib.new_face is called twice for face_index=0
120-
// instead, call it once, create a pre-allocated vector of faces for num_faces count
121-
// add the already open 0th, and add all remaining ones to it
122129
let face = lib.new_face(&path, 0).expect("Unable to load font");
123130
let num_faces = face.num_faces() as usize;
124131
let faces: Vec<Face> = (0..num_faces)
@@ -177,21 +184,118 @@ fn render_worker(
177184
}
178185
}
179186

187+
/// A worker function that converts a font to a set of SDF glyphs using ttf-parser (bezier backend).
188+
fn render_worker_bezier(
189+
base_out_dir: &Path,
190+
overwrite: bool,
191+
radius: usize,
192+
cutoff: f64,
193+
rx: Receiver<Option<(PathBuf, PathBuf)>>,
194+
) {
195+
use pbf_font_tools::glyph_range_for_face_ttf;
196+
197+
while let Ok(Some((path, stem))) = rx.recv() {
198+
let out_dir = base_out_dir.join(stem.to_str().expect("Unable to extract file stem"));
199+
std::fs::create_dir_all(&out_dir).expect("Unable to create output directory");
200+
201+
println!("Processing {} (bezier)", path.display());
202+
203+
let font_data = std::fs::read(&path).expect("Unable to read font file");
204+
let num_faces = ttf_parser::fonts_in_collection(&font_data).unwrap_or(1);
205+
206+
let mut start = 0u32;
207+
let mut end = 255u32;
208+
let mut glyphs_rendered = 0;
209+
let mut glyphs_skipped = 0;
210+
let path_str = path
211+
.to_str()
212+
.expect("Unable to convert path to a valid UTF-8 string.");
213+
214+
while start < 65536 {
215+
let glyph_path = out_dir.join(format!("{start}-{end}.pbf"));
216+
if !overwrite && glyph_path.exists() {
217+
glyphs_skipped += 256;
218+
} else {
219+
let mut glyphs = Glyphs::default();
220+
221+
for face_index in 0..num_faces {
222+
match ttf_parser::Face::parse(&font_data, face_index) {
223+
Ok(face) => {
224+
if let Ok(stack) = glyph_range_for_face_ttf(
225+
&face, start, end, 24.0, radius, cutoff,
226+
) {
227+
glyphs_rendered += stack.glyphs.len();
228+
glyphs.stacks.push(stack);
229+
} else {
230+
println!(
231+
"ERROR: Failed to render fontstack for face {face_index} in {path_str}",
232+
);
233+
}
234+
}
235+
Err(e) => {
236+
println!(
237+
"ERROR: Failed to parse face {face_index} in {path_str}: {e}",
238+
);
239+
}
240+
}
241+
}
242+
243+
let encoded_bytes = glyphs.encode_to_vec();
244+
let mut file = std::fs::File::create(glyph_path).expect("Unable to create file");
245+
file.write_all(&encoded_bytes)
246+
.expect("Unable to write to file");
247+
}
248+
249+
start += 256;
250+
end += 256;
251+
}
252+
253+
if glyphs_skipped > 0 {
254+
println!("Skipped up to {glyphs_skipped} glyphs in {path_str}");
255+
}
256+
if glyphs_skipped != 65536 {
257+
println!(
258+
"Found {glyphs_rendered} valid glyphs across {num_faces} face(s) in {path_str}"
259+
);
260+
}
261+
262+
TOTAL_GLYPHS_RENDERED.fetch_add(glyphs_rendered, Ordering::Relaxed);
263+
}
264+
}
265+
180266
fn main() {
181267
let args = Args::parse();
182268

183269
let font_dir = &args.font_dir;
184270
let out_dir = &args.out_dir;
271+
let backend = args.backend;
185272

186273
let (mut tx, rx) = channel();
187274
let num_threads = thread::available_parallelism().unwrap().get();
188-
println!("Starting {num_threads} worker threads...");
275+
println!(
276+
"Starting {num_threads} worker threads (backend: {})...",
277+
match backend {
278+
Backend::Bitmap => "bitmap",
279+
Backend::Bezier => "bezier",
280+
}
281+
);
189282

190283
let join_handles: Vec<_> = (0..num_threads)
191284
.map(|_| {
192285
let out_dir = out_dir.clone();
193286
let rx = rx.clone();
194-
thread::spawn(move || render_worker(&out_dir, args.overwrite, 8, 0.25, rx))
287+
match backend {
288+
Backend::Bitmap => {
289+
thread::spawn(move || {
290+
render_worker_bitmap(&out_dir, args.overwrite, 8, 0.25, rx)
291+
})
292+
}
293+
Backend::Bezier => {
294+
thread::spawn(move || {
295+
render_worker_bezier(&out_dir, args.overwrite, 8, 0.25, rx)
296+
})
297+
}
298+
}
195299
})
196300
.collect();
197301

@@ -234,11 +338,6 @@ fn main() {
234338
}
235339

236340
if let Some(path) = args.combinations_path {
237-
// Async code, as necessary. Most of the rest of the code is actually truly blocking
238-
// since it's calling C libs or compute-heavy functions. Glyph combination however
239-
// happens to actually leverage async I/O, so we fire up a runtime here. It makes
240-
// the rest of the code simpler in this case to isolate the async code esp as it isn't
241-
// in the normal execution path.
242341
tokio::runtime::Builder::new_multi_thread()
243342
.enable_all()
244343
.build()

pbf_font_tools/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ license.workspace = true
1515
default = ["protoc-vendored"]
1616
freetype = ["dep:sdf_glyph_renderer", "sdf_glyph_renderer?/freetype"]
1717
freetype-system = ["dep:sdf_glyph_renderer", "sdf_glyph_renderer?/freetype-system"]
18+
ttf-parser = ["dep:sdf_glyph_renderer", "sdf_glyph_renderer?/ttf-parser"]
1819
protoc-from-src = ["dep:protobuf-src"]
1920
protoc-vendored = ["dep:protoc-bin-vendored"]
2021

pbf_font_tools/src/error.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ pub enum PbfFontError {
44
JoinError(#[from] tokio::task::JoinError),
55
#[error("Protobuf decoding error: {0}")]
66
ProtobufError(#[from] prost::DecodeError),
7-
#[cfg(feature = "freetype")]
7+
#[cfg(any(feature = "freetype", feature = "ttf-parser"))]
88
#[error("SDF glyph error: {0}")]
99
SdfGlyphError(#[from] sdf_glyph_renderer::SdfGlyphError),
1010
#[error("Font family name is not set")]
1111
MissingFontFamilyName,
1212
#[cfg(feature = "freetype")]
1313
#[error("Freetype error: {0}")]
1414
FreetypeError(#[from] crate::freetype::Error),
15+
#[error("ttf-parser error: {0}")]
16+
TtfParserError(String),
1517
#[error(transparent)]
1618
IoError(#[from] std::io::Error),
1719
}

0 commit comments

Comments
 (0)