use crate::pdf::pdf_from_objects;
use crate::{LoError, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PdfFont {
Helvetica,
HelveticaBold,
HelveticaOblique,
Courier,
}
impl PdfFont {
fn resource_name(self) -> &'static str {
match self {
Self::Helvetica => "F1",
Self::HelveticaBold => "F2",
Self::HelveticaOblique => "F3",
Self::Courier => "F4",
}
}
fn base_font(self) -> &'static str {
match self {
Self::Helvetica => "Helvetica",
Self::HelveticaBold => "Helvetica-Bold",
Self::HelveticaOblique => "Helvetica-Oblique",
Self::Courier => "Courier",
}
}
}
#[derive(Clone, Debug)]
pub struct PdfPage {
pub width: f32,
pub height: f32,
commands: String,
}
impl PdfPage {
pub fn new(width: f32, height: f32) -> Self {
Self {
width,
height,
commands: String::new(),
}
}
pub fn text(&mut self, x: f32, y: f32, size: f32, font: PdfFont, text: &str) {
self.commands.push_str("BT\n");
self.commands
.push_str(&format!("/{} {} Tf\n", font.resource_name(), size));
self.commands
.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", x, y));
self.commands.push('(');
self.commands.push_str(&pdf_escape(text));
self.commands.push_str(") Tj\nET\n");
}
pub fn line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
self.commands
.push_str(&format!("{:.2} {:.2} m {:.2} {:.2} l S\n", x1, y1, x2, y2));
}
pub fn rect_stroke(&mut self, x: f32, y: f32, width: f32, height: f32) {
self.commands.push_str(&format!(
"{:.2} {:.2} {:.2} {:.2} re S\n",
x, y, width, height
));
}
pub fn rect_fill_rgb(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
r: f32,
g: f32,
b: f32,
) {
self.commands
.push_str(&format!("{:.3} {:.3} {:.3} rg\n", r, g, b));
self.commands.push_str(&format!(
"{:.2} {:.2} {:.2} {:.2} re f\n",
x, y, width, height
));
self.commands.push_str("0 0 0 rg\n");
}
pub fn raw(&mut self, command: &str) {
self.commands.push_str(command);
if !command.ends_with('\n') {
self.commands.push('\n');
}
}
}
#[derive(Clone, Debug, Default)]
pub struct PdfDocument {
pages: Vec<PdfPage>,
}
impl PdfDocument {
pub fn new() -> Self {
Self { pages: Vec::new() }
}
pub fn add_page(&mut self, width: f32, height: f32) -> usize {
self.pages.push(PdfPage::new(width, height));
self.pages.len() - 1
}
pub fn page_mut(&mut self, index: usize) -> Result<&mut PdfPage> {
self.pages
.get_mut(index)
.ok_or_else(|| LoError::InvalidInput(format!("pdf page index out of range: {index}")))
}
pub fn pages(&self) -> &[PdfPage] {
&self.pages
}
pub fn finish(self) -> Vec<u8> {
if self.pages.is_empty() {
return empty_pdf();
}
let mut objects = Vec::new();
objects.push(String::new());
objects.push(String::new());
for font in [
PdfFont::Helvetica,
PdfFont::HelveticaBold,
PdfFont::HelveticaOblique,
PdfFont::Courier,
] {
objects.push(format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /{} >>",
font.base_font()
));
}
let page_start = 7usize;
let mut kids = Vec::new();
for (index, page) in self.pages.iter().enumerate() {
let page_obj = page_start + index * 2;
let content_obj = page_obj + 1;
kids.push(format!("{} 0 R", page_obj));
let resources = "<< /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R /F4 6 0 R >> >>";
objects.push(format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {:.2} {:.2}] /Resources {} /Contents {} 0 R >>",
page.width, page.height, resources, content_obj
));
objects.push(format!(
"<< /Length {} >>\nstream\n{}endstream",
page.commands.len(),
page.commands
));
}
objects[0] = "<< /Type /Catalog /Pages 2 0 R >>".to_string();
objects[1] = format!(
"<< /Type /Pages /Count {} /Kids [{}] >>",
self.pages.len(),
kids.join(" ")
);
pdf_from_objects(&objects)
}
}
fn pdf_escape(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('(', "\\(")
.replace(')', "\\)")
}
fn empty_pdf() -> Vec<u8> {
let mut doc = PdfDocument::new();
let page = doc.add_page(595.0, 842.0);
let _ = doc
.page_mut(page)
.map(|p| p.text(50.0, 792.0, 12.0, PdfFont::Helvetica, ""));
doc.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finish_emits_valid_pdf() {
let mut doc = PdfDocument::new();
let p = doc.add_page(595.0, 842.0);
doc.page_mut(p)
.unwrap()
.text(50.0, 792.0, 12.0, PdfFont::Helvetica, "hello");
let bytes = doc.finish();
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
#[test]
fn multi_page_documents_have_two_kids() {
let mut doc = PdfDocument::new();
doc.add_page(595.0, 842.0);
doc.add_page(595.0, 842.0);
let bytes = doc.finish();
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("/Count 2"));
}
#[test]
fn empty_document_still_produces_pdf() {
let bytes = PdfDocument::new().finish();
assert!(bytes.starts_with(b"%PDF-1.4"));
}
}