diff --git a/Cargo.lock b/Cargo.lock index 8b7857c..a2f65a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -943,6 +943,7 @@ dependencies = [ "hayro-syntax", "kurbo", "log", + "quick-xml 0.38.4", "regex", "serde", "serde_json", diff --git a/ROADMAP.md b/ROADMAP.md index 6906fb5..236e320 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -75,6 +75,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. - Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step. - Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize. +- XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging and DTDs aren't there yet. - 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/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 408ff59..a0f59ef 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -93,6 +93,8 @@ fn services() -> Services { let d = rfd::FileDialog::new(); let d = if purpose == "swatches" { d.add_filter("Swatch Exchange (ASE)", &["ase"]) + } else if purpose == "xml" { + d.add_filter("XML", &["xml"]) } else if purpose == "library" { d.add_filter("Object Library", &["dclib"]) } else if purpose == "place" { diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index b709b87..e92b48a 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -132,6 +132,7 @@ impl Document { inks: Default::default(), stroke_styles: vec![], articles: vec![], + xml: Default::default(), endnote_options: Default::default(), endnote_story: None, sections: vec![Section { diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index f629bac..fb4090e 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -712,6 +712,9 @@ pub struct Item { /// Alternative text (Object Export Options) for tagged PDF and EPUB. #[serde(default, skip_serializing_if = "String::is_empty")] pub alt_text: String, + /// XML tag (Tags panel); its content is the element's content in XML export/import. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub xml_tag: String, /// Groups: Isolate Blending (blend modes only within the group) and Knockout Group (the /// group's objects don't show through each other). #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -751,6 +754,7 @@ impl Item { overrides: None, label: String::new(), alt_text: String::new(), + xml_tag: String::new(), isolate: false, knockout: false, } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0180371..d4b9976 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -228,6 +228,29 @@ impl InkManager { } } +/// XML tags and mappings. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct XmlSettings { + pub root: String, + pub tags: Vec, + /// Paragraph style → tag: tagged frames' paragraphs become elements of that name. + pub style_map: Vec<(String, String)>, +} + +impl XmlSettings { + pub fn is_empty(&self) -> bool { + self.tags.is_empty() && self.style_map.is_empty() && self.root.is_empty() + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct XmlTag { + pub name: String, + pub color: [u8; 3], +} + /// An article: objects in reading order; `export` includes it when exporting. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -418,6 +441,9 @@ pub struct Document { /// the top level. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub color_groups: Vec, + /// XML: the tags (Tags panel) and the paragraph style → tag map (Map Styles to Tags). + #[serde(default, skip_serializing_if = "XmlSettings::is_empty")] + pub xml: XmlSettings, /// Articles panel: named reading-order lists of objects (EPUB / HTML export order). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub articles: Vec
, diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index a598e8c..aed43dc 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -26,6 +26,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } regex = { workspace = true } +quick-xml = { workspace = true } [dev-dependencies] zip = { workspace = true } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b6f7552..d779e6d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -41,6 +41,7 @@ pub mod table; pub mod text; mod toc; mod variables; +mod xml; mod xref; use designcraft_doc::{ItemId, SpreadRef}; @@ -148,6 +149,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(conditions::specs()); v.extend(library::specs()); v.extend(lists::specs()); + v.extend(xml::specs()); v.extend(strokes::specs()); v.extend(path_type::specs()); v.extend(qr::specs()); diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs new file mode 100644 index 0000000..865e4a0 --- /dev/null +++ b/crates/engine/src/cmd/xml.rs @@ -0,0 +1,381 @@ +//! Structure and XML: tags on frames (Tags panel), paragraph styles mapped to tags, the structure +//! tree, XML export and import (content flows into the frames with matching tags). + +use designcraft_doc::{Content, Document, Item, ItemId, ParaFormat, Story, StoryId, XmlTag}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, ids_param, ok, str_param}; +use crate::{EngineError, Result, Session}; + +const COLORS: [[u8; 3]; 6] = [[230, 120, 40], [60, 140, 220], [70, 170, 90], [200, 70, 160], [150, 110, 220], [200, 170, 40]]; + +fn root_name(d: &Document) -> String { + if d.xml.root.is_empty() { "Root".into() } else { d.xml.root.clone() } +} + +fn valid_name(n: &str) -> bool { + let mut c = n.chars(); + c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && n.chars().all(|ch| ch.is_alphanumeric() || matches!(ch, '_' | '-' | '.')) +} + +/// Tagged objects in reading order (pages, then top to bottom, left to right), nested ones included. +fn tagged(d: &Document) -> Vec { + let mut keyed: Vec<((usize, i64, i64), ItemId)> = Vec::new(); + for (si, sp) in d.spreads.iter().enumerate() { + let first = d.first_page_of_spread(si); + for top in &sp.items { + top.walk(&mut |it: &Item| { + if !it.xml_tag.is_empty() { + let b = it.bounds(); + let page = first + sp.page_at_x(b.center().x).unwrap_or(0); + keyed.push(((page, (b.y0 * 10.0) as i64, (b.x0 * 10.0) as i64), it.id)); + } + }); + } + } + keyed.sort_by_key(|(k, _)| *k); + keyed.into_iter().map(|(_, i)| i).collect() +} + +fn esc(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// Plain text of a story (markers dropped, forced breaks as newlines). +fn plain(t: &str) -> String { + t.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect() +} + +/// The document's tagged content as XML. +pub fn export_xml(d: &Document) -> String { + let mut out = format!("\n<{}>\n", root_name(d)); + for id in tagged(d) { + let Some(it) = d.item(id) else { continue }; + let tag = &it.xml_tag; + match &it.content { + Content::Text(tf) => { + let Some(st) = d.story(tf.story) else { continue }; + let mapped = |style: &str| d.xml.style_map.iter().find(|(s, _)| s == style).map(|(_, t)| t.clone()); + let paras: Vec<(Option, String)> = + st.para_ranges().into_iter().enumerate().map(|(i, r)| (mapped(&st.paras[i].style), plain(&st.text[r]))).collect(); + if paras.iter().any(|(m, _)| m.is_some()) { + out.push_str(&format!(" <{tag}>\n")); + for (m, text) in paras { + match m { + Some(t) => out.push_str(&format!(" <{t}>{}\n", esc(&text))), + None => out.push_str(&format!(" {}\n", esc(&text))), + } + } + out.push_str(&format!(" \n")); + } else { + out.push_str(&format!(" <{tag}>{}\n", esc(¶s.into_iter().map(|(_, t)| t).collect::>().join("\n")))); + } + } + Content::Graphic(g) => { + let name = d.assets.get(&g.asset).map(|a| a.link.clone().unwrap_or_else(|| a.name.clone())).unwrap_or_default(); + out.push_str(&format!(" <{tag} href=\"file://{}\"/>\n", esc(&name))); + } + _ => out.push_str(&format!(" <{tag}/>\n")), + } + } + out.push_str(&format!("\n", root_name(d))); + out +} + +/// One element under the root: its name and either text or (child name, text) paragraphs. +struct Element { + name: String, + text: String, + children: Vec<(String, String)>, +} + +fn parse(xml: &str) -> std::result::Result, String> { + use quick_xml::events::Event; + let mut r = quick_xml::Reader::from_str(xml); + let mut depth = 0usize; + let mut out: Vec = Vec::new(); + let mut child: Option<(String, String)> = None; + loop { + match r.read_event().map_err(|e| e.to_string())? { + Event::Start(e) => { + depth += 1; + let name = String::from_utf8_lossy(e.local_name().as_ref()).to_string(); + match depth { + 2 => out.push(Element { name, text: String::new(), children: vec![] }), + 3 => child = Some((name, String::new())), + _ => {} + } + } + Event::Empty(e) if depth == 1 => { + out.push(Element { name: String::from_utf8_lossy(e.local_name().as_ref()).to_string(), text: String::new(), children: vec![] }); + } + ev @ (Event::Text(_) | Event::GeneralRef(_) | Event::CData(_)) => { + let s = match ev { + Event::Text(t) => t.decode().map_err(|e| e.to_string())?.to_string(), + Event::CData(t) => String::from_utf8_lossy(&t).to_string(), + Event::GeneralRef(g) => match g.resolve_char_ref().map_err(|e| e.to_string())? { + Some(c) => c.to_string(), + None => match g.decode().map_err(|e| e.to_string())?.as_ref() { + "amp" => "&".into(), + "lt" => "<".into(), + "gt" => ">".into(), + "quot" => "\"".into(), + "apos" => "'".into(), + _ => String::new(), + }, + }, + _ => unreachable!(), + }; + match (depth, child.as_mut(), out.last_mut()) { + (3, Some(c), _) => c.1.push_str(&s), + (2, _, Some(el)) => el.text.push_str(&s), + _ => {} + } + } + Event::End(_) => { + if depth == 3 + && let (Some(c), Some(el)) = (child.take(), out.last_mut()) + { + el.children.push(c); + } + depth = depth.saturating_sub(1); + } + Event::Eof => break, + _ => {} + } + } + Ok(out) +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "xml.tags", "Tags", [], None, "{} → {root, tags: [{name, color}], styleMap: [[style, tag]]}", has_doc, |s, _| { + let d = &s.doc()?.doc; + Ok(json!({"root": root_name(d), "tags": d.xml.tags, "styleMap": d.xml.style_map})) + }), + cmd!("xml.newTag", "New Tag", ["Window", "Utilities", "Tags"], None, "{name, color?: [r, g, b]}", has_doc, |s, p| { + let name = str_param(p, "name").unwrap_or("").trim().to_string(); + if !valid_name(&name) { + return Err(bad("xml.newTag", format!("`{name}` isn't a valid XML name"))); + } + s.edit(|d, _| { + if !d.xml.tags.iter().any(|t| t.name == name) { + let color = COLORS[d.xml.tags.len() % COLORS.len()]; + d.xml.tags.push(XmlTag { name: name.clone(), color }); + } + Ok(json!({"name": name})) + }) + }), + cmd!("xml.deleteTag", "Delete Tag", [], None, "{name} — tagged objects are untagged", has_doc, |s, p| { + let name = str_param(p, "name").unwrap_or("").to_string(); + let ids: Vec = { + let d = &s.doc()?.doc; + tagged(d).into_iter().filter(|i| d.item(*i).is_some_and(|it| it.xml_tag == name)).collect() + }; + s.edit(|d, _| { + d.xml.tags.retain(|t| t.name != name); + d.xml.style_map.retain(|(_, t)| *t != name); + for id in &ids { + if let Some(it) = d.item_mut(*id) { + it.xml_tag.clear(); + } + } + ok() + }) + }), + cmd!("xml.tag", "Tag Frame", [], None, "{tag (made if new), ids? (default: the selection)} — `tag: null` untags", has_doc, |s, p| { + let tag = str_param(p, "tag").map(str::to_string); + if let Some(t) = &tag + && !valid_name(t) + { + return Err(bad("xml.tag", format!("`{t}` isn't a valid XML name"))); + } + let ids = ids_param(p, "ids").unwrap_or_else(|| s.active().map(|d| d.selection.items.clone()).unwrap_or_default()); + if ids.is_empty() { + return Err(bad("xml.tag", "select objects to tag")); + } + s.edit(|d, _| { + if let Some(t) = &tag + && !d.xml.tags.iter().any(|x| x.name == *t) + { + let color = COLORS[d.xml.tags.len() % COLORS.len()]; + d.xml.tags.push(XmlTag { name: t.clone(), color }); + } + for id in &ids { + if let Some(it) = d.item_mut(*id) { + it.xml_tag = tag.clone().unwrap_or_default(); + } + } + ok() + }) + }), + cmd!( + "xml.mapStyle", + "Map Styles to Tags", + [], + None, + "{style: paragraph style, tag | null} — that style's paragraphs export as `` elements and import back with it", + has_doc, + |s, p| { + let style = str_param(p, "style").unwrap_or("").to_string(); + let tag = str_param(p, "tag").map(str::to_string); + if s.doc()?.doc.styles.para(&style).is_none() { + return Err(bad("xml.mapStyle", format!("no paragraph style `{style}`"))); + } + s.edit(|d, _| { + d.xml.style_map.retain(|(st, _)| *st != style); + if let Some(t) = tag { + if !d.xml.tags.iter().any(|x| x.name == t) { + let color = COLORS[d.xml.tags.len() % COLORS.len()]; + d.xml.tags.push(XmlTag { name: t.clone(), color }); + } + d.xml.style_map.push((style.clone(), t)); + } + ok() + }) + } + ), + cmd!(query "xml.structure", "Structure", [], None, "{} → {root, elements: [{tag, id, kind, text?}]} in reading order", has_doc, |s, _| { + let d = &s.doc()?.doc; + let elements: Vec = tagged(d) + .into_iter() + .filter_map(|id| d.item(id)) + .map(|it| { + let (kind, text) = match &it.content { + Content::Text(tf) => ("text", d.story(tf.story).map(|st| plain(&st.text))), + Content::Graphic(_) => ("graphic", None), + _ => ("object", None), + }; + json!({"tag": it.xml_tag, "id": it.id.0, "kind": kind, "text": text}) + }) + .collect(); + Ok(json!({"root": root_name(d), "elements": elements})) + }), + cmd!(noundo "file.exportXml", "Export XML…", ["File"], None, "{path?} — the tagged content in reading order → {path, bytes} (no path: {text})", has_doc, |s, p| { + let text = export_xml(&s.doc()?.doc); + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "bytes": text.len()})) + } + None => Ok(json!({"text": text, "bytes": text.len()})), + } + }), + cmd!( + "file.importXml", + "Import XML…", + ["File"], + None, + "{path | text} — each element's content goes into the next text frame with that tag (child elements of mapped tags become paragraphs in their style) → {placed, unmatched}", + has_doc, + import_xml + ), + ] +} + +fn import_xml(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.importXml"; + let text = match (str_param(p, "text"), str_param(p, "path")) { + (Some(t), _) => t.to_string(), + (None, Some(path)) => { + #[cfg(not(target_arch = "wasm32"))] + let t = std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + #[cfg(target_arch = "wasm32")] + let t = String::new(); + t + } + _ => return Err(bad(ID, "`path` or `text` required")), + }; + let elements = parse(&text).map_err(|e| bad(ID, format!("not well-formed XML: {e}")))?; + let d = &s.doc()?.doc; + // Frames by tag, in reading order; each element takes the next free one. + let mut slots: Vec<(String, StoryId, bool)> = + tagged(d).into_iter().filter_map(|id| d.item(id)).filter_map(|it| it.text_frame().map(|tf| (it.xml_tag.clone(), tf.story, false))).collect(); + let reverse: Vec<(String, String)> = d.xml.style_map.iter().map(|(st, t)| (t.clone(), st.clone())).collect(); + let mut fills: Vec<(StoryId, Vec<(Option, String)>)> = Vec::new(); + let mut unmatched = 0; + for el in elements { + let Some(slot) = slots.iter_mut().find(|(t, _, used)| *t == el.name && !used) else { + unmatched += 1; + continue; + }; + slot.2 = true; + let paras: Vec<(Option, String)> = if el.children.is_empty() { + el.text.trim().split('\n').map(|l| (None, l.trim().to_string())).collect() + } else { + el.children + .into_iter() + .map(|(t, txt)| (reverse.iter().find(|(tag, _)| *tag == t).map(|(_, st)| st.clone()), txt.trim().to_string())) + .collect() + }; + fills.push((slot.1, paras)); + } + let placed = fills.len(); + s.edit(|d, _| { + for (sid, paras) in fills { + let Some(st) = d.story_mut(sid) else { continue }; + let base = st.paras.first().cloned().unwrap_or_default(); + let text = paras.iter().map(|(_, t)| t.as_str()).collect::>().join("\n"); + let frames = std::mem::take(&mut st.frames); + let mut fresh = Story::with_text(sid, &text, ParaFormat { table: None, ..base.clone() }); + for (i, (style, _)) in paras.iter().enumerate() { + if let (Some(sty), Some(pf)) = (style, fresh.paras.get_mut(i)) { + pf.style = sty.clone(); + } + } + fresh.frames = frames; + fresh.rev = st.rev + 1; + *st = fresh; + } + Ok(json!({"placed": placed, "unmatched": unmatched})) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn tag_export_and_import_xml() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + let a = s + .execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "Title A\nBody & more", "caret": false})) + .unwrap(); + s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [72, 300, 400, 350], "content": "text", "text": "Caption", "caret": false})).unwrap(); + s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap(); + s.execute("xml.tag", &json!({"tag": "caption", "ids": [b["id"]]})).unwrap(); + s.execute("xml.mapStyle", &json!({"style": "Head", "tag": "title"})).unwrap(); + let x = s.execute("file.exportXml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!( + x.contains("") + && x.contains("Title A") + && x.contains("Body & more") + && x.contains("Caption"), + "{x}" + ); + assert!(x.find("").unwrap() < x.find("").unwrap(), "reading order"); + let st = s.execute("xml.structure", &json!({})).unwrap(); + assert_eq!(st["elements"].as_array().unwrap().len(), 2); + // Import new content into the same structure. + let r = s + .execute( + "file.importXml", + &json!({"text": "New title

New body

New caption
"}), + ) + .unwrap(); + assert_eq!((r["placed"].as_u64(), r["unmatched"].as_u64()), (Some(2), Some(1))); + let d = &s.doc().unwrap().doc; + let sa = d.stories[&designcraft_doc::StoryId(a["story"].as_u64().unwrap())].clone(); + assert_eq!(sa.text, "New title\nNew body"); + assert_eq!(sa.paras[0].style, "Head", "mapped tag → paragraph style"); + assert_eq!(d.stories[&designcraft_doc::StoryId(b["story"].as_u64().unwrap())].text, "New caption"); + assert!(s.execute("xml.tag", &json!({"tag": "1bad", "ids": [b["id"]]})).is_err()); + } +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index b5ed5f9..ff9459e 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2091,6 +2091,7 @@ impl<'r> Importer<'r> { inks: std::mem::take(&mut self.inks), stroke_styles: std::mem::take(&mut self.stroke_styles), articles: vec![], + xml: Default::default(), endnote_options: Default::default(), endnote_story: None, sections: std::mem::take(&mut self.sections), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d39ee55..c69a9c0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -609,6 +609,23 @@ fn draw_frames(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document for it in &sp.items { draw_item_edges(painter, xf, doc, it, a); } + // Tagged frames in their tag's colour. + if app.ui.tagged_frames { + for top in &sp.items { + top.walk(&mut |it: &Item| { + if it.xml_tag.is_empty() { + return; + } + let Some(t) = doc.xml.tags.iter().find(|t| t.name == it.xml_tag) else { return }; + let col = Color32::from_rgb(t.color[0], t.color[1], t.color[2]); + for poly in path_screen(it, xf, a) { + for w in poly.windows(2) { + painter.line_segment([w[0], w[1]], Stroke::new(2.5, col)); + } + } + }); + } + } } } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index b2f21e7..78789d0 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -36,6 +36,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("hyperlinks", "Hyperlinks", "panel-hyperlinks"), ("bookmarks", "Bookmarks", "panel-bookmarks"), ("articles", "Articles", "panel-articles"), + ("tags", "Tags", "panel-tags"), ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -177,6 +178,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "hyperlinks" => panels::interactive::hyperlinks(app, ui), "bookmarks" => panels::interactive::bookmarks(app, ui), "articles" => panels::interactive::articles(app, ui), + "tags" => panels::interactive::tags(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 6cce89c..9d6b32c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -316,6 +316,12 @@ 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-tags" => { + // Angle brackets. + pen.line(&[(7.0, 5.0), (3.0, 10.0), (7.0, 15.0)]); + pen.line(&[(13.0, 5.0), (17.0, 10.0), (13.0, 15.0)]); + pen.line(&[(11.0, 4.0), (9.0, 16.0)]); + } "panel-articles" => { // Numbered blocks in order. pen.rect(3.0, 3.0, 11.0, 8.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d235a24..36793f2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -97,6 +97,8 @@ pub struct UiState { pub document_grid: bool, pub text_threads: bool, pub hidden_characters: bool, + /// View › Structure › Show Tagged Frames. + pub tagged_frames: bool, /// View › Display Performance. pub display_quality: designcraft_render::DisplayQuality, /// Preferences › Interface › UI scaling (1 = 100%). @@ -156,6 +158,7 @@ impl Default for UiState { document_grid: false, text_threads: false, hidden_characters: false, + tagged_frames: false, display_quality: designcraft_render::DisplayQuality::High, ui_scale: 1.0, rich_black: false, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c26ebec..9ab8076 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -19,6 +19,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), ("app.exportEpub", "Export EPUB…", None, "{path?} — reflowable EPUB 3"), ("app.exportHtml", "Export HTML…", None, "{path?} — one self-contained web page"), + ("app.exportXml", "Export XML…", None, "{path?} — the tagged content"), ("app.printBooklet", "Print Booklet…", None, "{path?} — saddle-stitched printer spreads as PDF"), ("app.qrCode", "Generate QR Code…", None, "{} — the QR Code dialog (object.qrCode does the work)"), ("app.userDictionary", "User Dictionary…", None, "{} — hyphenation exceptions (hyphenation.* commands)"), @@ -59,6 +60,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.baselineGrid", "Show/Hide Baseline Grid", Some("Cmd+Alt+'"), "{}"), ("view.textThreads", "Show/Hide Text Threads", Some("Cmd+Alt+Y"), "{}"), ("view.hiddenCharacters", "Show/Hide Hidden Characters", Some("Cmd+Alt+I"), "{}"), + ("view.taggedFrames", "Show/Hide Tagged Frames", None, "{} — XML-tagged frames outlined in their tag colour"), ("view.overprintPreview", "Overprint Preview", Some("Cmd+Alt+Shift+Y"), "{} — show how overprinting inks mix"), ("view.fastDisplay", "Fast Display", Some("Cmd+Alt+Shift+Z"), "{} — placed graphics as grey boxes, no effects"), ("view.typicalDisplay", "Typical Display", Some("Cmd+Alt+Z"), "{} — low-resolution image proxies"), @@ -118,6 +120,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.exportIdml", "ui:app.exportEpub", "ui:app.exportHtml", + "ui:app.exportXml", "ui:app.exportText", ">Export", "cmd:snippet.export", @@ -285,6 +288,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.sizeDown", "-", "ui:view.hiddenCharacters", + "ui:view.taggedFrames", "-", ">Hyperlinks & Cross-References", "cmd:hyperlink.create", @@ -495,6 +499,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Hyperlinks|{\"panel\": \"hyperlinks\"}", "ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}", "ui:window.panel|Articles|{\"panel\": \"articles\"}", + "ui:window.panel|Tags|{\"panel\": \"tags\"}", "<", ">Utilities", "cmd:data.merge", @@ -584,6 +589,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option export_idml(app, p), "app.exportEpub" => export_bytes(app, p, "epub", "file.exportEpub"), "app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"), + "app.exportXml" => export_bytes(app, p, "xml", "file.exportXml"), "app.printBooklet" => export_bytes(app, p, "pdf", "file.printBooklet"), "app.fittingOptions" => { let g = app.session.active().and_then(|d| d.selection.items.first().and_then(|id| d.doc.item(*id)).and_then(|it| it.graphic().cloned())); @@ -834,6 +840,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.baseline_grid), "view.textThreads" => flag(&mut app.ui.text_threads), "view.hiddenCharacters" => flag(&mut app.ui.hidden_characters), + "view.taggedFrames" => flag(&mut app.ui.tagged_frames), "view.goToPage" => { match p.get("page") { Some(Value::Number(n)) => crate::canvas::go_to_page(app, (n.as_u64().unwrap_or(1) as usize).saturating_sub(1)), diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 8c86475..7b37c32 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -113,3 +113,67 @@ pub fn articles(app: &mut DesignApp, ui: &mut egui::Ui) { }); } } + +/// Tags panel and structure: tags (click to tag the selection), the structure in reading order, +/// XML export/import. +pub fn tags(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let Ok(info) = app.session.execute("xml.tags", &json!({})) else { return }; + let has_sel = app.session.active().is_some_and(|d| !d.selection.items.is_empty()); + let id = egui::Id::new("new_tag_name"); + let mut name: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut name).hint_text("New tag").desired_width(140.0)); + if ui.button("New").clicked() && !name.trim().is_empty() { + match app.run("xml.newTag", json!({"name": name.trim()})) { + Ok(_) => name.clear(), + Err(e) => app.status(format!("Tags: {e}")), + } + } + }); + ui.data_mut(|d| d.insert_temp(id, name)); + for tag in info["tags"].as_array().cloned().unwrap_or_default() { + let n = tag["name"].as_str().unwrap_or("").to_string(); + let c = tag["color"].as_array().map(|a| a.iter().map(|v| v.as_u64().unwrap_or(0) as u8).collect::>()).unwrap_or_default(); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover()); + if c.len() == 3 { + ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2])); + } + if ui.add_enabled(has_sel, egui::Button::new(&n).frame(false)).on_hover_text("Tag the selection").clicked() { + let _ = app.run("xml.tag", json!({"tag": n})); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Delete").clicked() { + let _ = app.run("xml.deleteTag", json!({"name": n})); + } + }); + }); + } + if has_sel && ui.small_button("Untag Selection").clicked() { + let _ = app.run("xml.tag", json!({"tag": null})); + } + ui.separator(); + ui.label(egui::RichText::new("Structure").strong()); + let st = app.session.execute("xml.structure", &json!({})).unwrap_or_default(); + ui.label(egui::RichText::new(st["root"].as_str().unwrap_or("Root")).size(11.0).color(t.text_dim)); + for el in st["elements"].as_array().cloned().unwrap_or_default() { + let text = el["text"].as_str().unwrap_or("").chars().take(40).collect::(); + let label = format!(" <{}> {}", el["tag"].as_str().unwrap_or(""), text); + if ui.selectable_label(false, egui::RichText::new(label).size(11.0)).clicked() { + let _ = app.run("selection.set", json!({"ids": [el["id"]]})); + } + } + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Export XML…").clicked() { + let _ = app.run("app.exportXml", json!({})); + } + if ui.button("Import XML…").clicked() + && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("xml")) + && let Err(e) = app.run("file.importXml", json!({"path": path})) + { + app.status(format!("Import XML: {e}")); + } + }); +} diff --git a/docs/parity.md b/docs/parity.md index 00cc10c..f17b652 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 | 27 | 14 | 35 | 45% | +| P2 | 76 | 27 | 18 | 31 | 47% | | Area | Parity | |---|---| @@ -29,9 +29,9 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Tables | 92% | | Long documents | 88% | | Interactivity & digital | 75% | -| Output & production | 75% | -| XML & automation | 61% | -| View & navigation | 86% | +| Output & production | 78% | +| XML & automation | 72% | +| View & navigation | 88% | | Undo, history, saving | 100% | | Accessibility | 70% | @@ -286,10 +286,10 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Output & production | Export IDML | P0 | D | | Output & production | Export EPUB/HTML | P2 | P | | Output & production | Export RTF/TXT/Tagged text | P2 | P | -| Output & production | Export XML | P2 | M | +| Output & production | Export XML | P2 | P | | Output & production | Separations / Flattener / Ink limit preview | P2 | M | -| XML & automation | Tags panel, Structure | P2 | M | -| XML & automation | Import/Export XML | P2 | M | +| XML & automation | Tags panel, Structure | P2 | P | +| XML & automation | Import/Export XML | P2 | P | | XML & automation | Data Merge | P2 | D | | XML & automation | Scripts panel | P2 | P | | XML & automation | Script Label | P2 | D | @@ -303,7 +303,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | View & navigation | Overprint preview | P1 | D | | View & navigation | Rulers, guides, grids toggles | P0 | D | | View & navigation | Show/hide frame edges, threads, hidden chars | P0 | D | -| View & navigation | Structure / tag markers | P2 | M | +| View & navigation | Structure / tag markers | P2 | P | | View & navigation | Display performance | P0 | D | | View & navigation | Navigate pages | P0 | D | | View & navigation | Split window | P2 | M |