I: Object Export Options — artifact, rasterize, alignment, page break

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 10:48:58 -04:00
1 parent b7cd7f6309
commit 8eca6305f8
8 files changed
+126 -13

No files matched your search

+1
View File
@@ -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.
+24
View File
@@ -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<ObjectLiquid>,
/// 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<FormField>,
@@ -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(),
+51 -8
View File
@@ -124,6 +124,9 @@ fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache,
fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<designcraft_doc::Document>, 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<designcraft_doc::Document>, 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("<figure style=\"text-align:center;page-break-before:always;break-before:page;\"><img") && x.contains("alt=\"A dot\""),
"{x}"
);
// Tagged PDF: an artifact object adds no figure.
let pdf = |s: &mut Session| {
String::from_utf8_lossy(&super::super::file::base64_decode(
s.execute("file.exportPdf", &json!({"tagged": true})).unwrap()["base64"].as_str().unwrap(),
))
.matches("/S/Figure")
.count()
};
let figures = pdf(&mut s);
s.execute("object.exportOptions", &json!({"ids": [r["id"]], "artifact": true})).unwrap();
assert!(pdf(&mut s) < figures);
}
#[test]
fn epub_cover_and_fixed_layout() {
let mut s = Session::new();
+34
View File
@@ -316,6 +316,40 @@ pub fn specs() -> Vec<CommandSpec> {
)
}
),
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",
+11 -1
View File
@@ -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, "<figure><img src=\"{}\" alt=\"{}\"/></figure>", 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, "<figure{style}><img src=\"{}\" alt=\"{}\"/></figure>", img_src(&name, &a.mime, &a.data), esc(alt));
}
}
}
+1 -1
View File
@@ -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)));
+1
View File
@@ -448,6 +448,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"-",
"cmd:object.textFrameOptions",
"ui:app.layerOptions",
"cmd:object.exportOptions",
"cmd:object.primaryTextFrame",
"cmd:object.cornerOptions",
"-",
+3 -3
View File
@@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|---|---|---|---|---|---|
| P0 | 102 | 100 | 2 | 0 | 99% |
| P1 | 97 | 94 | 3 | 0 | 98% |
| P2 | 76 | 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 |