Skip to main content

ratex_pdf/
renderer.rs

1//! Core rendering: convert a [`DisplayList`] into PDF bytes via pdf-writer.
2//!
3//! Two-pass architecture:
4//! 1. Collect all glyphs used across the display list.
5//! 2. Subset & embed fonts, then write the content stream.
6
7use std::collections::HashMap;
8
9use pdf_writer::{Content, Filter, Finish, Name, Pdf, Rect, Ref, Str};
10use ratex_font::FontId;
11use ratex_types::color::Color;
12use ratex_types::display_item::{DisplayItem, DisplayList};
13use ratex_types::path_command::PathCommand;
14
15use crate::fonts::{self, EmbeddedFont};
16
17/// Options controlling PDF output.
18///
19/// If the crate is built **without** `embed-fonts`, you must set [`Self::font_dir`] to a directory
20/// of KaTeX `.ttf` files before calling [`render_to_pdf`]. [`Self::default`] leaves `font_dir`
21/// empty on purpose so callers do not assume a magic path. With `embed-fonts`, `font_dir` is
22/// ignored (glyphs come from `ratex-katex-fonts`).
23#[derive(Debug, Clone)]
24pub struct PdfOptions {
25    /// User units per em. Default: 40.
26    pub font_size: f64,
27    /// Padding on all sides, in user units. Default: 10.
28    pub padding: f64,
29    /// Stroke width for unfilled paths, in user units. Default: 1.5.
30    pub stroke_width: f64,
31    /// Directory containing KaTeX `.ttf` files. Used only when **`embed-fonts` is disabled**;
32    /// otherwise ignored.
33    pub font_dir: String,
34}
35
36impl Default for PdfOptions {
37    /// Numeric fields match typical CLI defaults. `font_dir` is empty: set it unless using
38    /// `embed-fonts`.
39    fn default() -> Self {
40        Self {
41            font_size: 40.0,
42            padding: 10.0,
43            stroke_width: 1.5,
44            font_dir: String::new(),
45        }
46    }
47}
48
49/// Errors that can occur during PDF rendering.
50#[derive(Debug)]
51pub enum PdfError {
52    Font(String),
53    Render(String),
54}
55
56impl std::fmt::Display for PdfError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            PdfError::Font(s) => write!(f, "Font error: {s}"),
60            PdfError::Render(s) => write!(f, "Render error: {s}"),
61        }
62    }
63}
64
65impl std::error::Error for PdfError {}
66
67/// Render a [`DisplayList`] to a PDF byte buffer.
68pub fn render_to_pdf(
69    display_list: &DisplayList,
70    options: &PdfOptions,
71) -> Result<Vec<u8>, PdfError> {
72    let em = options.font_size;
73    let pad = options.padding;
74    let sw = options.stroke_width;
75
76    let total_h = display_list.height + display_list.depth;
77    let page_w = display_list.width * em + 2.0 * pad;
78    let page_h = total_h * em + 2.0 * pad;
79
80    // Load raw font data.
81    let font_data = fonts::load_all_fonts(&options.font_dir).map_err(PdfError::Font)?;
82
83    // Pass 1: collect glyph usage.
84    let usages = fonts::collect_glyph_usage(&display_list.items, &font_data);
85
86    // Build the PDF.
87    let mut pdf = Pdf::new();
88    let mut alloc = Ref::new(1);
89
90    let catalog_ref = alloc.bump();
91    let pages_ref = alloc.bump();
92    let page_ref = alloc.bump();
93    let content_ref = alloc.bump();
94
95    // Pass 2: embed fonts.
96    let embedded = fonts::embed_fonts(&mut pdf, &mut alloc, &usages, &font_data)
97        .map_err(PdfError::Font)?;
98
99
100    // Build lookup: FontId → EmbeddedFont index.
101    let font_index: HashMap<FontId, usize> = embedded
102        .iter()
103        .enumerate()
104        .map(|(i, ef)| (ef.font_id, i))
105        .collect();
106
107    // Generate content stream.
108    let content_bytes = build_content_stream(
109        &display_list.items,
110        &embedded,
111        &font_index,
112        &font_data,
113        em,
114        pad,
115        page_h,
116        sw,
117    );
118
119    // Compress content stream.
120    let compressed = miniz_oxide::deflate::compress_to_vec_zlib(&content_bytes, 6);
121
122    // Write content stream object.
123    let mut stream = pdf.stream(content_ref, &compressed);
124    stream.filter(Filter::FlateDecode);
125    stream.pair(Name(b"Length1"), content_bytes.len() as i32);
126    stream.finish();
127
128    // Page object.
129    let mut page = pdf.page(page_ref);
130    page.parent(pages_ref);
131    page.media_box(Rect::new(0.0, 0.0, page_w as f32, page_h as f32));
132    page.contents(content_ref);
133
134    // Page Resources: font dictionary.
135    let mut resources = page.resources();
136    let mut font_dict = resources.fonts();
137    for ef in &embedded {
138        font_dict.pair(Name(ef.res_name.as_bytes()), ef.type0_ref);
139    }
140    font_dict.finish();
141    resources.finish();
142    page.finish();
143
144    // Pages node.
145    let mut pages = pdf.pages(pages_ref);
146    pages.count(1);
147    pages.kids([page_ref]);
148    pages.finish();
149
150    // Catalog.
151    pdf.catalog(catalog_ref).pages(pages_ref);
152
153    Ok(pdf.finish())
154}
155
156// ---------------------------------------------------------------------------
157// Content stream generation
158// ---------------------------------------------------------------------------
159
160#[allow(clippy::too_many_arguments)]
161fn build_content_stream(
162    items: &[DisplayItem],
163    embedded: &[EmbeddedFont],
164    font_index: &HashMap<FontId, usize>,
165    font_data: &fonts::RawFontData,
166    em: f64,
167    pad: f64,
168    page_h: f64,
169    stroke_width: f64,
170) -> Vec<u8> {
171    let mut content = Content::new();
172
173    for item in items {
174        match item {
175            DisplayItem::GlyphPath {
176                x,
177                y,
178                scale,
179                font,
180                char_code,
181                color,
182                ..
183            } => {
184                emit_glyph(
185                    &mut content,
186                    *x * em + pad,
187                    *y * em + pad,
188                    font,
189                    *char_code,
190                    *scale,
191                    color,
192                    em,
193                    page_h,
194                    embedded,
195                    font_index,
196                    font_data,
197                );
198            }
199            DisplayItem::Line {
200                x,
201                y,
202                width,
203                thickness,
204                color,
205                dashed,
206            } => {
207                emit_line(
208                    &mut content,
209                    &LineParams {
210                        x: *x * em + pad,
211                        y: *y * em + pad,
212                        width: *width * em,
213                        thickness: *thickness * em,
214                        color: *color,
215                        dashed: *dashed,
216                        page_h,
217                    },
218                );
219            }
220            DisplayItem::Rect {
221                x,
222                y,
223                width,
224                height,
225                color,
226            } => {
227                emit_rect(
228                    &mut content,
229                    *x * em + pad,
230                    *y * em + pad,
231                    *width * em,
232                    *height * em,
233                    color,
234                    page_h,
235                );
236            }
237            DisplayItem::Path {
238                x,
239                y,
240                commands,
241                fill,
242                color,
243            } => {
244                emit_path(
245                    &mut content,
246                    *x * em + pad,
247                    *y * em + pad,
248                    commands,
249                    *fill,
250                    color,
251                    em,
252                    stroke_width,
253                    page_h,
254                );
255            }
256        }
257    }
258
259    content.finish().into_vec()
260}
261
262/// Flip Y: PDF origin is bottom-left, DisplayList origin is top-left.
263#[inline]
264fn flip_y(y: f64, page_h: f64) -> f32 {
265    (page_h - y) as f32
266}
267
268// ---------------------------------------------------------------------------
269// Glyph
270// ---------------------------------------------------------------------------
271
272#[allow(clippy::too_many_arguments)]
273fn emit_glyph(
274    content: &mut Content,
275    px: f64,
276    py: f64,
277    font_name: &str,
278    char_code: u32,
279    scale: f64,
280    color: &Color,
281    em: f64,
282    page_h: f64,
283    embedded: &[EmbeddedFont],
284    font_index: &HashMap<FontId, usize>,
285    font_data: &fonts::RawFontData,
286) {
287    let font_id = FontId::parse(font_name).unwrap_or(FontId::MainRegular);
288
289    // Resolve the actual font (with fallback to MainRegular).
290    let actual_fid = if font_data.contains_key(&font_id) {
291        font_id
292    } else {
293        FontId::MainRegular
294    };
295
296    let bytes = match font_data.get(&actual_fid) {
297        Some(b) => b,
298        None => return,
299    };
300
301    let gid = match fonts::resolve_glyph_id(bytes, font_id, char_code) {
302        Some(g) => g,
303        None => return,
304    };
305
306    let ef_idx = match font_index.get(&actual_fid) {
307        Some(&i) => i,
308        None => return,
309    };
310    let ef = &embedded[ef_idx];
311
312    let new_cid = match ef.remapper.get(gid) {
313        Some(c) => c,
314        None => return,
315    };
316
317    let glyph_em = (scale * em) as f32;
318    let pdf_x = px as f32;
319    let pdf_y = flip_y(py, page_h);
320
321    // CID as 2-byte big-endian.
322    let cid_bytes = [(new_cid >> 8) as u8, (new_cid & 0xFF) as u8];
323
324    set_fill_rgb(content, color);
325    content.begin_text();
326    content.set_font(Name(ef.res_name.as_bytes()), glyph_em);
327    content.set_text_matrix([1.0, 0.0, 0.0, 1.0, pdf_x, pdf_y]);
328    content.show(Str(&cid_bytes));
329    content.end_text();
330}
331
332// ---------------------------------------------------------------------------
333// Line
334// ---------------------------------------------------------------------------
335
336struct LineParams {
337    x: f64,
338    y: f64,
339    width: f64,
340    thickness: f64,
341    color: Color,
342    dashed: bool,
343    page_h: f64,
344}
345
346fn emit_line(content: &mut Content, line: &LineParams) {
347    let t = line.thickness.max(0.5);
348
349    set_fill_rgb(content, &line.color);
350
351    if line.dashed {
352        let dash_len = (4.0 * t).max(1.0);
353        let gap_len = (4.0 * t).max(1.0);
354        let period = dash_len + gap_len;
355        let top = line.y - t / 2.0;
356        let mut cur_x = line.x;
357        while cur_x < line.x + line.width {
358            let seg_w = dash_len.min(line.x + line.width - cur_x).max(0.5);
359            let pdf_x = cur_x as f32;
360            let pdf_y = flip_y(top + t, line.page_h); // bottom edge in PDF coords
361            content.rect(pdf_x, pdf_y, seg_w as f32, t as f32);
362            cur_x += period;
363        }
364        content.fill_nonzero();
365    } else {
366        let top = line.y - t / 2.0;
367        let pdf_x = line.x as f32;
368        let pdf_y = flip_y(top + t, line.page_h);
369        content.rect(pdf_x, pdf_y, line.width as f32, t as f32);
370        content.fill_nonzero();
371    }
372}
373
374// ---------------------------------------------------------------------------
375// Rect
376// ---------------------------------------------------------------------------
377
378fn emit_rect(
379    content: &mut Content,
380    x: f64,
381    y: f64,
382    width: f64,
383    height: f64,
384    color: &Color,
385    page_h: f64,
386) {
387    let w = width.max(0.5);
388    let h = height.max(0.5);
389
390    set_fill_rgb(content, color);
391    let pdf_x = x as f32;
392    let pdf_y = flip_y(y + h, page_h); // bottom-left corner in PDF coords
393    content.rect(pdf_x, pdf_y, w as f32, h as f32);
394    content.fill_nonzero();
395}
396
397// ---------------------------------------------------------------------------
398// Path
399// ---------------------------------------------------------------------------
400
401#[allow(clippy::too_many_arguments)]
402fn emit_path(
403    content: &mut Content,
404    ox: f64,
405    oy: f64,
406    commands: &[PathCommand],
407    fill: bool,
408    color: &Color,
409    em: f64,
410    stroke_width: f64,
411    page_h: f64,
412) {
413    if fill {
414        // Split by MoveTo to avoid cross-contour winding issues (same as ratex-render).
415        let mut start = 0;
416        for i in 1..commands.len() {
417            if matches!(commands[i], PathCommand::MoveTo { .. }) {
418                emit_path_segment(content, ox, oy, &commands[start..i], true, color, em, stroke_width, page_h);
419                start = i;
420            }
421        }
422        emit_path_segment(content, ox, oy, &commands[start..], true, color, em, stroke_width, page_h);
423    } else {
424        emit_path_segment(content, ox, oy, commands, false, color, em, stroke_width, page_h);
425    }
426}
427
428#[allow(clippy::too_many_arguments)]
429fn emit_path_segment(
430    content: &mut Content,
431    ox: f64,
432    oy: f64,
433    commands: &[PathCommand],
434    fill: bool,
435    color: &Color,
436    em: f64,
437    stroke_width: f64,
438    page_h: f64,
439) {
440    if commands.is_empty() {
441        return;
442    }
443
444    // Track current point for quad-to-cubic promotion.
445    let mut cur = (0.0f32, 0.0f32);
446
447    for cmd in commands {
448        match cmd {
449            PathCommand::MoveTo { x, y } => {
450                let px = (ox + x * em) as f32;
451                let py = flip_y(oy + y * em, page_h);
452                content.move_to(px, py);
453                cur = (px, py);
454            }
455            PathCommand::LineTo { x, y } => {
456                let px = (ox + x * em) as f32;
457                let py = flip_y(oy + y * em, page_h);
458                content.line_to(px, py);
459                cur = (px, py);
460            }
461            PathCommand::CubicTo { x1, y1, x2, y2, x, y } => {
462                let end_x = (ox + x * em) as f32;
463                let end_y = flip_y(oy + y * em, page_h);
464                content.cubic_to(
465                    (ox + x1 * em) as f32,
466                    flip_y(oy + y1 * em, page_h),
467                    (ox + x2 * em) as f32,
468                    flip_y(oy + y2 * em, page_h),
469                    end_x,
470                    end_y,
471                );
472                cur = (end_x, end_y);
473            }
474            PathCommand::QuadTo { x1, y1, x, y } => {
475                // PDF has no native quadratic Bezier; promote to cubic.
476                // Q(P0,P1,P2) → C(P0, P0+2/3*(P1-P0), P2+2/3*(P1-P2), P2)
477                let qx = (ox + x1 * em) as f32;
478                let qy = flip_y(oy + y1 * em, page_h);
479                let end_x = (ox + x * em) as f32;
480                let end_y = flip_y(oy + y * em, page_h);
481                let cp1_x = cur.0 + 2.0 / 3.0 * (qx - cur.0);
482                let cp1_y = cur.1 + 2.0 / 3.0 * (qy - cur.1);
483                let cp2_x = end_x + 2.0 / 3.0 * (qx - end_x);
484                let cp2_y = end_y + 2.0 / 3.0 * (qy - end_y);
485                content.cubic_to(cp1_x, cp1_y, cp2_x, cp2_y, end_x, end_y);
486                cur = (end_x, end_y);
487            }
488            PathCommand::Close => {
489                content.close_path();
490            }
491        }
492    }
493
494    if fill {
495        set_fill_rgb(content, color);
496        content.fill_even_odd();
497    } else {
498        set_stroke_rgb(content, color);
499        content.set_line_width(stroke_width as f32);
500        content.stroke();
501    }
502}
503
504// ---------------------------------------------------------------------------
505// Helpers
506// ---------------------------------------------------------------------------
507
508fn set_fill_rgb(content: &mut Content, color: &Color) {
509    content.set_fill_rgb(color.r, color.g, color.b);
510}
511
512fn set_stroke_rgb(content: &mut Content, color: &Color) {
513    content.set_stroke_rgb(color.r, color.g, color.b);
514}