lo_writer 0.3.4

Writer-like document editing with Markdown and plain text import/export
Documentation
//! Block-aware PDF layout for `TextDocument`.
//!
//! This is the rendering engine `lo_writer::pdf::to_pdf` uses. It walks
//! every `Block` in the document, picks a font/size for headings, wraps
//! paragraph text to the page width, draws bullet markers, paints
//! bordered table cells, and inserts new pages on `Block::PageBreak`
//! or when a row would overflow.
//!
//! It is intentionally line-based: there is no kerning, justification,
//! or font shaping. The width estimate `font_size * 0.52` is calibrated
//! for Helvetica at 12pt and overshoots wide glyphs slightly so wrapped
//! lines never exceed the right margin in practice.

use lo_core::{
    Block, Heading, Inline, ListBlock, ListItem, Paragraph, PdfDocument, PdfFont, Table,
    TextDocument,
};

const PAGE_W: f32 = 595.0;
const PAGE_H: f32 = 842.0;
const MARGIN_L: f32 = 50.0;
const MARGIN_R: f32 = 50.0;
const MARGIN_T: f32 = 60.0;
const MARGIN_B: f32 = 60.0;

pub fn render_document_pdf(doc: &TextDocument) -> Vec<u8> {
    let mut pdf = PdfDocument::new();
    let mut page_index = pdf.add_page(PAGE_W, PAGE_H);
    let mut y = PAGE_H - MARGIN_T;

    // Title (from metadata) goes at the top of the first page in
    // bold. Empty titles render as a blank line so the rest of the
    // document still lines up the same way.
    {
        let page = pdf.page_mut(page_index).expect("first page");
        page.text(MARGIN_L, y, 22.0, PdfFont::HelveticaBold, &doc.meta.title);
    }
    y -= 32.0;

    for block in &doc.body {
        match block {
            Block::Heading(Heading { level, content }) => {
                let size = match *level {
                    1 => 20.0,
                    2 => 18.0,
                    3 => 16.0,
                    _ => 14.0,
                };
                y = ensure_room(&mut pdf, &mut page_index, y, size + 10.0);
                let lines = wrap_text(&content.to_plain_text(), PAGE_W - MARGIN_L - MARGIN_R, size);
                for line in lines {
                    let page = pdf.page_mut(page_index).expect("page");
                    page.text(MARGIN_L, y, size, PdfFont::HelveticaBold, &line);
                    y -= size + 6.0;
                }
                y -= 4.0;
            }
            Block::Paragraph(paragraph) => {
                y = render_paragraph(
                    &mut pdf,
                    &mut page_index,
                    y,
                    MARGIN_L,
                    paragraph,
                    12.0,
                    PdfFont::Helvetica,
                );
                y -= 6.0;
            }
            Block::List(ListBlock { ordered, items }) => {
                for (index, item) in items.iter().enumerate() {
                    y = ensure_room(&mut pdf, &mut page_index, y, 18.0);
                    let marker = if *ordered {
                        format!("{}.", index + 1)
                    } else {
                        "".to_string()
                    };
                    {
                        let page = pdf.page_mut(page_index).expect("page");
                        page.text(MARGIN_L + 6.0, y, 12.0, PdfFont::Helvetica, &marker);
                    }
                    y = render_list_item(&mut pdf, &mut page_index, y, item);
                    y -= 2.0;
                }
                y -= 4.0;
            }
            Block::Table(table) => {
                y = render_table(&mut pdf, &mut page_index, y, table);
                y -= 8.0;
            }
            Block::Image(image) => {
                // We don't decode images yet, but we still want a
                // visible placeholder so the document layout stays
                // honest about how much vertical space is consumed.
                y = ensure_room(&mut pdf, &mut page_index, y, 24.0);
                let label = format!("[image: {}]", image.alt);
                let page = pdf.page_mut(page_index).expect("page");
                page.text(MARGIN_L, y, 11.0, PdfFont::HelveticaOblique, &label);
                y -= 18.0;
            }
            Block::Section(section) => {
                y = ensure_room(&mut pdf, &mut page_index, y, 18.0);
                {
                    let page = pdf.page_mut(page_index).expect("page");
                    page.text(
                        MARGIN_L,
                        y,
                        13.0,
                        PdfFont::HelveticaBold,
                        &format!("[{}]", section.name),
                    );
                }
                y -= 18.0;
                for nested in &section.blocks {
                    if let Block::Paragraph(paragraph) = nested {
                        y = render_paragraph(
                            &mut pdf,
                            &mut page_index,
                            y,
                            MARGIN_L,
                            paragraph,
                            12.0,
                            PdfFont::Helvetica,
                        );
                        y -= 4.0;
                    }
                }
            }
            Block::HorizontalRule => {
                y = ensure_room(&mut pdf, &mut page_index, y, 12.0);
                let page = pdf.page_mut(page_index).expect("page");
                page.line(MARGIN_L, y, PAGE_W - MARGIN_R, y);
                y -= 10.0;
            }
            Block::PageBreak => {
                page_index = pdf.add_page(PAGE_W, PAGE_H);
                y = PAGE_H - MARGIN_T;
            }
        }
    }

    pdf.finish()
}

fn render_list_item(
    pdf: &mut PdfDocument,
    page_index: &mut usize,
    mut y: f32,
    item: &ListItem,
) -> f32 {
    for block in &item.blocks {
        if let Block::Paragraph(paragraph) = block {
            y = render_paragraph(
                pdf,
                page_index,
                y,
                MARGIN_L + 18.0,
                paragraph,
                12.0,
                PdfFont::Helvetica,
            );
        }
    }
    y
}

fn render_paragraph(
    pdf: &mut PdfDocument,
    page_index: &mut usize,
    mut y: f32,
    x: f32,
    paragraph: &Paragraph,
    size: f32,
    font: PdfFont,
) -> f32 {
    let width = PAGE_W - x - MARGIN_R;
    let lines = wrap_text(&paragraph_to_plain(paragraph), width, size);
    for line in lines {
        y = ensure_room(pdf, page_index, y, size + 3.0);
        let page = pdf.page_mut(*page_index).expect("page");
        page.text(x, y, size, font, &line);
        y -= size + 3.0;
    }
    y
}

fn paragraph_to_plain(paragraph: &Paragraph) -> String {
    let mut out = String::new();
    for span in &paragraph.spans {
        match span {
            Inline::Text(text) | Inline::Bold(text) | Inline::Italic(text) | Inline::Code(text) => {
                out.push_str(text)
            }
            Inline::Link { label, .. } => out.push_str(label),
            Inline::LineBreak => out.push('\n'),
        }
    }
    out
}

fn render_table(pdf: &mut PdfDocument, page_index: &mut usize, mut y: f32, table: &Table) -> f32 {
    let cols = table
        .rows
        .iter()
        .map(|row| row.cells.len())
        .max()
        .unwrap_or(1)
        .max(1);
    let table_w = PAGE_W - MARGIN_L - MARGIN_R;
    let col_w = table_w / cols as f32;
    for row in &table.rows {
        let cell_texts: Vec<String> = row
            .cells
            .iter()
            .map(|cell| {
                cell.paragraphs
                    .iter()
                    .map(paragraph_to_plain)
                    .collect::<Vec<_>>()
                    .join("\n")
            })
            .collect();
        let wrapped = cell_texts
            .iter()
            .map(|cell| wrap_text(cell, col_w - 8.0, 10.0))
            .collect::<Vec<_>>();
        let max_lines = wrapped
            .iter()
            .map(|lines| lines.len())
            .max()
            .unwrap_or(1)
            .max(1);
        let row_h = max_lines as f32 * 13.0 + 8.0;
        y = ensure_room(pdf, page_index, y, row_h + 2.0);
        let top = y;
        let bottom = y - row_h;
        {
            let page = pdf.page_mut(*page_index).expect("page");
            for col in 0..=cols {
                let x = MARGIN_L + col as f32 * col_w;
                page.line(x, top, x, bottom);
            }
            page.line(MARGIN_L, top, PAGE_W - MARGIN_R, top);
            page.line(MARGIN_L, bottom, PAGE_W - MARGIN_R, bottom);
            for (col_index, cell_lines) in wrapped.iter().enumerate() {
                let cell_x = MARGIN_L + col_index as f32 * col_w + 4.0;
                let mut line_y = top - 12.0;
                for line in cell_lines {
                    page.text(cell_x, line_y, 10.0, PdfFont::Helvetica, line);
                    line_y -= 13.0;
                }
            }
        }
        y = bottom - 4.0;
    }
    y
}

fn ensure_room(pdf: &mut PdfDocument, page_index: &mut usize, y: f32, needed: f32) -> f32 {
    if y - needed >= MARGIN_B {
        y
    } else {
        *page_index = pdf.add_page(PAGE_W, PAGE_H);
        PAGE_H - MARGIN_T
    }
}

fn wrap_text(text: &str, max_width: f32, font_size: f32) -> Vec<String> {
    let approx_char_w = (font_size * 0.52).max(5.0);
    let max_chars = ((max_width / approx_char_w).floor() as usize).max(8);
    let mut lines = Vec::new();
    for paragraph_line in text.split('\n') {
        let mut current = String::new();
        for word in paragraph_line.split_whitespace() {
            let candidate = if current.is_empty() {
                word.to_string()
            } else {
                format!("{current} {word}")
            };
            if candidate.chars().count() <= max_chars {
                current = candidate;
            } else {
                if !current.is_empty() {
                    lines.push(current);
                }
                if word.chars().count() <= max_chars {
                    current = word.to_string();
                } else {
                    let mut chunk = String::new();
                    for ch in word.chars() {
                        chunk.push(ch);
                        if chunk.chars().count() >= max_chars {
                            lines.push(std::mem::take(&mut chunk));
                        }
                    }
                    current = chunk;
                }
            }
        }
        if !current.is_empty() {
            lines.push(current);
        } else if paragraph_line.is_empty() {
            lines.push(String::new());
        }
    }
    if lines.is_empty() {
        lines.push(String::new());
    }
    lines
}