Skip to main content

max / balanced_breakfast

15.3 KB · 456 lines History Blame Raw
1 use printpdf::{
2 BuiltinFont, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt, TextItem,
3 };
4 use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
5
6 use crate::Error;
7 use crate::layout::{
8 BLOCK_GAP_MM, BODY_PT, H1_PT, H2_PT, H3_PT, HEADING_GAP_MM, LIST_INDENT_MM, MARGIN, MONO_PT,
9 PAGE_H, PAGE_W, QUOTE_INDENT_MM, content_bottom_mm, content_top_mm, content_width_mm,
10 line_height_mm, text_width_mm,
11 };
12
13 pub fn render_article(html: &str, title: &str, url: Option<&str>) -> Result<Vec<u8>, Error> {
14 let markdown = pter::convert(html);
15 let markdown = markdown.trim();
16 if markdown.is_empty() {
17 return Err(Error::Empty);
18 }
19
20 let mut r = Renderer::new(title);
21 if !title.trim().is_empty() {
22 r.draw_heading(title, H1_PT);
23 if let Some(u) = url {
24 r.draw_paragraph(
25 u,
26 Style {
27 italic: true,
28 ..Style::default()
29 },
30 );
31 }
32 r.block_gap();
33 }
34 r.render_markdown(markdown);
35 r.finish()
36 }
37
38 #[derive(Copy, Clone, Default)]
39 struct Style {
40 bold: bool,
41 italic: bool,
42 mono: bool,
43 size_pt: f32,
44 left_indent_mm: f32,
45 bullet: Option<char>,
46 number: Option<u64>,
47 }
48
49 impl Style {
50 fn body() -> Self {
51 Self {
52 size_pt: BODY_PT,
53 ..Self::default()
54 }
55 }
56 }
57
58 struct Renderer {
59 title: String,
60 /// Pages already flushed. The page currently being filled lives in `ops`.
61 pages: Vec<PdfPage>,
62 /// Content operations for the page being filled.
63 ops: Vec<Op>,
64 y_mm: f32,
65 }
66
67 impl Renderer {
68 fn new(title: &str) -> Self {
69 let doc_title = if title.trim().is_empty() {
70 "Balanced Breakfast"
71 } else {
72 title
73 };
74 Self {
75 title: doc_title.to_string(),
76 pages: Vec::new(),
77 ops: Vec::new(),
78 y_mm: content_top_mm(),
79 }
80 }
81
82 fn finish(mut self) -> Result<Vec<u8>, Error> {
83 // Flush the page still being filled.
84 self.pages
85 .push(PdfPage::new(PAGE_W, PAGE_H, std::mem::take(&mut self.ops)));
86 let mut doc = PdfDocument::new(&self.title);
87 doc.with_pages(self.pages);
88 let mut warnings = Vec::new();
89 Ok(doc.save(&PdfSaveOptions::default(), &mut warnings))
90 }
91
92 fn render_markdown(&mut self, md: &str) {
93 let opts = Options::ENABLE_STRIKETHROUGH;
94 let parser = Parser::new_ext(md, opts);
95 let mut style_stack: Vec<Style> = vec![Style::body()];
96 let mut list_stack: Vec<Option<u64>> = Vec::new();
97 let mut buf = String::new();
98
99 for event in parser {
100 match event {
101 Event::Start(tag) => match tag {
102 Tag::Heading { level, .. } => {
103 self.flush_paragraph(&mut buf, *style_stack.last().unwrap());
104 self.block_gap_heading();
105 let pt = match level {
106 HeadingLevel::H1 => H1_PT,
107 HeadingLevel::H2 => H2_PT,
108 _ => H3_PT,
109 };
110 style_stack.push(Style {
111 size_pt: pt,
112 bold: true,
113 ..*style_stack.last().unwrap()
114 });
115 }
116 Tag::Paragraph => {
117 // nothing; paragraph text flushes on End(Paragraph)
118 }
119 Tag::BlockQuote(_) => {
120 self.flush_paragraph(&mut buf, *style_stack.last().unwrap());
121 self.block_gap();
122 let mut s = *style_stack.last().unwrap();
123 s.italic = true;
124 s.left_indent_mm += QUOTE_INDENT_MM;
125 style_stack.push(s);
126 }
127 Tag::List(start) => {
128 self.flush_paragraph(&mut buf, *style_stack.last().unwrap());
129 self.block_gap();
130 list_stack.push(start);
131 }
132 Tag::Item => {
133 let mut s = *style_stack.last().unwrap();
134 s.left_indent_mm += LIST_INDENT_MM;
135 match list_stack.last_mut() {
136 Some(Some(n)) => {
137 s.number = Some(*n);
138 *n += 1;
139 }
140 _ => s.bullet = Some('\u{2022}'),
141 }
142 style_stack.push(s);
143 }
144 Tag::Emphasis => {
145 let mut s = *style_stack.last().unwrap();
146 s.italic = true;
147 style_stack.push(s);
148 }
149 Tag::Strong => {
150 let mut s = *style_stack.last().unwrap();
151 s.bold = true;
152 style_stack.push(s);
153 }
154 Tag::CodeBlock(_) => {
155 self.flush_paragraph(&mut buf, *style_stack.last().unwrap());
156 self.block_gap();
157 let mut s = *style_stack.last().unwrap();
158 s.mono = true;
159 s.size_pt = MONO_PT;
160 style_stack.push(s);
161 }
162 Tag::Link { .. } | Tag::Image { .. } => {
163 // Render link text inline; drop images entirely.
164 }
165 _ => {}
166 },
167 Event::End(end) => match end {
168 TagEnd::Heading(_) => {
169 self.flush_paragraph(&mut buf, *style_stack.last().unwrap());
170 style_stack.pop();
171 }
172 TagEnd::Paragraph => {
173 self.flush_paragraph(&mut buf, *style_stack.last().unwrap());
174 self.block_gap();
175 }
176 TagEnd::BlockQuote(_) | TagEnd::CodeBlock => {
177 self.flush_paragraph(&mut buf, *style_stack.last().unwrap());
178 self.block_gap();
179 style_stack.pop();
180 }
181 TagEnd::List(_) => {
182 list_stack.pop();
183 }
184 TagEnd::Item => {
185 self.flush_paragraph(&mut buf, *style_stack.last().unwrap());
186 style_stack.pop();
187 }
188 TagEnd::Emphasis | TagEnd::Strong => {
189 // Style-scoped runs are simplified: we buffer per-paragraph
190 // as a single run of the outer style, so nested bold/italic
191 // is lost. This is a v1 limitation.
192 style_stack.pop();
193 }
194 _ => {}
195 },
196 Event::Text(t) | Event::Code(t) => {
197 if !buf.is_empty() && !buf.ends_with(char::is_whitespace) {
198 buf.push(' ');
199 }
200 buf.push_str(&t);
201 }
202 Event::SoftBreak => buf.push(' '),
203 Event::HardBreak => {
204 self.flush_paragraph(&mut buf, *style_stack.last().unwrap());
205 }
206 _ => {}
207 }
208 }
209 self.flush_paragraph(&mut buf, *style_stack.last().unwrap());
210 }
211
212 fn flush_paragraph(&mut self, buf: &mut String, style: Style) {
213 let text = buf.trim();
214 if text.is_empty() {
215 buf.clear();
216 return;
217 }
218 self.draw_paragraph(text, style);
219 buf.clear();
220 }
221
222 fn draw_heading(&mut self, text: &str, pt: f32) {
223 let style = Style {
224 size_pt: pt,
225 bold: true,
226 ..Style::default()
227 };
228 self.draw_paragraph(text, style);
229 }
230
231 fn draw_paragraph(&mut self, text: &str, style: Style) {
232 let leader = if let Some(n) = style.number {
233 format!("{n}. ")
234 } else if let Some(c) = style.bullet {
235 format!("{c} ")
236 } else {
237 String::new()
238 };
239
240 let line_h = line_height_mm(if style.size_pt > 0.0 {
241 style.size_pt
242 } else {
243 BODY_PT
244 });
245 let text_indent = style.left_indent_mm;
246 let text_width = content_width_mm() - text_indent;
247
248 let mut first = true;
249 for line in wrap(text, style, text_width) {
250 self.ensure_room(line_h);
251 let x = MARGIN.0 + text_indent;
252 let font = Self::font_for(style);
253 let baseline_y = self.y_mm - style.size_pt * 25.4 / 72.0;
254
255 if first && !leader.is_empty() {
256 self.draw_text(&leader, style.size_pt, x - LIST_INDENT_MM, baseline_y, font);
257 }
258 self.draw_text(&line, style.size_pt, x, baseline_y, font);
259 self.y_mm -= line_h;
260 first = false;
261 }
262 }
263
264 /// Emit a single positioned text run into the current page. The origin is
265 /// the page's bottom-left corner and `baseline_mm` is the text baseline,
266 /// matching the geometry the layout math assumes.
267 fn draw_text(
268 &mut self,
269 text: &str,
270 size_pt: f32,
271 x_mm: f32,
272 baseline_mm: f32,
273 font: BuiltinFont,
274 ) {
275 self.ops.push(Op::StartTextSection);
276 self.ops.push(Op::SetFont {
277 font: PdfFontHandle::Builtin(font),
278 size: Pt(size_pt),
279 });
280 self.ops.push(Op::SetTextCursor {
281 pos: Point::new(Mm(x_mm), Mm(baseline_mm)),
282 });
283 self.ops.push(Op::ShowText {
284 items: vec![TextItem::Text(text.to_string())],
285 });
286 self.ops.push(Op::EndTextSection);
287 }
288
289 fn font_for(style: Style) -> BuiltinFont {
290 if style.mono {
291 BuiltinFont::Courier
292 } else if style.bold {
293 BuiltinFont::HelveticaBold
294 } else if style.italic {
295 BuiltinFont::HelveticaOblique
296 } else {
297 BuiltinFont::Helvetica
298 }
299 }
300
301 fn ensure_room(&mut self, needed_mm: f32) {
302 if self.y_mm - needed_mm < content_bottom_mm() {
303 let ops = std::mem::take(&mut self.ops);
304 self.pages.push(PdfPage::new(PAGE_W, PAGE_H, ops));
305 self.y_mm = content_top_mm();
306 }
307 }
308
309 fn block_gap(&mut self) {
310 self.y_mm -= BLOCK_GAP_MM;
311 }
312
313 fn block_gap_heading(&mut self) {
314 self.y_mm -= HEADING_GAP_MM;
315 }
316 }
317
318 fn wrap(text: &str, style: Style, max_width_mm: f32) -> Vec<String> {
319 let mut lines: Vec<String> = Vec::new();
320 let mut cur = String::new();
321 for word in text.split_whitespace() {
322 // A single token wider than the column can never fit on any line; break it
323 // on character boundaries so it wraps instead of running off the page edge.
324 if text_width_mm(word, style.size_pt, style.bold, style.mono) > max_width_mm {
325 if !cur.is_empty() {
326 lines.push(std::mem::take(&mut cur));
327 }
328 let mut pieces = hard_break(word, style, max_width_mm);
329 // Keep the trailing piece in `cur` so the next word can share its line.
330 if let Some(last) = pieces.pop() {
331 lines.append(&mut pieces);
332 cur = last;
333 }
334 continue;
335 }
336 let candidate = if cur.is_empty() {
337 word.to_string()
338 } else {
339 format!("{cur} {word}")
340 };
341 if text_width_mm(&candidate, style.size_pt, style.bold, style.mono) > max_width_mm
342 && !cur.is_empty()
343 {
344 lines.push(std::mem::take(&mut cur));
345 cur.push_str(word);
346 } else {
347 cur = candidate;
348 }
349 }
350 if !cur.is_empty() {
351 lines.push(cur);
352 }
353 if lines.is_empty() {
354 lines.push(String::new());
355 }
356 lines
357 }
358
359 /// Break a single token wider than the column into character-boundary pieces that
360 /// each fit within `max_width_mm`. A char wider than the column on its own still
361 /// gets its own piece (nothing narrower exists), so a piece is never empty.
362 fn hard_break(word: &str, style: Style, max_width_mm: f32) -> Vec<String> {
363 let mut pieces = Vec::new();
364 let mut cur = String::new();
365 for ch in word.chars() {
366 cur.push(ch);
367 if text_width_mm(&cur, style.size_pt, style.bold, style.mono) > max_width_mm
368 && cur.chars().count() > 1
369 {
370 cur.pop();
371 pieces.push(std::mem::take(&mut cur));
372 cur.push(ch);
373 }
374 }
375 if !cur.is_empty() {
376 pieces.push(cur);
377 }
378 pieces
379 }
380
381 #[cfg(test)]
382 mod tests {
383 use super::*;
384
385 #[test]
386 fn wraps_long_paragraph() {
387 let text = "one two three four five six seven eight nine ten eleven twelve";
388 let lines = wrap(text, Style::body(), 30.0);
389 assert!(lines.len() > 1);
390 for l in &lines {
391 assert!(!l.is_empty());
392 }
393 }
394
395 #[test]
396 fn wrap_single_word_never_empties() {
397 let lines = wrap("hello", Style::body(), 200.0);
398 assert_eq!(lines, vec!["hello".to_string()]);
399 }
400
401 #[test]
402 fn wrap_hard_breaks_overlong_token() {
403 // A single token far wider than the column must be split, and no resulting
404 // line may exceed the column width.
405 let long = "supercalifragilisticexpialidocioussupercalifragilistic";
406 let lines = wrap(long, Style::body(), 20.0);
407 assert!(
408 lines.len() > 1,
409 "an over-wide token should wrap onto several lines"
410 );
411 let style = Style::body();
412 for l in &lines {
413 assert!(!l.is_empty());
414 assert!(
415 text_width_mm(l, style.size_pt, style.bold, style.mono) <= 20.0,
416 "line {l:?} exceeds the column width",
417 );
418 }
419 // No characters are lost across the break.
420 assert_eq!(lines.concat(), long);
421 }
422
423 #[test]
424 fn empty_body_is_error() {
425 let out = render_article("<p></p>", "t", None);
426 assert!(matches!(out, Err(Error::Empty)));
427 }
428
429 #[test]
430 fn end_to_end_produces_pdf_bytes() {
431 let html = r"
432 <h1>Hello</h1>
433 <p>This is a <strong>bold</strong> paragraph with an <em>italic</em> word.</p>
434 <ul><li>one</li><li>two</li></ul>
435 <blockquote>Quoted material.</blockquote>
436 ";
437 let bytes = render_article(html, "Test Article", Some("https://example.com/a")).unwrap();
438 assert!(bytes.starts_with(b"%PDF"));
439 assert!(bytes.len() > 1000);
440 }
441
442 #[test]
443 fn long_article_produces_multiple_pages() {
444 let mut html = String::from("<h1>Long</h1>");
445 for i in 0..200 {
446 html.push_str(&format!(
447 "<p>Paragraph number {i}. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>"
448 ));
449 }
450 let bytes = render_article(&html, "Long", None).unwrap();
451 assert!(bytes.starts_with(b"%PDF"));
452 // Very rough sanity check that the doc grew past a single page's worth
453 assert!(bytes.len() > 8000);
454 }
455 }
456