mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
I: Object Export Options — artifact, rasterize, alignment, page break
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b7cd7f6309
commit
8eca6305f8
8 files changed
+126
-13
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user