From fcec2902bef9a8a53948b0611341b91e6b11ecef Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:56:24 -0400 Subject: [PATCH] =?UTF-8?q?X:=20Articles=20panel=20=E2=80=94=20reading=20o?= =?UTF-8?q?rder=20for=20EPUB/HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/build.rs | 1 + crates/doc/src/lib.rs | 13 +++ crates/engine/src/cmd/articles.rs | 114 +++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/epub/src/lib.rs | 20 ++++ crates/idml/src/import.rs | 1 + crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/icons.rs | 7 ++ crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/interactive.rs | 33 +++++++ docs/parity.md | 6 +- 12 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/cmd/articles.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0c084bf2f..382882d16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -71,6 +71,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). - Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). +- Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - 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/build.rs b/crates/doc/src/build.rs index 523a870c7..b709b87ac 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -131,6 +131,7 @@ impl Document { conditions: vec![], inks: Default::default(), stroke_styles: vec![], + articles: vec![], endnote_options: Default::default(), endnote_story: None, sections: vec![Section { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9556e30a5..0180371cc 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -228,6 +228,16 @@ impl InkManager { } } +/// An article: objects in reading order; `export` includes it when exporting. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Article { + pub name: String, + pub items: Vec, + #[serde(default = "yes")] + pub export: bool, +} + /// A Swatches panel colour group: a named folder of swatches (by name). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -408,6 +418,9 @@ pub struct Document { /// the top level. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub color_groups: Vec, + /// Articles panel: named reading-order lists of objects (EPUB / HTML export order). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub articles: Vec
, /// Custom stroke styles (Object › Stroke Styles). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub stroke_styles: Vec, diff --git a/crates/engine/src/cmd/articles.rs b/crates/engine/src/cmd/articles.rs new file mode 100644 index 000000000..bd3710df3 --- /dev/null +++ b/crates/engine/src/cmd/articles.rs @@ -0,0 +1,114 @@ +//! Window › Articles: named reading-order lists of objects (EPUB / HTML export order). + +use designcraft_doc::{Article, ItemId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, ids_param, ok, str_param}; +use crate::Session; + +fn sel_ids(s: &Session, p: &Value) -> Vec { + ids_param(p, "ids").unwrap_or_else(|| s.active().map(|d| d.selection.items.clone()).unwrap_or_default()) +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "article.list", "Articles", [], None, "{} → [{name, items, export}]", has_doc, |s, _| Ok(serde_json::to_value(&s.doc()?.doc.articles).unwrap_or_default())), + cmd!("article.new", "New Article", ["Window", "Articles"], None, "{name?, ids? (default: the selection)} → {name}", has_doc, |s, p| { + let ids = sel_ids(s, p); + let want = str_param(p, "name").map(str::to_string); + s.edit(|d, _| { + let name = match want { + Some(n) if d.articles.iter().any(|a| a.name == n) => return Err(bad("article.new", format!("an article named `{n}` exists"))), + Some(n) => n, + None => (1..).map(|i| format!("Article {i}")).find(|n| !d.articles.iter().any(|a| a.name == *n)).expect("free"), + }; + d.articles.push(Article { name: name.clone(), items: ids.into_iter().filter(|i| d.item(*i).is_some()).collect(), export: true }); + Ok(json!({"name": name})) + }) + }), + cmd!("article.add", "Add Selection to Article", [], None, "{name, ids? (default: the selection)} — appended in order", has_doc, |s, p| { + let ids = sel_ids(s, p); + let name = str_param(p, "name").unwrap_or("").to_string(); + s.edit(|d, _| { + let valid: Vec = ids.into_iter().filter(|i| d.item(*i).is_some()).collect(); + let a = d.articles.iter_mut().find(|a| a.name == name).ok_or_else(|| bad("article.add", format!("no article `{name}`")))?; + for i in valid { + if !a.items.contains(&i) { + a.items.push(i); + } + } + ok() + }) + }), + cmd!("article.remove", "Remove from Article", [], None, "{name, ids}", has_doc, |s, p| { + let ids = sel_ids(s, p); + let name = str_param(p, "name").unwrap_or("").to_string(); + s.edit(|d, _| { + let a = d.articles.iter_mut().find(|a| a.name == name).ok_or_else(|| bad("article.remove", format!("no article `{name}`")))?; + a.items.retain(|i| !ids.contains(i)); + ok() + }) + }), + cmd!("article.options", "Article Options", [], None, "{name, to?: new name, export?: bool, order?: [ids] (reorder)}", has_doc, |s, p| { + let name = str_param(p, "name").unwrap_or("").to_string(); + let to = str_param(p, "to").map(str::to_string); + let export = p.get("export").and_then(Value::as_bool); + let order = ids_param(p, "order"); + s.edit(|d, _| { + let a = d.articles.iter_mut().find(|a| a.name == name).ok_or_else(|| bad("article.options", format!("no article `{name}`")))?; + if let Some(e) = export { + a.export = e; + } + if let Some(o) = order { + let mut items: Vec = o.into_iter().filter(|i| a.items.contains(i)).collect(); + items.extend(a.items.iter().filter(|i| !items.contains(i)).copied().collect::>()); + a.items = items; + } + if let Some(t) = to { + a.name = t; + } + ok() + }) + }), + cmd!("article.delete", "Delete Article", [], None, "{name}", has_doc, |s, p| { + let name = str_param(p, "name").unwrap_or("").to_string(); + s.edit(|d, _| { + let n = d.articles.len(); + d.articles.retain(|a| a.name != name); + if n == d.articles.len() { + return Err(bad("article.delete", format!("no article `{name}`"))); + } + ok() + }) + }), + ] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn articles_set_the_export_reading_order() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = + s.execute("frame.create", &json!({"rect": [72, 72, 300, 100], "content": "text", "text": "Top story", "caret": false})).unwrap()["id"] + .clone(); + let b = + s.execute("frame.create", &json!({"rect": [72, 400, 300, 430], "content": "text", "text": "Lower story", "caret": false})).unwrap()["id"] + .clone(); + let html = |s: &mut Session| s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + let h = html(&mut s); + assert!(h.find("Top").unwrap() < h.find("Lower").unwrap(), "page order without articles"); + s.execute("article.new", &json!({"name": "Main", "ids": [b, a]})).unwrap(); + let h = html(&mut s); + assert!(h.find("Lower").unwrap() < h.find("Top").unwrap(), "article order"); + s.execute("article.remove", &json!({"name": "Main", "ids": [a]})).unwrap(); + assert!(!html(&mut s).contains("Top story"), "only articles' objects are exported"); + s.execute("article.options", &json!({"name": "Main", "export": false})).unwrap(); + assert!(html(&mut s).contains("Top story"), "no exported articles: page order again"); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3872ca680..b6f755251 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ mod anchored; pub mod anchors; +mod articles; mod captions; mod conditions; mod datamerge; @@ -141,6 +142,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(editorial::specs()); v.extend(object::specs()); v.extend(anchors::specs()); + v.extend(articles::specs()); v.extend(paths::specs()); v.extend(captions::specs()); v.extend(conditions::specs()); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index ed6eb55a1..c4953cac3 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -260,6 +260,26 @@ pub enum Block { } pub fn reading_order(doc: &Document) -> Vec { + // Articles, when there are any to export, decide the order (and what's included). + if doc.articles.iter().any(|a| a.export) { + let mut out = Vec::new(); + let mut seen = Vec::new(); + for a in doc.articles.iter().filter(|a| a.export) { + for id in &a.items { + let Some(top) = doc.item(*id) else { continue }; + let mut push = |it: &Item| match &it.content { + Content::Text(tf) if !seen.contains(&tf.story) => { + seen.push(tf.story); + out.push(Block::Story(tf.story)); + } + Content::Graphic(_) => out.push(Block::Image(it.id)), + _ => {} + }; + top.walk(&mut |i| push(i)); + } + } + return out; + } let mut keyed: Vec<((usize, i64, i64), Block)> = Vec::new(); let mut seen_stories = Vec::new(); for (si, sp) in doc.spreads.iter().enumerate() { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 4cd8d4582..b5ed5f943 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2090,6 +2090,7 @@ impl<'r> Importer<'r> { conditions: std::mem::take(&mut self.conditions), inks: std::mem::take(&mut self.inks), stroke_styles: std::mem::take(&mut self.stroke_styles), + articles: vec![], endnote_options: Default::default(), endnote_story: None, sections: std::mem::take(&mut self.sections), diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index b113c52c5..b2f21e73e 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -35,6 +35,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("library", "Library", "panel-library"), ("hyperlinks", "Hyperlinks", "panel-hyperlinks"), ("bookmarks", "Bookmarks", "panel-bookmarks"), + ("articles", "Articles", "panel-articles"), ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -175,6 +176,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "library" => panels::library::show(app, ui), "hyperlinks" => panels::interactive::hyperlinks(app, ui), "bookmarks" => panels::interactive::bookmarks(app, ui), + "articles" => panels::interactive::articles(app, ui), "table" => panels::table::show(app, ui), _ => panels::properties::info_panel(app, ui), } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 57564d2db..6cce89cdb 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -316,6 +316,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.closed(&[(17.0, 10.0), (13.0, 14.0), (10.0, 14.0), (13.0, 11.0), (14.0, 9.0), (13.0, 7.0), (15.0, 6.0)]); pen.line(&[(8.0, 12.0), (12.0, 8.0)]); } + "panel-articles" => { + // Numbered blocks in order. + pen.rect(3.0, 3.0, 11.0, 8.0); + pen.rect(3.0, 12.0, 11.0, 17.0); + pen.line(&[(14.0, 5.5), (17.0, 5.5)]); + pen.line(&[(14.0, 14.5), (17.0, 14.5)]); + } "panel-bookmarks" => { pen.closed(&[(6.0, 3.0), (14.0, 3.0), (14.0, 17.0), (10.0, 13.0), (6.0, 17.0)]); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fedac3e0f..97dc1b7cc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -490,6 +490,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ ">Interactive", "ui:window.panel|Hyperlinks|{\"panel\": \"hyperlinks\"}", "ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}", + "ui:window.panel|Articles|{\"panel\": \"articles\"}", "<", ">Utilities", "cmd:data.merge", diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index cdafafab2..8c8647589 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -80,3 +80,36 @@ pub fn bookmarks(app: &mut DesignApp, ui: &mut egui::Ui) { }); } } + +/// Articles panel: reading-order lists for EPUB / HTML export. +pub fn articles(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let has_sel = app.session.active().is_some_and(|d| !d.selection.items.is_empty()); + if ui.add_enabled(has_sel, egui::Button::new("New Article from Selection")).clicked() { + let _ = app.run("article.new", json!({})); + } + ui.separator(); + let list = app.session.execute("article.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + if list.is_empty() { + ui.label(egui::RichText::new("No articles: exports follow page order.").size(11.0).color(t.text_dim)); + } + for a in list { + let name = a["name"].as_str().unwrap_or("").to_string(); + let mut export = a["export"].as_bool().unwrap_or(true); + ui.horizontal(|ui| { + if ui.checkbox(&mut export, "").on_hover_text("Include when exporting").changed() { + let _ = app.run("article.options", json!({"name": name, "export": export})); + } + ui.label(egui::RichText::new(&name).strong()); + ui.label(egui::RichText::new(format!("{} objects", a["items"].as_array().map_or(0, Vec::len))).size(10.5).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Delete").clicked() { + let _ = app.run("article.delete", json!({"name": name})); + } + if ui.add_enabled(has_sel, egui::Button::new("Add").small()).on_hover_text("Add the selection").clicked() { + let _ = app.run("article.add", json!({"name": name})); + } + }); + }); + } +} diff --git a/docs/parity.md b/docs/parity.md index 6c3f881f3..80af35663 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 | 99 | 3 | 0 | 99% | | P1 | 97 | 92 | 4 | 1 | 97% | -| P2 | 76 | 23 | 14 | 39 | 39% | +| P2 | 76 | 24 | 14 | 38 | 41% | | Area | Parity | |---|---| @@ -28,7 +28,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Styles | 97% | | Tables | 92% | | Long documents | 88% | -| Interactivity & digital | 65% | +| Interactivity & digital | 75% | | Output & production | 75% | | XML & automation | 61% | | View & navigation | 86% | @@ -272,7 +272,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Interactivity & digital | Bookmarks | P1 | D | | Interactivity & digital | Buttons and Forms | P2 | M | | Interactivity & digital | Object States | P2 | M | -| Interactivity & digital | Articles panel | P2 | M | +| Interactivity & digital | Articles panel | P2 | D | | Interactivity & digital | Object Export Options | P2 | P | | Interactivity & digital | EPUB export | P2 | D | | Interactivity & digital | HTML export | P2 | D |