const PAGE_W: i32 = 612;
const PAGE_H: i32 = 792;
const MARGIN: i32 = 54;
const FONT_SIZE: i32 = 9;
const LEADING: i32 = 12;
const TOP_Y: i32 = PAGE_H - MARGIN;
const LINES_PER_PAGE: usize = ((PAGE_H - 2 * MARGIN) / LEADING) as usize;
const WRAP: usize = 100;
pub fn to_pdf(text: &str) -> Vec<u8> {
let lines = layout_lines(text);
let pages: Vec<&[String]> = if lines.is_empty() {
vec![&[][..]]
} else {
lines.chunks(LINES_PER_PAGE).collect()
};
let mut buf: Vec<u8> = Vec::new();
let mut offsets: Vec<usize> = vec![0];
buf.extend_from_slice(b"%PDF-1.7\n");
buf.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
let page_count = pages.len();
let page_obj = |p: usize| 4 + 2 * p;
let content_obj = |p: usize| 5 + 2 * p;
push_obj(&mut buf, &mut offsets, "<< /Type /Catalog /Pages 2 0 R >>");
let kids: String = (0..page_count)
.map(|p| format!("{} 0 R", page_obj(p)))
.collect::<Vec<_>>()
.join(" ");
push_obj(
&mut buf,
&mut offsets,
&format!("<< /Type /Pages /Kids [{kids}] /Count {page_count} >>"),
);
push_obj(
&mut buf,
&mut offsets,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
);
for (p, page_lines) in pages.iter().enumerate() {
let body = page_content(page_lines);
push_obj(
&mut buf,
&mut offsets,
&format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {PAGE_W} {PAGE_H}] \
/Resources << /Font << /F1 3 0 R >> >> /Contents {} 0 R >>",
content_obj(p)
),
);
push_stream_obj(&mut buf, &mut offsets, &body);
}
write_xref_and_trailer(&mut buf, &offsets);
buf
}
fn push_obj(buf: &mut Vec<u8>, offsets: &mut Vec<usize>, dict: &str) {
let obj_num = offsets.len();
offsets.push(buf.len());
buf.extend_from_slice(format!("{obj_num} 0 obj\n{dict}\nendobj\n").as_bytes());
}
fn push_stream_obj(buf: &mut Vec<u8>, offsets: &mut Vec<usize>, content: &str) {
let obj_num = offsets.len();
offsets.push(buf.len());
buf.extend_from_slice(
format!(
"{obj_num} 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n",
content.len()
)
.as_bytes(),
);
}
fn page_content(lines: &[String]) -> String {
let mut s = String::new();
s.push_str("BT\n");
s.push_str(&format!("/F1 {FONT_SIZE} Tf\n"));
s.push_str(&format!("{LEADING} TL\n"));
s.push_str(&format!("{MARGIN} {TOP_Y} Td\n"));
for line in lines {
s.push('(');
s.push_str(&escape_pdf_text(line));
s.push_str(") Tj\n");
s.push_str("T*\n");
}
s.push_str("ET");
s
}
fn write_xref_and_trailer(buf: &mut Vec<u8>, offsets: &[usize]) {
let xref_offset = buf.len();
let size = offsets.len();
buf.extend_from_slice(format!("xref\n0 {size}\n").as_bytes());
buf.extend_from_slice(b"0000000000 65535 f \n");
for &off in &offsets[1..] {
buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
buf.extend_from_slice(
format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n")
.as_bytes(),
);
}
fn escape_pdf_text(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("\\)"),
_ => out.push(c),
}
}
out
}
fn layout_lines(text: &str) -> Vec<String> {
let mut out = Vec::new();
for raw in text.lines() {
let sanitized = sanitize_ascii(raw);
out.extend(wrap_line(&sanitized, WRAP));
}
out
}
fn sanitize_ascii(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\t' => out.push_str(" "),
'\u{20}'..='\u{7e}' => out.push(c),
'\u{2014}' | '\u{2013}' => out.push('-'), '\u{2265}' => out.push_str(">="), '\u{2264}' => out.push_str("<="), '\u{00d7}' => out.push('x'), '\u{2192}' => out.push_str("->"), '\u{2019}' | '\u{2018}' => out.push('\''), '\u{201c}' | '\u{201d}' => out.push('"'),
'\u{2026}' => out.push_str("..."), _ => out.push('?'),
}
}
out
}
fn wrap_line(line: &str, width: usize) -> Vec<String> {
if line.len() <= width {
return vec![line.to_string()];
}
let indent = line.len() - line.trim_start().len();
let indent = indent.min(width.saturating_sub(10));
let pad = " ".repeat(indent);
let budget = width.saturating_sub(indent).max(1);
let mut rows = Vec::new();
let mut current = String::new();
for word in line.trim_start().split(' ') {
if word.len() > budget {
if !current.is_empty() {
rows.push(format!("{pad}{current}"));
current.clear();
}
let chars: Vec<char> = word.chars().collect();
for chunk in chars.chunks(budget) {
rows.push(format!("{pad}{}", chunk.iter().collect::<String>()));
}
continue;
}
let extra = usize::from(!current.is_empty());
if current.len() + extra + word.len() > budget {
rows.push(format!("{pad}{current}"));
current = word.to_string();
} else {
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
}
if !current.is_empty() {
rows.push(format!("{pad}{current}"));
}
if rows.is_empty() {
rows.push(pad);
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn produces_valid_pdf_envelope() {
let pdf = to_pdf("Hello compliance");
assert!(pdf.starts_with(b"%PDF-1.7"), "must begin with PDF header");
assert!(pdf.ends_with(b"%%EOF\n"), "must end with EOF marker");
let text = String::from_utf8_lossy(&pdf);
assert!(text.contains("/Type /Catalog"));
assert!(text.contains("/BaseFont /Helvetica"));
assert!(text.contains("startxref"));
assert!(text.contains("(Hello compliance) Tj"));
}
#[test]
fn xref_offsets_point_at_object_headers() {
let pdf = to_pdf("one line");
let s = String::from_utf8_lossy(&pdf);
let idx = s.rfind("startxref\n").unwrap() + "startxref\n".len();
let end = s[idx..].find('\n').unwrap() + idx;
let xref_off: usize = s[idx..end].parse().unwrap();
assert_eq!(&pdf[xref_off..xref_off + 4], b"xref");
}
#[test]
fn long_input_paginates() {
let many = (0..200)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let pdf = to_pdf(&many);
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("/Type /Pages"));
let count_marker = s.find("/Count ").unwrap();
let count: usize = s[count_marker + 7..]
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.unwrap();
assert!(count >= 3, "expected ≥3 pages, got {count}");
}
#[test]
fn escapes_parens_and_backslash() {
assert_eq!(escape_pdf_text("a(b)c\\d"), "a\\(b\\)c\\\\d");
}
#[test]
fn sanitizes_non_ascii() {
assert_eq!(sanitize_ascii("a\tb"), "a b");
assert_eq!(sanitize_ascii("café ●"), "caf? ?");
}
#[test]
fn wraps_long_tokens() {
let token = "x".repeat(250);
let rows = wrap_line(&token, WRAP);
assert!(rows.len() >= 3);
assert!(rows.iter().all(|r| r.len() <= WRAP));
}
}