From 7a9976af9ac642e0dfe999e71172af56d61fa859 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 9 Dec 2025 21:07:27 -0600 Subject: [PATCH] feat: initial implementation of picopdf CLI and core functionality * CLI structure using clap for markdown to PDF conversion. * layout engine for positioning text and glyphs. --- Cargo.lock | 278 +++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- crates/cli/Cargo.toml | 12 ++ crates/cli/src/main.rs | 66 ++++++++ crates/core/Cargo.toml | 11 ++ crates/core/src/lib.rs | 1 + crates/core/src/md/layout.rs | 188 ++++++++++++++++++++++ crates/core/src/md/mod.rs | 60 +++++++ crates/core/src/md/parser.rs | 154 ++++++++++++++++++ crates/core/src/md/renderer.rs | 123 +++++++++++++++ crates/core/src/md/styler.rs | 147 +++++++++++++++++ 11 files changed, 1041 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/src/main.rs create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/lib.rs create mode 100644 crates/core/src/md/layout.rs create mode 100644 crates/core/src/md/mod.rs create mode 100644 crates/core/src/md/parser.rs create mode 100644 crates/core/src/md/renderer.rs create mode 100644 crates/core/src/md/styler.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7e8e282 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,278 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[package]] +name = "pdf-writer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a79477295a713c2ed425aa82a8b5d20cec3fdee203706cbe6f3854880c1c81" +dependencies = [ + "bitflags", + "itoa", + "memchr", + "ryu", +] + +[[package]] +name = "picopdf" +version = "0.1.0" +dependencies = [ + "clap", + "owo-colors", + "picopdf-core", +] + +[[package]] +name = "picopdf-core" +version = "0.1.0" +dependencies = [ + "pdf-writer", + "pulldown-cmark", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml index 5ee3ae4..2e3fc7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = [] +members = ["crates/cli", "crates/core"] [workspace.lints.clippy] bool_comparison = "deny" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..a47d8f5 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "picopdf" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.5.53", features = ["derive"] } +owo-colors = "4.2.3" +picopdf-core = { path = "../core" } + +[lints] +workspace = true diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..23b530b --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,66 @@ +use clap::{Parser, Subcommand}; +use owo_colors::OwoColorize; +use std::fs; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "picopdf")] +#[command(about = "A minimalist markdown to PDF converter", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Convert markdown file to PDF + Write { + /// Input markdown file + #[arg(short, long)] + input: PathBuf, + + /// Output PDF file + #[arg(short, long)] + output: PathBuf, + }, +} + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Commands::Write { input, output } => { + if let Err(e) = write_command(&input, &output) { + eprintln!("{} {}", "Error:".red().bold(), e); + std::process::exit(1); + } + } + } +} + +/// 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> { + 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))?; + + println!("{} markdown to PDF...", "Converting".cyan().bold()); + + let pdf_bytes = picopdf_core::md::markdown_to_pdf(&markdown)?; + + println!("{} PDF to {}", "Writing".cyan().bold(), output.display()); + + fs::write(output, pdf_bytes).map_err(|e| format!("Failed to write output file '{}': {}", output.display(), e))?; + + println!( + "{} Successfully converted {} to {}", + "✓".green().bold(), + input.display(), + output.display() + ); + + Ok(()) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..12f628f --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "picopdf-core" +version = "0.1.0" +edition = "2024" + +[dependencies] +pdf-writer = "0.14.0" +pulldown-cmark = "0.13.0" + +[lints] +workspace = true diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..0301ec5 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1 @@ +pub mod md; diff --git a/crates/core/src/md/layout.rs b/crates/core/src/md/layout.rs new file mode 100644 index 0000000..f671fb6 --- /dev/null +++ b/crates/core/src/md/layout.rs @@ -0,0 +1,188 @@ +use crate::md::styler::{Style, StyledBlock}; + +/// A positioned glyph with its exact location and styling. +/// +/// Represents a single character after layout, ready for rendering. +#[derive(Debug, Clone)] +pub struct Glyph { + pub ch: char, + pub x: f32, + pub y: f32, + pub font_size: f32, + pub is_bold: bool, + pub is_monospace: bool, +} + +/// A line of positioned glyphs. +/// +/// Represents a single line of text after line breaking and positioning. +#[derive(Debug, Clone)] +pub struct PositionedLine { + pub glyphs: Vec, + pub y: f32, + pub height: f32, +} + +/// 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, + margin: f32, + current_y: f32, +} + +impl LayoutEngine { + fn new(page_width: f32, _page_height: f32, margin: f32) -> Self { + Self { page_width, margin, current_y: margin } + } + + 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(); + let max_width = self.page_width - (2.0 * self.margin); + let char_width = self.char_width(&block.style); + + self.current_y += block.style.margin_top; + + let words: Vec<&str> = block.content.split_whitespace().collect(); + let mut current_line = String::new(); + let mut line_width = 0.0; + + for word in words { + let word_width = word.len() as f32 * char_width; + 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; + current_line.clear(); + line_width = 0.0; + } + + if !current_line.is_empty() { + current_line.push(' '); + line_width += space_width; + } + + current_line.push_str(word); + line_width += word_width; + } + + if !current_line.is_empty() { + lines.push(self.create_line(¤t_line, &block.style)); + self.current_y += block.style.line_height; + } + + self.current_y += block.style.margin_bottom; + lines + } + + fn create_line(&self, text: &str, style: &Style) -> PositionedLine { + let char_width = self.char_width(style); + let mut glyphs = Vec::new(); + let mut x = self.margin; + + for ch in text.chars() { + glyphs.push(Glyph { + ch, + x, + y: self.current_y, + font_size: style.font_size, + is_bold: style.is_bold, + is_monospace: style.is_monospace, + }); + x += char_width; + } + + PositionedLine { glyphs, y: self.current_y, height: style.line_height } + } +} + +/// Layouts styled blocks into positioned lines with glyphs. +/// +/// Performs line breaking and vertical positioning 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(); + + for block in blocks { + let lines = engine.layout_block(block); + all_lines.extend(lines); + } + + all_lines +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::md::styler::{Style, StyledBlock}; + + #[test] + fn test_simple_layout() { + let blocks = vec![StyledBlock { + content: "Hello world".to_string(), + style: Style { + font_size: 12.0, + line_height: 16.0, + margin_top: 0.0, + margin_bottom: 0.0, + is_bold: false, + is_monospace: false, + }, + is_list_item: false, + }]; + + let lines = layout_blocks(&blocks); + assert_eq!(lines.len(), 1); + assert!(!lines[0].glyphs.is_empty()); + } + + #[test] + fn test_line_breaking() { + let long_text = + "This is a very long line that should be broken into multiple lines when laid out on the page".to_string(); + let blocks = vec![StyledBlock { + content: long_text, + style: Style { + font_size: 12.0, + line_height: 16.0, + margin_top: 0.0, + margin_bottom: 0.0, + is_bold: false, + is_monospace: false, + }, + is_list_item: false, + }]; + + let lines = layout_blocks(&blocks); + assert!(lines.len() > 1, "Long text should break into multiple lines"); + } + + #[test] + fn test_glyph_positioning() { + let blocks = vec![StyledBlock { + content: "AB".to_string(), + style: Style { + font_size: 12.0, + line_height: 16.0, + margin_top: 0.0, + margin_bottom: 0.0, + is_bold: false, + is_monospace: false, + }, + is_list_item: false, + }]; + + let lines = layout_blocks(&blocks); + assert_eq!(lines[0].glyphs.len(), 2); + assert!( + lines[0].glyphs[1].x > lines[0].glyphs[0].x, + "Second glyph should be positioned after first" + ); + } +} diff --git a/crates/core/src/md/mod.rs b/crates/core/src/md/mod.rs new file mode 100644 index 0000000..274bd39 --- /dev/null +++ b/crates/core/src/md/mod.rs @@ -0,0 +1,60 @@ +/// Stage 1: Markdown -> AST/IR (structure) +mod parser; + +/// Stage 2: AST -> styled blocks (semantics/theme) +mod styler; + +/// Stage 3: Blocks -> positioned lines & glyphs (layout) +mod layout; + +/// Stage 4: Lines -> PDF (backend) +mod renderer; + +pub use layout::{LayoutEngine, PositionedLine}; +pub use parser::parse_markdown; +pub use renderer::render_to_pdf; +pub use styler::{Style, StyledBlock}; + +use std::io; + +/// Converts markdown text to PDF bytes through the four-stage pipeline. +/// +/// Pipeline stages: +/// 1. Parse markdown into intermediate representation +/// 2. Apply styling and semantics to create styled blocks +/// 3. Layout blocks into positioned lines and glyphs +/// 4. Render positioned content to PDF format +pub fn markdown_to_pdf(markdown: &str) -> io::Result> { + let ir = parse_markdown(markdown); + let styled = styler::apply_styles(ir); + let positioned = layout::layout_blocks(&styled); + render_to_pdf(&positioned) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_complete_pipeline() { + let markdown = "# Title\n\nThis is a paragraph.\n\n## Subtitle\n\n- Item 1\n- Item 2"; + let result = markdown_to_pdf(markdown); + assert!(result.is_ok()); + let pdf = result.unwrap(); + assert!(pdf.len() > 200, "Complete PDF should have substantial content"); + } + + #[test] + fn test_pipeline_with_code() { + let markdown = "# Code Example\n\n```rust\nfn main() {\n println!(\"Hello\");\n}\n```"; + let result = markdown_to_pdf(markdown); + assert!(result.is_ok()); + } + + #[test] + fn test_pipeline_empty() { + let markdown = ""; + let result = markdown_to_pdf(markdown); + assert!(result.is_ok()); + } +} diff --git a/crates/core/src/md/parser.rs b/crates/core/src/md/parser.rs new file mode 100644 index 0000000..992e095 --- /dev/null +++ b/crates/core/src/md/parser.rs @@ -0,0 +1,154 @@ +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; + +/// Intermediate representation of the markdown document structure. +/// +/// This IR captures the semantic structure without styling or layout concerns. +#[derive(Debug, Clone)] +pub enum IRNode { + Heading { level: u8, text: String }, + Paragraph { text: String }, + CodeBlock { lang: Option, code: String }, + ListItem { text: String }, + Text { content: String }, +} + +/// Parses markdown text into an intermediate representation using pulldown-cmark. +/// +/// This stage focuses purely on extracting document structure, deferring all styling and layout decisions to later pipeline stages. +pub fn parse_markdown(markdown: &str) -> Vec { + let parser = Parser::new(markdown); + let mut nodes = Vec::new(); + let mut current_text = String::new(); + let mut in_heading = None; + let mut in_paragraph = false; + let mut in_code_block = false; + let mut code_lang = None; + let mut in_list_item = false; + + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Heading { level, .. } => { + in_heading = Some(level as u8); + current_text.clear(); + } + Tag::Paragraph => { + in_paragraph = true; + current_text.clear(); + } + Tag::CodeBlock(kind) => { + in_code_block = true; + code_lang = match kind { + pulldown_cmark::CodeBlockKind::Fenced(lang) => Some(lang.to_string()), + pulldown_cmark::CodeBlockKind::Indented => None, + }; + current_text.clear(); + } + Tag::Item => { + in_list_item = true; + current_text.clear(); + } + _ => {} + }, + Event::End(tag) => match tag { + TagEnd::Heading(_) => { + if let Some(level) = in_heading.take() { + nodes.push(IRNode::Heading { level, text: current_text.clone() }); + current_text.clear(); + } + } + TagEnd::Paragraph => { + if in_paragraph { + nodes.push(IRNode::Paragraph { text: current_text.clone() }); + current_text.clear(); + in_paragraph = false; + } + } + TagEnd::CodeBlock => { + if in_code_block { + nodes.push(IRNode::CodeBlock { lang: code_lang.take(), code: current_text.clone() }); + current_text.clear(); + in_code_block = false; + } + } + TagEnd::Item => { + if in_list_item { + nodes.push(IRNode::ListItem { text: current_text.clone() }); + current_text.clear(); + in_list_item = false; + } + } + _ => {} + }, + Event::Text(text) => current_text.push_str(&text), + Event::Code(code) => current_text.push_str(&code), + Event::SoftBreak | Event::HardBreak => current_text.push(' '), + _ => {} + } + } + + nodes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_heading() { + let markdown = "# Heading 1\n## Heading 2"; + let nodes = parse_markdown(markdown); + + assert_eq!(nodes.len(), 2); + match &nodes[0] { + IRNode::Heading { level, text } => { + assert_eq!(*level, 1); + assert_eq!(text, "Heading 1"); + } + _ => panic!("Expected heading"), + } + } + + #[test] + fn test_parse_paragraph() { + let markdown = "This is a paragraph."; + let nodes = parse_markdown(markdown); + + assert_eq!(nodes.len(), 1); + match &nodes[0] { + IRNode::Paragraph { text } => { + assert_eq!(text, "This is a paragraph."); + } + _ => panic!("Expected paragraph"), + } + } + + #[test] + fn test_parse_code_block() { + let markdown = "```rust\nfn main() {}\n```"; + let nodes = parse_markdown(markdown); + + assert_eq!(nodes.len(), 1); + match &nodes[0] { + IRNode::CodeBlock { lang, code } => { + assert_eq!(lang.as_deref(), Some("rust")); + assert_eq!(code, "fn main() {}\n"); + } + _ => panic!("Expected code block"), + } + } + + #[test] + fn test_parse_list() { + let markdown = "- Item 1\n- Item 2"; + let nodes = parse_markdown(markdown); + + assert_eq!(nodes.len(), 2); + match &nodes[0] { + IRNode::ListItem { text } => { + assert_eq!(text, "Item 1"); + } + _ => panic!("Expected list item"), + } + } +} diff --git a/crates/core/src/md/renderer.rs b/crates/core/src/md/renderer.rs new file mode 100644 index 0000000..4fa8bc6 --- /dev/null +++ b/crates/core/src/md/renderer.rs @@ -0,0 +1,123 @@ +use crate::md::layout::PositionedLine; +use pdf_writer::{Content, Finish, Name, Pdf, Rect, Ref, Str}; +use std::io; + +/// Renders positioned lines 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> { + 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); + + 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; + } + + 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()); + + Ok(pdf.finish()) +} + +/// Embeds a standard PDF font into the document. +/// +/// 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())); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::md::layout::{Glyph, PositionedLine}; + + #[test] + fn test_render_empty() { + let lines = vec![]; + let result = render_to_pdf(&lines); + assert!(result.is_ok()); + let pdf = result.unwrap(); + assert!(!pdf.is_empty(), "PDF should have basic structure even when empty"); + } + + #[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 result = render_to_pdf(&lines); + assert!(result.is_ok()); + let pdf = result.unwrap(); + assert!(pdf.len() > 100, "PDF with content should be larger"); + } + + #[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 result = render_to_pdf(&lines); + assert!(result.is_ok()); + } +} diff --git a/crates/core/src/md/styler.rs b/crates/core/src/md/styler.rs new file mode 100644 index 0000000..fb7bba9 --- /dev/null +++ b/crates/core/src/md/styler.rs @@ -0,0 +1,147 @@ +use crate::md::parser::IRNode; + +/// Style properties applied to text. +/// +/// Encapsulates visual styling decisions separate from document structure. +#[derive(Debug, Clone)] +pub struct Style { + pub font_size: f32, + pub line_height: f32, + pub margin_top: f32, + pub margin_bottom: f32, + pub is_bold: bool, + pub is_monospace: bool, +} + +impl Style { + fn heading(level: u8) -> Self { + let font_size = match level { + 1 => 24.0, + 2 => 20.0, + 3 => 16.0, + _ => 14.0, + }; + + Self { + font_size, + line_height: font_size * 1.2, + margin_top: font_size * 0.8, + margin_bottom: font_size * 0.4, + is_bold: true, + is_monospace: false, + } + } + + fn paragraph() -> Self { + Self { + font_size: 12.0, + line_height: 16.0, + margin_top: 6.0, + margin_bottom: 6.0, + is_bold: false, + is_monospace: false, + } + } + + fn code_block() -> Self { + Self { + font_size: 10.0, + line_height: 14.0, + margin_top: 8.0, + margin_bottom: 8.0, + is_bold: false, + is_monospace: true, + } + } + + fn list_item() -> Self { + Self { + font_size: 12.0, + line_height: 16.0, + margin_top: 2.0, + margin_bottom: 2.0, + is_bold: false, + is_monospace: false, + } + } +} + +/// A block of content with associated styling. +/// +/// Represents the semantic unit after style application but before layout. +#[derive(Debug, Clone)] +pub struct StyledBlock { + pub content: String, + pub style: Style, + pub is_list_item: bool, +} + +/// Applies semantic styling to IR nodes, converting structure to styled blocks. +/// +/// This stage maps document semantics (headings, code, etc.) to visual styles while remaining independent of specific layout or rendering concerns. +pub fn apply_styles(nodes: Vec) -> Vec { + nodes + .into_iter() + .map(|node| match node { + IRNode::Heading { level, text } => { + StyledBlock { content: text, style: Style::heading(level), is_list_item: 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 } + } + IRNode::ListItem { text } => { + StyledBlock { content: format!("- {}", text), style: Style::list_item(), is_list_item: true } + } + IRNode::Text { content } => StyledBlock { content, style: Style::paragraph(), is_list_item: false }, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::md::parser::IRNode; + + #[test] + fn test_heading_style() { + let nodes = vec![IRNode::Heading { level: 1, text: "Title".to_string() }]; + let blocks = apply_styles(nodes); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].content, "Title"); + assert_eq!(blocks[0].style.font_size, 24.0); + assert!(blocks[0].style.is_bold); + } + + #[test] + fn test_paragraph_style() { + let nodes = vec![IRNode::Paragraph { text: "Content".to_string() }]; + let blocks = apply_styles(nodes); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].content, "Content"); + assert_eq!(blocks[0].style.font_size, 12.0); + assert!(!blocks[0].style.is_bold); + } + + #[test] + fn test_code_block_style() { + let nodes = vec![IRNode::CodeBlock { lang: Some("rust".to_string()), code: "let x = 5;".to_string() }]; + let blocks = apply_styles(nodes); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].content, "let x = 5;"); + assert!(blocks[0].style.is_monospace); + } + + #[test] + fn test_list_item_style() { + let nodes = vec![IRNode::ListItem { text: "First item".to_string() }]; + let blocks = apply_styles(nodes); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].content, "- First item"); + assert!(blocks[0].is_list_item); + } +} -- 2.51.2