use crate::types::revisions::{DocumentRevision, RevisionDelta, RevisionKind};
const MAX_REVISIONS: usize = 128;
#[cfg(test)]
const EOF_SCAN_WINDOW: usize = 1024;
#[cfg(test)]
fn collect_startxref_offsets(content: &[u8]) -> Vec<usize> {
let mut offsets: Vec<usize> = Vec::new();
let len = content.len();
let mut search_start = 0usize;
while search_start < len {
let Some(eof_pos) = find_subsequence(&content[search_start..], b"%%EOF").map(|p| p + search_start) else {
break;
};
let window_start = eof_pos.saturating_sub(EOF_SCAN_WINDOW);
let window = &content[window_start..eof_pos];
if let Some(sx_rel) = find_last_subsequence(window, b"startxref") {
let sx_abs = window_start + sx_rel;
let after = sx_abs + b"startxref".len();
if let Some(offset) = parse_decimal_after(content, after)
&& offset < len
&& !offsets.contains(&offset)
{
offsets.push(offset);
}
}
search_start = eof_pos + b"%%EOF".len();
}
offsets.sort_unstable();
offsets
}
fn collect_prev_chain(content: &[u8], xref_offset: usize) -> Vec<usize> {
let mut chain: Vec<usize> = Vec::new();
let mut current = xref_offset;
let mut seen: Vec<usize> = Vec::new();
loop {
if seen.contains(¤t) {
break;
}
seen.push(current);
let slice = &content[current..];
let prev = extract_prev_from_trailer(slice);
match prev {
Some(p) if p < content.len() && p != current => {
chain.push(p);
current = p;
}
_ => break,
}
if chain.len() >= MAX_REVISIONS {
break;
}
}
chain.reverse();
chain
}
fn extract_prev_from_trailer(slice: &[u8]) -> Option<usize> {
let trailer_pos = find_subsequence(slice, b"trailer")?;
let after_trailer = &slice[trailer_pos + b"trailer".len()..];
let dict_start = find_subsequence(after_trailer, b"<<")?;
let dict_slice = &after_trailer[dict_start..];
let dict_end = find_subsequence(dict_slice, b">>")?;
let dict_content = &dict_slice[..dict_end + 2];
let prev_key = b"/Prev";
let prev_pos = find_subsequence(dict_content, prev_key)?;
let after_prev = &dict_content[prev_pos + prev_key.len()..];
let trimmed = trim_leading_whitespace(after_prev);
parse_decimal_value(trimmed)
}
fn extract_lopdf_info_metadata(document: &lopdf::Document) -> (Option<String>, Option<String>) {
use lopdf::Object;
let info_id = match document
.trailer
.get(b"Info")
.ok()
.and_then(|obj| obj.as_reference().ok())
{
Some(id) => id,
None => return (None, None),
};
let info_dict = match document.get_object(info_id) {
Ok(Object::Dictionary(dict)) => dict,
_ => return (None, None),
};
let author = info_dict.get(b"Author").ok().and_then(extract_lopdf_string);
let timestamp = info_dict
.get(b"ModDate")
.ok()
.and_then(extract_lopdf_string)
.or_else(|| info_dict.get(b"CreationDate").ok().and_then(extract_lopdf_string))
.map(|raw| parse_pdf_date_string(&raw));
(author, timestamp)
}
fn extract_lopdf_string(obj: &lopdf::Object) -> Option<String> {
use lopdf::Object;
match obj {
Object::String(bytes, _) => {
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
let u16s: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
let s = String::from_utf16_lossy(&u16s);
let trimmed = s.trim().to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
} else {
let s = String::from_utf8_lossy(bytes);
let trimmed = s.trim().to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
}
}
Object::Name(bytes) => {
let s = String::from_utf8_lossy(bytes);
let trimmed = s.trim().to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
}
_ => None,
}
}
fn parse_pdf_date_string(raw: &str) -> String {
let cleaned = raw.trim();
let digits = if let Some(stripped) = cleaned.strip_prefix("D:") {
stripped
} else {
cleaned
};
if digits.len() >= 8 {
let year = &digits[..4];
let month = &digits[4..6];
let day = &digits[6..8];
if digits.len() >= 14 {
let hour = &digits[8..10];
let min = &digits[10..12];
let sec = &digits[12..14];
format!("{year}-{month}-{day}T{hour}:{min}:{sec}Z")
} else {
format!("{year}-{month}-{day}T00:00:00Z")
}
} else {
raw.to_string()
}
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|window| window == needle)
}
#[cfg(test)]
fn find_last_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).rposition(|window| window == needle)
}
fn trim_leading_whitespace(bytes: &[u8]) -> &[u8] {
let skip = bytes
.iter()
.position(|&b| !matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
.unwrap_or(bytes.len());
&bytes[skip..]
}
fn parse_decimal_value(bytes: &[u8]) -> Option<usize> {
let bytes = trim_leading_whitespace(bytes);
let end = bytes.iter().position(|b| !b.is_ascii_digit()).unwrap_or(bytes.len());
if end == 0 {
return None;
}
std::str::from_utf8(&bytes[..end])
.ok()
.and_then(|s| s.parse::<usize>().ok())
}
#[cfg(test)]
fn parse_decimal_after(content: &[u8], after: usize) -> Option<usize> {
parse_decimal_value(&content[after.min(content.len())..])
}
pub(crate) fn extract_pdf_xref_revisions(content: &[u8], document: &lopdf::Document) -> Option<Vec<DocumentRevision>> {
let final_offset = document.xref_start;
let historical_offsets = collect_prev_chain(content, final_offset);
if historical_offsets.is_empty() {
return None;
}
let (author, timestamp) = extract_lopdf_info_metadata(document);
let revisions: Vec<DocumentRevision> = historical_offsets
.into_iter()
.take(MAX_REVISIONS)
.map(|offset| DocumentRevision {
revision_id: format!("xref-offset-{offset}"),
author: author.clone(),
timestamp: timestamp.clone(),
kind: RevisionKind::Insertion,
anchor: None,
delta: RevisionDelta::default(),
})
.collect();
if revisions.is_empty() { None } else { Some(revisions) }
}
#[cfg(test)]
mod tests {
use super::*;
fn build_minimal_pdf() -> Vec<u8> {
let mut buf = Vec::<u8>::new();
let header = b"%PDF-1.4\n";
buf.extend_from_slice(header);
let obj1_offset = buf.len();
buf.extend_from_slice(b"1 0 obj\n<</Type /Catalog /Pages 2 0 R>>\nendobj\n");
let obj2_offset = buf.len();
buf.extend_from_slice(b"2 0 obj\n<</Type /Pages /Kids [3 0 R] /Count 1>>\nendobj\n");
let obj3_offset = buf.len();
buf.extend_from_slice(b"3 0 obj\n<</Type /Page /MediaBox [0 0 612 792] /Parent 2 0 R>>\nendobj\n");
let xref_offset = buf.len();
buf.extend_from_slice(b"xref\n");
buf.extend_from_slice(b"0 4\n");
buf.extend_from_slice(b"0000000000 65535 f \n");
buf.extend_from_slice(format!("{:010} 00000 n \n", obj1_offset).as_bytes());
buf.extend_from_slice(format!("{:010} 00000 n \n", obj2_offset).as_bytes());
buf.extend_from_slice(format!("{:010} 00000 n \n", obj3_offset).as_bytes());
buf.extend_from_slice(b"trailer\n<</Size 4 /Root 1 0 R>>\n");
buf.extend_from_slice(format!("startxref\n{}\n%%EOF\n", xref_offset).as_bytes());
buf
}
fn build_incremental_pdf(base: &[u8], base_xref_offset: usize) -> Vec<u8> {
let mut buf = base.to_vec();
let new_obj_offset = buf.len();
buf.extend_from_slice(b"4 0 obj\n<</Update true>>\nendobj\n");
let new_xref_offset = buf.len();
buf.extend_from_slice(b"xref\n");
buf.extend_from_slice(b"4 1\n");
buf.extend_from_slice(format!("{:010} 00000 n \n", new_obj_offset).as_bytes());
buf.extend_from_slice(format!("trailer\n<</Size 5 /Root 1 0 R /Prev {}>>\n", base_xref_offset).as_bytes());
buf.extend_from_slice(format!("startxref\n{}\n%%EOF\n", new_xref_offset).as_bytes());
buf
}
fn parse_last_startxref(bytes: &[u8]) -> usize {
let len = bytes.len();
let window = &bytes[len.saturating_sub(256)..];
let sx = find_last_subsequence(window, b"startxref").expect("no startxref");
let after = sx + b"startxref".len();
parse_decimal_value(trim_leading_whitespace(&window[after..])).expect("no offset")
}
#[test]
fn should_find_subsequence_at_start() {
assert_eq!(find_subsequence(b"hello world", b"hello"), Some(0));
}
#[test]
fn should_find_subsequence_in_middle() {
assert_eq!(find_subsequence(b"hello world", b"world"), Some(6));
}
#[test]
fn should_return_none_when_subsequence_absent() {
assert_eq!(find_subsequence(b"hello", b"xyz"), None);
}
#[test]
fn should_find_last_subsequence() {
assert_eq!(find_last_subsequence(b"abcabc", b"abc"), Some(3));
}
#[test]
fn should_parse_decimal_value_with_leading_whitespace() {
assert_eq!(parse_decimal_value(b" 42 rest"), Some(42));
}
#[test]
fn should_parse_decimal_value_returns_none_for_empty() {
assert_eq!(parse_decimal_value(b""), None);
}
#[test]
fn should_parse_decimal_value_returns_none_for_non_digit() {
assert_eq!(parse_decimal_value(b"abc"), None);
}
#[test]
fn should_parse_pdf_date_with_d_prefix_and_full_timestamp() {
assert_eq!(parse_pdf_date_string("D:20240315103045"), "2024-03-15T10:30:45Z");
}
#[test]
fn should_parse_pdf_date_with_d_prefix_date_only() {
assert_eq!(parse_pdf_date_string("D:20240315"), "2024-03-15T00:00:00Z");
}
#[test]
fn should_parse_pdf_date_without_d_prefix() {
assert_eq!(parse_pdf_date_string("20240315"), "2024-03-15T00:00:00Z");
}
#[test]
fn should_return_raw_string_for_malformed_date() {
assert_eq!(parse_pdf_date_string("bad"), "bad");
}
#[test]
fn should_extract_prev_from_trailer_with_prev_key() {
let trailer = b"trailer\n<</Size 5 /Root 1 0 R /Prev 100>>\nstartxref\n";
assert_eq!(extract_prev_from_trailer(trailer), Some(100));
}
#[test]
fn should_return_none_when_no_prev_in_trailer() {
let trailer = b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n";
assert_eq!(extract_prev_from_trailer(trailer), None);
}
#[test]
fn should_return_none_when_no_trailer_keyword() {
let slice = b"not a trailer at all";
assert_eq!(extract_prev_from_trailer(slice), None);
}
#[test]
fn should_collect_one_startxref_offset_from_single_save_pdf() {
let pdf = build_minimal_pdf();
let offsets = collect_startxref_offsets(&pdf);
assert_eq!(
offsets.len(),
1,
"single-save PDF must yield exactly one startxref offset"
);
}
#[test]
fn should_collect_two_startxref_offsets_from_incremental_pdf() {
let base = build_minimal_pdf();
let base_xref = parse_last_startxref(&base);
let pdf = build_incremental_pdf(&base, base_xref);
let offsets = collect_startxref_offsets(&pdf);
assert_eq!(
offsets.len(),
2,
"incremental PDF must yield two startxref offsets; got {:?}",
offsets
);
}
#[test]
#[cfg(feature = "pdf")]
fn should_return_none_for_single_save_pdf() {
let pdf = build_minimal_pdf();
let doc = lopdf::Document::load_mem(&pdf).expect("lopdf must parse minimal PDF");
let result = extract_pdf_xref_revisions(&pdf, &doc);
assert!(
result.is_none(),
"single-save PDF must yield revisions = None, got {:?}",
result
);
}
#[test]
#[cfg(feature = "pdf")]
fn should_return_one_revision_for_incremental_pdf() {
let base = build_minimal_pdf();
let base_xref = parse_last_startxref(&base);
let pdf = build_incremental_pdf(&base, base_xref);
let doc = lopdf::Document::load_mem(&pdf).expect("lopdf must parse incremental PDF");
let revisions = extract_pdf_xref_revisions(&pdf, &doc).expect("incremental PDF must yield Some(revisions)");
assert_eq!(revisions.len(), 1, "one prior save must yield one revision");
}
#[test]
#[cfg(feature = "pdf")]
fn should_produce_revision_ids_in_xref_offset_format() {
let base = build_minimal_pdf();
let base_xref = parse_last_startxref(&base);
let pdf = build_incremental_pdf(&base, base_xref);
let doc = lopdf::Document::load_mem(&pdf).expect("lopdf must parse incremental PDF");
let revisions = extract_pdf_xref_revisions(&pdf, &doc).expect("incremental PDF must yield Some(revisions)");
for rev in &revisions {
assert!(
rev.revision_id.starts_with("xref-offset-"),
"revision_id must start with 'xref-offset-', got '{}'",
rev.revision_id
);
let suffix = &rev.revision_id["xref-offset-".len()..];
suffix
.parse::<usize>()
.expect("revision_id suffix must be a valid usize");
}
}
#[test]
#[cfg(feature = "pdf")]
fn should_use_insertion_as_revision_kind_placeholder() {
let base = build_minimal_pdf();
let base_xref = parse_last_startxref(&base);
let pdf = build_incremental_pdf(&base, base_xref);
let doc = lopdf::Document::load_mem(&pdf).expect("lopdf must parse incremental PDF");
let revisions = extract_pdf_xref_revisions(&pdf, &doc).expect("incremental PDF must yield Some(revisions)");
for rev in &revisions {
assert!(
matches!(rev.kind, RevisionKind::Insertion),
"kind must be Insertion placeholder"
);
}
}
#[test]
#[cfg(feature = "pdf")]
fn should_produce_no_anchor_for_pdf_revisions() {
let base = build_minimal_pdf();
let base_xref = parse_last_startxref(&base);
let pdf = build_incremental_pdf(&base, base_xref);
let doc = lopdf::Document::load_mem(&pdf).expect("lopdf must parse incremental PDF");
let revisions = extract_pdf_xref_revisions(&pdf, &doc).expect("incremental PDF must yield Some(revisions)");
for rev in &revisions {
assert!(rev.anchor.is_none(), "anchor must be None for PDF revisions");
}
}
#[test]
#[cfg(feature = "pdf")]
fn should_produce_empty_delta_for_pdf_revisions() {
let base = build_minimal_pdf();
let base_xref = parse_last_startxref(&base);
let pdf = build_incremental_pdf(&base, base_xref);
let doc = lopdf::Document::load_mem(&pdf).expect("lopdf must parse incremental PDF");
let revisions = extract_pdf_xref_revisions(&pdf, &doc).expect("incremental PDF must yield Some(revisions)");
for rev in &revisions {
assert!(rev.delta.content.is_empty(), "delta.content must be empty (deferred)");
assert!(
rev.delta.table_changes.is_empty(),
"delta.table_changes must be empty (deferred)"
);
}
}
#[test]
#[cfg(feature = "pdf")]
fn should_surface_author_and_timestamp_from_info_dict() {
use lopdf::{Dictionary, Document, Object, ObjectId};
let base = build_minimal_pdf();
let base_xref = parse_last_startxref(&base);
let pdf_bytes = build_incremental_pdf(&base, base_xref);
let mut doc = Document::load_mem(&pdf_bytes).expect("lopdf must parse incremental PDF");
let mut info = Dictionary::new();
info.set(
"Author",
Object::String(b"Test Author".to_vec(), lopdf::StringFormat::Literal),
);
info.set(
"ModDate",
Object::String(b"D:20240101120000".to_vec(), lopdf::StringFormat::Literal),
);
let info_id: ObjectId = (99, 0);
doc.objects.insert(info_id, Object::Dictionary(info));
doc.trailer.set("Info", Object::Reference(info_id));
let revisions =
extract_pdf_xref_revisions(&pdf_bytes, &doc).expect("incremental PDF must yield Some(revisions)");
let rev = &revisions[0];
assert_eq!(
rev.author.as_deref(),
Some("Test Author"),
"author must be extracted from /Info"
);
assert_eq!(
rev.timestamp.as_deref(),
Some("2024-01-01T12:00:00Z"),
"timestamp must be extracted and formatted from /Info/ModDate"
);
}
}