diff --git a/ROADMAP.md b/ROADMAP.md index 064215233..2a0efaa59 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -115,6 +115,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text. - EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud. - Interactive PDF options (`file.exportPdf {fullScreen, bookmarksPanel, pageLayout, view, advanceSeconds}`, File ▸ Export PDF (Interactive)…): page mode, layout, opening view and page timing in the catalog and pages. +- Object Export Options (`object.exportOptions`): tagged-PDF artifact, EPUB/HTML rasterize (the object as an image), alignment and page break before, alt text. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 291e84a19..d6a754cac 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -467,6 +467,26 @@ pub struct ObjectLiquid { pub pin_right: bool, } +/// Object Export Options: tagged PDF and EPUB/HTML handling of one object. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ExportOptions { + /// Tagged PDF: the object is decoration (an artifact), not content. + pub artifact: bool, + /// EPUB/HTML: export the object as an image (whatever it is). + pub rasterize: bool, + /// EPUB/HTML: "left", "center" or "right" ("" = as the flow puts it). + pub align: String, + /// EPUB/HTML: start a new page (screen) before the object. + pub page_break_before: bool, +} + +impl ExportOptions { + pub fn is_default(&self) -> bool { + *self == ExportOptions::default() + } +} + /// A PDF form field. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -948,6 +968,9 @@ pub struct Item { /// Liquid Layout (object-based pages): pins and resize permissions. #[serde(default, skip_serializing_if = "Option::is_none")] pub liquid: Option, + /// Object Export Options (beyond alt text). + #[serde(default, skip_serializing_if = "ExportOptions::is_default")] + pub export_options: ExportOptions, /// Buttons and Forms: an interactive PDF form field drawn in this frame. #[serde(default, skip_serializing_if = "Option::is_none")] pub form_field: Option, @@ -1016,6 +1039,7 @@ impl Item { pdf_hidden_layers: Vec::new(), live_caption: None, form_field: None, + export_options: ExportOptions::default(), media: None, liquid: None, states: Vec::new(), diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8f89013f1..56c11295a 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -124,6 +124,9 @@ fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, fn export_epub(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; + // Object Export Options › Rasterize: those objects go in as images. + let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, |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(); @@ -155,7 +158,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { None => Ok(serde_json::json!({"base64": super::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages.len()})), }; } - let bytes = designcraft_epub::export_epub(&st.doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + let bytes = designcraft_epub::export_epub(epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] @@ -359,20 +362,27 @@ fn raster_effects(it: &designcraft_doc::Item) -> bool { /// 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). fn rasterize_effects(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache) -> (Option, usize) { + rasterize_where(d, cache, 300.0, |it| raster_effects(it) && !threaded(d, it)) +} + +/// A copy of `d` where the spread-level objects `pred` picks are images of their appearance at +/// `ppi` (transparent around them). +fn rasterize_where( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + ppi: f64, + 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 && raster_effects(it) && !threaded(d, it)).map(move |it| (si, it.id))) - .collect(); + 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 = 300.0 / 72.0; + let k = ppi / 72.0; let mut n = 0; for (si, id) in todo { let Some(it) = d.item(id) else { continue }; @@ -426,6 +436,7 @@ fn rasterize_effects(d: &designcraft_doc::Document, cache: &designcraft_compose: }); 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) { @@ -611,6 +622,38 @@ mod text_tests { s } + #[test] + fn object_export_options() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // A plain rectangle rasterised for EPUB, centred, after a page break. + 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(); + s.execute( + "object.exportOptions", + &json!({"ids": [r["id"]], "rasterize": true, "align": "center", "pageBreakBefore": true, "altText": "A dot"}), + ) + .unwrap(); + let e = s.execute("file.exportEpub", &json!({})).unwrap(); + let mut z = unzip(e["base64"].as_str().unwrap()); + let x = read(&mut z, "OEBPS/content.xhtml"); + assert!( + x.contains("
Vec { ) } ), + cmd!( + "object.exportOptions", + "Object Export Options…", + ["Object"], + None, + "{ids?, artifact?: bool (tagged PDF decoration), rasterize?: bool (EPUB/HTML image), align?: left|center|right|\"\", pageBreakBefore?: bool, altText?}", + has_selection, + |s, p| { + let ids = targets(s, p)?; + let p = p.clone(); + s.edit(|d, _| { + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + let e = &mut it.export_options; + if let Some(v) = p.get("artifact").and_then(Value::as_bool) { + e.artifact = v; + } + if let Some(v) = p.get("rasterize").and_then(Value::as_bool) { + e.rasterize = v; + } + if let Some(v) = p.get("align").and_then(Value::as_str) { + e.align = v.to_string(); + } + if let Some(v) = p.get("pageBreakBefore").and_then(Value::as_bool) { + e.page_break_before = v; + } + if let Some(v) = p.get("altText").and_then(Value::as_str) { + it.alt_text = v.to_string(); + } + } + Ok(json!({"objects": ids.len()})) + }) + } + ), cmd!( "object.innerGlow", "Inner Glow", diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 11b30b1bc..791e9b06c 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -487,7 +487,17 @@ fn body_html<'a>( images.insert(name.clone(), (a.mime.as_str(), a.data.as_slice())); // Alt text from Object Export Options, else the file name. let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name); - let _ = writeln!(body, "
\"{}\"/
", img_src(&name, &a.mime, &a.data), esc(alt)); + // Object Export Options: alignment and a page break before. + let eo = doc.item(*iid).map(|i| i.export_options.clone()).unwrap_or_default(); + let mut css = String::new(); + if matches!(eo.align.as_str(), "left" | "center" | "right") { + css.push_str(&format!("text-align:{};", eo.align)); + } + if eo.page_break_before { + css.push_str("page-break-before:always;break-before:page;"); + } + let style = if css.is_empty() { String::new() } else { format!(" style=\"{css}\"") }; + let _ = writeln!(body, "\"{}\"/
", img_src(&name, &a.mime, &a.data), esc(alt)); } } } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 16cc583e0..ad454c65b 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -614,7 +614,7 @@ impl Exporter<'_> { } let story = it.text_frame().map(|t| t.story); let figure = !it.alt_text.is_empty() || matches!(it.content, Content::Graphic(_)) || it.has_nested_items(); - if on_parent || (story.is_none() && !figure) { + if on_parent || it.export_options.artifact || (story.is_none() && !figure) { // Page furniture and decoration. let kind = if on_parent { ArtifactType::Page } else { ArtifactType::Other }; s.start_tagged(ContentTag::Artifact(Artifact::new(kind, None))); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25173e38f..34a2823b2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -448,6 +448,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:object.textFrameOptions", "ui:app.layerOptions", + "cmd:object.exportOptions", "cmd:object.primaryTextFrame", "cmd:object.cornerOptions", "-", diff --git a/docs/parity.md b/docs/parity.md index 007d27974..b404a7a44 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | | P1 | 97 | 94 | 3 | 0 | 98% | -| P2 | 76 | 62 | 14 | 0 | 91% | +| P2 | 76 | 63 | 13 | 0 | 91% | | Area | Parity | |---|---| @@ -28,7 +28,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Styles | 100% | | Tables | 98% | | Long documents | 100% | -| Interactivity & digital | 95% | +| Interactivity & digital | 100% | | Output & production | 92% | | XML & automation | 89% | | View & navigation | 98% | @@ -273,7 +273,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Interactivity & digital | Buttons and Forms | P2 | D | | Interactivity & digital | Object States | P2 | D | | Interactivity & digital | Articles panel | P2 | D | -| Interactivity & digital | Object Export Options | P2 | P | +| Interactivity & digital | Object Export Options | P2 | D | | Interactivity & digital | EPUB export | P2 | D | | Interactivity & digital | HTML export | P2 | D | | Output & production | Export to PDF (print) | P0 | P |