Skip to content

Commit f9be5ab

Browse files
committed
correct font spacing for truetype fonts
1 parent bd5e30c commit f9be5ab

2 files changed

Lines changed: 178 additions & 17 deletions

File tree

src/sketch/truetype.rs

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use geo::{
88
orient::Direction,
99
};
1010
use std::fmt::Debug;
11-
use ttf_parser::OutlineBuilder;
11+
use ttf_parser::{Face, GlyphId, OutlineBuilder};
1212
use ttf_utils::Outline;
1313

1414
// For flattening curves, how many segments per quad/cubic
@@ -21,7 +21,7 @@ impl<S: Clone + Debug + Send + Sync> Sketch<S> {
2121
/// and any open contours become `LineString`s.
2222
///
2323
/// # Arguments
24-
/// - `text`: the text string (no multiline logic here)
24+
/// - `text`: the text string
2525
/// - `font_data`: raw bytes of a TTF file
2626
/// - `scale`: a uniform scale factor for glyphs
2727
/// - `metadata`: optional metadata for the resulting `Sketch`
@@ -42,29 +42,62 @@ impl<S: Clone + Debug + Send + Sync> Sketch<S> {
4242
},
4343
};
4444

45-
// 1 font unit, 2048 font units / em, scale points / em, 0.352777 points / mm
46-
let font_scale = 1.0 / 2048.0 * scale * 0.3527777;
45+
// Treat `scale` as points-per-em and convert points to millimeters.
46+
let units_per_em = face.units_per_em() as Real;
47+
if units_per_em <= 0.0 || !scale.is_finite() {
48+
return Sketch::new();
49+
}
50+
let font_scale = scale * 0.3527777 / units_per_em;
51+
let default_advance = default_advance(&face, font_scale);
52+
let line_advance = line_advance(&face, font_scale);
53+
let tab_advance = default_advance * 4.0;
4754

4855
// 2) We'll collect all glyph geometry into one GeometryCollection
4956
let mut geo_coll = GeometryCollection::default();
5057

5158
// 3) A simple "pen" cursor for horizontal text layout
5259
let mut cursor_x = 0.0 as Real;
60+
let mut cursor_y = 0.0 as Real;
61+
let mut previous_glyph = None;
5362

5463
for ch in text.chars() {
55-
// Skip control chars:
56-
if ch.is_control() {
57-
continue;
64+
match ch {
65+
'\n' => {
66+
cursor_x = 0.0;
67+
cursor_y -= line_advance;
68+
previous_glyph = None;
69+
continue;
70+
},
71+
'\r' => {
72+
cursor_x = 0.0;
73+
previous_glyph = None;
74+
continue;
75+
},
76+
'\t' => {
77+
cursor_x += tab_advance;
78+
previous_glyph = None;
79+
continue;
80+
},
81+
ch if ch.is_control() => {
82+
previous_glyph = None;
83+
continue;
84+
},
85+
_ => {},
5886
}
5987

6088
// Find glyph index in the font
6189
if let Some(gid) = face.glyph_index(ch) {
90+
if let Some(previous) = previous_glyph {
91+
cursor_x += glyph_pair_kerning(&face, previous, gid) * font_scale;
92+
}
93+
6294
// Extract the glyph outline (if any)
6395
if let Some(outline) = Outline::new(&face, gid) {
6496
// Flatten the outline into line segments
6597
let mut collector =
66-
OutlineFlattener::new(font_scale as Real, cursor_x as Real, 0.0);
98+
OutlineFlattener::new(font_scale as Real, cursor_x as Real, cursor_y);
6799
outline.emit(&mut collector);
100+
collector.finish_open_subpath();
68101

69102
// Now `collector.contours` holds closed subpaths,
70103
// and `collector.open_contours` holds open polylines.
@@ -132,17 +165,14 @@ impl<S: Clone + Debug + Send + Sync> Sketch<S> {
132165
}
133166
}
134167

135-
// Finally, advance our pen by the glyph's bounding-box width
136-
let bbox = outline.bbox();
137-
let glyph_width = bbox.width() as Real * font_scale;
138-
cursor_x += glyph_width;
139-
} else {
140-
// If there's no outline (e.g., space), just move a bit
141-
cursor_x += font_scale as Real * 0.3;
142168
}
169+
170+
cursor_x += glyph_advance(&face, gid, font_scale, default_advance);
171+
previous_glyph = Some(gid);
143172
} else {
144173
// Missing glyph => small blank advance
145-
cursor_x += font_scale as Real * 0.3;
174+
cursor_x += default_advance;
175+
previous_glyph = None;
146176
}
147177
}
148178

@@ -151,6 +181,104 @@ impl<S: Clone + Debug + Send + Sync> Sketch<S> {
151181
}
152182
}
153183

184+
fn glyph_advance(
185+
face: &Face<'_>,
186+
glyph_id: GlyphId,
187+
font_scale: Real,
188+
default_advance: Real,
189+
) -> Real {
190+
face.glyph_hor_advance(glyph_id)
191+
.map(|advance| advance as Real * font_scale)
192+
.filter(|advance| advance.is_finite() && *advance >= 0.0)
193+
.unwrap_or(default_advance)
194+
}
195+
196+
fn default_advance(face: &Face<'_>, font_scale: Real) -> Real {
197+
face.glyph_index(' ')
198+
.and_then(|glyph_id| face.glyph_hor_advance(glyph_id))
199+
.map(|advance| advance as Real * font_scale)
200+
.filter(|advance| advance.is_finite() && *advance > 0.0)
201+
.unwrap_or_else(|| face.units_per_em() as Real * font_scale * 0.5)
202+
}
203+
204+
fn line_advance(face: &Face<'_>, font_scale: Real) -> Real {
205+
let height = face.height() as Real;
206+
let line_gap = face.line_gap() as Real;
207+
let advance = (height + line_gap).max(face.units_per_em() as Real) * font_scale;
208+
if advance.is_finite() && advance > 0.0 {
209+
advance
210+
} else {
211+
face.units_per_em() as Real * font_scale
212+
}
213+
}
214+
215+
fn glyph_pair_kerning(face: &Face<'_>, left: GlyphId, right: GlyphId) -> Real {
216+
let gpos_adjustment = gpos_pair_adjustment(face, left, right);
217+
if gpos_adjustment != 0.0 {
218+
return gpos_adjustment;
219+
}
220+
221+
kern_pair_adjustment(face, left, right)
222+
}
223+
224+
fn kern_pair_adjustment(face: &Face<'_>, left: GlyphId, right: GlyphId) -> Real {
225+
let Some(kern) = face.tables().kern else {
226+
return 0.0;
227+
};
228+
229+
kern.subtables
230+
.into_iter()
231+
.filter(|subtable| {
232+
subtable.horizontal && !subtable.has_cross_stream && !subtable.has_state_machine
233+
})
234+
.filter_map(|subtable| subtable.glyphs_kerning(left, right))
235+
.map(|value| value as Real)
236+
.sum()
237+
}
238+
239+
fn gpos_pair_adjustment(face: &Face<'_>, left: GlyphId, right: GlyphId) -> Real {
240+
let Some(gpos) = face.tables().gpos else {
241+
return 0.0;
242+
};
243+
244+
let mut adjustment = 0.0;
245+
246+
for lookup in gpos.lookups {
247+
for subtable in lookup
248+
.subtables
249+
.into_iter::<ttf_parser::gpos::PositioningSubtable>()
250+
{
251+
let ttf_parser::gpos::PositioningSubtable::Pair(pair) = subtable else {
252+
continue;
253+
};
254+
255+
adjustment += match pair {
256+
ttf_parser::gpos::PairAdjustment::Format1 { coverage, sets } => coverage
257+
.get(left)
258+
.and_then(|index| sets.get(index))
259+
.and_then(|set| set.get(right))
260+
.map(|(left_value, right_value)| {
261+
left_value.x_advance as Real + right_value.x_placement as Real
262+
})
263+
.unwrap_or(0.0),
264+
ttf_parser::gpos::PairAdjustment::Format2 {
265+
classes, matrix, ..
266+
} => {
267+
let class_pair = (classes.0.get(left), classes.1.get(right));
268+
matrix
269+
.get(class_pair)
270+
.map(|(left_value, right_value)| {
271+
left_value.x_advance as Real + right_value.x_placement as Real
272+
})
273+
.unwrap_or(0.0)
274+
},
275+
};
276+
}
277+
}
278+
279+
adjustment
280+
}
281+
154282
/// A helper that implements `ttf_parser::OutlineBuilder`.
155283
/// It receives MoveTo/LineTo/QuadTo/CurveTo calls from `outline.emit(self)`.
156284
/// We flatten curves and accumulate polylines.
@@ -209,7 +337,7 @@ impl OutlineFlattener {
209337

210338
/// Finish the current subpath as open (do not close).
211339
/// (We call this if a new `MoveTo` or the entire glyph ends.)
212-
fn _finish_open_subpath(&mut self) {
340+
fn finish_open_subpath(&mut self) {
213341
if self.subpath_open && !self.current.is_empty() {
214342
self.open_contours.push(self.current.clone());
215343
}

src/tests/csg_tests.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,39 @@ fn test_csg_text() {
430430
assert!(!text_csg.geometry.is_empty());
431431
}
432432

433+
#[cfg(feature = "truetype-text")]
434+
#[test]
435+
fn test_truetype_text_spacing_and_line_breaks() {
436+
let font_data = include_bytes!("../../asar.ttf");
437+
438+
let compact: Sketch<()> = Sketch::text("AA", font_data, 20.0, None);
439+
let spaced: Sketch<()> = Sketch::text("A A", font_data, 20.0, None);
440+
let stacked: Sketch<()> = Sketch::text("A\nA", font_data, 20.0, None);
441+
442+
let compact_bb = compact.bounding_box();
443+
let spaced_bb = spaced.bounding_box();
444+
let stacked_bb = stacked.bounding_box();
445+
446+
let compact_width = compact_bb.maxs.x - compact_bb.mins.x;
447+
let spaced_width = spaced_bb.maxs.x - spaced_bb.mins.x;
448+
let stacked_width = stacked_bb.maxs.x - stacked_bb.mins.x;
449+
let compact_height = compact_bb.maxs.y - compact_bb.mins.y;
450+
let stacked_height = stacked_bb.maxs.y - stacked_bb.mins.y;
451+
452+
assert!(
453+
spaced_width > compact_width,
454+
"space glyph advance should widen text layout"
455+
);
456+
assert!(
457+
stacked_width < compact_width,
458+
"newline should reset the horizontal pen"
459+
);
460+
assert!(
461+
stacked_height > compact_height,
462+
"newline should advance to a new baseline"
463+
);
464+
}
465+
433466
#[test]
434467
fn test_csg_to_trimesh() {
435468
let cube: Mesh<()> = Mesh::cube(2.0, None);

0 commit comments

Comments
 (0)