From 5c354023ab178ce7f153b8193f7d4e750d6e1fed Mon Sep 17 00:00:00 2001 From: Streetblock Date: Fri, 9 Oct 2026 15:13:56 +0200 Subject: [PATCH] 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 --- ROADMAP.md | 2 + crates/render/src/inspect.rs | 122 +++++++++++++++++++++++++++++++---- 2 files changed, 110 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6793596238..a7fc14b1f6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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%). diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 938525e65a..03af9fa271 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -304,30 +304,35 @@ pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result 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");