Annotate fonts with ruby (pinyin/romaji) and produce modified TTF/WOFF2 outputs.
1use std::sync::atomic::Ordering;
2
3use atomic_float::AtomicF64;
4use fontcull_read_fonts::FontRef;
5use fontcull_skrifa::{GlyphId, MetadataProvider, instance::Size};
6use kurbo::{BezPath, Shape};
7
8use crate::renderer::RubyPosition;
9
10pub type GlyphPaths = Vec<(GlyphId, BezPath)>;
11
12/// Collect glyph paths; returns None if any glyph cannot be found or drawn.
13pub fn collect_glyph_paths(font: &FontRef, text: String) -> Option<GlyphPaths> {
14 let cmap = font.charmap();
15 let outlines = font.outline_glyphs();
16
17 let mut glyph_paths: Vec<(GlyphId, BezPath)> = Vec::new();
18
19 for pc in text.chars() {
20 match cmap.map(pc) {
21 Some(pgid) if pgid.to_u32() != 0 => {
22 if let Some(pglyph) = outlines.get(pgid) {
23 let mut ppen = crate::PathPen::new();
24 let res = pglyph.draw(Size::unscaled(), &mut ppen);
25
26 if res.is_ok() {
27 glyph_paths.push((pgid, ppen.path));
28 } else {
29 return None;
30 }
31 } else {
32 return None;
33 }
34 }
35 _ => return None,
36 }
37 }
38
39 Some(glyph_paths)
40}
41
42/// Compute widths for each text given a closure to get advance (in font units).
43pub fn compute_glyph_widths(
44 glyph_paths: &GlyphPaths,
45 p_scale_factor: f64,
46 mut get_adv: impl FnMut(GlyphId) -> f64,
47) -> Vec<f64> {
48 let mut text_widths: Vec<f64> = Vec::new();
49
50 for (pgid, _) in glyph_paths {
51 let mut text_width = 0.0;
52 let adv = get_adv(*pgid);
53 text_width += adv * p_scale_factor;
54 text_widths.push(text_width);
55 }
56
57 text_widths
58}
59
60/// Render top/bottom annotated text into `final_path`.
61#[allow(clippy::too_many_arguments)]
62pub fn render_top_bottom(
63 final_path: &mut BezPath,
64 glyph_paths: GlyphPaths,
65 text_widths: &[f64],
66 p_scale_factor: f64,
67 main_upem: f64,
68 orig_advance: f64,
69 position: RubyPosition,
70 gutter_em: f64,
71 baseline_offset_em: f64,
72 tight: bool,
73 cached_top: &AtomicF64,
74 cached_bottom: &AtomicF64,
75 mut get_adv: impl FnMut(GlyphId) -> f64,
76) {
77 let total_width = text_widths.iter().sum::<f64>();
78
79 let bbox = final_path.bounding_box();
80 let gutter_units = gutter_em * main_upem;
81 let approx_height = main_upem * (p_scale_factor * (1.0 / (p_scale_factor.max(0.00001)))) * 0.8; // conservative
82
83 let baseline_offset_units = baseline_offset_em * main_upem;
84
85 // Measure min/max y of the pinyin glyphs in unscaled font units
86
87 let mut min_y: f64 = f64::INFINITY;
88 let mut max_y: f64 = f64::NEG_INFINITY;
89
90 for (_pgid, p_path) in &glyph_paths {
91 for el in p_path.elements() {
92 match el {
93 kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => {
94 min_y = min_y.min(p.y);
95 max_y = max_y.max(p.y);
96 }
97 kurbo::PathEl::QuadTo(p1, p2) => {
98 min_y = min_y.min(p1.y).min(p2.y);
99 max_y = max_y.max(p1.y).max(p2.y);
100 }
101 kurbo::PathEl::CurveTo(p1, p2, p3) => {
102 min_y = min_y.min(p1.y).min(p2.y).min(p3.y);
103 max_y = max_y.max(p1.y).max(p2.y).max(p3.y);
104 }
105 kurbo::PathEl::ClosePath => {}
106 }
107 }
108 }
109
110 if !min_y.is_finite() {
111 min_y = 0.0;
112 }
113 if !max_y.is_finite() {
114 max_y = approx_height / p_scale_factor;
115 }
116
117 let min_y_scaled = min_y * p_scale_factor;
118 let max_y_scaled = max_y * p_scale_factor;
119
120 let required_top_target = bbox.y1 + gutter_units + baseline_offset_units - min_y_scaled;
121 let required_bottom_target = bbox.y0 - gutter_units - baseline_offset_units - max_y_scaled;
122
123 let target_y = if tight {
124 if position == RubyPosition::Top {
125 bbox.y1 + gutter_units
126 } else {
127 bbox.y0 - gutter_units - approx_height
128 }
129 } else {
130 if position == RubyPosition::Top {
131 cached_top
132 .fetch_max(required_top_target, Ordering::Relaxed)
133 .max(required_top_target)
134 } else {
135 cached_bottom
136 .fetch_min(required_bottom_target, Ordering::Relaxed)
137 .min(required_bottom_target)
138 }
139 };
140
141 let mut current_x = (orig_advance - total_width) / 2.0;
142
143 for (pgid, mut p_path) in glyph_paths.into_iter() {
144 let xform =
145 kurbo::Affine::translate((current_x, target_y)) * kurbo::Affine::scale(p_scale_factor);
146
147 p_path.apply_affine(xform);
148
149 for el in p_path.elements() {
150 match el {
151 kurbo::PathEl::MoveTo(p) => final_path.move_to(*p),
152 kurbo::PathEl::LineTo(p) => final_path.line_to(*p),
153 kurbo::PathEl::QuadTo(p1, p2) => final_path.quad_to(*p1, *p2),
154 kurbo::PathEl::CurveTo(p1, p2, p3) => final_path.curve_to(*p1, *p2, *p3),
155 kurbo::PathEl::ClosePath => final_path.close_path(),
156 }
157 }
158
159 let adv = get_adv(pgid);
160 current_x += adv * p_scale_factor;
161 }
162}
163
164/// Render side-positioned annotations (left/right, up/down stacking)
165#[allow(clippy::too_many_arguments)]
166pub fn render_side(
167 final_path: &mut BezPath,
168 glyph_paths: &GlyphPaths,
169 p_scale_factor: f64,
170 main_upem: f64,
171 orig_advance: f64,
172 position: RubyPosition,
173 gutter_em: f64,
174 bbox_center_y: f64,
175 get_adv: &mut impl FnMut(GlyphId) -> f64,
176) {
177 let mut glyph_list: Vec<(f64, BezPath)> = Vec::new();
178
179 for (pgid, p_path) in glyph_paths {
180 let adv = get_adv(*pgid);
181 glyph_list.push((adv * p_scale_factor, p_path.clone()));
182 }
183
184 if glyph_list.is_empty() {
185 return;
186 }
187
188 let max_glyph_width = glyph_list.iter().map(|(w, _)| *w).fold(0.0f64, f64::max);
189 let vertical_step = main_upem * p_scale_factor * 0.8;
190 let gutter_units = gutter_em * main_upem;
191
192 let start_x = match position {
193 RubyPosition::LeftDown | RubyPosition::LeftUp => -(max_glyph_width + gutter_units),
194 _ => orig_advance + gutter_units,
195 };
196
197 let n = glyph_list.len() as f64;
198 let mut current_y = match position {
199 RubyPosition::LeftDown | RubyPosition::RightDown => {
200 bbox_center_y + ((n - 1.0) / 2.0) * vertical_step
201 }
202 _ => bbox_center_y - ((n - 1.0) / 2.0) * vertical_step,
203 };
204
205 for (w, mut p_path) in glyph_list {
206 let tx = start_x + (max_glyph_width - w) / 2.0;
207
208 let xform =
209 kurbo::Affine::translate((tx, current_y)) * kurbo::Affine::scale(p_scale_factor);
210
211 p_path.apply_affine(xform);
212
213 for el in p_path.elements() {
214 match el {
215 kurbo::PathEl::MoveTo(p) => final_path.move_to(*p),
216 kurbo::PathEl::LineTo(p) => final_path.line_to(*p),
217 kurbo::PathEl::QuadTo(p1, p2) => final_path.quad_to(*p1, *p2),
218 kurbo::PathEl::CurveTo(p1, p2, p3) => final_path.curve_to(*p1, *p2, *p3),
219 kurbo::PathEl::ClosePath => final_path.close_path(),
220 }
221 }
222
223 match position {
224 RubyPosition::LeftDown | RubyPosition::RightDown => current_y -= vertical_step,
225 _ => current_y += vertical_step,
226 }
227 }
228}