From b34a442c762a35c5adf1a3f1b0b509d291b54ed9 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 9 Dec 2025 21:44:34 -0600 Subject: [PATCH] feat: added custom font support and added paginated rendering --- Cargo.lock | 7 + README.md | 54 ++++++- README.pdf | 280 +++++++++++++++++++++++++++++++++ crates/cli/src/main.rs | 41 ++++- crates/core/Cargo.toml | 5 +- crates/core/src/md/font.rs | 110 +++++++++++++ crates/core/src/md/layout.rs | 203 ++++++++++++++++++++---- crates/core/src/md/mod.rs | 18 ++- crates/core/src/md/renderer.rs | 280 ++++++++++++++++++++++++--------- crates/core/src/md/styler.rs | 20 ++- 10 files changed, 902 insertions(+), 116 deletions(-) create mode 100644 README.pdf create mode 100644 crates/core/src/md/font.rs diff --git a/Cargo.lock b/Cargo.lock index 7e8e282..a936bc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -176,6 +176,7 @@ version = "0.1.0" dependencies = [ "pdf-writer", "pulldown-cmark", + "ttf-parser", ] [[package]] @@ -238,6 +239,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "unicase" version = "2.8.1" diff --git a/README.md b/README.md index a713eb3..a6a75c6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,55 @@ # picopdf -Command-line PDF tools +A minimalist markdown to PDF converter with custom font support. + +## Usage + +Convert markdown to PDF using default fonts: + +```bash +picopdf write -i input.md -o output.pdf +``` + +Use custom TrueType fonts: + +```bash +picopdf write -i input.md -o output.pdf \ + --font-regular ~/fonts/regular.ttf \ + --font-bold ~/fonts/bold.ttf \ + --font-mono ~/fonts/mono.ttf +``` + +## Architecture + +Four-stage markdown conversion pipeline: + +1. Markdown to AST/IR using pulldown-cmark +2. AST to styled blocks with semantic styling +3. Blocks to positioned lines and glyphs via layout engine +4. Lines to PDF using pdf-writer with embedded fonts + +## Font Support + +Default fonts are built-in PDF Type1 fonts (Helvetica, Courier) requiring no embedding. +Custom TrueType/OpenType fonts are fully embedded in the PDF with proper Unicode support. + +Default PDFs are around pretty small (2-5KB), see this file as an [example](./README.pdf). +Custom font PDFs include the full font files, typically 2-7MB depending on font complexity. + +## Supported Markdown + +- Headings (H1-H4) +- Paragraphs +- Lists +- Code blocks +- Inline code + +## Example + +```bash +# Using 0xProto Nerd Font +picopdf write -i README.md -o README.pdf \ + --font-regular ~/.local/share/fonts/0xProto-Regular.ttf \ + --font-bold ~/.local/share/fonts/0xProto-Bold.ttf \ + --font-mono ~/.local/share/fonts/0xProtoMono-Regular.ttf +``` diff --git a/README.pdf b/README.pdf new file mode 100644 index 0000000..a13a783 --- /dev/null +++ b/README.pdf @@ -0,0 +1,280 @@ +%PDF-1.7 +%���� + +1 0 obj +<< + /Type /Catalog + /Pages 2 0 R +>> +endobj + +2 0 obj +<< + /Type /Pages + /Kids [6 0 R 8 0 R] + /Count 2 +>> +endobj + +3 0 obj +<< + /Type /Font + /Subtype /Type1 + /BaseFont /Helvetica +>> +endobj + +4 0 obj +<< + /Type /Font + /Subtype /Type1 + /BaseFont /Helvetica-Bold +>> +endobj + +5 0 obj +<< + /Type /Font + /Subtype /Type1 + /BaseFont /Courier +>> +endobj + +6 0 obj +<< + /Type /Page + /MediaBox [0 0 595 842] + /Parent 2 0 R + /Contents 7 0 R + /Resources << + /Font << + /F1 3 0 R + /F2 4 0 R + /F3 5 0 R + >> + >> +>> +endobj + +7 0 obj +<< + /Length 1931 +>> +stream +BT +/F2 24 Tf +1 0 0 1 50 772.8 Tm +(picopdf) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 728.4 Tm +(A minimalist markdown to PDF converter with custom font support.) Tj +ET +BT +/F2 20 Tf +1 0 0 1 50 690.4 Tm +(Building) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 650.4 Tm +(cargo build --release) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 622.4 Tm +(The binary will be at target/release/picopdf.) Tj +ET +BT +/F2 20 Tf +1 0 0 1 50 584.4 Tm +(Usage) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 546.4 Tm +(Convert markdown to PDF using default fonts:) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 516.4 Tm +(picopdf write -i input.md -o output.pdf) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 488.4 Tm +(Use custom TrueType fonts:) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 458.4 Tm +(picopdf write -i input.md -o output.pdf \\ --font-regular ~/fonts/regular.ttf \\) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 444.4 Tm +(--font-bold ~/fonts/bold.ttf \\ --font-mono ~/fonts/mono.ttf) Tj +ET +BT +/F2 20 Tf +1 0 0 1 50 406.4 Tm +(Architecture) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 368.4 Tm +(Four-stage pipeline:) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 344.4 Tm +(- Markdown to AST/IR using pulldown-cmark) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 324.40002 Tm +(- AST to styled blocks with semantic styling) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 304.40002 Tm +(- Blocks to positioned lines and glyphs via layout engine) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 284.40002 Tm +(- Lines to PDF using pdf-writer with embedded fonts) Tj +ET +BT +/F2 20 Tf +1 0 0 1 50 250.40002 Tm +(Font Support) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 212.40002 Tm +(Default fonts are built-in PDF Type1 fonts (Helvetica, Courier) requiring no) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 196.40002 Tm +(embedding. Custom TrueType/OpenType fonts are fully embedded in the PDF with) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 180.40002 Tm +(proper Unicode support.) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 152.40002 Tm +(Default PDFs are around 2KB. Custom font PDFs include the full font files,) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 136.40002 Tm +(typically 2-7MB depending on font complexity.) Tj +ET +endstream +endobj + +8 0 obj +<< + /Type /Page + /MediaBox [0 0 595 842] + /Parent 2 0 R + /Contents 9 0 R + /Resources << + /Font << + /F1 3 0 R + /F2 4 0 R + /F3 5 0 R + >> + >> +>> +endobj + +9 0 obj +<< + /Length 762 +>> +stream +BT +/F2 20 Tf +1 0 0 1 50 776 Tm +(Supported Markdown) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 742 Tm +(- Headings (H1-H4)) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 722 Tm +(- Paragraphs) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 702 Tm +(- Lists) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 682 Tm +(- Code blocks) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 662 Tm +(- Inline code) Tj +ET +BT +/F2 20 Tf +1 0 0 1 50 628 Tm +(Example) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 588 Tm +(# Using 0xProto Nerd Font picopdf write -i README.md -o README.pdf \\) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 574 Tm +(--font-regular ~/.local/share/fonts/0xProto-Regular.ttf \\ --font-bold) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 560 Tm +(~/.local/share/fonts/0xProto-Bold.ttf \\ --font-mono) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 546 Tm +(~/.local/share/fonts/0xProtoMono-Regular.ttf) Tj +ET +endstream +endobj + +xref +0 10 +0000000000 65535 f +0000000016 00000 n +0000000070 00000 n +0000000140 00000 n +0000000217 00000 n +0000000299 00000 n +0000000374 00000 n +0000000559 00000 n +0000002545 00000 n +0000002730 00000 n +trailer +<< + /Size 10 + /Root 1 0 R +>> +startxref +3546 +%%EOF \ No newline at end of file diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 23b530b..55cbcc2 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -22,6 +22,18 @@ enum Commands { /// Output PDF file #[arg(short, long)] output: PathBuf, + + /// Custom regular font file (TTF/OTF) + #[arg(long)] + font_regular: Option, + + /// Custom bold font file (TTF/OTF) + #[arg(long)] + font_bold: Option, + + /// Custom monospace font file (TTF/OTF) + #[arg(long)] + font_mono: Option, }, } @@ -29,8 +41,8 @@ fn main() { let cli = Cli::parse(); match cli.command { - Commands::Write { input, output } => { - if let Err(e) = write_command(&input, &output) { + Commands::Write { input, output, font_regular, font_bold, font_mono } => { + if let Err(e) = write_command(&input, &output, &font_regular, &font_bold, &font_mono) { eprintln!("{} {}", "Error:".red().bold(), e); std::process::exit(1); } @@ -41,15 +53,36 @@ fn main() { /// Executes the write command to convert markdown to PDF. /// /// Reads the input markdown file, processes it through the four-stage pipeline, and writes the resulting PDF to the output file. -fn write_command(input: &PathBuf, output: &PathBuf) -> Result<(), Box> { +fn write_command( + input: &PathBuf, output: &PathBuf, font_regular: &Option, font_bold: &Option, + font_mono: &Option, +) -> Result<(), Box> { println!("{} {}", "Reading".cyan().bold(), input.display()); let markdown = fs::read_to_string(input).map_err(|e| format!("Failed to read input file '{}': {}", input.display(), e))?; + let fonts = if font_regular.is_some() || font_bold.is_some() || font_mono.is_some() { + println!("{} custom fonts...", "Loading".cyan().bold()); + match picopdf_core::md::FontConfig::from_files( + font_regular.as_deref(), + font_bold.as_deref(), + font_mono.as_deref(), + ) { + Ok(config) => config, + Err(e) => { + eprintln!("{} {}", "Warning:".yellow().bold(), e); + eprintln!("{} Using default fonts instead", " ".yellow()); + picopdf_core::md::FontConfig::default() + } + } + } else { + picopdf_core::md::FontConfig::default() + }; + println!("{} markdown to PDF...", "Converting".cyan().bold()); - let pdf_bytes = picopdf_core::md::markdown_to_pdf(&markdown)?; + let pdf_bytes = picopdf_core::md::markdown_to_pdf_with_fonts(&markdown, &fonts)?; println!("{} PDF to {}", "Writing".cyan().bold(), output.display()); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 12f628f..148e77a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -4,8 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] -pdf-writer = "0.14.0" -pulldown-cmark = "0.13.0" +pdf-writer = "0.14" +pulldown-cmark = "0.13" +ttf-parser = "0.25" [lints] workspace = true diff --git a/crates/core/src/md/font.rs b/crates/core/src/md/font.rs new file mode 100644 index 0000000..2b20165 --- /dev/null +++ b/crates/core/src/md/font.rs @@ -0,0 +1,110 @@ +use std::fs; +use std::path::Path; +use ttf_parser::Face; + +/// Font configuration for PDF rendering. +/// +/// Allows customization of fonts used in the PDF output while providing +/// sensible defaults that work without external dependencies. +#[derive(Debug, Clone)] +pub struct FontConfig { + pub regular: FontSource, + pub bold: FontSource, + pub monospace: FontSource, +} + +impl Default for FontConfig { + fn default() -> Self { + Self { + regular: FontSource::BuiltIn("Helvetica"), + bold: FontSource::BuiltIn("Helvetica-Bold"), + monospace: FontSource::BuiltIn("Courier"), + } + } +} + +/// Source of a font, either built-in PDF font or external file. +/// +/// Built-in fonts are part of the PDF standard and always available. +/// Custom fonts must be embedded in the PDF from external files. +#[derive(Debug, Clone)] +pub enum FontSource { + BuiltIn(&'static str), + TrueType { + path: String, + data: Vec, + info: FontInfo, + }, +} + +/// Parsed information from a TrueType font. +/// +/// Contains metrics and metadata needed for PDF embedding. +#[derive(Debug, Clone)] +pub struct FontInfo { + pub postscript_name: String, + pub ascent: i16, + pub descent: i16, + pub cap_height: i16, + pub units_per_em: u16, + pub bbox: (i16, i16, i16, i16), +} + +impl FontConfig { + /// Creates a font configuration with custom font files. + /// + /// Loads font files from disk. If a font file cannot be loaded, + /// returns an error with details about which file failed. + pub fn from_files(regular: Option<&Path>, bold: Option<&Path>, monospace: Option<&Path>) -> Result { + let mut config = Self::default(); + + if let Some(path) = regular { + config.regular = load_font_file(path, "regular")?; + } + + if let Some(path) = bold { + config.bold = load_font_file(path, "bold")?; + } + + if let Some(path) = monospace { + config.monospace = load_font_file(path, "monospace")?; + } + + Ok(config) + } +} + +/// Loads a font file from disk and parses it. +/// +/// Returns a FontSource::TrueType with the loaded data and parsed metrics, +/// or an error if the file cannot be read or parsed. +fn load_font_file(path: &Path, font_type: &str) -> Result { + let data = + fs::read(path).map_err(|e| format!("Failed to load {} font from '{}': {}", font_type, path.display(), e))?; + + let face = Face::parse(&data, 0) + .map_err(|e| format!("Failed to parse {} font from '{}': {:?}", font_type, path.display(), e))?; + + let postscript_name = face + .names() + .into_iter() + .find(|n| n.name_id == ttf_parser::name_id::POST_SCRIPT_NAME) + .and_then(|n| n.to_string()) + .unwrap_or_else(|| "CustomFont".to_string()); + + let info = FontInfo { + postscript_name, + ascent: face.ascender(), + descent: face.descender(), + cap_height: face.capital_height().unwrap_or_else(|| face.ascender()), + units_per_em: face.units_per_em(), + bbox: ( + face.global_bounding_box().x_min, + face.global_bounding_box().y_min, + face.global_bounding_box().x_max, + face.global_bounding_box().y_max, + ), + }; + + Ok(FontSource::TrueType { path: path.display().to_string(), data, info }) +} diff --git a/crates/core/src/md/layout.rs b/crates/core/src/md/layout.rs index f671fb6..e33a4f5 100644 --- a/crates/core/src/md/layout.rs +++ b/crates/core/src/md/layout.rs @@ -23,26 +23,85 @@ pub struct PositionedLine { pub height: f32, } +/// A page containing positioned lines. +/// +/// Represents a single PDF page with its content. +#[derive(Debug, Clone)] +pub struct Page { + pub lines: Vec, +} + /// Layout engine that converts styled blocks into positioned lines. /// /// Handles text measurement, line breaking, and vertical positioning while preparing content for final PDF rendering. pub struct LayoutEngine { page_width: f32, + page_height: f32, margin: f32, current_y: f32, + pages: Vec, } impl LayoutEngine { - fn new(page_width: f32, _page_height: f32, margin: f32) -> Self { - Self { page_width, margin, current_y: margin } + fn new(page_width: f32, page_height: f32, margin: f32) -> Self { + Self { page_width, page_height, margin, current_y: margin, pages: vec![Page { lines: Vec::new() }] } + } + + fn should_page_break(&self, additional_height: f32) -> bool { + self.current_y + additional_height > self.page_height - self.margin + } + + fn usable_height(&self) -> f32 { + self.page_height - (2.0 * self.margin) + } + + fn new_page(&mut self) { + self.current_y = self.margin; + self.pages.push(Page { lines: Vec::new() }); + } + + fn current_page_mut(&mut self) -> &mut Page { + self.pages.last_mut().expect("LayoutEngine always has a page") } fn char_width(&self, style: &Style) -> f32 { if style.is_monospace { style.font_size * 0.6 } else { style.font_size * 0.5 } } - fn layout_block(&mut self, block: &StyledBlock) -> Vec { - let mut lines = Vec::new(); + fn measure_block_height(&self, block: &StyledBlock) -> f32 { + let max_width = self.page_width - (2.0 * self.margin); + let char_width = self.char_width(&block.style); + let mut line_width = 0.0; + let mut line_count = 0; + + for word in block.content.split_whitespace() { + let word_width = word.len() as f32 * char_width; + let space_width = if line_width == 0.0 { 0.0 } else { char_width }; + + if line_width > 0.0 && line_width + space_width + word_width > max_width { + line_count += 1; + line_width = 0.0; + } + + if line_width > 0.0 { + line_width += space_width; + } + + line_width += word_width; + } + + if line_width > 0.0 { + line_count += 1; + } + + block.style.margin_top + block.style.margin_bottom + (line_count as f32 * block.style.line_height) + } + + fn layout_block(&mut self, block: &StyledBlock, block_height: f32) { + let is_page_start = (self.current_y - self.margin).abs() < f32::EPSILON; + if self.should_page_break(block_height) && !is_page_start { + self.new_page(); + } let max_width = self.page_width - (2.0 * self.margin); let char_width = self.char_width(&block.style); @@ -57,8 +116,7 @@ impl LayoutEngine { let space_width = char_width; if line_width + word_width + space_width > max_width && !current_line.is_empty() { - lines.push(self.create_line(¤t_line, &block.style)); - self.current_y += block.style.line_height; + self.add_line(¤t_line, &block.style); current_line.clear(); line_width = 0.0; } @@ -73,15 +131,27 @@ impl LayoutEngine { } if !current_line.is_empty() { - lines.push(self.create_line(¤t_line, &block.style)); - self.current_y += block.style.line_height; + self.add_line(¤t_line, &block.style); } self.current_y += block.style.margin_bottom; - lines } - fn create_line(&self, text: &str, style: &Style) -> PositionedLine { + fn add_line(&mut self, text: &str, style: &Style) { + if text.is_empty() { + return; + } + + if self.should_page_break(style.line_height) { + self.new_page(); + } + + let line = self.create_line(text, style, self.current_y); + self.current_page_mut().lines.push(line); + self.current_y += style.line_height; + } + + fn create_line(&self, text: &str, style: &Style, y: f32) -> PositionedLine { let char_width = self.char_width(style); let mut glyphs = Vec::new(); let mut x = self.margin; @@ -90,7 +160,7 @@ impl LayoutEngine { glyphs.push(Glyph { ch, x, - y: self.current_y, + y, font_size: style.font_size, is_bold: style.is_bold, is_monospace: style.is_monospace, @@ -98,23 +168,36 @@ impl LayoutEngine { x += char_width; } - PositionedLine { glyphs, y: self.current_y, height: style.line_height } + PositionedLine { glyphs, y, height: style.line_height } } } -/// Layouts styled blocks into positioned lines with glyphs. +/// Layouts styled blocks into pages with positioned lines. /// -/// Performs line breaking and vertical positioning to prepare content for final rendering to PDF format. -pub fn layout_blocks(blocks: &[StyledBlock]) -> Vec { +/// Performs line breaking, vertical positioning, and pagination to prepare content for final rendering to PDF format. +pub fn layout_blocks(blocks: &[StyledBlock]) -> Vec { let mut engine = LayoutEngine::new(595.0, 842.0, 50.0); - let mut all_lines = Vec::new(); + let mut idx = 0; - for block in blocks { - let lines = engine.layout_block(block); - all_lines.extend(lines); + while idx < blocks.len() { + let block = &blocks[idx]; + let block_height = engine.measure_block_height(block); + + if block.keep_with_next + && let Some(next_block) = blocks.get(idx + 1) + { + let next_height = engine.measure_block_height(next_block); + let combined_height = block_height + next_height; + if combined_height <= engine.usable_height() && engine.should_page_break(combined_height) { + engine.new_page(); + } + } + + engine.layout_block(block, block_height); + idx += 1; } - all_lines + engine.pages } #[cfg(test)] @@ -135,11 +218,12 @@ mod tests { is_monospace: false, }, is_list_item: false, + keep_with_next: false, }]; - let lines = layout_blocks(&blocks); - assert_eq!(lines.len(), 1); - assert!(!lines[0].glyphs.is_empty()); + let pages = layout_blocks(&blocks); + assert_eq!(pages.len(), 1); + assert!(!pages[0].lines[0].glyphs.is_empty()); } #[test] @@ -157,10 +241,12 @@ mod tests { is_monospace: false, }, is_list_item: false, + keep_with_next: false, }]; - let lines = layout_blocks(&blocks); - assert!(lines.len() > 1, "Long text should break into multiple lines"); + let pages = layout_blocks(&blocks); + let total_lines: usize = pages.iter().map(|p| p.lines.len()).sum(); + assert!(total_lines > 1, "Long text should break into multiple lines"); } #[test] @@ -176,13 +262,74 @@ mod tests { is_monospace: false, }, is_list_item: false, + keep_with_next: false, }]; - let lines = layout_blocks(&blocks); - assert_eq!(lines[0].glyphs.len(), 2); + let pages = layout_blocks(&blocks); + assert_eq!(pages[0].lines[0].glyphs.len(), 2); assert!( - lines[0].glyphs[1].x > lines[0].glyphs[0].x, + pages[0].lines[0].glyphs[1].x > pages[0].lines[0].glyphs[0].x, "Second glyph should be positioned after first" ); } + + #[test] + fn test_keep_with_next_moves_block() { + let filler = StyledBlock { + content: "filler".to_string(), + style: Style { + font_size: 12.0, + line_height: 700.0, + margin_top: 0.0, + margin_bottom: 0.0, + is_bold: false, + is_monospace: false, + }, + is_list_item: false, + keep_with_next: false, + }; + + let heading = StyledBlock { + content: "KEEP_WITH_NEXT".to_string(), + style: Style { + font_size: 20.0, + line_height: 24.0, + margin_top: 10.0, + margin_bottom: 6.0, + is_bold: true, + is_monospace: false, + }, + is_list_item: false, + keep_with_next: true, + }; + + let list = StyledBlock { + content: "- bullet".to_string(), + style: Style { + font_size: 12.0, + line_height: 16.0, + margin_top: 2.0, + margin_bottom: 2.0, + is_bold: false, + is_monospace: false, + }, + is_list_item: true, + keep_with_next: false, + }; + + let pages = layout_blocks(&[filler, heading, list]); + assert!( + pages.len() >= 2, + "Expect filler to consume first page so heading moves to next page" + ); + let first_page_text: String = pages[0] + .lines + .iter() + .flat_map(|line| line.glyphs.iter().map(|g| g.ch)) + .collect(); + assert!( + !first_page_text.contains("KEEP_WITH_NEXT"), + "Heading should have been moved to next page" + ); + } } diff --git a/crates/core/src/md/mod.rs b/crates/core/src/md/mod.rs index 274bd39..791e061 100644 --- a/crates/core/src/md/mod.rs +++ b/crates/core/src/md/mod.rs @@ -10,7 +10,11 @@ mod layout; /// Stage 4: Lines -> PDF (backend) mod renderer; -pub use layout::{LayoutEngine, PositionedLine}; +/// Font configuration and loading +mod font; + +pub use font::{FontConfig, FontSource}; +pub use layout::{LayoutEngine, Page, PositionedLine}; pub use parser::parse_markdown; pub use renderer::render_to_pdf; pub use styler::{Style, StyledBlock}; @@ -24,11 +28,21 @@ use std::io; /// 2. Apply styling and semantics to create styled blocks /// 3. Layout blocks into positioned lines and glyphs /// 4. Render positioned content to PDF format +/// +/// Uses default built-in PDF fonts (Helvetica, Courier). pub fn markdown_to_pdf(markdown: &str) -> io::Result> { + markdown_to_pdf_with_fonts(markdown, &FontConfig::default()) +} + +/// Converts markdown text to PDF bytes with custom font configuration. +/// +/// Allows specification of custom fonts for regular, bold, and monospace text. +/// Falls back to built-in PDF fonts if custom fonts are not provided. +pub fn markdown_to_pdf_with_fonts(markdown: &str, fonts: &FontConfig) -> io::Result> { let ir = parse_markdown(markdown); let styled = styler::apply_styles(ir); let positioned = layout::layout_blocks(&styled); - render_to_pdf(&positioned) + renderer::render_to_pdf_with_fonts(&positioned, fonts) } #[cfg(test)] diff --git a/crates/core/src/md/renderer.rs b/crates/core/src/md/renderer.rs index 4fa8bc6..62435a1 100644 --- a/crates/core/src/md/renderer.rs +++ b/crates/core/src/md/renderer.rs @@ -1,89 +1,219 @@ -use crate::md::layout::PositionedLine; +use crate::md::font::{FontConfig, FontSource}; +use crate::md::layout::Page; use pdf_writer::{Content, Finish, Name, Pdf, Rect, Ref, Str}; use std::io; -/// Renders positioned lines to PDF format using pdf-writer. +/// Renders pages to PDF format using pdf-writer. /// /// This final stage takes fully positioned glyphs and generates the PDF /// byte stream with proper font embedding and text rendering operators. -pub fn render_to_pdf(lines: &[PositionedLine]) -> io::Result> { +/// +/// Uses default built-in PDF fonts. +pub fn render_to_pdf(pages: &[Page]) -> io::Result> { + render_to_pdf_with_fonts(pages, &FontConfig::default()) +} + +/// Renders pages to PDF format with custom fonts. +/// +/// Supports both built-in PDF fonts and custom font files. +/// Custom fonts are embedded in the PDF as TrueType fonts. +pub fn render_to_pdf_with_fonts(pages: &[Page], fonts: &FontConfig) -> io::Result> { let mut pdf = Pdf::new(); let catalog_id = Ref::new(1); let page_tree_id = Ref::new(2); - let page_id = Ref::new(3); - let content_id = Ref::new(4); - let font_regular_id = Ref::new(5); - let font_bold_id = Ref::new(6); - let font_mono_id = Ref::new(7); + let font_regular_id = Ref::new(3); + let font_bold_id = Ref::new(4); + let font_mono_id = Ref::new(5); + + let mut next_id = 6; + let mut page_ids = Vec::new(); + let mut content_ids = Vec::new(); + + for _ in 0..pages.len() { + page_ids.push(Ref::new(next_id)); + next_id += 1; + content_ids.push(Ref::new(next_id)); + next_id += 1; + } pdf.catalog(catalog_id).pages(page_tree_id); - pdf.pages(page_tree_id).kids([page_id]).count(1); - - let mut page = pdf.page(page_id); - page.media_box(Rect::new(0.0, 0.0, 595.0, 842.0)); - page.parent(page_tree_id); - page.contents(content_id); - let mut resources = page.resources(); - resources - .fonts() - .pair(Name(b"F1"), font_regular_id) - .pair(Name(b"F2"), font_bold_id) - .pair(Name(b"F3"), font_mono_id); - resources.finish(); - page.finish(); - - embed_font(&mut pdf, font_regular_id, "Helvetica"); - embed_font(&mut pdf, font_bold_id, "Helvetica-Bold"); - embed_font(&mut pdf, font_mono_id, "Courier"); - - let mut content = Content::new(); - - for line in lines { - if line.glyphs.is_empty() { - continue; + pdf.pages(page_tree_id) + .kids(page_ids.iter().copied()) + .count(pages.len() as i32); + + embed_font_source(&mut pdf, font_regular_id, &fonts.regular); + embed_font_source(&mut pdf, font_bold_id, &fonts.bold); + embed_font_source(&mut pdf, font_mono_id, &fonts.monospace); + + for (page_idx, page_content) in pages.iter().enumerate() { + let page_id = page_ids[page_idx]; + let content_id = content_ids[page_idx]; + + let mut page = pdf.page(page_id); + page.media_box(Rect::new(0.0, 0.0, 595.0, 842.0)); + page.parent(page_tree_id); + page.contents(content_id); + let mut resources = page.resources(); + resources + .fonts() + .pair(Name(b"F1"), font_regular_id) + .pair(Name(b"F2"), font_bold_id) + .pair(Name(b"F3"), font_mono_id); + resources.finish(); + page.finish(); + + let mut content = Content::new(); + + for line in &page_content.lines { + if line.glyphs.is_empty() { + continue; + } + + let text: String = line.glyphs.iter().map(|g| g.ch).collect(); + let first_glyph = &line.glyphs[0]; + + let (font_name, font_source) = if first_glyph.is_bold { + (Name(b"F2"), &fonts.bold) + } else if first_glyph.is_monospace { + (Name(b"F3"), &fonts.monospace) + } else { + (Name(b"F1"), &fonts.regular) + }; + + content.begin_text(); + content.set_font(font_name, first_glyph.font_size); + content.set_text_matrix([1.0, 0.0, 0.0, 1.0, first_glyph.x, 842.0 - first_glyph.y]); + + match font_source { + FontSource::BuiltIn(_) => { + content.show(Str(text.as_bytes())); + } + FontSource::TrueType { .. } => { + let utf16: Vec = text.encode_utf16().collect(); + let mut bytes = Vec::new(); + for code in utf16 { + bytes.push((code >> 8) as u8); + bytes.push(code as u8); + } + content.show(Str(&bytes)); + } + } + + content.end_text(); } - let text: String = line.glyphs.iter().map(|g| g.ch).collect(); - let first_glyph = &line.glyphs[0]; - - let font_name = if first_glyph.is_bold { - Name(b"F2") - } else if first_glyph.is_monospace { - Name(b"F3") - } else { - Name(b"F1") - }; - - content.begin_text(); - content.set_font(font_name, first_glyph.font_size); - content.set_text_matrix([1.0, 0.0, 0.0, 1.0, first_glyph.x, 842.0 - first_glyph.y]); - content.show(Str(text.as_bytes())); - content.end_text(); + pdf.stream(content_id, &content.finish()); } - pdf.stream(content_id, &content.finish()); - Ok(pdf.finish()) } -/// Embeds a standard PDF font into the document. +/// Embeds a font into the PDF document. +/// +/// Handles both built-in Type1 fonts and custom TrueType fonts. +/// Built-in fonts don't require embedding, while custom fonts are +/// fully embedded in the PDF. +fn embed_font_source(pdf: &mut Pdf, font_id: Ref, source: &FontSource) { + match source { + FontSource::BuiltIn(name) => { + pdf.type1_font(font_id).base_font(Name(name.as_bytes())); + } + FontSource::TrueType { data, info, .. } => { + embed_truetype_font(pdf, font_id, data, info); + } + } +} + +/// Embeds a TrueType font into the PDF. +/// +/// Creates the necessary PDF objects: font descriptor, CIDFont, and Type0 font. +fn embed_truetype_font(pdf: &mut Pdf, font_id: Ref, data: &[u8], info: &crate::md::font::FontInfo) { + let descriptor_id = Ref::new(font_id.get() + 100); + let cid_font_id = Ref::new(font_id.get() + 200); + let font_stream_id = Ref::new(font_id.get() + 300); + let cid_to_gid_id = Ref::new(font_id.get() + 400); + + let mut type0 = pdf.type0_font(font_id); + type0.base_font(Name(info.postscript_name.as_bytes())); + type0.encoding_predefined(Name(b"Identity-H")); + type0.descendant_font(cid_font_id); + type0.to_unicode(cid_to_gid_id); + type0.finish(); + + let mut cid_font = pdf.cid_font(cid_font_id); + cid_font.subtype(pdf_writer::types::CidFontType::Type2); + cid_font.base_font(Name(info.postscript_name.as_bytes())); + cid_font.system_info(pdf_writer::types::SystemInfo { + registry: Str(b"Adobe"), + ordering: Str(b"Identity"), + supplement: 0, + }); + cid_font.font_descriptor(descriptor_id); + cid_font.default_width(1000.0); + let mut widths = cid_font.widths(); + widths.same(0, 255, 500.0); + widths.finish(); + cid_font.finish(); + + let mut descriptor = pdf.font_descriptor(descriptor_id); + descriptor.name(Name(info.postscript_name.as_bytes())); + descriptor.flags(pdf_writer::types::FontFlags::SYMBOLIC); + descriptor.bbox(pdf_writer::Rect::new( + info.bbox.0 as f32, + info.bbox.1 as f32, + info.bbox.2 as f32, + info.bbox.3 as f32, + )); + descriptor.italic_angle(0.0); + descriptor.ascent(info.ascent as f32); + descriptor.descent(info.descent as f32); + descriptor.cap_height(info.cap_height as f32); + descriptor.stem_v(80.0); + descriptor.font_file2(font_stream_id); + descriptor.finish(); + + let mut font_stream = pdf.stream(font_stream_id, data); + font_stream.filter(pdf_writer::Filter::FlateDecode); + font_stream.finish(); + + let cmap = create_identity_cmap(); + pdf.stream(cid_to_gid_id, cmap.as_bytes()); +} + +/// Creates an identity CMap for Unicode to CID mapping. /// -/// Uses Type1 fonts which are part of the PDF standard and don't require external font files to be embedded. -fn embed_font(pdf: &mut Pdf, font_id: Ref, base_font: &str) { - pdf.type1_font(font_id).base_font(Name(base_font.as_bytes())); +/// This simple CMap maps each Unicode code point to the same CID value. +fn create_identity_cmap() -> String { + r#"/CIDInit /ProcSet findresource begin +12 dict begin +begincmap +/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def +/CMapName /Adobe-Identity-UCS def +/CMapType 2 def +1 begincodespacerange +<0000> +endcodespacerange +1 beginbfrange +<0000> <0000> +endbfrange +endcmap +CMapName currentdict /CMap defineresource pop +end +end"# + .to_string() } #[cfg(test)] mod tests { use super::*; - use crate::md::layout::{Glyph, PositionedLine}; + use crate::md::layout::{Glyph, Page, PositionedLine}; #[test] fn test_render_empty() { - let lines = vec![]; - let result = render_to_pdf(&lines); + let pages = vec![]; + let result = render_to_pdf(&pages); assert!(result.is_ok()); let pdf = result.unwrap(); assert!(!pdf.is_empty(), "PDF should have basic structure even when empty"); @@ -91,16 +221,18 @@ mod tests { #[test] fn test_render_single_line() { - let lines = vec![PositionedLine { - glyphs: vec![ - Glyph { ch: 'H', x: 50.0, y: 50.0, font_size: 12.0, is_bold: false, is_monospace: false }, - Glyph { ch: 'i', x: 56.0, y: 50.0, font_size: 12.0, is_bold: false, is_monospace: false }, - ], - y: 50.0, - height: 16.0, + let pages = vec![Page { + lines: vec![PositionedLine { + glyphs: vec![ + Glyph { ch: 'H', x: 50.0, y: 50.0, font_size: 12.0, is_bold: false, is_monospace: false }, + Glyph { ch: 'i', x: 56.0, y: 50.0, font_size: 12.0, is_bold: false, is_monospace: false }, + ], + y: 50.0, + height: 16.0, + }], }]; - let result = render_to_pdf(&lines); + let result = render_to_pdf(&pages); assert!(result.is_ok()); let pdf = result.unwrap(); assert!(pdf.len() > 100, "PDF with content should be larger"); @@ -108,16 +240,18 @@ mod tests { #[test] fn test_render_with_different_fonts() { - let lines = vec![PositionedLine { - glyphs: vec![ - Glyph { ch: 'A', x: 50.0, y: 50.0, font_size: 12.0, is_bold: true, is_monospace: false }, - Glyph { ch: 'B', x: 56.0, y: 50.0, font_size: 12.0, is_bold: false, is_monospace: true }, - ], - y: 50.0, - height: 16.0, + let pages = vec![Page { + lines: vec![PositionedLine { + glyphs: vec![ + Glyph { ch: 'A', x: 50.0, y: 50.0, font_size: 12.0, is_bold: true, is_monospace: false }, + Glyph { ch: 'B', x: 56.0, y: 50.0, font_size: 12.0, is_bold: false, is_monospace: true }, + ], + y: 50.0, + height: 16.0, + }], }]; - let result = render_to_pdf(&lines); + let result = render_to_pdf(&pages); assert!(result.is_ok()); } } diff --git a/crates/core/src/md/styler.rs b/crates/core/src/md/styler.rs index fb7bba9..d513985 100644 --- a/crates/core/src/md/styler.rs +++ b/crates/core/src/md/styler.rs @@ -74,6 +74,7 @@ pub struct StyledBlock { pub content: String, pub style: Style, pub is_list_item: bool, + pub keep_with_next: bool, } /// Applies semantic styling to IR nodes, converting structure to styled blocks. @@ -84,16 +85,23 @@ pub fn apply_styles(nodes: Vec) -> Vec { .into_iter() .map(|node| match node { IRNode::Heading { level, text } => { - StyledBlock { content: text, style: Style::heading(level), is_list_item: false } + StyledBlock { content: text, style: Style::heading(level), is_list_item: false, keep_with_next: true } + } + IRNode::Paragraph { text } => { + StyledBlock { content: text, style: Style::paragraph(), is_list_item: false, keep_with_next: false } } - IRNode::Paragraph { text } => StyledBlock { content: text, style: Style::paragraph(), is_list_item: false }, IRNode::CodeBlock { code, .. } => { - StyledBlock { content: code, style: Style::code_block(), is_list_item: false } + StyledBlock { content: code, style: Style::code_block(), is_list_item: false, keep_with_next: false } } - IRNode::ListItem { text } => { - StyledBlock { content: format!("- {}", text), style: Style::list_item(), is_list_item: true } + IRNode::ListItem { text } => StyledBlock { + content: format!("- {}", text), + style: Style::list_item(), + is_list_item: true, + keep_with_next: false, + }, + IRNode::Text { content } => { + StyledBlock { content, style: Style::paragraph(), is_list_item: false, keep_with_next: false } } - IRNode::Text { content } => StyledBlock { content, style: Style::paragraph(), is_list_item: false }, }) .collect() } -- 2.51.2