glyphweaveforge 0.1.6

Convert Markdown into PDF through an explicit Rust pipeline with minimal and Typst backends.
Documentation
use crate::adapters::render::plan::{
    PageSpec, RenderPage, build_render_plan, paginate, render_lines,
};
use crate::core::ports::{RenderBackend, RenderRequest};
use crate::core::{Document, Result};

#[derive(Debug, Default)]
pub struct MinimalPdfRenderer;

impl RenderBackend for MinimalPdfRenderer {
    fn render(&self, document: &Document, request: &RenderRequest) -> Result<Vec<u8>> {
        let plan = build_render_plan(document, request)?;
        let lines = render_lines(&plan);
        let pages = paginate(
            &lines,
            plan.page_size,
            request.layout_mode,
            plan.theme.margin_mm,
        );
        Ok(MinimalPdfWriter::from_pages(&request.source_name, plan.page_size, &pages).finish())
    }
}

struct MinimalPdfWriter {
    objects: Vec<Vec<u8>>,
}

impl MinimalPdfWriter {
    fn from_pages(title: &str, page_size: PageSpec, pages: &[RenderPage]) -> Self {
        let mut writer = Self {
            objects: Vec::new(),
        };
        let info_id =
            writer.push_object(format!("<< /Title ({}) >>", escape_pdf_text(title)).into_bytes());
        let pages_id = writer.reserve_object();
        let font_id = writer.push_object(
            b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
                .to_vec(),
        );
        let mut page_ids = Vec::new();
        for page in pages {
            let content_id = writer.push_stream_object(build_content_stream(
                page.lines
                    .iter()
                    .map(|line| (line.text.as_str(), line.font_size_pt)),
            ));
            let page_id = writer.push_object(
                format!(
                    "<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {:.2} {:.2}] /Resources << /Font << /F1 {font_id} 0 R >> >> /Contents {content_id} 0 R >>",
                    mm_to_points(page_size.width_mm),
                    mm_to_points(page_size.height_mm)
                )
                .into_bytes(),
            );
            page_ids.push(page_id);
        }
        writer.replace_object(
            pages_id,
            format!(
                "<< /Type /Pages /Kids [{}] /Count {} >>",
                page_ids
                    .iter()
                    .map(|id| format!("{id} 0 R"))
                    .collect::<Vec<_>>()
                    .join(" "),
                page_ids.len()
            )
            .into_bytes(),
        );
        let catalog_id =
            writer.push_object(format!("<< /Type /Catalog /Pages {pages_id} 0 R >>").into_bytes());
        let _ = (info_id, catalog_id);
        writer
    }

    fn reserve_object(&mut self) -> usize {
        self.objects.push(Vec::new());
        self.objects.len()
    }

    fn push_object(&mut self, content: Vec<u8>) -> usize {
        self.objects.push(content);
        self.objects.len()
    }

    fn replace_object(&mut self, id: usize, content: Vec<u8>) {
        self.objects[id - 1] = content;
    }

    fn push_stream_object(&mut self, stream: Vec<u8>) -> usize {
        let mut object = format!("<< /Length {} >>\nstream\n", stream.len()).into_bytes();
        object.extend_from_slice(&stream);
        object.extend_from_slice(b"\nendstream");
        self.push_object(object)
    }

    fn finish(self) -> Vec<u8> {
        let info_id = 1usize;
        let catalog_id = self.objects.len();
        let mut pdf = b"%PDF-1.4\n".to_vec();
        let mut offsets = vec![0usize];
        for (index, object) in self.objects.iter().enumerate() {
            offsets.push(pdf.len());
            pdf.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes());
            pdf.extend_from_slice(object);
            pdf.extend_from_slice(b"\nendobj\n");
        }
        let xref_offset = pdf.len();
        pdf.extend_from_slice(format!("xref\n0 {}\n", self.objects.len() + 1).as_bytes());
        pdf.extend_from_slice(b"0000000000 65535 f \n");
        for offset in offsets.iter().skip(1) {
            pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
        }
        pdf.extend_from_slice(
            format!(
                "trailer\n<< /Size {} /Info {info_id} 0 R /Root {catalog_id} 0 R >>\nstartxref\n{xref_offset}\n%%EOF",
                self.objects.len() + 1
            )
            .as_bytes(),
        );
        pdf
    }
}

fn build_content_stream<'a>(lines: impl Iterator<Item = (&'a str, f32)>) -> Vec<u8> {
    let mut stream = Vec::new();
    let mut y = 800.0f32;
    for (text, font_size) in lines {
        let safe_text = escape_pdf_text(text);
        stream.extend_from_slice(
            format!("BT\n/F1 {font_size:.2} Tf\n1 0 0 1 40 {y:.2} Tm\n({safe_text}) Tj\nET\n")
                .as_bytes(),
        );
        y -= (font_size + 6.0).max(12.0);
    }
    stream
}

fn escape_pdf_text(text: &str) -> String {
    let mut escaped = String::with_capacity(text.len());
    for ch in text.chars() {
        match ch {
            '(' => escaped.push_str("\\("),
            ')' => escaped.push_str("\\)"),
            '\\' => escaped.push_str("\\\\"),
            '\n' | '\r' => escaped.push(' '),
            _ if ch.is_ascii() && !ch.is_ascii_control() => escaped.push(ch),
            _ => escaped.push('?'),
        }
    }
    escaped
}

fn mm_to_points(mm: f32) -> f32 {
    mm * 72.0 / 25.4
}