diff --git a/ROADMAP.md b/ROADMAP.md index 4cc21620..06421523 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index a451b551..8f89013f 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -9,7 +9,7 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { 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 Option { + 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 { 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 { 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> = (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> = 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(); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b18ed4bc..77352f17 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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)] diff --git a/crates/pdf/src/transitions.rs b/crates/pdf/src/transitions.rs index d8c7c452..0828c7b2 100644 --- a/crates/pdf/src/transitions.rs +++ b/crates/pdf/src/transitions.rs @@ -111,3 +111,32 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option]) -> Option> 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> { + 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<>\nstartxref\n{xref_at}\n%%EOF\n") + .as_bytes(), + ); + Some(out) +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7cb9f5ff..25173e38 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { + 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"), diff --git a/docs/parity.md b/docs/parity.md index 7cd069a6..007d2797 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |