mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
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:
co-authored by
Claude Opus 5.5
parent
dc2114be26
commit
b7cd7f6309
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user