From d93acf7120bdb94c61b6ddfcff969b8b2b98be95 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:53:57 +0000 Subject: [PATCH] refactor(engine): split cmd/export.rs into cmd/export/ --- crates/engine/src/cmd/export.rs | 1530 +--------------------- crates/engine/src/cmd/export/flatten.rs | 610 +++++++++ crates/engine/src/cmd/export/formats.rs | 413 ++++++ crates/engine/src/cmd/export/pdf.rs | 463 +++++++ crates/engine/src/cmd/export/registry.rs | 53 + 5 files changed, 1547 insertions(+), 1522 deletions(-) create mode 100644 crates/engine/src/cmd/export/flatten.rs create mode 100644 crates/engine/src/cmd/export/formats.rs create mode 100644 crates/engine/src/cmd/export/pdf.rs create mode 100644 crates/engine/src/cmd/export/registry.rs diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f308f45a3..a4ee73ce4 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -7,1531 +7,17 @@ use std::{borrow::Cow, sync::Arc}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; 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; with standard x1a, the resolution complex transparent regions are rasterized at: high 300, medium 150, low 72, default 300), 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\"|\"x1a\"|\"a2b\" (x1a: PDF/X-1a:2003, the PDF/X-4 file flattened in its inks by craft-prepress: no transparency, only CMYK, gray and spot color), compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {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\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), split?: \"off\"|\"exportTags\"|{style: name} (Split Document for this export, not stored; the document's `document.epubSplit` by default), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded", - has_doc, export_epub), - cmd!( - "document.epubSplit", - "EPUB Split Document", - [], - None, - "{split?: \"off\"|\"exportTags\"|{style: name}} — EPUB Export Options › Split Document, stored with the document: one content file (off, the default), a file before each paragraph whose style has Export Tagging › Split Document on (exportTags), or before each paragraph of one style → {split}", - has_doc, - |s, p| { - const ID: &str = "document.epubSplit"; - let cur = s.doc()?.doc.settings.epub_split.clone(); - let Some(v) = p.get("split") else { return Ok(json!({"split": cur})) }; - let split = epub_split_param(ID, &s.doc()?.doc, v)?; - if split != cur { - let stored = split.clone(); - s.edit(move |d, _| { - d.settings.epub_split = stored; - Ok(Value::Null) - })?; - } - Ok(json!({"split": split})) - } - ), - cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, - "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", - has_doc, |s, p| { - export_epub(s, &super::with_param(p, "fixedLayout", serde_json::json!(true))) - }), - cmd!(noundo "file.printBooklet", "Print Booklet…", ["File"], None, - "{path?, type?: saddleStitch|twoUpConsecutive, spaceBetween? (pt)} — printer spreads as PDF (pages imposed in booklet order) → {path, bytes, sheets} (no path: {base64, …})", - has_doc, print_booklet), - cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None, - "{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes, warnings} (no path: {text, bytes, warnings})", - has_doc, export_html), - cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None, - "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})", - has_doc, export_pptx), - cmd!(noundo "file.exportText", "Export Text…", ["File"], None, - "{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})", - has_story_target, export_text), - ] -} - -fn print_booklet(s: &mut Session, p: &Value) -> Result { - const ID: &str = "file.printBooklet"; - let kind = match str_param(p, "type").unwrap_or("saddleStitch") { - "saddleStitch" => designcraft_pdf::BookletKind::SaddleStitch, - "twoUpConsecutive" | "twoUp" => designcraft_pdf::BookletKind::TwoUpConsecutive, - t => { - return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)"))); - } - }; - let opts = designcraft_pdf::BookletOptions { kind, space_between: p.get("spaceBetween").and_then(Value::as_f64).unwrap_or(0.0), title: None }; - let st = s.doc()?; - let r = designcraft_pdf::export_booklet(&super::interchange::with_pdf_boxes(&st.doc), &s.cache, &opts) - .map_err(|e| EngineError::Other(e.to_string()))?; - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), - } -} - -/// HTML and reflowable EPUB embed the EPS preview; keep the session's source bytes intact. -fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Document>, Vec) { - // Match the exporter's reading order (including Article exclusions), not every asset - // in the document: unused or hidden graphics must not prevent an export. - let mut used = std::collections::BTreeSet::new(); - for block in designcraft_epub::reading_order(d) { - let designcraft_epub::Block::Image(id) = block else { - continue; - }; - let Some(it) = d.item(id) else { continue }; - let designcraft_doc::Content::Graphic(g) = &it.content else { - continue; - }; - used.insert(g.asset); - if d.assets.get(&g.asset).is_some_and(|a| designcraft_doc::media_kind(&a.mime).is_some()) - && let Some(poster) = it.media.as_ref().and_then(|m| m.poster) - { - used.insert(poster); - } - } - let mut out = Cow::Borrowed(d); - let mut warnings = Vec::new(); - for id in used { - let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else { - continue; - }; - let png = designcraft_images::eps_png(&a.data); - if png.is_none() { - warnings.push(format!("{}: unusable EPS preview; graphic omitted", a.name)); - } - if let Some(a) = out.to_mut().assets.get_mut(&id) { - let a = Arc::make_mut(a); - a.mime = "image/png".into(); - a.data = Arc::new(png.unwrap_or_default()); - } - } - (out, warnings) -} - -fn export_html(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let opts = designcraft_epub::HtmlOptions { - title: str_param(p, "title").map(str::to_string), - language: str_param(p, "language").map(str::to_string), - generic_families: generic_families(&st.doc), - }; - let (doc, warnings) = eps_previews(&st.doc); - let text = designcraft_epub::export_html(&doc, &opts); - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": text.len(), "warnings": warnings})) - } - None => Ok(json!({"bytes": text.len(), "text": text, "warnings": warnings})), - } -} - -fn export_pptx(s: &mut Session, p: &Value) -> Result { - const ID: &str = "file.exportPptx"; - let st = s.doc()?; - let line_breaks = match str_param(p, "lineBreaks") { - None | Some("keep") => designcraft_pptx::LineBreaks::Keep, - Some("reflow") => designcraft_pptx::LineBreaks::Reflow, - Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))), - }; - let opts = designcraft_pptx::PptxOptions { - pages: pages_param(ID, p, st.doc.page_count())?, - line_breaks, - title: str_param(p, "title").map(str::to_string), - author: str_param(p, "author").map(str::to_string), - created: None, - embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true), - }; - // Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame - // stays text: a picture of it would drop the rest of its story). - let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true); - let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, true, |it| { - !threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it))) - }); - let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); - let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - if rasterized > 0 { - r.warnings.push(format!("{rasterized} object(s) went in as images")); - } - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})), - } -} - -fn has_story_target(s: &Session) -> std::result::Result<(), String> { - super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into()) -} - -fn export_text(s: &mut Session, p: &Value) -> Result { - const ID: &str = "file.exportText"; - let sid = super::text::story_of(s, p).ok_or_else(|| bad(ID, "edit text or select a text frame"))?; - let doc = &s.doc()?.doc; - let story = doc.stories.get(&sid).ok_or_else(|| bad(ID, format!("no story {}", sid.0)))?; - let path = str_param(p, "path"); - let format = match str_param(p, "format") { - Some(f @ ("rtf" | "tagged")) => f, - Some("txt") | Some("text") => "txt", - Some(f) => return Err(bad(ID, format!("unknown format `{f}` (txt, rtf, tagged)"))), - None if path.is_some_and(|x| x.to_ascii_lowercase().ends_with(".rtf")) => "rtf", - None => "txt", - }; - let text = match format { - "rtf" => designcraft_textimport::export::rtf(doc, story), - "tagged" => designcraft_textimport::tagged::tagged_text(doc, story), - _ => designcraft_textimport::export::plain_text(story), - }; - match path { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": text.len()})) - } - None => Ok(json!({"bytes": text.len(), "text": text})), - } -} - -/// Text of the frames on page `abs`, in line order (for fixed-layout pages). -fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, abs: usize) -> String { - let mut out = String::new(); - for st in d.stories.values() { - let cs = cache.get(d, st.id, None); - for ft in &cs.frames { - if d.page_of_item(ft.frame) != Some(abs) { - continue; - } - for l in &ft.lines { - let r = l.range.start.min(st.text.len())..l.range.end.min(st.text.len()); - let t: String = st.text[r].chars().filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)).collect(); - if !t.trim().is_empty() { - out.push_str(t.trim_end_matches('\n')); - out.push('\n'); - } - } - } - } - out -} - -/// The CSS generic family of each font family the EPUB/HTML stylesheet names, from the fonts' -/// classification (PANOSE, OS/2 class, name), for the `font-family` fallbacks. -fn generic_families(d: &designcraft_doc::Document) -> std::collections::BTreeMap { - use designcraft_fonts::FontClass; - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let mut out = std::collections::BTreeMap::new(); - for (family, _) in designcraft_epub::stylesheet_fonts(d) { - if out.contains_key(&family) { - continue; - } - let generic = match db.family_kind(&family).map(|(k, _)| k.class) { - Some(FontClass::Serif | FontClass::SlabSerif) => "serif", - Some(FontClass::SansSerif) => "sans-serif", - Some(FontClass::Monospaced) => "monospace", - Some(FontClass::Script) => "cursive", - Some(FontClass::Decorative) => "fantasy", - Some(FontClass::Symbol | FontClass::Other) | None => continue, - }; - out.insert(family, generic.to_string()); - } - out -} - -/// The faces the EPUB stylesheet names, to package with it; `warnings` gets the ones left out -/// (not available, a licence that forbids embedding, or part of a font collection). -fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> Vec { - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let mut out = Vec::new(); - for (family, style) in designcraft_epub::stylesheet_fonts(d) { - let face = db.face(&family, &style); - if !face.family.eq_ignore_ascii_case(&family) { - warnings.push(format!("font `{family} {style}` is not available; not embedded")); - } else if face.restricted_licence() || face.bitmap_only() { - warnings.push(format!("font `{family} {style}`: its licence doesn't allow embedding; not embedded")); - } else if face.data().starts_with(b"ttcf") { - warnings.push(format!("font `{family} {style}` is part of a font collection; not embedded")); - } else { - out.push(designcraft_epub::EmbeddedFont { family, style: face.style.clone(), data: face.data().to_vec() }); - } - } - out -} - -/// A Split Document setting (`"off"`, `"exportTags"` or `{"style": name}`), naming a paragraph -/// style the document has. -pub(crate) fn epub_split_param(id: &str, doc: &designcraft_doc::Document, v: &Value) -> Result { - let split: designcraft_doc::EpubSplit = - serde_json::from_value(v.clone()).map_err(|_| bad(id, "`split`: \"off\", \"exportTags\" or {\"style\": name}"))?; - if let designcraft_doc::EpubSplit::Style(name) = &split - && doc.styles.para(name).is_none() - { - return Err(bad(id, format!("no paragraph style `{name}`"))); - } - Ok(split) -} - -fn export_epub(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let split = p.get("split").map(|v| epub_split_param("file.exportEpub", &st.doc, v)).transpose()?; - // Object Export Options › Rasterize: those objects go in as images. - let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it)); - let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); - let fixed = p.get("fixedLayout").and_then(Value::as_bool).unwrap_or(false); - let want_cover = p.get("cover").and_then(Value::as_bool).unwrap_or(false); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; - let mut warnings: Vec = Vec::new(); - // A cover image file (PNG or JPEG), else the first page rendered when `cover` asks for it. - let cover = match p.get("coverImage").and_then(Value::as_str).filter(|_| !fixed) { - Some(path) => Some(super::datamerge::read_path_capped(path, super::datamerge::MAX_IMAGE_FILE_BYTES).map_err(crate::EngineError::Other)?), - None => (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(), - }; - if let Some(c) = &cover - && !(c.starts_with(b"\x89PNG") || c.starts_with(&[0xFF, 0xD8, 0xFF])) - { - return Err(crate::EngineError::Other("the cover image must be a PNG or JPEG file".into())); - } - let opts = designcraft_epub::EpubOptions { - title: p.get("title").and_then(Value::as_str).map(str::to_string), - author: p.get("author").and_then(Value::as_str).map(str::to_string), - language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), - identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string), - cover, - toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string), - split, - generic_families: generic_families(&st.doc), - fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed { - embedded_fonts(&st.doc, &mut warnings) - } else { - Vec::new() - }, - // The export time; on the web the document's own save time stands in. - #[cfg(not(target_arch = "wasm32"))] - modified: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()), - #[cfg(target_arch = "wasm32")] - modified: None, - }; - if fixed { - let d = &st.doc; - let mut pages = Vec::new(); - for i in 0..d.page_count() { - let Some((si, pi)) = d.page_loc(i) else { - continue; - }; - let b = d.spreads[si].pages[pi].bounds(); - let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else { - continue; - }; - pages.push(designcraft_epub::FixedPage { png: img.to_png(), width: b.width(), height: b.height(), text: page_text(d, &s.cache, i) }); - } - let bytes = designcraft_epub::export_fixed_epub(d, &pages, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; - return match p.get("path").and_then(Value::as_str) { - Some(path) => { - write_file(path, &bytes)?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "pages": pages.len()})) - } - None => Ok(serde_json::json!({"base64": super::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages.len()})), - }; - } - let (epub_doc, eps_warnings) = eps_previews(epub_doc); - warnings.extend(eps_warnings); - let bytes = designcraft_epub::export_epub(&epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; - warnings.extend( - designcraft_epub::images_without_alt(&epub_doc) - .into_iter() - .map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")), - ); - match p.get("path").and_then(Value::as_str) { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) - } - None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), - } -} - -/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions. -fn pages_param(id: &str, p: &Value, page_count: usize) -> Result>> { - Ok(match p.get("pages") { - None | Some(Value::Null) => None, - Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?), - Some(Value::Array(a)) => { - let mut v = Vec::new(); - for x in a { - let n = x - .as_u64() - .and_then(|n| usize::try_from(n).ok()) - .filter(|n| *n >= 1 && *n <= page_count) - .ok_or_else(|| bad(id, format!("bad page {x}")))?; - // A page named twice is one page, as `"2,2"` already is: the list says which - // pages to export, not how many times. `[2, 2]` exported two copies of page 2 - // while `"2,2"` exported one. - if !v.contains(&(n - 1)) { - v.push(n - 1); - } - } - Some(v) - } - Some(Value::Number(n)) => { - let n = n - .as_u64() - .and_then(|n| usize::try_from(n).ok()) - .filter(|n| *n >= 1 && *n <= page_count) - .ok_or_else(|| bad(id, format!("bad page {n}")))?; - Some(vec![n - 1]) - } - Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))), - }) -} - -pub(crate) fn options(p: &Value, page_count: usize) -> Result { - const ID: &str = "file.exportPdf"; - - let pages = pages_param(ID, p, page_count)?; - let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false); - let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false); - let marks = match p.get("marks") { - Some(Value::Bool(true)) => Marks::ALL, - Some(Value::Object(m)) => { - let b = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); - Marks { - crop: b("crop"), - bleed: b("bleed"), - page_info: b("pageInfo"), - weight: m.get("weight").and_then(Value::as_f64).unwrap_or(0.25), - offset: m.get("offset").and_then(Value::as_f64).unwrap_or(6.0), - } - } - _ => Marks::NONE, - }; - let standard = match str_param(p, "standard") { - Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, x1a, a2b)")))?, - None => Standard::None, - }; - let created = match p.get("created") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?), - }; - Ok(PdfOptions { - pages, - spreads, - bleed, - marks, - standard, - compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false), - title: str_param(p, "title").map(str::to_string), - author: str_param(p, "author").map(str::to_string), - tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false), - media: p.get("media").and_then(Value::as_bool).unwrap_or(false), - created, - ..PdfOptions::default() - }) -} - -/// Does this object (or anything in it) need the transparency flattener? -fn transparent(it: &designcraft_doc::Item) -> bool { - it.involves_transparency() -} - -/// Transparency Flattener: each object involving transparency becomes an opaque image of its -/// area as it looks over what's beneath it (so the rest stays vector); spreads whose parent items -/// involve transparency are rasterised whole. Works on a copy of the document made for export. -/// Returns the copy and the number of rasterised regions. -fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, ppi: f64) -> (designcraft_doc::Document, usize) { - use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; - let mut out = d.clone(); - let mut n = 0; - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let k = ppi / 72.0; - let parent_transparent = |pid: Option| { - pid.and_then(|id| d.parent_index(id)) - .is_some_and(|pi| d.parent_chain(pi).iter().filter_map(|&i| d.parents.get(i)).any(|p| p.items.iter().any(|it| transparent(it)))) - }; - // An opaque image item showing `img` over `r`. - let image_item = - |out: &mut designcraft_doc::Document, r: designcraft_geom::Rect, img: designcraft_render::Rendered, layer: designcraft_doc::LayerId| { - let (w, h) = (img.width, img.height); - let aid = AssetId(out.alloc()); - out.assets.insert( - aid, - std::sync::Arc::new(Asset { - id: aid, - name: format!("flattened-{}.png", aid.0), - mime: "image/png".into(), - data: std::sync::Arc::new(img.to_png()), - pixels: Some((w, h)), - ..Default::default() - }), - ); - let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); - it.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (w as f64, h as f64), - xf: designcraft_geom::Affine::translate((r.x0, r.y0)) - * designcraft_geom::Affine::scale_non_uniform(r.width() / w as f64, r.height() / h as f64), - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - it.stroke.weight = 0.0; - it - }; - for (si, sp) in d.spreads.iter().enumerate() { - let whole = sp.pages.iter().any(|pg| pg.show_parent_items && parent_transparent(pg.parent)); - if whole { - // Parent items involve transparency: every page of the spread as one image. - let first = d.first_page_of_spread(si); - let lid = out.default_layer(); - let mut images = Vec::new(); - for (pi, pg) in sp.pages.iter().enumerate() { - if let Some(img) = rr.render_page(d, cache, first + pi, k, false, &Default::default()) { - images.push((pg.bounds(), img)); - } - } - let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); - osp.items.clear(); - for pg in &mut osp.pages { - pg.show_parent_items = false; - } - for (b, img) in images { - let it = image_item(&mut out, b, img, lid); - let _ = out.insert_item(SpreadRef::Doc(si), it, None); - n += 1; - } - continue; - } - // Region by region, bottom to top. - for (idx, it) in sp.items.iter().enumerate() { - if it.hidden || !transparent(it) { - continue; - } - let reach = it.bounds().inflate( - designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, - designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, - ); - // Only the part on the spread's pages and pasteboard that prints. - let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); - if w as u64 * h as u64 > 80_000_000 { - continue; - } - // The stack up to and including this object. - let mut below = d.clone(); - std::sync::Arc::make_mut(&mut below.spreads[si]).items.truncate(idx + 1); - let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); - let opts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; - let img = rr.render( - &below, - cache, - &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], - w, - h, - view, - &opts, - ); - let new = image_item(&mut out, reach, img, it.layer); - let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); - let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else { - continue; - }; - if threaded(d, it) { - // Keep the frame (its story flows on) under the image. - osp.items.insert(pos + 1, std::sync::Arc::new(new)); - } else { - osp.items[pos] = std::sync::Arc::new(new); - } - n += 1; - } - } - (out, n) -} - -/// A text frame in a story that runs through other frames (replacing it would reflow the story). -fn threaded(d: &designcraft_doc::Document, it: &designcraft_doc::Item) -> bool { - it.text_frame().and_then(|t| d.story(t.story)).is_some_and(|st| st.frames.len() > 1) -} - -/// Soft effects other than the gradient feather (which PDF export draws itself). -fn raster_effects(it: &designcraft_doc::Item) -> bool { - let e = &it.effects; - e.drop_shadow.on - || e.feather > 0.0 - || e.inner_shadow.on - || e.outer_glow.on - || e.inner_glow.on - || e.bevel.on - || e.satin.on - || e.directional_feather.on -} - -/// A copy of `d` where spread-level objects with soft effects are 300 ppi images of their whole -/// appearance (transparent around them, so they composite over what's behind). Objects in `skip` -/// stay as they are. -fn rasterize_effects( - d: &designcraft_doc::Document, - cache: &designcraft_compose::Cache, - skip: &std::collections::HashMap, -) -> (Option, usize) { - rasterize_where(d, cache, 300.0, true, |it| raster_effects(it) && !threaded(d, it) && !skip.contains_key(&it.id)) -} - -/// A drop shadow is the object's only soft effect, and the PDF writer draws its shadow itself -/// (a leaf object, not a group or a frame with items pasted into it). -fn shadow_only(it: &designcraft_doc::Item) -> bool { - let e = &it.effects; - e.drop_shadow.on - && !(e.feather > 0.0 - || e.inner_shadow.on - || e.outer_glow.on - || e.inner_glow.on - || e.bevel.on - || e.satin.on - || e.directional_feather.on - || e.gradient_feather.on) - && it.children().is_empty() - && !it.has_nested_items() -} - -/// For PDF/X-1a: the coverage of each spread object's soft drop shadow at `ppi`, so the PDF draws -/// the shadow in its own swatch's inks through a mask (which craft-prepress flattens per ink) -/// instead of an RGB image of the object. Objects whose only soft effect is the drop shadow. -/// -/// The coverage comes from the object drawn alone, at full opacity and Normal blending, with its -/// shadow and without it ([`shadow_coverage`]): the shadow's own alpha, its opacity included. The -/// object's own opacity and blend mode stay on the object's group in the PDF. -fn shadow_masks( - d: &designcraft_doc::Document, - cache: &designcraft_compose::Cache, - ppi: f64, -) -> std::collections::HashMap { - use designcraft_doc::SpreadRef; - let mut out = std::collections::HashMap::new(); - let todo: Vec<(usize, designcraft_doc::ItemId)> = d - .spreads - .iter() - .enumerate() - .flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && shadow_only(it)).map(move |it| (si, it.id))) - .collect(); - if todo.is_empty() { - return out; - } - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let k = ppi.clamp(36.0, 1200.0) / 72.0; - for (si, id) in todo { - let Some(it) = d.item(id) else { continue }; - let grow = designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0; - let reach = it.bounds().inflate(grow, grow); - let (w, h) = ((reach.width() * k).ceil().max(1.0), (reach.height() * k).ceil().max(1.0)); - if !(w.is_finite() && h.is_finite()) || w * h > 60_000_000.0 { - continue; - } - let (w, h) = (w as u32, h as u32); - let mut alpha = |shadow: bool| { - let mut solo = d.clone(); - let Some(sp) = solo.spreads.get_mut(si) else { return Vec::new() }; - let sp = std::sync::Arc::make_mut(sp); - sp.items.retain(|x| x.id == id); - for pg in &mut sp.pages { - pg.show_parent_items = false; - } - if let Some(x) = sp.items.first_mut() { - let x = std::sync::Arc::make_mut(x); - x.opacity = 1.0; - x.blend = Default::default(); - x.effects.drop_shadow.on = shadow; - } - let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); - let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; - let placed = [designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::IDENTITY }]; - rr.render(&solo, cache, &placed, w, h, view, &opts).pixels.as_chunks::<4>().0.iter().map(|px| px[3]).collect::>() - }; - let (with, without) = (alpha(true), alpha(false)); - let n = w as usize * h as usize; - if with.len() != n || without.len() != n { - continue; - } - let coverage: Vec = with.iter().zip(&without).map(|(a, b)| shadow_coverage(*a, *b)).collect(); - out.insert(id, designcraft_pdf::ShadowMask { rect: reach, width: w, height: h, coverage: std::sync::Arc::new(coverage) }); - } - out -} - -/// The shadow's alpha `s` at a pixel where the object alone has alpha `without` and the object -/// over its shadow `with`: `with = a + s(1 − a)` for the object's alpha `a`, so the PDF, which -/// draws the object over the shadow, gets the same pixel back. 0 where the object is opaque. -fn shadow_coverage(with: u8, without: u8) -> u8 { - let rest = 255 - u32::from(without); - if rest == 0 { - return 0; - } - let shown = u32::from(with.saturating_sub(without)); - u8::try_from(((shown * 255 + rest / 2) / rest).min(255)).unwrap_or(u8::MAX) -} - -/// A copy of `d` where the spread-level objects `pred` picks are images of their appearance at -/// `ppi` (transparent around them). With `keep_wrap`, each object also stays behind as a -/// nonprinting [`wrap_stand_in`], so text wraps around it as before. -fn rasterize_where( - d: &designcraft_doc::Document, - cache: &designcraft_compose::Cache, - ppi: f64, - keep_wrap: bool, - pred: impl Fn(&designcraft_doc::Item) -> bool, -) -> (Option, usize) { - use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; - let todo: Vec<(usize, ItemId)> = - d.spreads.iter().enumerate().flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && pred(it)).map(move |it| (si, it.id))).collect(); - if todo.is_empty() { - return (None, 0); - } - let mut out = d.clone(); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let k = ppi / 72.0; - let mut n = 0; - for (si, id) in todo { - let Some(it) = d.item(id) else { continue }; - let reach = it.bounds().inflate( - designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, - designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, - ); - // Just this object on its spread, no paper. - let mut solo = d.clone(); - let sp = std::sync::Arc::make_mut(&mut solo.spreads[si]); - sp.items.retain(|x| x.id == id); - for pg in &mut sp.pages { - pg.show_parent_items = false; - } - let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); - if w as u64 * h as u64 > 60_000_000 { - continue; - } - let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); - let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; - let img = rr.render( - &solo, - cache, - &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], - w, - h, - view, - &opts, - ); - let aid = AssetId(out.alloc()); - out.assets.insert( - aid, - std::sync::Arc::new(Asset { - id: aid, - name: format!("effects-{}.png", id.0), - mime: "image/png".into(), - data: std::sync::Arc::new(img.to_png()), - pixels: Some((w, h)), - ..Default::default() - }), - ); - let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); - img_item.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (w as f64, h as f64), - xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k), - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - img_item.stroke.weight = 0.0; - img_item.alt_text = it.alt_text.clone(); - img_item.export_options = it.export_options.clone(); - // Same place in the stacking order. - let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); - if let Some(pos) = osp.items.iter().position(|x| x.id == id) { - osp.items[pos] = std::sync::Arc::new(img_item); - if keep_wrap && has_wrap(it, 0) { - osp.items.insert(pos, std::sync::Arc::new(wrap_stand_in(it, 0))); - } - n += 1; - } - } - (Some(out), n) -} - -/// Whether `it`, its placed graphic or an item in it wraps text. -fn has_wrap(it: &designcraft_doc::Item, depth: usize) -> bool { - use designcraft_doc::{Content, WrapMode}; - if depth > MAX_STAND_IN_DEPTH { - return false; - } - it.wrap.mode != WrapMode::None - || match &it.content { - Content::Graphic(g) => g.wrap.mode != WrapMode::None, - Content::Group { items } => items.iter().any(|c| has_wrap(c, depth + 1)), - _ => false, - } -} - -/// Nesting depth past which groups inside a rasterised object keep no wrap stand-ins. -const MAX_STAND_IN_DEPTH: usize = 64; - -/// A nonprinting copy of `it` that keeps its text wrap (and its graphic's and its items') and -/// nothing else that reaches the PDF: no text, form field, button or media. The image of the -/// object stands in for all of that, as it does without the copy. -fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::Item { - use designcraft_doc::Content; - let mut c = it.clone(); - c.nonprinting = true; - c.form_field = None; - c.button = None; - c.media = None; - c.content = match &it.content { - Content::Text(_) => Content::Unassigned, - Content::Group { items } if depth < MAX_STAND_IN_DEPTH => { - Content::Group { items: items.iter().map(|i| std::sync::Arc::new(wrap_stand_in(i, depth + 1))).collect() } - } - Content::Group { .. } => Content::Group { items: Vec::new() }, - other => other.clone(), - }; - c -} - -/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. -/// -/// Parent-page and anchored placements count too, including anchors inside table-cell, -/// footnote and endnote text. Only the export copy is rewritten. -fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { - let choices: Vec<_> = d - .placed_items() - .into_iter() - .filter(|it| it.has_pdf_layer_override()) - .filter_map(|it| { - let g = it.graphic()?; - let a = d.assets.get(&g.asset).filter(|a| designcraft_render::is_pdf(&a.data))?; - Some((it.id, g.clone(), a.clone(), it.pdf_hidden_layers.clone())) - }) - .collect(); - if choices.is_empty() { - return Ok((None, 0)); - } - let mut out = d.clone(); - let mut images = std::collections::HashMap::new(); - let mut n = 0; - for (id, g, a, hidden) in choices { - // The whole page at 300 ppi (the graphic may show only a box of it). - let shown = designcraft_render::shown_box(&a); - let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); - let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t)); - let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32; - let png = designcraft_render::pdf_page_png_options_checked(&a.data, a.page as usize, side, Some(&hidden), shown) - .map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?; - let px = designcraft_render::image_size(&png); - let aid = designcraft_doc::AssetId(out.alloc()); - out.assets.insert( - aid, - std::sync::Arc::new(designcraft_doc::Asset { - id: aid, - name: format!("{}.png", a.name), - mime: "image/png".into(), - data: std::sync::Arc::new(png), - pixels: px, - ..Default::default() - }), - ); - images.insert(id, aid); - n += 1; - } - out.update_placed_items( - |it| images.contains_key(&it.id), - |it| { - if let designcraft_doc::Content::Graphic(gg) = &mut it.content - && let Some(aid) = images.get(&it.id) - { - gg.asset = *aid; - } - }, - ); - Ok((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 { - 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 r = pdf_export(s, p)?; - match str_param(p, "path") { - Some(path) => { - write_file(path, &r.bytes)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})), - } -} - -/// Transparency Flattener presets (`flatten`): High / Medium / Low Resolution, or a ppi. -pub(crate) fn flatten_ppi(id: &str, p: &Value) -> Result> { - Ok(match p.get("flatten") { - None | Some(Value::Null) | Some(Value::Bool(false)) => None, - Some(Value::String(s)) if s == "high" => Some(300.0), - Some(Value::String(s)) if s == "medium" => Some(150.0), - Some(Value::String(s)) if s == "low" => Some(72.0), - Some(Value::Bool(true)) => Some(300.0), - Some(v) => Some(v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad(id, "`flatten`: high|medium|low or 36–1200 ppi"))?), - }) -} - -/// The print PDF of `opts`' pages: the transparency flattener at `ppi` (if any), placed PDFs -/// with hidden layers and soft effects as images, the PDF boxes of placed graphics, the PDF -/// itself and its transparency blend space. Export PDF and File › Print add their view options -/// to it ([`pdf_export`]); Separations Preview separates it as it is. -pub(crate) fn print_pdf( - d: &designcraft_doc::Document, - cache: &designcraft_compose::Cache, - opts: &PdfOptions, - ppi: Option, -) -> Result { - // PDF/X-1a: craft-prepress flattens the PDF in its inks; `ppi` sets its resolution. - let x1a = opts.standard == Standard::PdfX1a; - let mut opts = opts.clone(); - if x1a { - opts.flatten_dpi = ppi.unwrap_or(300.0) as f32; - } - let flattened = if x1a { None } else { ppi.map(|ppi| flatten(d, cache, ppi)) }; - let base: &designcraft_doc::Document = flattened.as_ref().map_or(d, |f| &f.0); - let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) }; - let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); - let shadows = if x1a { shadow_masks(base2, cache, f64::from(opts.flatten_dpi)) } else { Default::default() }; - let (fx_doc, fx_count) = rasterize_effects(base2, cache, &shadows); - opts.shadows = std::sync::Arc::new(shadows); - let opts = &opts; - let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); - let boxed = super::interchange::with_pdf_boxes(doc); - let doc: &designcraft_doc::Document = &boxed; - let mut r = designcraft_pdf::export_pdf_with_report(doc, cache, opts).map_err(|e| EngineError::Other(e.to_string()))?; - // Flattening and effect rasterization replace placements, so inspect the source too. - if d.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) { - r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into()); - } - // Transparency Blend Space: the page group of pages with transparency. CMYK print output - // gets its CMYK page groups from the writer. - let spreads = designcraft_pdf::sheet_spreads(doc, opts); - let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); - if !designcraft_pdf::print_color(doc, opts) && blended.iter().any(|b| *b) { - match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) { - Some(b) => r.bytes = b, - None => r.warnings.push("the transparency blend space couldn't be set".into()), - } - } - if fx_count > 0 { - r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) exported as images")); - } - if layer_count > 0 { - r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images")); - } - if let Some((_, n)) = &flattened - && *n > 0 - { - r.warnings.push(format!("transparency flattened: {n} region(s) rasterised")); - } - Ok(r) -} - -/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF -/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it. -pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result { - let st = s.doc()?; - let opts = options(p, st.doc.page_count())?; - let ppi = flatten_ppi("file.exportPdf", p)?; - let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?; - let doc: &designcraft_doc::Document = &st.doc; - let spreads = designcraft_pdf::sheet_spreads(doc, &opts); - // 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(); - if trans.iter().any(Option::is_some) { - match designcraft_pdf::add_transitions(&r.bytes, &trans) { - Some(b) => r.bytes = b, - None => r.warnings.push("page transitions couldn't be added to this PDF".into()), - } - } - Ok(r) -} +mod flatten; +mod formats; +mod pdf; +mod registry; #[cfg(test)] -mod placed_layer_tests { - use std::sync::Arc; - - use designcraft_doc::{AnchoredObject, Document, Item, ItemId, SpreadRef, StoryId, Table}; - use designcraft_geom::Rect; - use serde_json::json; - - use crate::Session; - - fn two_layer_pdf() -> Vec { - let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; - let objs = [ - "<>>>>>" - .to_string(), - "<>".to_string(), - "<>>>/Contents 4 0 R>>".to_string(), - format!("<>\nstream\n{content}\nendstream", content.len()), - "<>".to_string(), - "<>".to_string(), - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, o).as_bytes()); - } - let x = out.len(); - let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); - for o in offs { - t.push_str(&format!("{o:010} 00000 n\r\n")); - } - t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); - out.extend_from_slice(t.as_bytes()); - out - } - - fn placement<'a>(d: &'a Document, location: &str, id: ItemId, sid: StoryId) -> &'a Item { - match location { - "cell" => &d.story(sid).unwrap().tables.values().next().unwrap().cells[0].text.objects[0].item, - "footnote" => &d.story(sid).unwrap().notes[0].text.objects[0].item, - "endnote" => &d.story(sid).unwrap().endnotes[0].text.objects[0].item, - "anchor" => &d.story(sid).unwrap().objects[0].item, - _ => d.item(id).unwrap(), - } - } - - #[test] - fn stale_layer_fields_on_non_pdf_placements_do_not_block_pdf_a() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let result = s - .execute( - "file.place", - &json!({ - "base64": super::super::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf", - }), - ) - .unwrap(); - let id = ItemId(result["id"].as_u64().unwrap()); - s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": ["Blue"]})).unwrap(); - // Relinking to an ordinary image can leave legacy fields on the placement. - // They are not an actionable placed-PDF override and must not fail closed. - let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); - let aid = d.item(id).unwrap().graphic().unwrap().asset; - let asset = Arc::make_mut(d.assets.get_mut(&aid).unwrap()); - asset.mime = "image/png".into(); - asset.data = Arc::new(designcraft_render::Rendered { width: 1, height: 1, pixels: vec![0, 0, 0, 0] }.to_png()); - let (copy, count) = super::rasterize_layered(d).unwrap(); - assert_eq!(count, 0); - assert!(copy.is_none()); - s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); - } - - fn check_placement(location: &str) { - for all_on in [false, true] { - check_choice(location, all_on); - } - } - - fn check_choice(location: &str, all_on: bool) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); - let placed = s - .execute( - "file.place", - &json!({ - "base64": super::super::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf", - "x": 0, "y": 0, "width": 100, - "spread": {"kind": if location == "parent" { "parent" } else { "doc" }, "index": 0} - }), - ) - .unwrap(); - let id = ItemId(placed["id"].as_u64().unwrap()); - let hidden = if all_on { vec![] } else { vec!["Blue"] }; - s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": hidden, "useDefaults": false})).unwrap(); - let sid = { - let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); - let layer = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap(); - if !matches!(location, "doc" | "parent") { - let item = (**d.spreads[0].items.iter().find(|it| it.id == id).unwrap()).clone(); - Arc::make_mut(&mut d.spreads[0]).items.retain(|it| it.id != id); - let object = AnchoredObject { item, position: Default::default() }; - let story = d.story_mut(sid).unwrap(); - match location { - "cell" => { - let mut table = Table::new(1, 1, 1, 0, 0, 20.0); - table.cells[0].text.insert_object(0, object); - story.insert_table(0, table); - } - "footnote" => { - story.insert_note(0, "Note", Default::default()).unwrap(); - Arc::make_mut(&mut story.notes[0]).text.insert_object(0, object); - } - "endnote" => { - story.insert_endnote(0, "Endnote", Default::default()).unwrap(); - Arc::make_mut(&mut story.endnotes[0]).text.insert_object(0, object); - } - "anchor" => story.insert_object(0, object), - _ => assert!(matches!(location, "doc" | "parent")), - } - } - sid - }; - // Exercise the real export command for each location, not just the traversal helper. - let result = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); - assert!( - result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 placed PDF(s)")), - "{location}: {}", - result["warnings"] - ); - // Check the rewritten placement, including its pixels. Counting a placement without - // updating the nested object would still export the file's default layer visibility. - let source = &s.doc().unwrap().doc; - let (copy, count) = super::rasterize_layered(source).unwrap(); - assert_eq!(count, 1, "{location}"); - let copy = copy.unwrap(); - let item = placement(©, location, id, sid); - let asset = ©.assets[&item.graphic().unwrap().asset]; - assert_eq!(asset.mime, "image/png", "{location}"); - let image = designcraft_render::images::decoded(&asset.data, 0); - let image = image.unwrap(); - let pixel = image.data()[usize::from(image.height()) / 2 * usize::from(image.width()) + usize::from(image.width()) / 4]; - assert_eq!(pixel.a, if all_on { 255 } else { 0 }, "Blue visibility at {location}, all_on={all_on}"); - // Export preparation must not change the open document's placement or source asset. - let original = placement(source, location, id, sid); - assert_eq!(original.pdf_hidden_layers, hidden); - assert!(original.pdf_layer_override); - assert_eq!(source.assets[&original.graphic().unwrap().asset].mime, "application/pdf"); - } - - #[test] - fn layered_pdf_a_document_placement() { - check_placement("doc"); - } - #[test] - fn layered_pdf_a_parent_placement() { - check_placement("parent"); - } - #[test] - fn layered_pdf_a_story_anchor() { - check_placement("anchor"); - } - #[test] - fn layered_pdf_a_table_cell_anchor() { - check_placement("cell"); - } - #[test] - fn layered_pdf_a_footnote_anchor() { - check_placement("footnote"); - } - #[test] - fn layered_pdf_a_endnote_anchor() { - check_placement("endnote"); - } -} - +use flatten::{rasterize_effects, shadow_coverage}; #[cfg(not(target_arch = "wasm32"))] -fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - crate::io::write_file(path, bytes).map_err(EngineError::Other) -} -#[cfg(target_arch = "wasm32")] -fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { - Err(EngineError::Other(format!("{path}: no file system on the web; omit `path` to get the bytes"))) -} +pub(crate) use pdf::pdf_export; +pub(crate) use pdf::{flatten_ppi, options, print_pdf}; +pub use registry::specs; #[cfg(test)] mod text_tests; - -#[cfg(test)] -mod tagged_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn tagged_pdf_has_structure_and_alt_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 320, 200, 420]})).unwrap(); - s.execute("object.altText", &json!({"text": "A plain box", "ids": [r["id"]]})).unwrap(); - let pdf = |s: &mut Session, tagged: bool| { - let b64 = s.execute("file.exportPdf", &json!({"tagged": tagged})).unwrap()["base64"].as_str().unwrap().to_string(); - String::from_utf8_lossy(&super::super::file::base64_decode(&b64)).into_owned() - }; - let t = pdf(&mut s, true); - assert!(t.contains("/StructTreeRoot") && t.contains("/Figure") && t.contains("A plain box"), "tagged"); - assert!(t.contains("/P") && t.contains("/MarkInfo")); - assert!(!pdf(&mut s, false).contains("/StructTreeRoot")); - } -} - -#[cfg(test)] -mod variable_font_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn variable_font_instance_exports_to_pdf() { - let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Skia.ttf") else { - return; - }; - designcraft_fonts::FontDb::global().add_font(data); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Variable"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); - s.execute("type.char", &json!({"fontFamily": "Skia", "fontStyle": "Black"})).unwrap(); - let out = s.execute("file.exportPdf", &json!({})).unwrap(); - assert!(out["bytes"].as_u64().unwrap() > 1000); - let fonts = s.execute("font.list", &json!({})).unwrap(); - assert!(fonts.to_string().contains("Black"), "{fonts}"); - } -} - -#[cfg(test)] -mod booklet_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn booklet_imposes_pages_in_pairs() { - use designcraft_pdf::{BookletKind, booklet_pairs}; - assert_eq!(booklet_pairs(8, BookletKind::SaddleStitch), [(Some(7), Some(0)), (Some(1), Some(6)), (Some(5), Some(2)), (Some(3), Some(4))]); - assert_eq!(booklet_pairs(6, BookletKind::SaddleStitch)[0], (None, Some(0)), "padded with blanks"); - assert_eq!(booklet_pairs(3, BookletKind::TwoUpConsecutive), [(Some(0), Some(1)), (Some(2), None)]); - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - let r = s.execute("file.printBooklet", &json!({"spaceBetween": 18})).unwrap(); - assert_eq!(r["sheets"], 2); - let pdf = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).into_owned(); - let mb = pdf.find("/MediaBox").map(|i| pdf[i..i + 40].to_string()).unwrap_or_default(); - assert!(mb.contains("1242"), "two 612 pt pages and an 18 pt gap: {mb}"); - assert!(s.execute("file.printBooklet", &json!({"type": "perfectBound"})).is_err()); - // Page 1's black box lands on the right half of the first sheet (page 4 is on the left). - let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap(); - let r = s.execute("file.printBooklet", &json!({})).unwrap(); - let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - let px = designcraft_render::decode_pixmap_page(&bytes, 0).expect("rasterised"); - let (w, h) = (px.width() as f64, px.height() as f64); - let at = |x: f64, y: f64| px.sample((x / 1224.0 * w) as u16, (y / 792.0 * h) as u16); - let (ink, blank) = (at(612.0 + 200.0, 200.0), at(200.0, 200.0)); - assert!(ink.a > 200 && ink.r < 60, "right half has page 1: {ink:?}"); - assert!(blank.a < 30 || blank.r > 200, "left half (page 4) is empty: {blank:?}"); - } -} - -#[cfg(test)] -mod placed_pdf_print_tests { - use serde_json::json; - - use crate::Session; - use crate::cmd::{base64_decode, base64_encode}; - - /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. - fn logo() -> Vec { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); - s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - base64_decode(r["base64"].as_str().unwrap()) - } - - fn page_with(pdf: &[u8]) -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); - s - } - - /// A placed PDF 1.7 failed the PDF/X-4 (PDF 1.6) export with - /// "PDF writer error: Pdf(PdfDocument(PdfDocumentRepr { .. }), VersionMismatch(Pdf17), None)". - #[test] - fn pdf_17_placed_in_pdfx4() { - let logo = logo(); - assert!(logo.starts_with(b"%PDF-1.7")); - let mut s = page_with(&logo); - let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap(); - assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); - assert!(base64_decode(r["base64"].as_str().unwrap()).starts_with(b"%PDF-1.6")); - // PDF 2.0 can't simply be relabelled: the export fails and names the file. - let mut v20 = logo.clone(); - v20[5..8].copy_from_slice(b"2.0"); - let mut s = page_with(&v20); - let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string(); - assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}"); - } - - /// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it. - fn catalog_v17(header: &str) -> Vec { - let content = "0 0 0 1 k 0 0 200 200 re f"; - let mut pdf = format!("{header}\n").into_bytes(); - let mut offsets = Vec::new(); - for body in [ - "<>".to_owned(), - "<>".to_owned(), - "<>>>".to_owned(), - format!("<>\nstream\n{content}\nendstream", content.len()), - ] { - offsets.push(pdf.len()); - pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); - } - let xref = pdf.len(); - pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); - for offset in offsets { - pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); - } - pdf.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); - pdf - } - - /// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export - /// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907). - #[test] - fn catalog_version_17_placed_in_pdfx4() { - for header in ["%PDF-1.7", "%PDF-1.4"] { - let pdf = catalog_v17(header); - assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17); - let mut s = page_with(&pdf); - let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}")); - assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); - let out = base64_decode(r["base64"].as_str().unwrap()); - assert!(out.starts_with(b"%PDF-1.6")); - assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16); - } - } -} - -#[cfg(test)] -mod print_group_tests { - use serde_json::json; - - use crate::Session; - use crate::cmd::{base64_decode, base64_encode}; - - /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. - fn logo() -> Vec { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); - s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - base64_decode(r["base64"].as_str().unwrap()) - } - - fn page_with(pdf: &[u8]) -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); - s - } - - /// A placed PDF's transparency group blends in CMYK in a print document and in RGB in a - /// screen one. - #[test] - fn print_transparency_groups_blend_in_cmyk() { - let mut s = page_with(&logo()); - let text = |s: &mut Session| { - let r = s.execute("file.exportPdf", &json!({"compressImages": false})).unwrap(); - String::from_utf8_lossy(&base64_decode(r["base64"].as_str().unwrap())).into_owned() - }; - let t = text(&mut s); - // The placed page's group inherits the page group, which blends in CMYK. - assert!(t.contains("/Group<>"), "placed page group without a blending space"); - assert!(t.contains("/Group<>"), "CMYK page group"); - assert!(!t.contains("DeviceRGB"), "no RGB left"); - assert!(!designcraft_pdf::has_rgb_groups(t.as_bytes())); - // The file still reads, with the same page. - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - let bytes = base64_decode(r["base64"].as_str().unwrap()); - assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1); - // An RGB blend space keeps RGB groups. - s.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap(); - let t = text(&mut s); - assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes())); - } -} - -#[cfg(test)] -mod pptx_tests { - use serde_json::json; - - use crate::Session; - - fn unzip(b64: &str) -> zip::ZipArchive>> { - zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() - } - - fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { - let mut s = String::new(); - std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); - s - } - - #[test] - fn sample_exports_editable_slides() { - let mut s = Session::new(); - s.execute("file.newSample", &json!({})).unwrap(); - let r = s.execute("file.exportPptx", &json!({})).unwrap(); - assert_eq!(r["slides"], 4); - let mut z = unzip(r["base64"].as_str().unwrap()); - // The swatch table is a PowerPoint table; the cover picture is embedded; text is text. - assert!(read(&mut z, "ppt/slides/slide4.xml").contains("")); - assert!(read(&mut z, "ppt/slides/slide1.xml").contains("")); - let text: String = read(&mut z, "ppt/slides/slide2.xml").split("").skip(1).filter_map(|t| t.split("").next()).collect(); - assert!(text.contains("Notes on the Grid"), "{text}"); - assert!(read(&mut z, "ppt/presentation.xml").contains("")); - - let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap(); - assert_eq!(r["slides"], 2); - assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded"))); - assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err()); - assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err()); - } - - #[test] - fn rasterize_option_places_a_picture() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); - let shapes = |s: &mut Session| { - let r = s.execute("file.exportPptx", &json!({})).unwrap(); - read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml") - }; - assert!(shapes(&mut s).contains("prst=\"ellipse\"")); - s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap(); - let x = shapes(&mut s); - assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}"); - } -} - -#[cfg(test)] -mod page_list_tests { - use serde_json::{Value, json}; - - use crate::Session; - - fn doc(pages: u64) -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap(); - s - } - - /// The same selection written two ways exported different files: `"2,2"` is one page, because - /// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2. - /// The list says which pages to export, not how many times. - #[test] - fn a_page_named_twice_is_exported_once_either_way() { - let mut s = doc(4); - for (pages, want) in [ - (json!("2,2"), 1), - (json!([2, 2]), 1), - (json!([2, 2, 2]), 1), - (json!("2-2,2"), 1), - (json!([1, 2, 1]), 2), - (json!("1,2,1"), 2), - (json!([4, 1]), 2), - (json!("4,1"), 2), - (json!("all"), 4), - ] { - let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap(); - assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}"); - let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap(); - assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}"); - } - // The two forms now agree byte for byte, and the pages come out in the document's order - // whichever order the list names them in (which is how the export already behaved). - // A fixed creation time: exports a second apart would differ in their dates. - let mut file = |pages: Value| { - let r = s.execute("file.exportPdf", &json!({"pages": pages, "created": 0})).unwrap(); - r["base64"].as_str().unwrap_or_default().to_string() - }; - assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree"); - assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order"); - assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page"); - // A page that does not exist is still refused, and an empty list still exports nothing. - assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err()); - assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err()); - } -} diff --git a/crates/engine/src/cmd/export/flatten.rs b/crates/engine/src/cmd/export/flatten.rs new file mode 100644 index 000000000..23059d8f4 --- /dev/null +++ b/crates/engine/src/cmd/export/flatten.rs @@ -0,0 +1,610 @@ +use super::*; + +/// Does this object (or anything in it) need the transparency flattener? +pub(super) fn transparent(it: &designcraft_doc::Item) -> bool { + it.involves_transparency() +} + +/// Transparency Flattener: each object involving transparency becomes an opaque image of its +/// area as it looks over what's beneath it (so the rest stays vector); spreads whose parent items +/// involve transparency are rasterised whole. Works on a copy of the document made for export. +/// Returns the copy and the number of rasterised regions. +pub(super) fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, ppi: f64) -> (designcraft_doc::Document, usize) { + use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; + let mut out = d.clone(); + let mut n = 0; + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let k = ppi / 72.0; + let parent_transparent = |pid: Option| { + pid.and_then(|id| d.parent_index(id)) + .is_some_and(|pi| d.parent_chain(pi).iter().filter_map(|&i| d.parents.get(i)).any(|p| p.items.iter().any(|it| transparent(it)))) + }; + // An opaque image item showing `img` over `r`. + let image_item = + |out: &mut designcraft_doc::Document, r: designcraft_geom::Rect, img: designcraft_render::Rendered, layer: designcraft_doc::LayerId| { + let (w, h) = (img.width, img.height); + let aid = AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(Asset { + id: aid, + name: format!("flattened-{}.png", aid.0), + mime: "image/png".into(), + data: std::sync::Arc::new(img.to_png()), + pixels: Some((w, h)), + ..Default::default() + }), + ); + let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + it.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (w as f64, h as f64), + xf: designcraft_geom::Affine::translate((r.x0, r.y0)) + * designcraft_geom::Affine::scale_non_uniform(r.width() / w as f64, r.height() / h as f64), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + it.stroke.weight = 0.0; + it + }; + for (si, sp) in d.spreads.iter().enumerate() { + let whole = sp.pages.iter().any(|pg| pg.show_parent_items && parent_transparent(pg.parent)); + if whole { + // Parent items involve transparency: every page of the spread as one image. + let first = d.first_page_of_spread(si); + let lid = out.default_layer(); + let mut images = Vec::new(); + for (pi, pg) in sp.pages.iter().enumerate() { + if let Some(img) = rr.render_page(d, cache, first + pi, k, false, &Default::default()) { + images.push((pg.bounds(), img)); + } + } + let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); + osp.items.clear(); + for pg in &mut osp.pages { + pg.show_parent_items = false; + } + for (b, img) in images { + let it = image_item(&mut out, b, img, lid); + let _ = out.insert_item(SpreadRef::Doc(si), it, None); + n += 1; + } + continue; + } + // Region by region, bottom to top. + for (idx, it) in sp.items.iter().enumerate() { + if it.hidden || !transparent(it) { + continue; + } + let reach = it.bounds().inflate( + designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, + designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, + ); + // Only the part on the spread's pages and pasteboard that prints. + let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); + if w as u64 * h as u64 > 80_000_000 { + continue; + } + // The stack up to and including this object. + let mut below = d.clone(); + std::sync::Arc::make_mut(&mut below.spreads[si]).items.truncate(idx + 1); + let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); + let opts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; + let img = rr.render( + &below, + cache, + &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], + w, + h, + view, + &opts, + ); + let new = image_item(&mut out, reach, img, it.layer); + let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); + let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else { + continue; + }; + if threaded(d, it) { + // Keep the frame (its story flows on) under the image. + osp.items.insert(pos + 1, std::sync::Arc::new(new)); + } else { + osp.items[pos] = std::sync::Arc::new(new); + } + n += 1; + } + } + (out, n) +} + +/// A text frame in a story that runs through other frames (replacing it would reflow the story). +pub(super) fn threaded(d: &designcraft_doc::Document, it: &designcraft_doc::Item) -> bool { + it.text_frame().and_then(|t| d.story(t.story)).is_some_and(|st| st.frames.len() > 1) +} + +/// Soft effects other than the gradient feather (which PDF export draws itself). +fn raster_effects(it: &designcraft_doc::Item) -> bool { + let e = &it.effects; + e.drop_shadow.on + || e.feather > 0.0 + || e.inner_shadow.on + || e.outer_glow.on + || e.inner_glow.on + || e.bevel.on + || e.satin.on + || e.directional_feather.on +} + +/// A copy of `d` where spread-level objects with soft effects are 300 ppi images of their whole +/// appearance (transparent around them, so they composite over what's behind). Objects in `skip` +/// stay as they are. +pub(super) fn rasterize_effects( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + skip: &std::collections::HashMap, +) -> (Option, usize) { + rasterize_where(d, cache, 300.0, true, |it| raster_effects(it) && !threaded(d, it) && !skip.contains_key(&it.id)) +} + +/// A drop shadow is the object's only soft effect, and the PDF writer draws its shadow itself +/// (a leaf object, not a group or a frame with items pasted into it). +fn shadow_only(it: &designcraft_doc::Item) -> bool { + let e = &it.effects; + e.drop_shadow.on + && !(e.feather > 0.0 + || e.inner_shadow.on + || e.outer_glow.on + || e.inner_glow.on + || e.bevel.on + || e.satin.on + || e.directional_feather.on + || e.gradient_feather.on) + && it.children().is_empty() + && !it.has_nested_items() +} + +/// For PDF/X-1a: the coverage of each spread object's soft drop shadow at `ppi`, so the PDF draws +/// the shadow in its own swatch's inks through a mask (which craft-prepress flattens per ink) +/// instead of an RGB image of the object. Objects whose only soft effect is the drop shadow. +/// +/// The coverage comes from the object drawn alone, at full opacity and Normal blending, with its +/// shadow and without it ([`shadow_coverage`]): the shadow's own alpha, its opacity included. The +/// object's own opacity and blend mode stay on the object's group in the PDF. +pub(super) fn shadow_masks( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + ppi: f64, +) -> std::collections::HashMap { + use designcraft_doc::SpreadRef; + let mut out = std::collections::HashMap::new(); + let todo: Vec<(usize, designcraft_doc::ItemId)> = d + .spreads + .iter() + .enumerate() + .flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && shadow_only(it)).map(move |it| (si, it.id))) + .collect(); + if todo.is_empty() { + return out; + } + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let k = ppi.clamp(36.0, 1200.0) / 72.0; + for (si, id) in todo { + let Some(it) = d.item(id) else { continue }; + let grow = designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0; + let reach = it.bounds().inflate(grow, grow); + let (w, h) = ((reach.width() * k).ceil().max(1.0), (reach.height() * k).ceil().max(1.0)); + if !(w.is_finite() && h.is_finite()) || w * h > 60_000_000.0 { + continue; + } + let (w, h) = (w as u32, h as u32); + let mut alpha = |shadow: bool| { + let mut solo = d.clone(); + let Some(sp) = solo.spreads.get_mut(si) else { return Vec::new() }; + let sp = std::sync::Arc::make_mut(sp); + sp.items.retain(|x| x.id == id); + for pg in &mut sp.pages { + pg.show_parent_items = false; + } + if let Some(x) = sp.items.first_mut() { + let x = std::sync::Arc::make_mut(x); + x.opacity = 1.0; + x.blend = Default::default(); + x.effects.drop_shadow.on = shadow; + } + let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); + let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; + let placed = [designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::IDENTITY }]; + rr.render(&solo, cache, &placed, w, h, view, &opts).pixels.as_chunks::<4>().0.iter().map(|px| px[3]).collect::>() + }; + let (with, without) = (alpha(true), alpha(false)); + let n = w as usize * h as usize; + if with.len() != n || without.len() != n { + continue; + } + let coverage: Vec = with.iter().zip(&without).map(|(a, b)| shadow_coverage(*a, *b)).collect(); + out.insert(id, designcraft_pdf::ShadowMask { rect: reach, width: w, height: h, coverage: std::sync::Arc::new(coverage) }); + } + out +} + +/// The shadow's alpha `s` at a pixel where the object alone has alpha `without` and the object +/// over its shadow `with`: `with = a + s(1 − a)` for the object's alpha `a`, so the PDF, which +/// draws the object over the shadow, gets the same pixel back. 0 where the object is opaque. +pub(super) fn shadow_coverage(with: u8, without: u8) -> u8 { + let rest = 255 - u32::from(without); + if rest == 0 { + return 0; + } + let shown = u32::from(with.saturating_sub(without)); + u8::try_from(((shown * 255 + rest / 2) / rest).min(255)).unwrap_or(u8::MAX) +} + +/// A copy of `d` where the spread-level objects `pred` picks are images of their appearance at +/// `ppi` (transparent around them). With `keep_wrap`, each object also stays behind as a +/// nonprinting [`wrap_stand_in`], so text wraps around it as before. +pub(super) fn rasterize_where( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + ppi: f64, + keep_wrap: bool, + pred: impl Fn(&designcraft_doc::Item) -> bool, +) -> (Option, usize) { + use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; + let todo: Vec<(usize, ItemId)> = + d.spreads.iter().enumerate().flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && pred(it)).map(move |it| (si, it.id))).collect(); + if todo.is_empty() { + return (None, 0); + } + let mut out = d.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let k = ppi / 72.0; + let mut n = 0; + for (si, id) in todo { + let Some(it) = d.item(id) else { continue }; + let reach = it.bounds().inflate( + designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, + designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, + ); + // Just this object on its spread, no paper. + let mut solo = d.clone(); + let sp = std::sync::Arc::make_mut(&mut solo.spreads[si]); + sp.items.retain(|x| x.id == id); + for pg in &mut sp.pages { + pg.show_parent_items = false; + } + let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); + if w as u64 * h as u64 > 60_000_000 { + continue; + } + let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); + let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; + let img = rr.render( + &solo, + cache, + &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], + w, + h, + view, + &opts, + ); + let aid = AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(Asset { + id: aid, + name: format!("effects-{}.png", id.0), + mime: "image/png".into(), + data: std::sync::Arc::new(img.to_png()), + pixels: Some((w, h)), + ..Default::default() + }), + ); + let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); + img_item.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (w as f64, h as f64), + xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + img_item.stroke.weight = 0.0; + img_item.alt_text = it.alt_text.clone(); + img_item.export_options = it.export_options.clone(); + // Same place in the stacking order. + let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); + if let Some(pos) = osp.items.iter().position(|x| x.id == id) { + osp.items[pos] = std::sync::Arc::new(img_item); + if keep_wrap && has_wrap(it, 0) { + osp.items.insert(pos, std::sync::Arc::new(wrap_stand_in(it, 0))); + } + n += 1; + } + } + (Some(out), n) +} + +/// Whether `it`, its placed graphic or an item in it wraps text. +fn has_wrap(it: &designcraft_doc::Item, depth: usize) -> bool { + use designcraft_doc::{Content, WrapMode}; + if depth > MAX_STAND_IN_DEPTH { + return false; + } + it.wrap.mode != WrapMode::None + || match &it.content { + Content::Graphic(g) => g.wrap.mode != WrapMode::None, + Content::Group { items } => items.iter().any(|c| has_wrap(c, depth + 1)), + _ => false, + } +} + +/// Nesting depth past which groups inside a rasterised object keep no wrap stand-ins. +const MAX_STAND_IN_DEPTH: usize = 64; + +/// A nonprinting copy of `it` that keeps its text wrap (and its graphic's and its items') and +/// nothing else that reaches the PDF: no text, form field, button or media. The image of the +/// object stands in for all of that, as it does without the copy. +fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::Item { + use designcraft_doc::Content; + let mut c = it.clone(); + c.nonprinting = true; + c.form_field = None; + c.button = None; + c.media = None; + c.content = match &it.content { + Content::Text(_) => Content::Unassigned, + Content::Group { items } if depth < MAX_STAND_IN_DEPTH => { + Content::Group { items: items.iter().map(|i| std::sync::Arc::new(wrap_stand_in(i, depth + 1))).collect() } + } + Content::Group { .. } => Content::Group { items: Vec::new() }, + other => other.clone(), + }; + c +} + +/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. +/// +/// Parent-page and anchored placements count too, including anchors inside table-cell, +/// footnote and endnote text. Only the export copy is rewritten. +pub(super) fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { + let choices: Vec<_> = d + .placed_items() + .into_iter() + .filter(|it| it.has_pdf_layer_override()) + .filter_map(|it| { + let g = it.graphic()?; + let a = d.assets.get(&g.asset).filter(|a| designcraft_render::is_pdf(&a.data))?; + Some((it.id, g.clone(), a.clone(), it.pdf_hidden_layers.clone())) + }) + .collect(); + if choices.is_empty() { + return Ok((None, 0)); + } + let mut out = d.clone(); + let mut images = std::collections::HashMap::new(); + let mut n = 0; + for (id, g, a, hidden) in choices { + // The whole page at 300 ppi (the graphic may show only a box of it). + let shown = designcraft_render::shown_box(&a); + let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); + let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t)); + let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32; + let png = designcraft_render::pdf_page_png_options_checked(&a.data, a.page as usize, side, Some(&hidden), shown) + .map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?; + let px = designcraft_render::image_size(&png); + let aid = designcraft_doc::AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(designcraft_doc::Asset { + id: aid, + name: format!("{}.png", a.name), + mime: "image/png".into(), + data: std::sync::Arc::new(png), + pixels: px, + ..Default::default() + }), + ); + images.insert(id, aid); + n += 1; + } + out.update_placed_items( + |it| images.contains_key(&it.id), + |it| { + if let designcraft_doc::Content::Graphic(gg) = &mut it.content + && let Some(aid) = images.get(&it.id) + { + gg.asset = *aid; + } + }, + ); + Ok((Some(out), n)) +} + +#[cfg(test)] +mod placed_layer_tests { + use std::sync::Arc; + + use designcraft_doc::{AnchoredObject, Document, Item, ItemId, SpreadRef, StoryId, Table}; + use designcraft_geom::Rect; + use serde_json::json; + + use crate::Session; + + fn two_layer_pdf() -> Vec { + let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; + let objs = [ + "<>>>>>" + .to_string(), + "<>".to_string(), + "<>>>/Contents 4 0 R>>".to_string(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>".to_string(), + "<>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, o).as_bytes()); + } + let x = out.len(); + let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); + for o in offs { + t.push_str(&format!("{o:010} 00000 n\r\n")); + } + t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); + out.extend_from_slice(t.as_bytes()); + out + } + + fn placement<'a>(d: &'a Document, location: &str, id: ItemId, sid: StoryId) -> &'a Item { + match location { + "cell" => &d.story(sid).unwrap().tables.values().next().unwrap().cells[0].text.objects[0].item, + "footnote" => &d.story(sid).unwrap().notes[0].text.objects[0].item, + "endnote" => &d.story(sid).unwrap().endnotes[0].text.objects[0].item, + "anchor" => &d.story(sid).unwrap().objects[0].item, + _ => d.item(id).unwrap(), + } + } + + #[test] + fn stale_layer_fields_on_non_pdf_placements_do_not_block_pdf_a() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let result = s + .execute( + "file.place", + &json!({ + "base64": crate::cmd::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf", + }), + ) + .unwrap(); + let id = ItemId(result["id"].as_u64().unwrap()); + s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": ["Blue"]})).unwrap(); + // Relinking to an ordinary image can leave legacy fields on the placement. + // They are not an actionable placed-PDF override and must not fail closed. + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let aid = d.item(id).unwrap().graphic().unwrap().asset; + let asset = Arc::make_mut(d.assets.get_mut(&aid).unwrap()); + asset.mime = "image/png".into(); + asset.data = Arc::new(designcraft_render::Rendered { width: 1, height: 1, pixels: vec![0, 0, 0, 0] }.to_png()); + let (copy, count) = super::rasterize_layered(d).unwrap(); + assert_eq!(count, 0); + assert!(copy.is_none()); + s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + } + + fn check_placement(location: &str) { + for all_on in [false, true] { + check_choice(location, all_on); + } + } + + fn check_choice(location: &str, all_on: bool) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let placed = s + .execute( + "file.place", + &json!({ + "base64": crate::cmd::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf", + "x": 0, "y": 0, "width": 100, + "spread": {"kind": if location == "parent" { "parent" } else { "doc" }, "index": 0} + }), + ) + .unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + let hidden = if all_on { vec![] } else { vec!["Blue"] }; + s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": hidden, "useDefaults": false})).unwrap(); + let sid = { + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let layer = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap(); + if !matches!(location, "doc" | "parent") { + let item = (**d.spreads[0].items.iter().find(|it| it.id == id).unwrap()).clone(); + Arc::make_mut(&mut d.spreads[0]).items.retain(|it| it.id != id); + let object = AnchoredObject { item, position: Default::default() }; + let story = d.story_mut(sid).unwrap(); + match location { + "cell" => { + let mut table = Table::new(1, 1, 1, 0, 0, 20.0); + table.cells[0].text.insert_object(0, object); + story.insert_table(0, table); + } + "footnote" => { + story.insert_note(0, "Note", Default::default()).unwrap(); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, object); + } + "endnote" => { + story.insert_endnote(0, "Endnote", Default::default()).unwrap(); + Arc::make_mut(&mut story.endnotes[0]).text.insert_object(0, object); + } + "anchor" => story.insert_object(0, object), + _ => assert!(matches!(location, "doc" | "parent")), + } + } + sid + }; + // Exercise the real export command for each location, not just the traversal helper. + let result = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + assert!( + result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 placed PDF(s)")), + "{location}: {}", + result["warnings"] + ); + // Check the rewritten placement, including its pixels. Counting a placement without + // updating the nested object would still export the file's default layer visibility. + let source = &s.doc().unwrap().doc; + let (copy, count) = super::rasterize_layered(source).unwrap(); + assert_eq!(count, 1, "{location}"); + let copy = copy.unwrap(); + let item = placement(©, location, id, sid); + let asset = ©.assets[&item.graphic().unwrap().asset]; + assert_eq!(asset.mime, "image/png", "{location}"); + let image = designcraft_render::images::decoded(&asset.data, 0); + let image = image.unwrap(); + let pixel = image.data()[usize::from(image.height()) / 2 * usize::from(image.width()) + usize::from(image.width()) / 4]; + assert_eq!(pixel.a, if all_on { 255 } else { 0 }, "Blue visibility at {location}, all_on={all_on}"); + // Export preparation must not change the open document's placement or source asset. + let original = placement(source, location, id, sid); + assert_eq!(original.pdf_hidden_layers, hidden); + assert!(original.pdf_layer_override); + assert_eq!(source.assets[&original.graphic().unwrap().asset].mime, "application/pdf"); + } + + #[test] + fn layered_pdf_a_document_placement() { + check_placement("doc"); + } + #[test] + fn layered_pdf_a_parent_placement() { + check_placement("parent"); + } + #[test] + fn layered_pdf_a_story_anchor() { + check_placement("anchor"); + } + #[test] + fn layered_pdf_a_table_cell_anchor() { + check_placement("cell"); + } + #[test] + fn layered_pdf_a_footnote_anchor() { + check_placement("footnote"); + } + #[test] + fn layered_pdf_a_endnote_anchor() { + check_placement("endnote"); + } +} diff --git a/crates/engine/src/cmd/export/formats.rs b/crates/engine/src/cmd/export/formats.rs new file mode 100644 index 000000000..025bcdac4 --- /dev/null +++ b/crates/engine/src/cmd/export/formats.rs @@ -0,0 +1,413 @@ +use super::flatten::{rasterize_where, threaded}; +use super::pdf::pages_param; +use super::*; + +pub(super) fn print_booklet(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.printBooklet"; + let kind = match str_param(p, "type").unwrap_or("saddleStitch") { + "saddleStitch" => designcraft_pdf::BookletKind::SaddleStitch, + "twoUpConsecutive" | "twoUp" => designcraft_pdf::BookletKind::TwoUpConsecutive, + t => { + return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)"))); + } + }; + let opts = designcraft_pdf::BookletOptions { kind, space_between: p.get("spaceBetween").and_then(Value::as_f64).unwrap_or(0.0), title: None }; + let st = s.doc()?; + let r = designcraft_pdf::export_booklet(&crate::cmd::interchange::with_pdf_boxes(&st.doc), &s.cache, &opts) + .map_err(|e| EngineError::Other(e.to_string()))?; + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) + } + None => Ok(json!({"base64": crate::cmd::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), + } +} + +/// HTML and reflowable EPUB embed the EPS preview; keep the session's source bytes intact. +fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Document>, Vec) { + // Match the exporter's reading order (including Article exclusions), not every asset + // in the document: unused or hidden graphics must not prevent an export. + let mut used = std::collections::BTreeSet::new(); + for block in designcraft_epub::reading_order(d) { + let designcraft_epub::Block::Image(id) = block else { + continue; + }; + let Some(it) = d.item(id) else { continue }; + let designcraft_doc::Content::Graphic(g) = &it.content else { + continue; + }; + used.insert(g.asset); + if d.assets.get(&g.asset).is_some_and(|a| designcraft_doc::media_kind(&a.mime).is_some()) + && let Some(poster) = it.media.as_ref().and_then(|m| m.poster) + { + used.insert(poster); + } + } + let mut out = Cow::Borrowed(d); + let mut warnings = Vec::new(); + for id in used { + let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else { + continue; + }; + let png = designcraft_images::eps_png(&a.data); + if png.is_none() { + warnings.push(format!("{}: unusable EPS preview; graphic omitted", a.name)); + } + if let Some(a) = out.to_mut().assets.get_mut(&id) { + let a = Arc::make_mut(a); + a.mime = "image/png".into(); + a.data = Arc::new(png.unwrap_or_default()); + } + } + (out, warnings) +} + +pub(super) fn export_html(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let opts = designcraft_epub::HtmlOptions { + title: str_param(p, "title").map(str::to_string), + language: str_param(p, "language").map(str::to_string), + generic_families: generic_families(&st.doc), + }; + let (doc, warnings) = eps_previews(&st.doc); + let text = designcraft_epub::export_html(&doc, &opts); + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": text.len(), "warnings": warnings})) + } + None => Ok(json!({"bytes": text.len(), "text": text, "warnings": warnings})), + } +} + +pub(super) fn export_pptx(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.exportPptx"; + let st = s.doc()?; + let line_breaks = match str_param(p, "lineBreaks") { + None | Some("keep") => designcraft_pptx::LineBreaks::Keep, + Some("reflow") => designcraft_pptx::LineBreaks::Reflow, + Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))), + }; + let opts = designcraft_pptx::PptxOptions { + pages: pages_param(ID, p, st.doc.page_count())?, + line_breaks, + title: str_param(p, "title").map(str::to_string), + author: str_param(p, "author").map(str::to_string), + created: None, + embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true), + }; + // Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame + // stays text: a picture of it would drop the rest of its story). + let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true); + let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, true, |it| { + !threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it))) + }); + let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); + let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + if rasterized > 0 { + r.warnings.push(format!("{rasterized} object(s) went in as images")); + } + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})) + } + None => Ok(json!({"base64": crate::cmd::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})), + } +} + +pub(super) fn has_story_target(s: &Session) -> std::result::Result<(), String> { + crate::cmd::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into()) +} + +pub(super) fn export_text(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.exportText"; + let sid = crate::cmd::text::story_of(s, p).ok_or_else(|| bad(ID, "edit text or select a text frame"))?; + let doc = &s.doc()?.doc; + let story = doc.stories.get(&sid).ok_or_else(|| bad(ID, format!("no story {}", sid.0)))?; + let path = str_param(p, "path"); + let format = match str_param(p, "format") { + Some(f @ ("rtf" | "tagged")) => f, + Some("txt") | Some("text") => "txt", + Some(f) => return Err(bad(ID, format!("unknown format `{f}` (txt, rtf, tagged)"))), + None if path.is_some_and(|x| x.to_ascii_lowercase().ends_with(".rtf")) => "rtf", + None => "txt", + }; + let text = match format { + "rtf" => designcraft_textimport::export::rtf(doc, story), + "tagged" => designcraft_textimport::tagged::tagged_text(doc, story), + _ => designcraft_textimport::export::plain_text(story), + }; + match path { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": text.len()})) + } + None => Ok(json!({"bytes": text.len(), "text": text})), + } +} + +/// Text of the frames on page `abs`, in line order (for fixed-layout pages). +fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, abs: usize) -> String { + let mut out = String::new(); + for st in d.stories.values() { + let cs = cache.get(d, st.id, None); + for ft in &cs.frames { + if d.page_of_item(ft.frame) != Some(abs) { + continue; + } + for l in &ft.lines { + let r = l.range.start.min(st.text.len())..l.range.end.min(st.text.len()); + let t: String = st.text[r].chars().filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)).collect(); + if !t.trim().is_empty() { + out.push_str(t.trim_end_matches('\n')); + out.push('\n'); + } + } + } + } + out +} + +/// The CSS generic family of each font family the EPUB/HTML stylesheet names, from the fonts' +/// classification (PANOSE, OS/2 class, name), for the `font-family` fallbacks. +fn generic_families(d: &designcraft_doc::Document) -> std::collections::BTreeMap { + use designcraft_fonts::FontClass; + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut out = std::collections::BTreeMap::new(); + for (family, _) in designcraft_epub::stylesheet_fonts(d) { + if out.contains_key(&family) { + continue; + } + let generic = match db.family_kind(&family).map(|(k, _)| k.class) { + Some(FontClass::Serif | FontClass::SlabSerif) => "serif", + Some(FontClass::SansSerif) => "sans-serif", + Some(FontClass::Monospaced) => "monospace", + Some(FontClass::Script) => "cursive", + Some(FontClass::Decorative) => "fantasy", + Some(FontClass::Symbol | FontClass::Other) | None => continue, + }; + out.insert(family, generic.to_string()); + } + out +} + +/// The faces the EPUB stylesheet names, to package with it; `warnings` gets the ones left out +/// (not available, a licence that forbids embedding, or part of a font collection). +fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> Vec { + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut out = Vec::new(); + for (family, style) in designcraft_epub::stylesheet_fonts(d) { + let face = db.face(&family, &style); + if !face.family.eq_ignore_ascii_case(&family) { + warnings.push(format!("font `{family} {style}` is not available; not embedded")); + } else if face.restricted_licence() || face.bitmap_only() { + warnings.push(format!("font `{family} {style}`: its licence doesn't allow embedding; not embedded")); + } else if face.data().starts_with(b"ttcf") { + warnings.push(format!("font `{family} {style}` is part of a font collection; not embedded")); + } else { + out.push(designcraft_epub::EmbeddedFont { family, style: face.style.clone(), data: face.data().to_vec() }); + } + } + out +} + +/// A Split Document setting (`"off"`, `"exportTags"` or `{"style": name}`), naming a paragraph +/// style the document has. +pub(crate) fn epub_split_param(id: &str, doc: &designcraft_doc::Document, v: &Value) -> Result { + let split: designcraft_doc::EpubSplit = + serde_json::from_value(v.clone()).map_err(|_| bad(id, "`split`: \"off\", \"exportTags\" or {\"style\": name}"))?; + if let designcraft_doc::EpubSplit::Style(name) = &split + && doc.styles.para(name).is_none() + { + return Err(bad(id, format!("no paragraph style `{name}`"))); + } + Ok(split) +} + +pub(super) fn export_epub(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let split = p.get("split").map(|v| epub_split_param("file.exportEpub", &st.doc, v)).transpose()?; + // Object Export Options › Rasterize: those objects go in as images. + let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it)); + let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); + let fixed = p.get("fixedLayout").and_then(Value::as_bool).unwrap_or(false); + let want_cover = p.get("cover").and_then(Value::as_bool).unwrap_or(false); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; + let mut warnings: Vec = Vec::new(); + // A cover image file (PNG or JPEG), else the first page rendered when `cover` asks for it. + let cover = match p.get("coverImage").and_then(Value::as_str).filter(|_| !fixed) { + Some(path) => { + Some(crate::cmd::datamerge::read_path_capped(path, crate::cmd::datamerge::MAX_IMAGE_FILE_BYTES).map_err(crate::EngineError::Other)?) + } + None => (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(), + }; + if let Some(c) = &cover + && !(c.starts_with(b"\x89PNG") || c.starts_with(&[0xFF, 0xD8, 0xFF])) + { + return Err(crate::EngineError::Other("the cover image must be a PNG or JPEG file".into())); + } + let opts = designcraft_epub::EpubOptions { + title: p.get("title").and_then(Value::as_str).map(str::to_string), + author: p.get("author").and_then(Value::as_str).map(str::to_string), + language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), + identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string), + cover, + toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string), + split, + generic_families: generic_families(&st.doc), + fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed { + embedded_fonts(&st.doc, &mut warnings) + } else { + Vec::new() + }, + // The export time; on the web the document's own save time stands in. + #[cfg(not(target_arch = "wasm32"))] + modified: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()), + #[cfg(target_arch = "wasm32")] + modified: None, + }; + if fixed { + let d = &st.doc; + let mut pages = Vec::new(); + for i in 0..d.page_count() { + let Some((si, pi)) = d.page_loc(i) else { + continue; + }; + let b = d.spreads[si].pages[pi].bounds(); + let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else { + continue; + }; + pages.push(designcraft_epub::FixedPage { png: img.to_png(), width: b.width(), height: b.height(), text: page_text(d, &s.cache, i) }); + } + let bytes = designcraft_epub::export_fixed_epub(d, &pages, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + return match p.get("path").and_then(Value::as_str) { + Some(path) => { + write_file(path, &bytes)?; + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "pages": pages.len()})) + } + None => Ok(serde_json::json!({"base64": crate::cmd::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages.len()})), + }; + } + let (epub_doc, eps_warnings) = eps_previews(epub_doc); + warnings.extend(eps_warnings); + let bytes = designcraft_epub::export_epub(&epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + warnings.extend( + designcraft_epub::images_without_alt(&epub_doc) + .into_iter() + .map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")), + ); + match p.get("path").and_then(Value::as_str) { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?; + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) + } + None => Ok(serde_json::json!({"base64": crate::cmd::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + crate::io::write_file(path, bytes).map_err(EngineError::Other) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { + Err(EngineError::Other(format!("{path}: no file system on the web; omit `path` to get the bytes"))) +} + +#[cfg(test)] +mod booklet_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn booklet_imposes_pages_in_pairs() { + use designcraft_pdf::{BookletKind, booklet_pairs}; + assert_eq!(booklet_pairs(8, BookletKind::SaddleStitch), [(Some(7), Some(0)), (Some(1), Some(6)), (Some(5), Some(2)), (Some(3), Some(4))]); + assert_eq!(booklet_pairs(6, BookletKind::SaddleStitch)[0], (None, Some(0)), "padded with blanks"); + assert_eq!(booklet_pairs(3, BookletKind::TwoUpConsecutive), [(Some(0), Some(1)), (Some(2), None)]); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + let r = s.execute("file.printBooklet", &json!({"spaceBetween": 18})).unwrap(); + assert_eq!(r["sheets"], 2); + let pdf = String::from_utf8_lossy(&crate::cmd::file::base64_decode(r["base64"].as_str().unwrap())).into_owned(); + let mb = pdf.find("/MediaBox").map(|i| pdf[i..i + 40].to_string()).unwrap_or_default(); + assert!(mb.contains("1242"), "two 612 pt pages and an 18 pt gap: {mb}"); + assert!(s.execute("file.printBooklet", &json!({"type": "perfectBound"})).is_err()); + // Page 1's black box lands on the right half of the first sheet (page 4 is on the left). + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap(); + let r = s.execute("file.printBooklet", &json!({})).unwrap(); + let bytes = crate::cmd::file::base64_decode(r["base64"].as_str().unwrap()); + let px = designcraft_render::decode_pixmap_page(&bytes, 0).expect("rasterised"); + let (w, h) = (px.width() as f64, px.height() as f64); + let at = |x: f64, y: f64| px.sample((x / 1224.0 * w) as u16, (y / 792.0 * h) as u16); + let (ink, blank) = (at(612.0 + 200.0, 200.0), at(200.0, 200.0)); + assert!(ink.a > 200 && ink.r < 60, "right half has page 1: {ink:?}"); + assert!(blank.a < 30 || blank.r > 200, "left half (page 4) is empty: {blank:?}"); + } +} + +#[cfg(test)] +mod pptx_tests { + use serde_json::json; + + use crate::Session; + + fn unzip(b64: &str) -> zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(crate::cmd::file::base64_decode(b64))).unwrap() + } + + fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); + s + } + + #[test] + fn sample_exports_editable_slides() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let r = s.execute("file.exportPptx", &json!({})).unwrap(); + assert_eq!(r["slides"], 4); + let mut z = unzip(r["base64"].as_str().unwrap()); + // The swatch table is a PowerPoint table; the cover picture is embedded; text is text. + assert!(read(&mut z, "ppt/slides/slide4.xml").contains("")); + assert!(read(&mut z, "ppt/slides/slide1.xml").contains("")); + let text: String = read(&mut z, "ppt/slides/slide2.xml").split("").skip(1).filter_map(|t| t.split("").next()).collect(); + assert!(text.contains("Notes on the Grid"), "{text}"); + assert!(read(&mut z, "ppt/presentation.xml").contains("")); + + let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap(); + assert_eq!(r["slides"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded"))); + assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err()); + assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err()); + } + + #[test] + fn rasterize_option_places_a_picture() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); + let shapes = |s: &mut Session| { + let r = s.execute("file.exportPptx", &json!({})).unwrap(); + read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml") + }; + assert!(shapes(&mut s).contains("prst=\"ellipse\"")); + s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap(); + let x = shapes(&mut s); + assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}"); + } +} diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs new file mode 100644 index 000000000..64011fa3c --- /dev/null +++ b/crates/engine/src/cmd/export/pdf.rs @@ -0,0 +1,463 @@ +use super::flatten::{flatten, rasterize_effects, rasterize_layered, shadow_masks, transparent}; +use super::formats::write_file; +use super::*; + +/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions. +pub(super) fn pages_param(id: &str, p: &Value, page_count: usize) -> Result>> { + Ok(match p.get("pages") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?), + Some(Value::Array(a)) => { + let mut v = Vec::new(); + for x in a { + let n = x + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| *n >= 1 && *n <= page_count) + .ok_or_else(|| bad(id, format!("bad page {x}")))?; + // A page named twice is one page, as `"2,2"` already is: the list says which + // pages to export, not how many times. `[2, 2]` exported two copies of page 2 + // while `"2,2"` exported one. + if !v.contains(&(n - 1)) { + v.push(n - 1); + } + } + Some(v) + } + Some(Value::Number(n)) => { + let n = n + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| *n >= 1 && *n <= page_count) + .ok_or_else(|| bad(id, format!("bad page {n}")))?; + Some(vec![n - 1]) + } + Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))), + }) +} + +pub(crate) fn options(p: &Value, page_count: usize) -> Result { + const ID: &str = "file.exportPdf"; + + let pages = pages_param(ID, p, page_count)?; + let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false); + let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false); + let marks = match p.get("marks") { + Some(Value::Bool(true)) => Marks::ALL, + Some(Value::Object(m)) => { + let b = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); + Marks { + crop: b("crop"), + bleed: b("bleed"), + page_info: b("pageInfo"), + weight: m.get("weight").and_then(Value::as_f64).unwrap_or(0.25), + offset: m.get("offset").and_then(Value::as_f64).unwrap_or(6.0), + } + } + _ => Marks::NONE, + }; + let standard = match str_param(p, "standard") { + Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, x1a, a2b)")))?, + None => Standard::None, + }; + let created = match p.get("created") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?), + }; + Ok(PdfOptions { + pages, + spreads, + bleed, + marks, + standard, + compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false), + title: str_param(p, "title").map(str::to_string), + author: str_param(p, "author").map(str::to_string), + tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false), + media: p.get("media").and_then(Value::as_bool).unwrap_or(false), + created, + ..PdfOptions::default() + }) +} + +/// Object number of the first page of a PDF (its page tree's first kid). +fn first_page_ref(pdf: &[u8]) -> Option { + let s = String::from_utf8_lossy(pdf); + let i = s.find("/Kids[")? + 6; + s[i..].split_whitespace().next()?.parse().ok() +} + +pub(super) fn export_pdf(s: &mut Session, p: &Value) -> Result { + let r = pdf_export(s, p)?; + match str_param(p, "path") { + Some(path) => { + write_file(path, &r.bytes)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})) + } + None => Ok(json!({"base64": crate::cmd::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})), + } +} + +/// Transparency Flattener presets (`flatten`): High / Medium / Low Resolution, or a ppi. +pub(crate) fn flatten_ppi(id: &str, p: &Value) -> Result> { + Ok(match p.get("flatten") { + None | Some(Value::Null) | Some(Value::Bool(false)) => None, + Some(Value::String(s)) if s == "high" => Some(300.0), + Some(Value::String(s)) if s == "medium" => Some(150.0), + Some(Value::String(s)) if s == "low" => Some(72.0), + Some(Value::Bool(true)) => Some(300.0), + Some(v) => Some(v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad(id, "`flatten`: high|medium|low or 36–1200 ppi"))?), + }) +} + +/// The print PDF of `opts`' pages: the transparency flattener at `ppi` (if any), placed PDFs +/// with hidden layers and soft effects as images, the PDF boxes of placed graphics, the PDF +/// itself and its transparency blend space. Export PDF and File › Print add their view options +/// to it ([`pdf_export`]); Separations Preview separates it as it is. +pub(crate) fn print_pdf( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + opts: &PdfOptions, + ppi: Option, +) -> Result { + // PDF/X-1a: craft-prepress flattens the PDF in its inks; `ppi` sets its resolution. + let x1a = opts.standard == Standard::PdfX1a; + let mut opts = opts.clone(); + if x1a { + opts.flatten_dpi = ppi.unwrap_or(300.0) as f32; + } + let flattened = if x1a { None } else { ppi.map(|ppi| flatten(d, cache, ppi)) }; + let base: &designcraft_doc::Document = flattened.as_ref().map_or(d, |f| &f.0); + let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) }; + let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); + let shadows = if x1a { shadow_masks(base2, cache, f64::from(opts.flatten_dpi)) } else { Default::default() }; + let (fx_doc, fx_count) = rasterize_effects(base2, cache, &shadows); + opts.shadows = std::sync::Arc::new(shadows); + let opts = &opts; + let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); + let boxed = crate::cmd::interchange::with_pdf_boxes(doc); + let doc: &designcraft_doc::Document = &boxed; + let mut r = designcraft_pdf::export_pdf_with_report(doc, cache, opts).map_err(|e| EngineError::Other(e.to_string()))?; + // Flattening and effect rasterization replace placements, so inspect the source too. + if d.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) { + r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into()); + } + // Transparency Blend Space: the page group of pages with transparency. CMYK print output + // gets its CMYK page groups from the writer. + let spreads = designcraft_pdf::sheet_spreads(doc, opts); + let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); + if !designcraft_pdf::print_color(doc, opts) && blended.iter().any(|b| *b) { + match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) { + Some(b) => r.bytes = b, + None => r.warnings.push("the transparency blend space couldn't be set".into()), + } + } + if fx_count > 0 { + r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) exported as images")); + } + if layer_count > 0 { + r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images")); + } + if let Some((_, n)) = &flattened + && *n > 0 + { + r.warnings.push(format!("transparency flattened: {n} region(s) rasterised")); + } + Ok(r) +} + +/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF +/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it. +pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result { + let st = s.doc()?; + let opts = options(p, st.doc.page_count())?; + let ppi = flatten_ppi("file.exportPdf", p)?; + let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?; + let doc: &designcraft_doc::Document = &st.doc; + let spreads = designcraft_pdf::sheet_spreads(doc, &opts); + // 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(); + if trans.iter().any(Option::is_some) { + match designcraft_pdf::add_transitions(&r.bytes, &trans) { + Some(b) => r.bytes = b, + None => r.warnings.push("page transitions couldn't be added to this PDF".into()), + } + } + Ok(r) +} + +#[cfg(test)] +mod tagged_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn tagged_pdf_has_structure_and_alt_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 320, 200, 420]})).unwrap(); + s.execute("object.altText", &json!({"text": "A plain box", "ids": [r["id"]]})).unwrap(); + let pdf = |s: &mut Session, tagged: bool| { + let b64 = s.execute("file.exportPdf", &json!({"tagged": tagged})).unwrap()["base64"].as_str().unwrap().to_string(); + String::from_utf8_lossy(&crate::cmd::file::base64_decode(&b64)).into_owned() + }; + let t = pdf(&mut s, true); + assert!(t.contains("/StructTreeRoot") && t.contains("/Figure") && t.contains("A plain box"), "tagged"); + assert!(t.contains("/P") && t.contains("/MarkInfo")); + assert!(!pdf(&mut s, false).contains("/StructTreeRoot")); + } +} + +#[cfg(test)] +mod variable_font_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn variable_font_instance_exports_to_pdf() { + let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Skia.ttf") else { + return; + }; + designcraft_fonts::FontDb::global().add_font(data); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Variable"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Skia", "fontStyle": "Black"})).unwrap(); + let out = s.execute("file.exportPdf", &json!({})).unwrap(); + assert!(out["bytes"].as_u64().unwrap() > 1000); + let fonts = s.execute("font.list", &json!({})).unwrap(); + assert!(fonts.to_string().contains("Black"), "{fonts}"); + } +} + +#[cfg(test)] +mod placed_pdf_print_tests { + use serde_json::json; + + use crate::Session; + use crate::cmd::{base64_decode, base64_encode}; + + /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. + fn logo() -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + base64_decode(r["base64"].as_str().unwrap()) + } + + fn page_with(pdf: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); + s + } + + /// A placed PDF 1.7 failed the PDF/X-4 (PDF 1.6) export with + /// "PDF writer error: Pdf(PdfDocument(PdfDocumentRepr { .. }), VersionMismatch(Pdf17), None)". + #[test] + fn pdf_17_placed_in_pdfx4() { + let logo = logo(); + assert!(logo.starts_with(b"%PDF-1.7")); + let mut s = page_with(&logo); + let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap(); + assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); + assert!(base64_decode(r["base64"].as_str().unwrap()).starts_with(b"%PDF-1.6")); + // PDF 2.0 can't simply be relabelled: the export fails and names the file. + let mut v20 = logo.clone(); + v20[5..8].copy_from_slice(b"2.0"); + let mut s = page_with(&v20); + let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string(); + assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}"); + } + + /// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it. + fn catalog_v17(header: &str) -> Vec { + let content = "0 0 0 1 k 0 0 200 200 re f"; + let mut pdf = format!("{header}\n").into_bytes(); + let mut offsets = Vec::new(); + for body in [ + "<>".to_owned(), + "<>".to_owned(), + "<>>>".to_owned(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ] { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + pdf + } + + /// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export + /// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907). + #[test] + fn catalog_version_17_placed_in_pdfx4() { + for header in ["%PDF-1.7", "%PDF-1.4"] { + let pdf = catalog_v17(header); + assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17); + let mut s = page_with(&pdf); + let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}")); + assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); + let out = base64_decode(r["base64"].as_str().unwrap()); + assert!(out.starts_with(b"%PDF-1.6")); + assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16); + } + } +} + +#[cfg(test)] +mod print_group_tests { + use serde_json::json; + + use crate::Session; + use crate::cmd::{base64_decode, base64_encode}; + + /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. + fn logo() -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + base64_decode(r["base64"].as_str().unwrap()) + } + + fn page_with(pdf: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); + s + } + + /// A placed PDF's transparency group blends in CMYK in a print document and in RGB in a + /// screen one. + #[test] + fn print_transparency_groups_blend_in_cmyk() { + let mut s = page_with(&logo()); + let text = |s: &mut Session| { + let r = s.execute("file.exportPdf", &json!({"compressImages": false})).unwrap(); + String::from_utf8_lossy(&base64_decode(r["base64"].as_str().unwrap())).into_owned() + }; + let t = text(&mut s); + // The placed page's group inherits the page group, which blends in CMYK. + assert!(t.contains("/Group<>"), "placed page group without a blending space"); + assert!(t.contains("/Group<>"), "CMYK page group"); + assert!(!t.contains("DeviceRGB"), "no RGB left"); + assert!(!designcraft_pdf::has_rgb_groups(t.as_bytes())); + // The file still reads, with the same page. + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let bytes = base64_decode(r["base64"].as_str().unwrap()); + assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1); + // An RGB blend space keeps RGB groups. + s.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap(); + let t = text(&mut s); + assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes())); + } +} + +#[cfg(test)] +mod page_list_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn doc(pages: u64) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap(); + s + } + + /// The same selection written two ways exported different files: `"2,2"` is one page, because + /// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2. + /// The list says which pages to export, not how many times. + #[test] + fn a_page_named_twice_is_exported_once_either_way() { + let mut s = doc(4); + for (pages, want) in [ + (json!("2,2"), 1), + (json!([2, 2]), 1), + (json!([2, 2, 2]), 1), + (json!("2-2,2"), 1), + (json!([1, 2, 1]), 2), + (json!("1,2,1"), 2), + (json!([4, 1]), 2), + (json!("4,1"), 2), + (json!("all"), 4), + ] { + let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap(); + assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}"); + let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap(); + assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}"); + } + // The two forms now agree byte for byte, and the pages come out in the document's order + // whichever order the list names them in (which is how the export already behaved). + // A fixed creation time: exports a second apart would differ in their dates. + let mut file = |pages: Value| { + let r = s.execute("file.exportPdf", &json!({"pages": pages, "created": 0})).unwrap(); + r["base64"].as_str().unwrap_or_default().to_string() + }; + assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree"); + assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order"); + assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page"); + // A page that does not exist is still refused, and an empty list still exports nothing. + assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err()); + assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err()); + } +} diff --git a/crates/engine/src/cmd/export/registry.rs b/crates/engine/src/cmd/export/registry.rs new file mode 100644 index 000000000..dfa126b89 --- /dev/null +++ b/crates/engine/src/cmd/export/registry.rs @@ -0,0 +1,53 @@ +use super::formats::{epub_split_param, export_epub, export_html, export_pptx, export_text, has_story_target, print_booklet}; +use super::pdf::export_pdf; +use super::*; + +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; with standard x1a, the resolution complex transparent regions are rasterized at: high 300, medium 150, low 72, default 300), 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\"|\"x1a\"|\"a2b\" (x1a: PDF/X-1a:2003, the PDF/X-4 file flattened in its inks by craft-prepress: no transparency, only CMYK, gray and spot color), compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {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\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), split?: \"off\"|\"exportTags\"|{style: name} (Split Document for this export, not stored; the document's `document.epubSplit` by default), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded", + has_doc, export_epub), + cmd!( + "document.epubSplit", + "EPUB Split Document", + [], + None, + "{split?: \"off\"|\"exportTags\"|{style: name}} — EPUB Export Options › Split Document, stored with the document: one content file (off, the default), a file before each paragraph whose style has Export Tagging › Split Document on (exportTags), or before each paragraph of one style → {split}", + has_doc, + |s, p| { + const ID: &str = "document.epubSplit"; + let cur = s.doc()?.doc.settings.epub_split.clone(); + let Some(v) = p.get("split") else { return Ok(json!({"split": cur})) }; + let split = epub_split_param(ID, &s.doc()?.doc, v)?; + if split != cur { + let stored = split.clone(); + s.edit(move |d, _| { + d.settings.epub_split = stored; + Ok(Value::Null) + })?; + } + Ok(json!({"split": split})) + } + ), + cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, + "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", + has_doc, |s, p| { + export_epub(s, &crate::cmd::with_param(p, "fixedLayout", serde_json::json!(true))) + }), + cmd!(noundo "file.printBooklet", "Print Booklet…", ["File"], None, + "{path?, type?: saddleStitch|twoUpConsecutive, spaceBetween? (pt)} — printer spreads as PDF (pages imposed in booklet order) → {path, bytes, sheets} (no path: {base64, …})", + has_doc, print_booklet), + cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None, + "{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes, warnings} (no path: {text, bytes, warnings})", + has_doc, export_html), + cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None, + "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})", + has_doc, export_pptx), + cmd!(noundo "file.exportText", "Export Text…", ["File"], None, + "{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})", + has_story_target, export_text), + ] +}