O: interactive PDF view options (full screen, layout, opening view, timing)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 10:48:58 -04:00
co-authored by Claude Opus 5.5
parent dc2114be26
commit b7cd7f6309
6 changed files with 107 additions and 5 deletions
+1
View File
@@ -114,6 +114,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters.
- Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text.
- EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud.
- Interactive PDF options (`file.exportPdf {fullScreen, bookmarksPanel, pageLayout, view, advanceSeconds}`, File ▸ Export PDF (Interactive)…): page mode, layout, opening view and page timing in the catalog and pages.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
+59 -1
View File
@@ -9,7 +9,7 @@ use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None,
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
has_doc, export_pdf),
cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None,
"{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})",
@@ -475,6 +475,13 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option<designcraft_doc::
(Some(out), n)
}
/// Object number of the first page of a PDF (its page tree's first kid).
fn first_page_ref(pdf: &[u8]) -> Option<usize> {
let s = String::from_utf8_lossy(pdf);
let i = s.find("/Kids[")? + 6;
s[i..].split_whitespace().next()?.parse().ok()
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let opts = options(p, st.doc.page_count())?;
@@ -507,6 +514,49 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
None => r.warnings.push("the transparency blend space couldn't be set".into()),
}
}
// Interactive PDF view options.
let mut cat = String::new();
if p.get("fullScreen").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/FullScreen");
} else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/UseOutlines");
}
match str_param(p, "pageLayout") {
Some("single") => cat.push_str("/PageLayout/SinglePage"),
Some("continuous") => cat.push_str("/PageLayout/OneColumn"),
Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"),
Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"),
Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"),
Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))),
None => {}
}
if let Some(v) = str_param(p, "view") {
// The first page opens with this view (pages are objects in order, so refer by index 0).
let dest = match v {
"fitPage" => "/Fit",
"fitWidth" => "/FitH null",
"actual" => "/XYZ null null 1",
o => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))),
};
if let Some(first) = first_page_ref(&r.bytes) {
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
}
}
if !cat.is_empty() {
match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) {
Some(b) => r.bytes = b,
None => r.warnings.push("the PDF view options couldn't be set".into()),
}
}
// Flip pages every N seconds (presentation).
if let Some(secs) = p.get("advanceSeconds").and_then(Value::as_f64).filter(|s| *s > 0.0) {
let n = designcraft_pdf::sheet_spreads(doc, &opts).len();
let dur: Vec<Option<String>> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect();
match designcraft_pdf::add_page_entries(&r.bytes, &dur) {
Some(b) => r.bytes = b,
None => r.warnings.push("page timings couldn't be set".into()),
}
}
// Page transitions (each spread's first page holds them).
let trans: Vec<Option<designcraft_doc::PageTransition>> =
spreads.into_iter().map(|si| doc.spreads.get(si).and_then(|sp| sp.pages.first()).and_then(|pg| pg.transition)).collect();
@@ -602,6 +652,14 @@ mod text_tests {
// The document itself is untouched.
assert!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).is_some());
assert!(s.execute("file.exportPdf", &json!({"flatten": 5})).is_err());
// Interactive view options land in the catalog and on the pages.
let r = s.execute("file.exportPdf", &json!({"fullScreen": true, "pageLayout": "single", "view": "fitPage", "advanceSeconds": 5})).unwrap();
let t = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string();
assert!(
t.contains("/PageMode/FullScreen") && t.contains("/PageLayout/SinglePage") && t.contains("/OpenAction[") && t.contains("/Dur 5.00"),
"view options"
);
assert!(s.execute("file.exportPdf", &json!({"pageLayout": "sideways"})).is_err());
// Unflattened, the page blends in the document's blend space (CMYK for print).
let r = s.execute("file.exportPdf", &json!({})).unwrap();
let text = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string();
+1 -1
View File
@@ -35,7 +35,7 @@ mod pdfx;
mod transitions;
pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, sheet_spreads};
pub use pdfx::{check_pdfx4, make_pdfx4};
pub use transitions::{add_blend_space, add_transitions};
pub use transitions::{add_blend_space, add_catalog_entries, add_page_entries, add_transitions};
/// PDF standard to target.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+29
View File
@@ -111,3 +111,32 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option<String>]) -> Option<Vec<u8>>
out.extend_from_slice(x.as_bytes());
Some(out)
}
/// Add `entries` (raw dictionary entries, e.g. `/PageMode/FullScreen`) to the catalog as an
/// incremental update.
pub fn add_catalog_entries(pdf: &[u8], entries: &str) -> Option<Vec<u8>> {
if entries.is_empty() {
return Some(pdf.to_vec());
}
let t_at = rfind(pdf, b"trailer")?;
let trailer = String::from_utf8_lossy(&pdf[t_at..]).to_string();
let size = int_after(&trailer, "/Size")?;
let root = int_after(&trailer, "/Root")?;
let prev = int_after(&trailer, "startxref")?;
let info = int_after(&trailer, "/Info").map(|i| format!("/Info {i} 0 R")).unwrap_or_default();
let id = trailer.find("/ID").and_then(|i| trailer[i..].find(']').map(|j| trailer[i..i + j + 1].to_string())).unwrap_or_default();
let cat = String::from_utf8_lossy(&pdf[object(pdf, root)?]).to_string();
let close = cat.rfind(">>")?;
let mut out = pdf.to_vec();
if !out.ends_with(b"\n") {
out.push(b'\n');
}
let at = out.len();
out.extend_from_slice(format!("{root} 0 obj\n{}{entries}{}\nendobj\n", cat[..close].trim(), &cat[close..].trim()).as_bytes());
let xref_at = out.len();
out.extend_from_slice(
format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<</Size {size}/Root {root} 0 R{info}{id}/Prev {prev}>>\nstartxref\n{xref_at}\n%%EOF\n")
.as_bytes(),
);
Some(out)
}
+14
View File
@@ -18,6 +18,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"),
("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"),
("app.exportEpub", "Export EPUB…", None, "{path?} — reflowable EPUB 3 (with the first page as its cover)"),
(
"app.exportInteractivePdf",
"Export PDF (Interactive)…",
None,
"{path?, fullScreen?, advanceSeconds?} — single pages fitted, bookmarks panel open, buttons, forms, transitions",
),
("app.exportFixedEpub", "Export EPUB (Fixed Layout)…", None, "{path?} — pre-paginated EPUB 3"),
("app.exportHtml", "Export HTML…", None, "{path?} — one self-contained web page"),
("app.exportXml", "Export XML…", None, "{path?} — the tagged content"),
@@ -152,6 +158,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:app.exportIdml",
"ui:app.exportEpub",
"ui:app.exportFixedEpub",
"ui:app.exportInteractivePdf",
"ui:app.exportHtml",
"ui:app.exportXml",
"ui:app.exportText",
@@ -706,6 +713,13 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
q["cover"] = json!(true);
export_bytes(app, &q, "epub", "file.exportEpub")
}
"app.exportInteractivePdf" => {
let mut q = if p.is_object() { p.clone() } else { json!({}) };
for (k, v) in [("pageLayout", json!("single")), ("view", json!("fitPage")), ("bookmarksPanel", json!(true))] {
q.as_object_mut().map(|o| o.entry(k).or_insert(v));
}
export_pdf(app, &q)
}
"app.exportFixedEpub" => export_bytes(app, p, "epub", "file.exportFixedEpub"),
"app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"),
"app.exportXml" => export_bytes(app, p, "xml", "file.exportXml"),
+3 -3
View File
@@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|---|---|---|---|---|---|
| P0 | 102 | 100 | 2 | 0 | 99% |
| P1 | 97 | 94 | 3 | 0 | 98% |
| P2 | 76 | 61 | 15 | 0 | 90% |
| P2 | 76 | 62 | 14 | 0 | 91% |
| Area | Parity |
|---|---|
@@ -29,7 +29,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Tables | 98% |
| Long documents | 100% |
| Interactivity & digital | 95% |
| Output & production | 90% |
| Output & production | 92% |
| XML & automation | 89% |
| View & navigation | 98% |
| Undo, history, saving | 100% |
@@ -277,7 +277,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
| Interactivity & digital | EPUB export | P2 | D |
| Interactivity & digital | HTML export | P2 | D |
| Output & production | Export to PDF (print) | P0 | P |
| Output & production | Interactive PDF | P2 | P |
| Output & production | Interactive PDF | P2 | D |
| Output & production | Print dialog | P1 | D |
| Output & production | Print booklet | P2 | D |
| Output & production | Package | P1 | D |