M2: handle Unicode dates and preserve inspection fallback pages (#469)

Read the date prefix at a checked UTF-8 boundary and leave malformed dates
unchanged. Commit auxiliary metadata only after successful inspection so
an escaped inspector panic cannot discard the renderer's page geometry.

Add synthetic date-boundary, UTF16BE annotation and independent failure
regressions.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Streetblock
2026-10-09 09:13:56 -04:00
committed by GitHub
co-authored by OpenAI Codex
parent a9a7eea2cc
commit 5c354023ab
2 changed files with 110 additions and 14 deletions
+2
View File
@@ -138,6 +138,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
Newest first. One line per session: the date, what moved, and the new overall percentage.
- **2026-10-09 (community, document inspection):** Annotation dates preserve non-date Unicode text instead of panicking while opening; failed auxiliary structure inspection keeps renderer page geometry and reports a warning. Synthetic UTF16BE annotation, UTF8-boundary and independent panic regressions cover the fix. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%).
+108 -14
View File
@@ -304,30 +304,35 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))),
}
let options = load_options(password);
let structure = catch_unwind(AssertUnwindSafe(|| match Document::load_mem_with_options(&bytes, options) {
Ok(doc) => {
let mut tmp = DocInfo::default();
std::mem::swap(&mut tmp.pages, &mut info.pages);
Inspector::new(&doc).fill(&mut tmp);
Ok(tmp)
}
Err(e) => Err(e.to_string()),
inspect_structure(&mut info, |tmp| {
let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?;
Inspector::new(&doc).fill(tmp);
Ok(())
});
if info.pages.is_empty() {
return Err(OpenError::Invalid("the document has no pages".into()));
}
Ok(info)
}
// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback.
fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) {
let structure = catch_unwind(AssertUnwindSafe(|| {
let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() };
fill(&mut tmp)?;
Ok::<_, String>(tmp)
}));
match structure {
Ok(Ok(mut filled)) => {
filled.file_size = info.file_size;
filled.pdf_version = std::mem::take(&mut info.pdf_version);
info = filled;
*info = filled;
}
Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")),
Err(p) => {
info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p)));
}
}
if info.pages.is_empty() {
return Err(OpenError::Invalid("the document has no pages".into()));
}
Ok(info)
}
struct Inspector<'a> {
@@ -955,7 +960,8 @@ fn alpha(n: usize) -> String {
/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged.
pub fn pretty_date(s: &str) -> String {
let d = s.trim_start_matches("D:");
if d.len() >= 12 && d[..12].bytes().all(|b| b.is_ascii_digit()) {
if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) {
// Twelve ASCII digits make every slice below a UTF-8 character boundary.
format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12])
} else {
s.to_string()
@@ -1106,6 +1112,94 @@ trailer << /Root 1 0 R >>
);
}
#[test]
fn structure_failure_preserves_renderer_pages() {
for crash in [true, false] {
let mut info = DocInfo {
file_size: 321,
pdf_version: "1.7".into(),
pages: vec![PageInfo { width: 300.0, height: 200.0, label: "1".into(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 90 }],
..Default::default()
};
inspect_structure(&mut info, |tmp| {
tmp.pages.clear();
tmp.title = Some("partially inspected".into());
if crash {
panic!("synthetic structure inspection failure");
}
Err("synthetic structure inspection failure".into())
});
assert_eq!(info.pages.len(), 1);
let page = &info.pages[0];
assert_eq!((page.width, page.height, page.label.as_str(), page.crop, page.rotation), (300.0, 200.0, "1", [10.0, 20.0, 210.0, 320.0], 90));
assert_eq!(info.file_size, 321);
assert_eq!(info.pdf_version, "1.7");
assert!(info.title.is_none());
assert_eq!(info.warnings.len(), 1);
assert!(info.warnings[0].contains("synthetic structure inspection failure"));
assert_eq!(info.warnings[0].contains("crashed"), crash);
}
}
#[test]
fn invalid_dates_with_unicode_are_unchanged() {
for input in ["", "D:", "D:20260930104", "D:202609x01045", "yesterday"] {
assert_eq!(pretty_date(input), input);
}
// Cover every position before the 12-byte prefix, including characters that
// straddle its end. None of these strings is an ASCII PDF date.
for character in ['é', '€', '😀'] {
for prefix_len in 0..12 {
let input = format!("D:{}{character}123456789012", "1".repeat(prefix_len));
assert_eq!(pretty_date(&input), input, "{character} after {prefix_len} digits");
}
}
}
#[test]
fn unicode_annotation_date_does_not_prevent_opening() {
use lopdf::dictionary;
let date = "D:12345678901éX";
let mut encoded_date = vec![0xFE, 0xFF];
encoded_date.extend(date.encode_utf16().flat_map(u16::to_be_bytes));
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let annot_id = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Text",
"Rect" => vec![10.into(), 10.into(), 30.into(), 30.into()],
"M" => Object::String(encoded_date, lopdf::StringFormat::Hexadecimal),
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()],
"Annots" => vec![annot_id.into()],
});
doc.objects.insert(
pages_id,
dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}
.into(),
);
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog_id);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).expect("write synthetic fixture");
let info = inspect(Arc::new(bytes), None).expect("opens despite a non-date /M string");
assert_eq!(info.pages.len(), 1);
assert_eq!((info.pages[0].width, info.pages[0].height), (200.0, 300.0));
assert_eq!(info.annotations.len(), 1);
assert_eq!(info.annotations[0].modified.as_deref(), Some(date));
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
}
#[test]
fn dates_are_prettified() {
assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45");