1use crate::render_report;
10use crate::DeviceHistory;
11use forensicnomicon::report::Finding;
12use std::fmt::Write as _;
13
14const LINES_PER_PAGE: usize = 60;
15const FONT_SIZE: i32 = 9;
16const LEADING: i32 = 12;
17const TOP: i32 = 760;
18const LEFT: i32 = 40;
19
20fn pdf_escape(text: &str) -> String {
24 let folded: String = text
25 .chars()
26 .map(|c| match c {
27 '\u{2014}' | '\u{2013}' => '-', '\u{201C}' | '\u{201D}' => '"', '\u{2018}' | '\u{2019}' => '\'', c if c.is_ascii() => c,
31 _ => '?',
32 })
33 .collect();
34 folded
35 .replace('\\', r"\\")
36 .replace('(', r"\(")
37 .replace(')', r"\)")
38}
39
40#[must_use]
42pub fn render_pdf(histories: &[DeviceHistory], findings: &[Finding]) -> Vec<u8> {
43 let report = render_report(histories, findings);
44 let lines: Vec<&str> = report.lines().collect();
47 let pages: Vec<&[&str]> = lines.chunks(LINES_PER_PAGE).collect();
48 let n = pages.len();
49
50 let content_base = 4;
52 let page_base = content_base + n;
53 let mut bodies: Vec<String> = Vec::with_capacity(3 + 2 * n);
54
55 bodies.push("<< /Type /Catalog /Pages 2 0 R >>".to_string());
56 let mut kids = String::new();
57 for i in 0..n {
58 let _ = write!(kids, "{} 0 R ", page_base + i);
59 }
60 bodies.push(format!("<< /Type /Pages /Kids [ {kids}] /Count {n} >>"));
61 bodies.push("<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".to_string());
62
63 for page in &pages {
64 let mut stream = String::new();
65 let _ = write!(stream, "BT /F0 {FONT_SIZE} Tf {LEADING} TL {LEFT} {TOP} Td");
66 for (row, line) in page.iter().enumerate() {
67 if row == 0 {
69 let _ = write!(stream, " ({}) Tj", pdf_escape(line));
70 } else {
71 let _ = write!(stream, " T* ({}) Tj", pdf_escape(line));
72 }
73 }
74 stream.push_str(" ET");
75 bodies.push(format!(
76 "<< /Length {} >>\nstream\n{stream}\nendstream",
77 stream.len()
78 ));
79 }
80 for i in 0..n {
81 bodies.push(format!(
82 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
83 /Resources << /Font << /F0 3 0 R >> >> /Contents {} 0 R >>",
84 content_base + i
85 ));
86 }
87
88 let mut out = String::from("%PDF-1.4\n");
90 let mut offsets = Vec::with_capacity(bodies.len());
91 for (idx, body) in bodies.iter().enumerate() {
92 offsets.push(out.len());
93 let _ = write!(out, "{} 0 obj\n{body}\nendobj\n", idx + 1);
94 }
95 let xref_at = out.len();
96 let count = bodies.len() + 1;
97 let _ = write!(out, "xref\n0 {count}\n0000000000 65535 f \n");
98 for off in &offsets {
99 let _ = writeln!(out, "{off:010} 00000 n ");
100 }
101 let _ = write!(
102 out,
103 "trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n"
104 );
105 out.into_bytes()
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use crate::model::{Attribute, DeviceKey, Provenance, SourceKind, Value};
112 use crate::{audit, correlate, Claim};
113
114 #[test]
115 fn pdf_escape_folds_unicode_and_escapes_specials() {
116 assert_eq!(pdf_escape(r"a(b)c\d"), r"a\(b\)c\\d");
117 assert_eq!(
119 pdf_escape("x\u{2014}y\u{2013}\u{201C}q\u{201D}\u{2018}r\u{2019}\u{1F600}z"),
120 "x-y-\"q\"'r'?z"
121 );
122 }
123
124 #[test]
125 fn pdf_is_well_formed_and_paginated() {
126 let claims: Vec<Claim> = (0..40)
128 .map(|i| Claim {
129 device: DeviceKey(format!("SN{i}")),
130 attribute: Attribute::FirstConnected,
131 value: Value::Timestamp(1_700_000_000 + i),
132 provenance: Provenance {
133 source: SourceKind::SetupApi,
134 locator: "log(1)".into(), },
136 })
137 .collect();
138 let histories = correlate(&claims);
139 let pdf = render_pdf(&histories, &audit(&histories));
140 let text = String::from_utf8_lossy(&pdf);
141 assert!(text.starts_with("%PDF-1.4"));
142 assert!(text.trim_end().ends_with("%%EOF"));
143 assert!(text.contains("/Type /Catalog"));
144 assert!(text.contains("startxref"));
145 assert!(
146 text.matches("/Type /Page ").count() >= 2,
147 "paginated across ≥2 pages"
148 );
149 assert!(text.contains(r"log\(1\)"), "parens escaped in the stream");
150 }
151
152 #[test]
153 fn empty_input_still_produces_one_valid_page() {
154 let pdf = render_pdf(&[], &[]);
155 let text = String::from_utf8_lossy(&pdf);
156 assert!(text.starts_with("%PDF-1.4"));
157 assert_eq!(text.matches("/Type /Page ").count(), 1);
158 }
159}