mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06:00
X: Articles panel — reading order for EPUB/HTML
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
faf23fc74b
commit
fcec2902be
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,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());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user