use std::fmt::Write as _;
const PAGE_W: f32 = 595.0; const PAGE_H: f32 = 842.0;
const MARGIN_X: f32 = 50.0;
const MARGIN_TOP: f32 = 60.0;
const MARGIN_BOTTOM: f32 = 60.0;
const DEFAULT_FONT_SIZE: f32 = 11.0;
const DEFAULT_LINE_HEIGHT: f32 = 14.0;
#[derive(Clone)]
pub struct Fragment {
pub text: String,
pub bold: bool,
pub color: Option<(f32, f32, f32)>,
pub highlight: Option<(f32, f32, f32)>,
}
pub struct Line {
pub fragments: Vec<Fragment>,
pub x: f32,
pub font_size: f32,
}
pub struct PdfWriter {
pages: Vec<Vec<Line>>,
current: Vec<Line>,
y: f32,
}
impl Default for PdfWriter {
fn default() -> Self {
Self::new()
}
}
impl PdfWriter {
#[must_use]
pub fn new() -> Self {
Self {
pages: Vec::new(),
current: Vec::new(),
y: PAGE_H - MARGIN_TOP,
}
}
pub fn push_fragments(&mut self, fragments: Vec<Fragment>, x: f32, font_size: f32) {
let line_h = (font_size * 1.3).max(DEFAULT_LINE_HEIGHT);
if self.y < MARGIN_BOTTOM + line_h {
self.flush_page();
}
self.current.push(Line {
fragments,
x,
font_size,
});
self.y -= line_h;
}
pub fn skip(&mut self, amount: f32) {
self.y -= amount;
if self.y < MARGIN_BOTTOM {
self.flush_page();
}
}
fn flush_page(&mut self) {
if !self.current.is_empty() {
self.pages.push(std::mem::take(&mut self.current));
}
self.y = PAGE_H - MARGIN_TOP;
}
#[must_use]
pub fn finish(mut self) -> Vec<u8> {
if !self.current.is_empty() {
self.pages.push(std::mem::take(&mut self.current));
}
if self.pages.is_empty() {
self.pages.push(Vec::new());
}
let mut objects: Vec<String> = Vec::new();
objects.push("<< /Type /Catalog /Pages 2 0 R >>".into());
let pages_obj_idx = objects.len();
objects.push(String::new());
objects.push(
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
.into(),
);
objects.push(
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"
.into(),
);
let mut page_obj_ids: Vec<usize> = Vec::new();
let pages_taken = std::mem::take(&mut self.pages);
for lines in &pages_taken {
let content = build_content_stream(lines);
let content_obj_id = objects.len() + 1;
objects.push(format!(
"<< /Length {} >>\nstream\n{content}\nendstream",
content.len()
));
let page_obj_id = objects.len() + 1;
page_obj_ids.push(page_obj_id);
objects.push(format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {PAGE_W} {PAGE_H}] /Contents {content_obj_id} 0 R /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> >>"
));
}
let kids = page_obj_ids
.iter()
.map(|id| format!("{id} 0 R"))
.collect::<Vec<_>>()
.join(" ");
objects[pages_obj_idx] = format!(
"<< /Type /Pages /Kids [{kids}] /Count {} >>",
page_obj_ids.len()
);
let mut s = String::with_capacity(2048);
s.push_str("%PDF-1.4\n");
let mut offsets: Vec<usize> = Vec::with_capacity(objects.len());
for (i, body) in objects.iter().enumerate() {
offsets.push(s.len());
let id = i + 1;
let _ = writeln!(s, "{id} 0 obj");
s.push_str(body);
s.push_str("\nendobj\n");
}
let xref_offset = s.len();
let n = objects.len() + 1;
let _ = writeln!(s, "xref\n0 {n}\n0000000000 65535 f ");
for offset in &offsets {
let _ = writeln!(s, "{offset:010} 00000 n ");
}
let _ = write!(
s,
"trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n"
);
s.into_bytes()
}
}
fn build_content_stream(lines: &[Line]) -> String {
if lines.is_empty() {
return String::new();
}
let mut content = String::new();
let mut current_y = PAGE_H - MARGIN_TOP;
for line in lines {
let line_h = (line.font_size * 1.3).max(DEFAULT_LINE_HEIGHT);
let mut current_x = line.x;
for fragment in &line.fragments {
if fragment.text.is_empty() {
continue;
}
if let Some((highlight_red, highlight_green, highlight_blue)) = fragment.highlight {
let width = estimate_text_width(&fragment.text, line.font_size);
let rect_y = current_y - line.font_size * 0.28;
let rect_h = line.font_size * 1.05;
let _ = writeln!(
content,
"q {highlight_red:.3} {highlight_green:.3} {highlight_blue:.3} rg {current_x:.3} {rect_y:.3} {width:.3} {rect_h:.3} re f Q"
);
}
let (text_red, text_green, text_blue) = fragment.color.unwrap_or((0.0, 0.0, 0.0));
let font = if fragment.bold { "/F2" } else { "/F1" };
let _ = writeln!(
content,
"BT {font} {} Tf {text_red:.3} {text_green:.3} {text_blue:.3} rg 1 0 0 1 {current_x:.3} {current_y:.3} Tm ({}) Tj ET",
line.font_size,
escape_pdf_string(&fragment.text)
);
current_x += estimate_text_width(&fragment.text, line.font_size);
}
current_y -= line_h;
}
content
}
fn estimate_text_width(text: &str, font_size: f32) -> f32 {
text.chars().fold(0.0, |count, _| count + 1.0) * font_size * 0.55
}
fn escape_pdf_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'(' => out.push_str("\\("),
')' => out.push_str("\\)"),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
ch if (ch as u32) < 0x80 => out.push(ch),
ch if (ch as u32) <= 0xFF => {
out.push(ch);
}
_ => out.push('?'),
}
}
out
}
pub const PAGE_MARGIN_X: f32 = MARGIN_X;
pub const PAGE_DEFAULT_FONT_SIZE: f32 = DEFAULT_FONT_SIZE;