M4: read page transitions and display times (/Trans, /Dur) into DocInfo (#2074)

Each page's presentation settings of ISO 32000-2 s12.4.4 are read with the
spec's defaults for what is missing or out of range: the transition style,
duration, dimension, motion, direction, Fly scale and backdrop, and the
/Dur display time. doc_info lists them; pdfcraft-cli info counts them.
This is the data full screen needs to play transitions and auto-advance;
playing and editing them stay planned.

Refs #379, #2070.


Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-15 02:42:04 -04:00
committed by GitHub
co-authored by Claude Fable 5.1
parent f00a84121b
commit 8772db0a6e
9 changed files with 230 additions and 8 deletions
+1
View File
@@ -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.
+1
View File
@@ -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::<Vec<_>>(),
"warnings": info.warnings,
});
+7
View File
@@ -2258,6 +2258,13 @@ fn info(d: &Document) -> Value {
},
})).collect::<Vec<_>>(),
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>(),
// 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::<Vec<_>>(),
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
// `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
+1 -1
View File
@@ -133,7 +133,7 @@ pub fn tools() -> Vec<ToolDef> {
.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"])),
+39
View File
@@ -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<u8>> = 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");
+173
View File
@@ -76,6 +76,8 @@ pub struct DocInfo {
pub layer_groups: Vec<Vec<(u32, u16)>>,
pub fonts: Vec<FontInfo>,
pub attachments: Vec<Attachment>,
/// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order.
pub transitions: Vec<PageTransition>,
pub warnings: Vec<String>,
}
@@ -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<TransitionStyle>,
/// `/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<u16>,
/// `/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<f32>,
}
/// 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::<Vec<_>>(), [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");
+1 -1
View File
@@ -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::{
+3 -2
View File
@@ -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 |
+4 -4
View File
@@ -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"