X: Articles panel — reading order for EPUB/HTML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 02:56:24 -04:00
co-authored by Claude Opus 5.5
parent faf23fc74b
commit fcec2902be
12 changed files with 198 additions and 3 deletions
+1
View File
@@ -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.
+1
View File
@@ -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 {
+13
View File
@@ -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<ItemId>,
#[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<ColorGroup>,
/// Articles panel: named reading-order lists of objects (EPUB / HTML export order).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub articles: Vec<Article>,
/// Custom stroke styles (Object › Stroke Styles).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stroke_styles: Vec<StrokeStyleDef>,
+114
View File
@@ -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<ItemId> {
ids_param(p, "ids").unwrap_or_else(|| s.active().map(|d| d.selection.items.clone()).unwrap_or_default())
}
pub fn specs() -> Vec<CommandSpec> {
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<ItemId> = 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<ItemId> = o.into_iter().filter(|i| a.items.contains(i)).collect();
items.extend(a.items.iter().filter(|i| !items.contains(i)).copied().collect::<Vec<_>>());
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");
}
}
+2
View File
@@ -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());
+20
View File
@@ -260,6 +260,26 @@ pub enum Block {
}
pub fn reading_order(doc: &Document) -> Vec<Block> {
// 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() {
+1
View File
@@ -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),
+2
View File
@@ -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),
}
+7
View File
@@ -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)]);
}
+1
View File
@@ -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",
+33
View File
@@ -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}));
}
});
});
}
}
+3 -3
View File
@@ -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 |