diff --git a/ROADMAP.md b/ROADMAP.md index 0aeb909a3..cc5ff96fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -168,6 +168,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-13 (M3, Single Page edge paging, #1924):** In Single Page view a page taller than the window pans with the wheel, and one more notch (or a fresh trackpad swipe) at the bottom/top edge shows the next page at its top / the previous page at its bottom. A swipe that panned to the edge, momentum included, does not turn. Estimates unchanged. - **2026-10-13 (M3, #1980):** Ctrl+Alt+Q in the digital ID email field no longer requests application exit. Quit requires exact Ctrl+Q/Cmd+Q modifiers and uses the existing unsaved-document confirmation. Synthetic keyboard regressions cover email input and clean/dirty quit requests; estimates unchanged. +- **2026-10-14 (M4, page transitions read, §12.4.4):** `/Trans` and `/Dur` are read into `DocInfo::transitions` and listed by `doc_info`, the data a full-screen presentation needs to play transitions and auto-advance; playing and editing them stay planned. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index a162cc705..d94c85447 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -312,6 +312,7 @@ fn info(args: &[String]) -> Result<(), CliError> { "xfa_layout": xfa_layout, "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), + "transitions": info.transitions.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, }); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 769cff813..c4ea39036 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2258,6 +2258,13 @@ fn info(d: &Document) -> Value { }, })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>(), + // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with + // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. + "transitions": i.transitions.iter().map(|t| json!({ + "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, + "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, + "display": t.display, + })).collect::>(), "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), // `substitute` names the face standing in for a program the document does not embed, and // `substitution` says why. Both are null for an embedded font: nothing stands in. A null diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1bbce5d89..1f3e965f0 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -133,7 +133,7 @@ pub fn tools() -> Vec { .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, attachments, fonts, security and repair notes.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, attachments, fonts, security and repair notes.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index fbf4b4bba..9eae3dfc0 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -115,6 +115,45 @@ fn auto(dir: &Path) -> Automation { Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000) } +/// Full-screen presentation settings (`/Trans`, `/Dur`) reach `doc_info`, 1-based pages, only +/// for the pages that carry them. +#[test] +fn doc_info_lists_page_transitions_and_display_times() { + let dir = workdir("transitions"); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Trans << /S /Fade /D 0.5 >> /Dur 4 >>".to_vec(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + std::fs::write(dir.join("slides.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "slides.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let t = info["transitions"].as_array().unwrap(); + assert_eq!(t.len(), 1, "{}", info["transitions"]); + assert_eq!( + (t[0]["page"].as_u64(), t[0]["style"].as_str(), t[0]["duration"].as_f64(), t[0]["display"].as_f64()), + (Some(2), Some("Fade"), Some(0.5), Some(4.0)), + "{}", + t[0] + ); +} + #[test] fn autotag_preview_proposes_text_without_changing_the_pdf() { let dir = workdir("autotag-preview"); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index eefc2e923..c1fee6f00 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -76,6 +76,8 @@ pub struct DocInfo { pub layer_groups: Vec>, pub fonts: Vec, pub attachments: Vec, + /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. + pub transitions: Vec, pub warnings: Vec, } @@ -90,6 +92,89 @@ pub enum Xfa { Dynamic, } +/// A page's presentation transition and display time (ISO 32000-2 §12.4.4): how the page comes +/// into view in a full-screen presentation, and how long it stays before advancing. Only pages +/// carrying `/Trans` or `/Dur` are listed. Values outside the spec's ranges fall back to the +/// defaults rather than being refused. +#[derive(Clone, Debug, PartialEq)] +pub struct PageTransition { + pub page: usize, + /// `/Trans /S`; `None` when the page has only `/Dur`. + pub style: Option, + /// `/D`: how long the transition takes, in seconds (default 1). + pub duration: f32, + /// `/Dm /H` (true) or `/V` (Split, Blinds). + pub horizontal: bool, + /// `/M /I` (true) or `/O` (Split, Box, Fly). + pub inward: bool, + /// `/Di`: 0, 90, 180, 270 or 315 degrees (Wipe, Glitter, Fly, Cover, Uncover, Push); `None` + /// for `/None` (Fly only). + pub direction: Option, + /// `/SS`: the Fly scale (default 1). + pub scale: f32, + /// `/B`: Fly with an opaque backdrop. + pub opaque: bool, + /// `/Dur`: seconds the page shows before the presentation advances; `None` waits for the + /// presenter. + pub display: Option, +} + +/// The transition styles of ISO 32000-2 table 164 (`/Trans /S`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransitionStyle { + Split, + Blinds, + Box, + Wipe, + Dissolve, + Glitter, + /// `/R`: the next page replaces the old one with no effect (the default). + Replace, + Fly, + Push, + Cover, + Uncover, + Fade, +} + +impl TransitionStyle { + /// The style a `/S` name means; an unknown name is `Replace`, the spec's default. + pub fn from_name(name: &str) -> Self { + match name { + "Split" => Self::Split, + "Blinds" => Self::Blinds, + "Box" => Self::Box, + "Wipe" => Self::Wipe, + "Dissolve" => Self::Dissolve, + "Glitter" => Self::Glitter, + "Fly" => Self::Fly, + "Push" => Self::Push, + "Cover" => Self::Cover, + "Uncover" => Self::Uncover, + "Fade" => Self::Fade, + _ => Self::Replace, + } + } + + /// The name as written in a PDF (`/S`). + pub fn name(self) -> &'static str { + match self { + Self::Split => "Split", + Self::Blinds => "Blinds", + Self::Box => "Box", + Self::Wipe => "Wipe", + Self::Dissolve => "Dissolve", + Self::Glitter => "Glitter", + Self::Replace => "R", + Self::Fly => "Fly", + Self::Push => "Push", + Self::Cover => "Cover", + Self::Uncover => "Uncover", + Self::Fade => "Fade", + } + } +} + #[derive(Clone, Debug)] pub struct PageInfo { /// Displayed size in points, after `/Rotate` and `/UserUnit`. @@ -548,6 +633,7 @@ impl<'a> Inspector<'a> { (info.outline, info.outline_more) = self.outline_window(first); } self.annotations(info); + self.transitions(info); let mut field_seen = HashSet::new(); if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) @@ -596,6 +682,53 @@ impl<'a> Inspector<'a> { // ── object helpers ────────────────────────────────────────────────────────────────────── + /// `/Trans` and `/Dur` of every page (ISO 32000-2 §12.4.4), in page order. Lenient: a value + /// outside the spec's choices takes that entry's default, and a page with neither entry is + /// not listed. Bounded by the page count. + fn transitions(&self, info: &mut DocInfo) { + for (page, &pid) in self.page_ids.iter().enumerate() { + let Ok(d) = self.doc.get_dictionary(pid) else { continue }; + let display = d.get(b"Dur").ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|s| s.is_finite() && *s >= 0.0); + let trans = d.get(b"Trans").ok().and_then(|o| self.dict(o)); + if trans.is_none() && display.is_none() { + continue; + } + let mut t = PageTransition { + page, + style: None, + duration: 1.0, + horizontal: true, + inward: true, + direction: Some(0), + scale: 1.0, + opaque: false, + display, + }; + if let Some(tr) = trans { + t.style = Some(self.name(tr, b"S").map_or(TransitionStyle::Replace, |n| TransitionStyle::from_name(&n))); + let num = |key: &[u8]| tr.get(key).ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|v| v.is_finite()); + t.duration = num(b"D").filter(|d| *d >= 0.0).unwrap_or(1.0); + t.horizontal = self.name(tr, b"Dm").as_deref() != Some("V"); + t.inward = self.name(tr, b"M").as_deref() != Some("O"); + t.direction = match tr.get(b"Di").map(|o| self.resolve(o)) { + Ok(Object::Name(n)) if n.as_slice() == b"None" => None, + Ok(o) => match o.as_i64().ok() { + Some(0) => Some(0), + Some(90) => Some(90), + Some(180) => Some(180), + Some(270) => Some(270), + Some(315) => Some(315), + _ => Some(0), + }, + Err(_) => Some(0), + }; + t.scale = num(b"SS").filter(|s| *s > 0.0).unwrap_or(1.0); + t.opaque = matches!(tr.get(b"B").map(|o| self.resolve(o)), Ok(Object::Boolean(true))); + } + info.transitions.push(t); + } + } + fn resolve(&self, o: &'a Object) -> &'a Object { self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) } @@ -2049,6 +2182,46 @@ trailer << /Root 1 0 R >> trailer << /Root 1 0 R >> %%EOF"; + /// `/Trans` and `/Dur` (ISO 32000-2 §12.4.4): read with the spec's defaults for what a page + /// leaves out, lenient about what is out of range, and only for pages that have either. + const TRANSITIONS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 200 200] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Trans << /Type /Trans /S /Wipe /D 2.5 /Di 270 >> /Dur 5 >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Dur 3 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R /Trans 8 0 R >> endobj +6 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Bogus /D (slow) /Di 45 /Dm /Q >> /Dur -1 >> endobj +7 0 obj << /Type /Page /Parent 2 0 R >> endobj +8 0 obj << /S /Fly /M /O /SS 0.5 /B true /Di /None /Dm /V >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + + #[test] + fn page_transitions_and_display_times_are_read() { + use super::TransitionStyle as S; + let info = inspect(Arc::new(TRANSITIONS.to_vec()), None).expect("opens"); + let t = &info.transitions; + assert_eq!(t.iter().map(|t| t.page).collect::>(), [0, 1, 2, 3], "pages with /Trans or /Dur, in order; page 5 has neither"); + assert_eq!((t[0].style, t[0].duration, t[0].direction, t[0].display), (Some(S::Wipe), 2.5, Some(270), Some(5.0))); + assert!((t[0].horizontal, t[0].inward, t[0].scale, t[0].opaque) == (true, true, 1.0, false), "defaults: {:?}", t[0]); + assert_eq!((t[1].style, t[1].duration, t[1].display), (None, 1.0, Some(3.0)), "/Dur without /Trans"); + assert_eq!( + (t[2].style, t[2].inward, t[2].scale, t[2].opaque, t[2].direction, t[2].horizontal), + (Some(S::Fly), false, 0.5, true, None, false), + "an indirect /Trans: {:?}", + t[2] + ); + // Out of range: an unknown style is the spec's default R, a non-numeric /D the default + // second, a direction that is not one of the five falls back to 0, a bad /Dm to + // horizontal, and a negative /Dur is no display time. + assert_eq!( + (t[3].style, t[3].duration, t[3].direction, t[3].horizontal, t[3].display), + (Some(S::Replace), 1.0, Some(0), true, None), + "{:?}", + t[3] + ); + } + #[test] fn radio_button_layer_groups_are_read() { let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ae146779f..b0a2373ab 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,7 +17,7 @@ pub mod text; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - RemoteDestination, Xfa, attachment_data, check_pages, inspect, pretty_date, + PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, pretty_date, }; pub use pixels::Pixels; pub use raster::{ diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index 23f2b66a3..4e21ef2ec 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -1,6 +1,6 @@ # PDF specification and standards parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (§7.6.4: written encrypted files open in Acrobat, #774) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (§12.4.4: page transitions and display times read) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -35,7 +35,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | §10 | Rendering: transfer functions, halftones, overprint, smoothness | full | borrowed; no overprint preview | `print.overprint-preview` planned | | §11 | Transparency: blend modes, soft masks, groups, knockout | full | borrowed (knockout shipped) | `view.render-transparency` partial | | §12.1–12.3 | Viewer preferences, page layout, page labels, outlines, destinations | full | **own**: initial view, page labels, bookmarks edit and from structure, named destinations rewired on copy | named-destination editing panel planned | -| §12.4 | Page transitions, thumbnails, articles, presentations | full | none (thumbnails discarded or kept) | `organize.page-transitions`, `view.articles-panel` planned | +| §12.4 | Page transitions, thumbnails, articles, presentations | full | `/Trans` and `/Dur` read and listed by `doc_info`, not played; thumbnails discarded or kept | `organize.page-transitions` partial, `view.articles-panel` planned | | §12.5 | Annotations | 28 types | see table below | | | §12.6 | Actions | 19 types | see table below | | | §12.7 | Interactive forms (AcroForm): fields, appearances, calculation order, JavaScript actions | full | **own**: all field types, regenerated appearances, Acrobat's AF functions and event order, sandboxed JavaScript (boa) | `form.js-host` partial (object-model subset); submit planned | @@ -130,5 +130,6 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | §12.4.4: `/Trans` and `/Dur` read into `DocInfo::transitions` and reported by `doc_info` | | 2026-10-11 | minor | §7.6.4: encrypted files we write open in Acrobat (#774, fixed by #836) | | 2026-10-10 | major | Created from the code, `vendor/README.md` and `parity/acrobat-features.toml`, against ISO 32000-2 and Acrobat Pro 26.002.21931's documented capabilities | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1d92d4439..2b9fada35 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3193,11 +3193,11 @@ title = "Page transitions" area = "D" tier = "P2" milestone = "M4" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_info"] +evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/automation.rs::doc_info_lists_page_transitions_and_display_times"] +notes = "Read only: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults for what is missing or out of range, and doc_info lists them (DocInfo::transitions). Not yet: playing them in full screen, auto-advancing on /Dur, or the Page Transitions dialog that sets them" [[feature]] id = "organize.page-tab-order"