Skip to main content

ratex_render/
renderer.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use ab_glyph::{Font, FontRef};
5use ratex_font::FontId;
6use ratex_types::color::Color;
7use ratex_types::display_item::{DisplayItem, DisplayList};
8use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
9
10pub struct RenderOptions {
11    pub font_size: f32,
12    pub padding: f32,
13    pub font_dir: String,
14    /// Multiplies pixels-per-em (and padding) so the same layout renders at higher resolution
15    /// (e.g. 2.0 to align RaTeX PNG pixel density with Puppeteer `deviceScaleFactor: 2` refs).
16    pub device_pixel_ratio: f32,
17}
18
19impl Default for RenderOptions {
20    fn default() -> Self {
21        Self {
22            font_size: 40.0,
23            padding: 10.0,
24            font_dir: String::new(),
25            device_pixel_ratio: 1.0,
26        }
27    }
28}
29
30pub fn render_to_png(
31    display_list: &DisplayList,
32    options: &RenderOptions,
33) -> Result<Vec<u8>, String> {
34    let em = options.font_size;
35    let pad = options.padding;
36    let dpr = options.device_pixel_ratio.clamp(0.01, 16.0);
37    let em_px = em * dpr;
38    let pad_px = pad * dpr;
39
40    let total_h = display_list.height + display_list.depth;
41    let img_w = (display_list.width as f32 * em_px + 2.0 * pad_px).ceil() as u32;
42    let img_h = (total_h as f32 * em_px + 2.0 * pad_px).ceil() as u32;
43
44    let img_w = img_w.max(1);
45    let img_h = img_h.max(1);
46
47    let mut pixmap = Pixmap::new(img_w, img_h)
48        .ok_or_else(|| format!("Failed to create pixmap {}x{}", img_w, img_h))?;
49
50    pixmap.fill(tiny_skia::Color::WHITE);
51
52    let font_data = load_all_fonts(&options.font_dir)?;
53    let font_cache = build_font_cache(&font_data)?;
54
55    for item in &display_list.items {
56        match item {
57            DisplayItem::GlyphPath {
58                x,
59                y,
60                scale,
61                font,
62                char_code,
63                commands: _,
64                color,
65            } => {
66                let glyph_em = em_px * *scale as f32;
67                render_glyph(
68                    &mut pixmap,
69                    *x as f32 * em_px + pad_px,
70                    *y as f32 * em_px + pad_px,
71                    font,
72                    *char_code,
73                    color,
74                    &font_cache,
75                    glyph_em,
76                );
77            }
78            DisplayItem::Line {
79                x,
80                y,
81                width,
82                thickness,
83                color,
84            } => {
85                render_line(
86                    &mut pixmap,
87                    *x as f32 * em_px + pad_px,
88                    *y as f32 * em_px + pad_px,
89                    *width as f32 * em_px,
90                    *thickness as f32 * em_px,
91                    color,
92                );
93            }
94            DisplayItem::Rect {
95                x,
96                y,
97                width,
98                height,
99                color,
100            } => {
101                render_rect(
102                    &mut pixmap,
103                    *x as f32 * em_px + pad_px,
104                    *y as f32 * em_px + pad_px,
105                    *width as f32 * em_px,
106                    *height as f32 * em_px,
107                    color,
108                );
109            }
110            DisplayItem::Path {
111                x,
112                y,
113                commands,
114                fill,
115                color,
116            } => {
117                render_path(
118                    &mut pixmap,
119                    *x as f32 * em_px + pad_px,
120                    *y as f32 * em_px + pad_px,
121                    commands,
122                    *fill,
123                    color,
124                    em_px,
125                    1.5 * dpr,
126                );
127            }
128        }
129    }
130
131    encode_png(&pixmap)
132}
133
134fn load_all_fonts(font_dir: &str) -> Result<HashMap<FontId, Vec<u8>>, String> {
135    let mut data = HashMap::new();
136    let font_map = [
137        (FontId::MainRegular, "KaTeX_Main-Regular.ttf"),
138        (FontId::MainBold, "KaTeX_Main-Bold.ttf"),
139        (FontId::MainItalic, "KaTeX_Main-Italic.ttf"),
140        (FontId::MainBoldItalic, "KaTeX_Main-BoldItalic.ttf"),
141        (FontId::MathItalic, "KaTeX_Math-Italic.ttf"),
142        (FontId::MathBoldItalic, "KaTeX_Math-BoldItalic.ttf"),
143        (FontId::AmsRegular, "KaTeX_AMS-Regular.ttf"),
144        (FontId::CaligraphicRegular, "KaTeX_Caligraphic-Regular.ttf"),
145        (FontId::FrakturRegular, "KaTeX_Fraktur-Regular.ttf"),
146        (FontId::SansSerifRegular, "KaTeX_SansSerif-Regular.ttf"),
147        (FontId::SansSerifBold, "KaTeX_SansSerif-Bold.ttf"),
148        (FontId::SansSerifItalic, "KaTeX_SansSerif-Italic.ttf"),
149        (FontId::ScriptRegular, "KaTeX_Script-Regular.ttf"),
150        (FontId::TypewriterRegular, "KaTeX_Typewriter-Regular.ttf"),
151        (FontId::Size1Regular, "KaTeX_Size1-Regular.ttf"),
152        (FontId::Size2Regular, "KaTeX_Size2-Regular.ttf"),
153        (FontId::Size3Regular, "KaTeX_Size3-Regular.ttf"),
154        (FontId::Size4Regular, "KaTeX_Size4-Regular.ttf"),
155    ];
156
157    let dir = Path::new(font_dir);
158    for (id, filename) in &font_map {
159        let path = dir.join(filename);
160        if path.exists() {
161            let bytes = std::fs::read(&path)
162                .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
163            data.insert(*id, bytes);
164        }
165    }
166
167    if data.is_empty() {
168        return Err(format!("No fonts found in {}", font_dir));
169    }
170
171    Ok(data)
172}
173
174fn build_font_cache(data: &HashMap<FontId, Vec<u8>>) -> Result<HashMap<FontId, FontRef<'_>>, String> {
175    let mut cache = HashMap::new();
176    for (id, bytes) in data {
177        let font = FontRef::try_from_slice(bytes)
178            .map_err(|e| format!("Failed to parse font {:?}: {}", id, e))?;
179        cache.insert(*id, font);
180    }
181    Ok(cache)
182}
183
184#[allow(clippy::too_many_arguments)]
185fn render_glyph(
186    pixmap: &mut Pixmap,
187    px: f32,
188    py: f32,
189    font_name: &str,
190    char_code: u32,
191    color: &Color,
192    font_cache: &HashMap<FontId, FontRef<'_>>,
193    em: f32,
194) {
195    let font_id = FontId::parse(font_name).unwrap_or(FontId::MainRegular);
196    let font = match font_cache.get(&font_id) {
197        Some(f) => f,
198        None => match font_cache.get(&FontId::MainRegular) {
199            Some(f) => f,
200            None => return,
201        },
202    };
203
204    let ch = char::from_u32(char_code).unwrap_or('?');
205    let glyph_id = font.glyph_id(ch);
206
207    if glyph_id.0 == 0 {
208        if let Some(fallback) = font_cache.get(&FontId::MainRegular) {
209            let fid = fallback.glyph_id(ch);
210            if fid.0 != 0 {
211                return render_glyph_with_font(pixmap, px, py, fallback, fid, color, em);
212            }
213        }
214        return;
215    }
216
217    render_glyph_with_font(pixmap, px, py, font, glyph_id, color, em);
218}
219
220fn render_glyph_with_font(
221    pixmap: &mut Pixmap,
222    px: f32,
223    py: f32,
224    font: &FontRef<'_>,
225    glyph_id: ab_glyph::GlyphId,
226    color: &Color,
227    em: f32,
228) {
229    let outline = match font.outline(glyph_id) {
230        Some(o) => o,
231        None => return,
232    };
233
234    let units_per_em = font.units_per_em().unwrap_or(1000.0);
235    let scale = em / units_per_em;
236
237    let mut builder = PathBuilder::new();
238    let mut last_end: Option<(f32, f32)> = None;
239
240    for curve in &outline.curves {
241        use ab_glyph::OutlineCurve;
242        let (start, end) = match curve {
243            OutlineCurve::Line(p0, p1) => {
244                let sx = px + p0.x * scale;
245                let sy = py - p0.y * scale;
246                let ex = px + p1.x * scale;
247                let ey = py - p1.y * scale;
248                ((sx, sy), (ex, ey))
249            }
250            OutlineCurve::Quad(p0, _, p2) => {
251                let sx = px + p0.x * scale;
252                let sy = py - p0.y * scale;
253                let ex = px + p2.x * scale;
254                let ey = py - p2.y * scale;
255                ((sx, sy), (ex, ey))
256            }
257            OutlineCurve::Cubic(p0, _, _, p3) => {
258                let sx = px + p0.x * scale;
259                let sy = py - p0.y * scale;
260                let ex = px + p3.x * scale;
261                let ey = py - p3.y * scale;
262                ((sx, sy), (ex, ey))
263            }
264        };
265
266        // New contour if start doesn't match previous end
267        let need_move = match last_end {
268            None => true,
269            Some((lx, ly)) => (lx - start.0).abs() > 0.01 || (ly - start.1).abs() > 0.01,
270        };
271
272        if need_move {
273            if last_end.is_some() {
274                builder.close();
275            }
276            builder.move_to(start.0, start.1);
277        }
278
279        match curve {
280            OutlineCurve::Line(_, p1) => {
281                builder.line_to(px + p1.x * scale, py - p1.y * scale);
282            }
283            OutlineCurve::Quad(_, p1, p2) => {
284                builder.quad_to(
285                    px + p1.x * scale,
286                    py - p1.y * scale,
287                    px + p2.x * scale,
288                    py - p2.y * scale,
289                );
290            }
291            OutlineCurve::Cubic(_, p1, p2, p3) => {
292                builder.cubic_to(
293                    px + p1.x * scale,
294                    py - p1.y * scale,
295                    px + p2.x * scale,
296                    py - p2.y * scale,
297                    px + p3.x * scale,
298                    py - p3.y * scale,
299                );
300            }
301        }
302
303        last_end = Some(end);
304    }
305
306    if last_end.is_some() {
307        builder.close();
308    }
309
310    if let Some(path) = builder.finish() {
311        let mut paint = Paint::default();
312        paint.set_color_rgba8(
313            (color.r * 255.0) as u8,
314            (color.g * 255.0) as u8,
315            (color.b * 255.0) as u8,
316            255,
317        );
318        paint.anti_alias = true;
319        pixmap.fill_path(
320            &path,
321            &paint,
322            tiny_skia::FillRule::EvenOdd,
323            Transform::identity(),
324            None,
325        );
326    }
327}
328
329fn render_line(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, thickness: f32, color: &Color) {
330    let t = thickness.max(1.0);
331    let rect = tiny_skia::Rect::from_xywh(x, y - t / 2.0, width, t);
332    if let Some(rect) = rect {
333        let mut paint = Paint::default();
334        paint.set_color_rgba8(
335            (color.r * 255.0) as u8,
336            (color.g * 255.0) as u8,
337            (color.b * 255.0) as u8,
338            255,
339        );
340        pixmap.fill_rect(rect, &paint, Transform::identity(), None);
341    }
342}
343
344fn render_rect(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, height: f32, color: &Color) {
345    let rect = tiny_skia::Rect::from_xywh(x, y, width, height);
346    if let Some(rect) = rect {
347        let mut paint = Paint::default();
348        paint.set_color_rgba8(
349            (color.r * 255.0) as u8,
350            (color.g * 255.0) as u8,
351            (color.b * 255.0) as u8,
352            255,
353        );
354        pixmap.fill_rect(rect, &paint, Transform::identity(), None);
355    }
356}
357
358#[allow(clippy::too_many_arguments)]
359fn render_path(
360    pixmap: &mut Pixmap,
361    x: f32,
362    y: f32,
363    commands: &[ratex_types::path_command::PathCommand],
364    fill: bool,
365    color: &Color,
366    em: f32,
367    stroke_width_px: f32,
368) {
369    // For filled paths, render each subpath (delimited by MoveTo) as a separate
370    // fill_path call.  KaTeX stretchy arrows are assembled from multiple path
371    // components (e.g. "lefthook" + "rightarrow") whose winding directions can
372    // be opposite.  Combining them into a single fill_path with FillRule::Winding
373    // causes the shaft region to cancel out (net winding = 0 → unfilled).
374    // Drawing each subpath independently avoids cross-component winding interactions.
375        if fill {
376            let mut start = 0;
377            for i in 1..commands.len() {
378                if matches!(commands[i], ratex_types::path_command::PathCommand::MoveTo { .. }) {
379                    render_path_segment(pixmap, x, y, &commands[start..i], fill, color, em, stroke_width_px);
380                    start = i;
381                }
382            }
383            render_path_segment(pixmap, x, y, &commands[start..], fill, color, em, stroke_width_px);
384            return;
385        }
386        render_path_segment(pixmap, x, y, commands, fill, color, em, stroke_width_px);
387}
388
389#[allow(clippy::too_many_arguments)]
390fn render_path_segment(
391    pixmap: &mut Pixmap,
392    x: f32,
393    y: f32,
394    commands: &[ratex_types::path_command::PathCommand],
395    fill: bool,
396    color: &Color,
397    em: f32,
398    stroke_width_px: f32,
399) {
400    let mut builder = PathBuilder::new();
401    for cmd in commands {
402        match cmd {
403            ratex_types::path_command::PathCommand::MoveTo { x: cx, y: cy } => {
404                builder.move_to(x + *cx as f32 * em, y + *cy as f32 * em);
405            }
406            ratex_types::path_command::PathCommand::LineTo { x: cx, y: cy } => {
407                builder.line_to(x + *cx as f32 * em, y + *cy as f32 * em);
408            }
409            ratex_types::path_command::PathCommand::CubicTo {
410                x1,
411                y1,
412                x2,
413                y2,
414                x: cx,
415                y: cy,
416            } => {
417                builder.cubic_to(
418                    x + *x1 as f32 * em,
419                    y + *y1 as f32 * em,
420                    x + *x2 as f32 * em,
421                    y + *y2 as f32 * em,
422                    x + *cx as f32 * em,
423                    y + *cy as f32 * em,
424                );
425            }
426            ratex_types::path_command::PathCommand::QuadTo { x1, y1, x: cx, y: cy } => {
427                builder.quad_to(
428                    x + *x1 as f32 * em,
429                    y + *y1 as f32 * em,
430                    x + *cx as f32 * em,
431                    y + *cy as f32 * em,
432                );
433            }
434            ratex_types::path_command::PathCommand::Close => {
435                builder.close();
436            }
437        }
438    }
439
440    if let Some(path) = builder.finish() {
441        let mut paint = Paint::default();
442        paint.set_color_rgba8(
443            (color.r * 255.0) as u8,
444            (color.g * 255.0) as u8,
445            (color.b * 255.0) as u8,
446            255,
447        );
448        if fill {
449            paint.anti_alias = true;
450            pixmap.fill_path(
451                &path,
452                &paint,
453                FillRule::Winding,
454                Transform::identity(),
455                None,
456            );
457        } else {
458            let stroke = Stroke {
459                width: stroke_width_px,
460                ..Default::default()
461            };
462            pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
463        }
464    }
465}
466
467fn encode_png(pixmap: &Pixmap) -> Result<Vec<u8>, String> {
468    let mut buf = Vec::new();
469    {
470        let mut encoder = png::Encoder::new(&mut buf, pixmap.width(), pixmap.height());
471        encoder.set_color(png::ColorType::Rgba);
472        encoder.set_depth(png::BitDepth::Eight);
473        let mut writer = encoder
474            .write_header()
475            .map_err(|e| format!("PNG header error: {}", e))?;
476        writer
477            .write_image_data(pixmap.data())
478            .map_err(|e| format!("PNG write error: {}", e))?;
479    }
480    Ok(buf)
481}