//! A very small PDF writer, enough to typeset a screenplay. //! //! This exists for the same reason `crate::odt` does: so an export needs no //! `pandoc` and no LibreOffice at runtime. It is not a general PDF library and //! does not try to be. A screenplay is set entirely in Courier, which is one of //! the fourteen faces every PDF reader is required to provide, so there is no //! font to embed and no font metrics to parse — the one measurement that //! matters is that Courier is monospaced at [`ADVANCE`] of the point size. //! //! Content streams are written uncompressed. A feature-length screenplay comes //! to a few hundred kilobytes that way, which is small enough not to care //! about, and it keeps the output readable in a text editor when something //! needs debugging. use std::io::Write; use std::path::Path; /// Width of one Courier character as a fraction of the font size. Courier's /// glyphs are all 600 units wide on a 1000-unit em, so 12pt Courier advances /// 7.2pt per character — exactly ten characters to the inch, which is what the /// whole geometry of a screenplay page is built on. pub const ADVANCE: f32 = 0.6; /// One point, as a fraction of an inch — the unit PDF measures in. pub const INCH: f32 = 72.0; /// Which face of Courier to set a run in. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Face { Regular, Bold, Italic, BoldItalic, } impl Face { /// The face carrying `bold` and `italic` together. pub fn of(bold: bool, italic: bool) -> Face { match (bold, italic) { (false, false) => Face::Regular, (true, false) => Face::Bold, (false, true) => Face::Italic, (true, true) => Face::BoldItalic, } } /// The resource name this face is bound to in a page's font dictionary. fn resource(self) -> &'static str { match self { Face::Regular => "F1", Face::Bold => "F2", Face::Italic => "F3", Face::BoldItalic => "F4", } } /// The base-14 font this face maps to. fn base_font(self) -> &'static str { match self { Face::Regular => "Courier", Face::Bold => "Courier-Bold", Face::Italic => "Courier-Oblique", Face::BoldItalic => "Courier-BoldOblique", } } } /// Map one character to its WinAnsi (CP1252) byte. /// /// The base-14 fonts are single-byte encoded, so anything outside CP1252 has no /// glyph to point at. Everything a manuscript realistically contains is in /// range — the curly quotes, dashes and ellipsis a word processor inserts live /// in the 0x80..0x9F block that distinguishes CP1252 from Latin-1 — and the /// rare character that is not degrades to a question mark rather than /// corrupting the stream. fn winansi(c: char) -> u8 { match c { '\u{20AC}' => 0x80, '\u{201A}' => 0x82, '\u{0192}' => 0x83, '\u{201E}' => 0x84, '\u{2026}' => 0x85, '\u{2020}' => 0x86, '\u{2021}' => 0x87, '\u{02C6}' => 0x88, '\u{2030}' => 0x89, '\u{0160}' => 0x8A, '\u{2039}' => 0x8B, '\u{0152}' => 0x8C, '\u{017D}' => 0x8E, '\u{2018}' => 0x91, '\u{2019}' => 0x92, '\u{201C}' => 0x93, '\u{201D}' => 0x94, '\u{2022}' => 0x95, '\u{2013}' => 0x96, '\u{2014}' => 0x97, '\u{02DC}' => 0x98, '\u{2122}' => 0x99, '\u{0161}' => 0x9A, '\u{203A}' => 0x9B, '\u{0153}' => 0x9C, '\u{017E}' => 0x9E, '\u{0178}' => 0x9F, c if (c as u32) < 0x80 || ((c as u32) >= 0xA0 && (c as u32) <= 0xFF) => c as u32 as u8, _ => b'?', } } /// Encode a string as a PDF literal string, escaping the three characters that /// would otherwise end it or be read as an escape. fn pdf_string(s: &str) -> Vec { let mut out = vec![b'(']; for c in s.chars() { let b = winansi(c); if b == b'(' || b == b')' || b == b'\\' { out.push(b'\\'); } out.push(b); } out.push(b')'); out } /// The drawing operations for one page, in PDF content-stream syntax. /// /// Coordinates are in points from the bottom-left corner of the page, which is /// PDF's own convention; the screenplay layout converts from its line grid. #[derive(Default)] pub struct PageContent { ops: Vec, } impl PageContent { pub fn new() -> PageContent { PageContent::default() } /// The raw content stream built so far, which is what gets written into /// the page's stream object. Exposed so a layout can be asserted on /// without rendering and re-parsing a whole document. #[cfg(test)] pub fn ops(&self) -> &[u8] { &self.ops } /// Set `text` with its baseline at (`x`, `y`). pub fn text(&mut self, x: f32, y: f32, face: Face, size: f32, text: &str) { if text.is_empty() { return; } let _ = write!( self.ops, "BT /{} {} Tf {:.2} {:.2} Td ", face.resource(), size, x, y ); self.ops.extend_from_slice(&pdf_string(text)); self.ops.extend_from_slice(b" Tj ET\n"); } /// Fill a rectangle — used to rule underlines, which Courier has no face /// for and which therefore have to be drawn. pub fn rule(&mut self, x: f32, y: f32, width: f32, height: f32) { let _ = write!( self.ops, "{:.2} {:.2} {:.2} {:.2} re f\n", x, y, width, height ); } } /// A document being assembled, one page at a time. pub struct Pdf { width: f32, height: f32, pages: Vec, /// Written into the document information dictionary, which is what a /// reader shows in its properties panel and its window title. pub title: String, pub author: String, } impl Pdf { /// A document of the given page size, in points. pub fn new(width: f32, height: f32) -> Pdf { Pdf { width, height, pages: Vec::new(), title: String::new(), author: String::new(), } } /// US Letter, the size a screenplay is submitted on. pub fn letter() -> Pdf { Pdf::new(8.5 * INCH, 11.0 * INCH) } pub fn add_page(&mut self, page: PageContent) { self.pages.push(page); } /// Serialise the document. /// /// Objects are laid down in order and their byte offsets recorded as they /// go, because the cross-reference table at the end has to name the exact /// offset of every one of them — get that wrong and readers reject the /// file outright. pub fn to_bytes(&self) -> Vec { // A document with no pages is still a valid PDF, but a reader will not // open one with an empty page tree, so guarantee at least a blank leaf. let blank = [PageContent::new()]; let pages: &[PageContent] = if self.pages.is_empty() { &blank } else { &self.pages }; // Fixed object numbers, then two per page (the page, then its stream). const CATALOG: usize = 1; const PAGE_TREE: usize = 2; const FIRST_FONT: usize = 3; const INFO: usize = 7; let first_page = 8; let mut out: Vec = Vec::new(); // Offset 0 is the free-list head, which never names a real object. let mut offsets: Vec = vec![0]; out.extend_from_slice(b"%PDF-1.7\n"); // A comment of high bytes, which tells anything transferring the file // that it is binary and must not be line-ending translated. out.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n"); let object = |out: &mut Vec, offsets: &mut Vec, body: &[u8]| { offsets.push(out.len()); let n = offsets.len() - 1; let _ = write!(out, "{n} 0 obj\n"); out.extend_from_slice(body); out.extend_from_slice(b"\nendobj\n"); }; let kids: String = (0..pages.len()) .map(|i| format!("{} 0 R", first_page + i * 2)) .collect::>() .join(" "); object( &mut out, &mut offsets, format!("<< /Type /Catalog /Pages {PAGE_TREE} 0 R >>").as_bytes(), ); object( &mut out, &mut offsets, format!( "<< /Type /Pages /Count {} /Kids [{}] >>", pages.len(), kids ) .as_bytes(), ); for face in [Face::Regular, Face::Bold, Face::Italic, Face::BoldItalic] { object( &mut out, &mut offsets, format!( "<< /Type /Font /Subtype /Type1 /BaseFont /{} \ /Encoding /WinAnsiEncoding >>", face.base_font() ) .as_bytes(), ); } let mut info = Vec::from(&b"<< /Producer (md-manuscript)"[..]); if !self.title.trim().is_empty() { info.extend_from_slice(b" /Title "); info.extend_from_slice(&pdf_string(self.title.trim())); } if !self.author.trim().is_empty() { info.extend_from_slice(b" /Author "); info.extend_from_slice(&pdf_string(self.author.trim())); } info.extend_from_slice(b" >>"); object(&mut out, &mut offsets, &info); debug_assert_eq!(offsets.len() - 1, INFO, "fixed object numbering drifted"); let fonts: String = [Face::Regular, Face::Bold, Face::Italic, Face::BoldItalic] .iter() .enumerate() .map(|(i, f)| format!("/{} {} 0 R", f.resource(), FIRST_FONT + i)) .collect::>() .join(" "); for (i, page) in pages.iter().enumerate() { let stream = first_page + i * 2 + 1; object( &mut out, &mut offsets, format!( "<< /Type /Page /Parent {PAGE_TREE} 0 R \ /MediaBox [0 0 {:.2} {:.2}] \ /Resources << /Font << {fonts} >> >> \ /Contents {stream} 0 R >>", self.width, self.height ) .as_bytes(), ); let mut body = format!("<< /Length {} >>\nstream\n", page.ops.len()).into_bytes(); body.extend_from_slice(&page.ops); body.extend_from_slice(b"endstream"); object(&mut out, &mut offsets, &body); } let xref_at = out.len(); let count = offsets.len(); let _ = write!(out, "xref\n0 {count}\n"); // Every entry is exactly twenty bytes, including the two-byte ending. out.extend_from_slice(b"0000000000 65535 f \n"); for off in &offsets[1..] { let _ = write!(out, "{off:010} 00000 n \n"); } let _ = write!( out, "trailer\n<< /Size {count} /Root {CATALOG} 0 R /Info {INFO} 0 R >>\n\ startxref\n{xref_at}\n%%EOF\n" ); out } /// Write the document to `path`. pub fn write(&self, path: &Path) -> std::io::Result<()> { std::fs::write(path, self.to_bytes()) } } #[cfg(test)] mod tests { use super::*; fn sample() -> Pdf { let mut pdf = Pdf::letter(); pdf.title = "The Winter Gate".to_string(); pdf.author = "A. Writer".to_string(); let mut p = PageContent::new(); p.text(108.0, 700.0, Face::Regular, 12.0, "INT. KITCHEN - NIGHT"); p.text(108.0, 676.0, Face::Bold, 12.0, "Bold action."); p.rule(108.0, 672.0, 72.0, 0.6); pdf.add_page(p); pdf } #[test] fn a_document_has_the_structure_a_reader_expects() { let bytes = sample().to_bytes(); let text = String::from_utf8_lossy(&bytes); assert!(text.starts_with("%PDF-1.7\n")); assert!(text.ends_with("%%EOF\n")); assert!(text.contains("/Type /Catalog")); assert!(text.contains("/Type /Pages /Count 1")); assert!(text.contains("/BaseFont /Courier ")); assert!(text.contains("/BaseFont /Courier-BoldOblique")); assert!(text.contains("/Title (The Winter Gate)")); assert!(text.contains("/Author (A. Writer)")); assert!(text.contains("(INT. KITCHEN - NIGHT) Tj")); } /// The cross-reference table is the part a reader trusts absolutely: each /// entry must be the true byte offset of the object it names, and each must /// be exactly twenty bytes wide. /// /// Worked on raw bytes throughout. The header carries a comment of /// deliberately invalid UTF-8 -- that is its whole purpose, to mark the file /// binary -- so decoding the document to a `String` first would shift every /// index past it and the offsets would appear wrong when they are right. #[test] fn every_cross_reference_offset_lands_on_its_object() { let bytes = sample().to_bytes(); fn find(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } let marker = b"startxref\n"; let at = find(&bytes, marker).expect("a startxref keyword") + marker.len(); let digits: String = bytes[at..] .iter() .take_while(|b| b.is_ascii_digit()) .map(|b| *b as char) .collect(); let xref_at: usize = digits.parse().expect("a startxref offset"); assert_eq!(&bytes[xref_at..xref_at + 4], b"xref"); // `xref\n`, then a `0 \n` subsection header, then the entries. let header_at = xref_at + 5; let header_len = bytes[header_at..] .iter() .position(|b| *b == b'\n') .expect("a subsection header"); let header: String = bytes[header_at..header_at + header_len] .iter() .map(|b| *b as char) .collect(); let count: usize = header.split_whitespace().nth(1).unwrap().parse().unwrap(); let entries_at = header_at + header_len + 1; // Entry 0 is the free-list head; every other must point at " 0 obj". for n in 1..count { let entry = &bytes[entries_at + n * 20..entries_at + (n + 1) * 20]; assert_eq!(&entry[16..], b" n \n", "entry {n} is malformed"); let off: usize = std::str::from_utf8(&entry[..10]) .unwrap() .parse() .expect("a numeric offset"); let expected = format!("{n} 0 obj"); assert_eq!( &bytes[off..off + expected.len()], expected.as_bytes(), "object {n} is not at offset {off}" ); } } #[test] fn each_page_adds_a_leaf_to_the_tree() { let mut pdf = Pdf::letter(); for _ in 0..3 { let mut p = PageContent::new(); p.text(72.0, 72.0, Face::Regular, 12.0, "x"); pdf.add_page(p); } let text = String::from_utf8_lossy(&pdf.to_bytes()).to_string(); assert!(text.contains("/Count 3")); assert!(text.contains("/Kids [8 0 R 10 0 R 12 0 R]")); } /// The three characters that would otherwise break out of a literal string. #[test] fn parentheses_and_backslashes_are_escaped() { let s = String::from_utf8(pdf_string(r"a (b) c \ d")).unwrap(); assert_eq!(s, r"(a \(b\) c \\ d)"); } /// Curly quotes and dashes are what a word processor leaves in prose, and /// they all have WinAnsi code points. #[test] fn typographic_punctuation_survives_the_encoding() { let bytes = pdf_string("\u{201C}Don\u{2019}t,\u{201D} she said \u{2014} then\u{2026}"); assert!(bytes.contains(&0x93) && bytes.contains(&0x94), "curly quotes"); assert!(bytes.contains(&0x92), "apostrophe"); assert!(bytes.contains(&0x97), "em dash"); assert!(bytes.contains(&0x85), "ellipsis"); // Something with no CP1252 glyph degrades rather than corrupting. assert_eq!(pdf_string("\u{4E2D}"), b"(?)".to_vec()); } /// Ten characters to the inch is the measurement the page grid rests on. #[test] fn courier_advances_ten_characters_to_the_inch_at_twelve_point() { assert!((ADVANCE * 12.0 - 7.2).abs() < 1e-6); assert!((INCH / (ADVANCE * 12.0) - 10.0).abs() < 1e-6); } #[test] fn a_document_with_no_pages_still_opens() { let text = String::from_utf8_lossy(&Pdf::letter().to_bytes()).to_string(); assert!(text.contains("/Count 1"), "a blank leaf stands in"); assert!(text.ends_with("%%EOF\n")); } }