From a84cf7eb8afe54fbbdd23d4b0ccc38c3639c6b0c Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 9 Dec 2025 22:46:05 -0600 Subject: [PATCH] feat: preserver escape characters and line breaks in code blocks --- README.md | 52 ++++++++ README.pdf | 235 ++++++++++++++++++++++++----------- TODO.md | 12 +- crates/core/src/md/layout.rs | 102 ++++++++------- crates/core/src/md/parser.rs | 8 +- crates/core/src/md/styler.rs | 42 +++++-- 6 files changed, 320 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index a6a75c6..a324456 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A minimalist markdown to PDF converter with custom font support. ## Usage +### Markdown to PDF + Convert markdown to PDF using default fonts: ```bash @@ -19,6 +21,56 @@ picopdf write -i input.md -o output.pdf \ --font-mono ~/fonts/mono.ttf ``` +### PDF Manipulation Tools + +Merge multiple PDFs: + +```bash +picopdf tool merge file1.pdf file2.pdf file3.pdf -o merged.pdf +``` + +Split PDF into individual pages: + +```bash +picopdf tool split -i input.pdf -o ./output_dir +``` + +Split PDF by page ranges: + +```bash +picopdf tool split -i input.pdf -o ./output_dir -r "1-3,5,7-9" +``` + +Extract specific pages: + +```bash +picopdf tool extract -i input.pdf -o output.pdf -p "1,3,5-7" +``` + +Delete specific pages: + +```bash +picopdf tool delete -i input.pdf -o output.pdf -p "2,4,6-8" +``` + +Rotate all pages 90 degrees: + +```bash +picopdf tool rotate -i input.pdf -o output.pdf +``` + +Rotate specific pages by custom angle: + +```bash +picopdf tool rotate -i input.pdf -o output.pdf -a 180 -p "1,3,5" +``` + +Reorganize pages in custom order: + +```bash +picopdf tool organize -i input.pdf -o output.pdf -n "3,1,2,4-6" +``` + ## Architecture Four-stage markdown conversion pipeline: diff --git a/README.pdf b/README.pdf index a13a783..d27b2d5 100644 --- a/README.pdf +++ b/README.pdf @@ -58,7 +58,7 @@ endobj 7 0 obj << - /Length 1931 + /Length 1882 >> stream BT @@ -74,107 +74,112 @@ ET BT /F2 20 Tf 1 0 0 1 50 690.4 Tm -(Building) Tj +(Usage) Tj ET BT -/F3 10 Tf -1 0 0 1 50 650.4 Tm -(cargo build --release) Tj +/F2 16 Tf +1 0 0 1 50 645.6 Tm +(Markdown to PDF) Tj ET BT /F1 12 Tf -1 0 0 1 50 622.4 Tm -(The binary will be at target/release/picopdf.) Tj +1 0 0 1 50 614 Tm +(Convert markdown to PDF using default fonts:) Tj ET BT -/F2 20 Tf -1 0 0 1 50 584.4 Tm -(Usage) Tj +/F3 10 Tf +1 0 0 1 50 584 Tm +(picopdf write -i input.md -o output.pdf) Tj ET BT /F1 12 Tf -1 0 0 1 50 546.4 Tm -(Convert markdown to PDF using default fonts:) Tj +1 0 0 1 50 556 Tm +(Use custom TrueType fonts:) Tj ET BT /F3 10 Tf -1 0 0 1 50 516.4 Tm -(picopdf write -i input.md -o output.pdf) Tj +1 0 0 1 50 526 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 +/F3 10 Tf +1 0 0 1 50 512 Tm +( --font-regular ~/fonts/regular.ttf \\) 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 +1 0 0 1 50 498 Tm +( --font-bold ~/fonts/bold.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 +1 0 0 1 50 484 Tm +( --font-mono ~/fonts/mono.ttf) Tj ET BT -/F2 20 Tf -1 0 0 1 50 406.4 Tm -(Architecture) Tj +/F2 16 Tf +1 0 0 1 50 449.2 Tm +(PDF Manipulation Tools) Tj ET BT /F1 12 Tf -1 0 0 1 50 368.4 Tm -(Four-stage pipeline:) Tj +1 0 0 1 50 417.6 Tm +(Merge multiple PDFs:) Tj ET BT -/F1 12 Tf -1 0 0 1 50 344.4 Tm -(- Markdown to AST/IR using pulldown-cmark) Tj +/F3 10 Tf +1 0 0 1 50 387.6 Tm +(picopdf tool merge file1.pdf file2.pdf file3.pdf -o merged.pdf) Tj ET BT /F1 12 Tf -1 0 0 1 50 324.40002 Tm -(- AST to styled blocks with semantic styling) Tj +1 0 0 1 50 359.6 Tm +(Split PDF into individual pages:) Tj ET BT -/F1 12 Tf -1 0 0 1 50 304.40002 Tm -(- Blocks to positioned lines and glyphs via layout engine) Tj +/F3 10 Tf +1 0 0 1 50 329.59998 Tm +(picopdf tool split -i input.pdf -o ./output_dir) Tj ET BT /F1 12 Tf -1 0 0 1 50 284.40002 Tm -(- Lines to PDF using pdf-writer with embedded fonts) Tj +1 0 0 1 50 301.59998 Tm +(Split PDF by page ranges:) Tj ET BT -/F2 20 Tf -1 0 0 1 50 250.40002 Tm -(Font Support) Tj +/F3 10 Tf +1 0 0 1 50 271.59998 Tm +(picopdf tool split -i input.pdf -o ./output_dir -r "1-3,5,7-9") 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 +1 0 0 1 50 243.59998 Tm +(Extract specific pages:) 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 +/F3 10 Tf +1 0 0 1 50 213.59998 Tm +(picopdf tool extract -i input.pdf -o output.pdf -p "1,3,5-7") Tj ET BT /F1 12 Tf -1 0 0 1 50 180.40002 Tm -(proper Unicode support.) Tj +1 0 0 1 50 185.59998 Tm +(Delete specific pages:) 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 +/F3 10 Tf +1 0 0 1 50 155.59998 Tm +(picopdf tool delete -i input.pdf -o output.pdf -p "2,4,6-8") Tj ET BT /F1 12 Tf -1 0 0 1 50 136.40002 Tm -(typically 2-7MB depending on font complexity.) Tj +1 0 0 1 50 127.599976 Tm +(Rotate all pages 90 degrees:) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 97.599976 Tm +(picopdf tool rotate -i input.pdf -o output.pdf) Tj ET endstream endobj @@ -197,63 +202,153 @@ endobj 9 0 obj << - /Length 762 + /Length 2280 >> stream BT +/F1 12 Tf +1 0 0 1 50 786 Tm +(Rotate specific pages by custom angle:) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 756 Tm +(picopdf tool rotate -i input.pdf -o output.pdf -a 180 -p "1,3,5") Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 728 Tm +(Reorganize pages in custom order:) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 698 Tm +(picopdf tool organize -i input.pdf -o output.pdf -n "3,1,2,4-6") Tj +ET +BT /F2 20 Tf -1 0 0 1 50 776 Tm +1 0 0 1 50 660 Tm +(Architecture) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 622 Tm +(Four-stage markdown conversion pipeline:) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 598 Tm +(- Markdown to AST/IR using pulldown-cmark) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 578 Tm +(- AST to styled blocks with semantic styling) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 558 Tm +(- Blocks to positioned lines and glyphs via layout engine) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 538 Tm +(- Lines to PDF using pdf-writer with embedded fonts) Tj +ET +BT +/F2 20 Tf +1 0 0 1 50 504 Tm +(Font Support) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 466 Tm +(Default fonts are built-in PDF Type1 fonts (Helvetica, Courier) requiring no) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 450 Tm +(embedding. Custom TrueType/OpenType fonts are fully embedded in the PDF with) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 434 Tm +(proper Unicode support.) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 406 Tm +(Default PDFs are around pretty small (2-5KB), see this file as an example. Custom) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 390 Tm +(font PDFs include the full font files, typically 2-7MB depending on font) Tj +ET +BT +/F1 12 Tf +1 0 0 1 50 374 Tm +(complexity.) Tj +ET +BT +/F2 20 Tf +1 0 0 1 50 336 Tm (Supported Markdown) Tj ET BT /F1 12 Tf -1 0 0 1 50 742 Tm +1 0 0 1 50 302 Tm (- Headings (H1-H4)) Tj ET BT /F1 12 Tf -1 0 0 1 50 722 Tm +1 0 0 1 50 282 Tm (- Paragraphs) Tj ET BT /F1 12 Tf -1 0 0 1 50 702 Tm +1 0 0 1 50 262 Tm (- Lists) Tj ET BT /F1 12 Tf -1 0 0 1 50 682 Tm +1 0 0 1 50 242 Tm (- Code blocks) Tj ET BT /F1 12 Tf -1 0 0 1 50 662 Tm +1 0 0 1 50 222 Tm (- Inline code) Tj ET BT /F2 20 Tf -1 0 0 1 50 628 Tm +1 0 0 1 50 188 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 +1 0 0 1 50 148 Tm +(# Using 0xProto Nerd Font) Tj +ET +BT +/F3 10 Tf +1 0 0 1 50 134 Tm +(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 +1 0 0 1 50 120 Tm +( --font-regular ~/.local/share/fonts/0xProto-Regular.ttf \\) Tj ET BT /F3 10 Tf -1 0 0 1 50 560 Tm -(~/.local/share/fonts/0xProto-Bold.ttf \\ --font-mono) Tj +1 0 0 1 50 106 Tm +( --font-bold ~/.local/share/fonts/0xProto-Bold.ttf \\) Tj ET BT /F3 10 Tf -1 0 0 1 50 546 Tm -(~/.local/share/fonts/0xProtoMono-Regular.ttf) Tj +1 0 0 1 50 92 Tm +( --font-mono ~/.local/share/fonts/0xProtoMono-Regular.ttf) Tj ET endstream endobj @@ -268,13 +363,13 @@ xref 0000000299 00000 n 0000000374 00000 n 0000000559 00000 n -0000002545 00000 n -0000002730 00000 n +0000002496 00000 n +0000002681 00000 n trailer << /Size 10 /Root 1 0 R >> startxref -3546 +5016 %%EOF \ No newline at end of file diff --git a/TODO.md b/TODO.md index 3c62c47..137b6ad 100644 --- a/TODO.md +++ b/TODO.md @@ -2,12 +2,12 @@ ## Tools -- [ ] Merge: Combine multiple PDFs into one. -- [ ] Split: Extract or split pages into separate PDFs. -- [ ] Rotate: Rotate one or all pages. -- [ ] Delete Pages: Remove pages. -- [ ] Extract Pages: Pull out selected pages into a new file. -- [ ] Organize: Rearrange, add, delete, and rotate pages in one UI. +- [x] Merge: Combine multiple PDFs into one. +- [x] Split: Extract or split pages into separate PDFs. +- [x] Rotate: Rotate one or all pages. +- [x] Delete Pages: Remove pages. +- [x] Extract Pages: Pull out selected pages into a new file. +- [x] Organize: Rearrange, add, delete, and rotate pages in one. - [ ] Compress: Reduce file size with basic/strong compression. ## From diff --git a/crates/core/src/md/layout.rs b/crates/core/src/md/layout.rs index e33a4f5..bb88699 100644 --- a/crates/core/src/md/layout.rs +++ b/crates/core/src/md/layout.rs @@ -69,30 +69,36 @@ impl LayoutEngine { } 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; + let line_count = if block.is_code_block { + block.content.lines().count().max(1) + } else { + 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 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 { + 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_width += space_width; + count += 1; } - line_width += word_width; - } - - if line_width > 0.0 { - line_count += 1; - } + count + }; block.style.margin_top + block.style.margin_bottom + (line_count as f32 * block.style.line_height) } @@ -102,36 +108,42 @@ impl LayoutEngine { 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); 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() { - self.add_line(¤t_line, &block.style); - current_line.clear(); - line_width = 0.0; + if block.is_code_block { + for line in block.content.lines() { + self.add_line(line, &block.style); + } + } else { + let max_width = self.page_width - (2.0 * self.margin); + let char_width = self.char_width(&block.style); + 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() { + self.add_line(¤t_line, &block.style); + 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() { - current_line.push(' '); - line_width += space_width; + self.add_line(¤t_line, &block.style); } - - current_line.push_str(word); - line_width += word_width; - } - - if !current_line.is_empty() { - self.add_line(¤t_line, &block.style); } self.current_y += block.style.margin_bottom; @@ -218,6 +230,7 @@ mod tests { is_monospace: false, }, is_list_item: false, + is_code_block: false, keep_with_next: false, }]; @@ -241,6 +254,7 @@ mod tests { is_monospace: false, }, is_list_item: false, + is_code_block: false, keep_with_next: false, }]; @@ -262,6 +276,7 @@ mod tests { is_monospace: false, }, is_list_item: false, + is_code_block: false, keep_with_next: false, }]; @@ -286,6 +301,7 @@ mod tests { is_monospace: false, }, is_list_item: false, + is_code_block: false, keep_with_next: false, }; @@ -300,6 +316,7 @@ mod tests { is_monospace: false, }, is_list_item: false, + is_code_block: false, keep_with_next: true, }; @@ -314,6 +331,7 @@ mod tests { is_monospace: false, }, is_list_item: true, + is_code_block: false, keep_with_next: false, }; diff --git a/crates/core/src/md/parser.rs b/crates/core/src/md/parser.rs index 992e095..472e9bb 100644 --- a/crates/core/src/md/parser.rs +++ b/crates/core/src/md/parser.rs @@ -82,7 +82,13 @@ pub fn parse_markdown(markdown: &str) -> Vec { }, Event::Text(text) => current_text.push_str(&text), Event::Code(code) => current_text.push_str(&code), - Event::SoftBreak | Event::HardBreak => current_text.push(' '), + Event::SoftBreak | Event::HardBreak => { + if in_code_block { + current_text.push('\n'); + } else { + current_text.push(' '); + } + } _ => {} } } diff --git a/crates/core/src/md/styler.rs b/crates/core/src/md/styler.rs index d513985..d7457fc 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 is_code_block: bool, pub keep_with_next: bool, } @@ -84,24 +85,41 @@ 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, keep_with_next: true } - } - IRNode::Paragraph { text } => { - StyledBlock { content: text, style: Style::paragraph(), is_list_item: false, keep_with_next: false } - } - IRNode::CodeBlock { code, .. } => { - StyledBlock { content: code, style: Style::code_block(), is_list_item: false, keep_with_next: false } - } + IRNode::Heading { level, text } => StyledBlock { + content: text, + style: Style::heading(level), + is_list_item: false, + is_code_block: false, + keep_with_next: true, + }, + IRNode::Paragraph { text } => StyledBlock { + content: text, + style: Style::paragraph(), + is_list_item: false, + is_code_block: false, + keep_with_next: false, + }, + IRNode::CodeBlock { code, .. } => StyledBlock { + content: code, + style: Style::code_block(), + is_list_item: false, + is_code_block: true, + keep_with_next: false, + }, IRNode::ListItem { text } => StyledBlock { content: format!("- {}", text), style: Style::list_item(), is_list_item: true, + is_code_block: false, + keep_with_next: false, + }, + IRNode::Text { content } => StyledBlock { + content, + style: Style::paragraph(), + is_list_item: false, + is_code_block: false, keep_with_next: false, }, - IRNode::Text { content } => { - StyledBlock { content, style: Style::paragraph(), is_list_item: false, keep_with_next: false } - } }) .collect() } -- 2.51.2