X: DTDs — load, validate the structure, Tags panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 00:57:18 -04:00
parent fe40d4b80b
commit f888e6b061
7 changed files with 602 additions and 12 deletions

View File

@ -75,7 +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 (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too; DTD validation isn't there yet.
- 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 (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too. DTDs (`xml.loadDtd`, `xml.validate`, `xml.deleteDtd`; Tags panel): loading one adds its elements as tags and names the root; validation checks content models (sequences, choices, `?*+`, mixed content, EMPTY/ANY, parameter entities) and required attributes, and lists each problem with its element path.
- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position.
- Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it.
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.

View File

@ -236,11 +236,14 @@ pub struct XmlSettings {
pub tags: Vec<XmlTag>,
/// Paragraph style → tag: tagged frames' paragraphs become elements of that name.
pub style_map: Vec<(String, String)>,
/// The loaded Document Type Definition (its text), for validation.
#[serde(skip_serializing_if = "String::is_empty")]
pub dtd: String,
}
impl XmlSettings {
pub fn is_empty(&self) -> bool {
self.tags.is_empty() && self.style_map.is_empty() && self.root.is_empty()
self.tags.is_empty() && self.style_map.is_empty() && self.root.is_empty() && self.dtd.is_empty()
}
}

View File

@ -177,9 +177,9 @@ fn parse(xml: &str) -> std::result::Result<Vec<Element>, String> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "xml.tags", "Tags", [], None, "{} → {root, tags: [{name, color}], styleMap: [[style, tag]]}", has_doc, |s, _| {
cmd!(query "xml.tags", "Tags", [], None, "{} → {root, tags: [{name, color}], styleMap: [[style, tag]], dtd: bool}", has_doc, |s, _| {
let d = &s.doc()?.doc;
Ok(json!({"root": root_name(d), "tags": d.xml.tags, "styleMap": d.xml.style_map}))
Ok(json!({"root": root_name(d), "tags": d.xml.tags, "styleMap": d.xml.style_map, "dtd": !d.xml.dtd.is_empty()}))
}),
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();
@ -311,6 +311,62 @@ pub fn specs() -> Vec<CommandSpec> {
None => Ok(json!({"text": text, "bytes": text.len()})),
}
}),
cmd!(
"xml.loadDtd",
"Load DTD…",
["Window", "Utilities", "Tags"],
None,
"{path | text} — keep the DTD for validation and add a tag for each declared element; the root takes the first element's name unless already named → {elements}",
has_doc,
|s, p| {
let text = text_or_path(p, "xml.loadDtd")?;
let dtd = crate::dtd::parse(&text).map_err(|e| bad("xml.loadDtd", e))?;
let names: Vec<String> = dtd.element_names().map(str::to_string).collect();
s.edit(|d, _| {
for n in &names {
if valid_name(n) && !d.xml.tags.iter().any(|t| t.name == *n) {
let color = COLORS[d.xml.tags.len() % COLORS.len()];
d.xml.tags.push(XmlTag { name: n.clone(), color });
}
}
if d.xml.root.is_empty()
&& let Some(first) = names.first()
{
d.xml.root = first.clone();
}
d.xml.dtd = text.clone();
Ok(json!({"elements": names.len()}))
})
}
),
cmd!(
"xml.deleteDtd",
"Delete DTD",
[],
None,
"{}",
|s: &Session| { if s.doc().is_ok_and(|d| !d.doc.xml.dtd.is_empty()) { Ok(()) } else { Err("no DTD loaded".into()) } },
|s, _| s.edit(|d, _| {
d.xml.dtd.clear();
ok()
})
),
cmd!(
query "xml.validate",
"Validate from Root Element",
[],
None,
"{} — check the structure (as exported) against the loaded DTD → {valid, problems: [{path, message}]}",
|s: &Session| {
if s.doc().is_ok_and(|d| !d.doc.xml.dtd.is_empty()) { Ok(()) } else { Err("load a DTD first".into()) }
},
|s, _| {
let d = &s.doc()?.doc;
let dtd = crate::dtd::parse(&d.xml.dtd).map_err(|e| bad("xml.validate", e))?;
let problems = crate::dtd::validate(&dtd, &export_xml(d));
Ok(json!({"valid": problems.is_empty(), "problems": problems}))
}
),
cmd!(
"file.importXml",
"Import XML…",
@ -323,9 +379,9 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn import_xml(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "file.importXml";
let text = match (str_param(p, "text"), str_param(p, "path")) {
/// The `text` parameter, or the file at `path`.
fn text_or_path(p: &Value, id: &str) -> Result<String> {
Ok(match (str_param(p, "text"), str_param(p, "path")) {
(Some(t), _) => t.to_string(),
(None, Some(path)) => {
#[cfg(not(target_arch = "wasm32"))]
@ -337,8 +393,13 @@ fn import_xml(s: &mut Session, p: &Value) -> Result<Value> {
};
t
}
_ => return Err(bad(ID, "`path` or `text` required")),
};
_ => return Err(bad(id, "`path` or `text` required")),
})
}
fn import_xml(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "file.importXml";
let text = text_or_path(p, ID)?;
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.
@ -390,6 +451,30 @@ mod tests {
use crate::Session;
#[test]
fn dtd_loads_tags_and_validates_the_structure() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
assert!(s.execute("xml.validate", &json!({})).is_err(), "no DTD yet");
let dtd = "<!ELEMENT article (headline, story+)><!ELEMENT headline (#PCDATA)><!ELEMENT story (#PCDATA | term)*><!ELEMENT term (#PCDATA)>";
let r = s.execute("xml.loadDtd", &json!({"text": dtd})).unwrap();
assert_eq!(r["elements"], 4);
let tags = s.execute("xml.tags", &json!({})).unwrap();
assert_eq!(tags["root"], "article");
assert_eq!(tags["tags"].as_array().unwrap().len(), 4);
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "Body", "caret": false})).unwrap();
s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap();
let v = s.execute("xml.validate", &json!({})).unwrap();
assert_eq!(v["valid"], false);
assert!(v["problems"][0]["message"].as_str().unwrap().contains("the DTD asks for (headline, story+)"), "{v}");
let h = s.execute("frame.create", &json!({"rect": [72, 20, 400, 60], "content": "text", "text": "Head", "caret": false})).unwrap();
s.execute("xml.tag", &json!({"tag": "headline", "ids": [h["id"]]})).unwrap();
assert_eq!(s.execute("xml.validate", &json!({})).unwrap()["valid"], true);
assert!(s.execute("xml.loadDtd", &json!({"text": "<!ELEMENT a (b,|c)>"})).is_err());
s.execute("xml.deleteDtd", &json!({})).unwrap();
assert!(s.execute("xml.validate", &json!({})).is_err());
}
#[test]
fn tag_export_and_import_xml() {
let mut s = Session::new();

452
crates/engine/src/dtd.rs Normal file
View File

@ -0,0 +1,452 @@
//! Document Type Definitions: element declarations (content models) and required attributes, and
//! validation of an XML document against them (Structure ▸ Validate).
use std::collections::HashMap;
/// A content particle: a name, a sequence, a choice, or one of those repeated (`?`, `*`, `+`).
#[derive(Clone, Debug, PartialEq)]
enum Cp {
Name(String),
Seq(Vec<Cp>),
Choice(Vec<Cp>),
Rep(Box<Cp>, char),
}
#[derive(Clone, Debug, PartialEq)]
enum Model {
Empty,
Any,
/// `(#PCDATA | a | b)*`: text and these elements in any order.
Mixed(Vec<String>),
Children(Cp),
}
/// A parsed DTD.
#[derive(Clone, Debug, Default)]
pub struct Dtd {
elements: Vec<(String, Model)>,
/// Element → its `#REQUIRED` attributes.
required: HashMap<String, Vec<String>>,
}
/// A problem found by validation: where (an element path such as `Root/story[2]`) and what.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct Problem {
pub path: String,
pub message: String,
}
impl Dtd {
/// Declared element names, in order.
pub fn element_names(&self) -> impl Iterator<Item = &str> {
self.elements.iter().map(|(n, _)| n.as_str())
}
fn model(&self, name: &str) -> Option<&Model> {
self.elements.iter().find(|(n, _)| n == name).map(|(_, m)| m)
}
}
/// Markup declarations (`<!…>`) with comments removed and quoted strings kept whole.
fn declarations(src: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = src;
while let Some(i) = rest.find("<!") {
rest = &rest[i..];
if rest.starts_with("<!--") {
rest = rest.find("-->").map_or("", |j| &rest[j + 3..]);
continue;
}
let mut quote = None;
let mut end = rest.len();
for (k, c) in rest.char_indices().skip(2) {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(None, '"' | '\'') => quote = Some(c),
(None, '>') => {
end = k;
break;
}
_ => {}
}
}
out.push(rest[2..end].to_string());
rest = rest.get(end + 1..).unwrap_or("");
}
out
}
fn tokens(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for c in s.chars() {
if c.is_whitespace() || "(),|?*+".contains(c) {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
if !c.is_whitespace() {
out.push(c.to_string());
}
} else {
cur.push(c);
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
/// Parse a content particle from `t[*i..]`.
fn particle(t: &[String], i: &mut usize) -> Result<Cp, String> {
let base = match t.get(*i).map(String::as_str) {
Some("(") => {
*i += 1;
let mut items = vec![particle(t, i)?];
let mut sep = None;
loop {
match t.get(*i).map(String::as_str) {
Some(")") => {
*i += 1;
break;
}
Some(s @ ("," | "|")) => {
if sep.is_some_and(|p| p != s) {
return Err("mixed `,` and `|` in one group".into());
}
sep = Some(if s == "," { "," } else { "|" });
*i += 1;
items.push(particle(t, i)?);
}
other => return Err(format!("unexpected `{}` in a content model", other.unwrap_or("end"))),
}
}
if sep == Some("|") { Cp::Choice(items) } else { Cp::Seq(items) }
}
Some(n) if !"),|?*+".contains(n) => {
*i += 1;
Cp::Name(n.to_string())
}
other => return Err(format!("unexpected `{}` in a content model", other.unwrap_or("end"))),
};
Ok(match t.get(*i).map(String::as_str) {
Some(r @ ("?" | "*" | "+")) => {
*i += 1;
Cp::Rep(Box::new(base), r.chars().next().unwrap_or('*'))
}
_ => base,
})
}
fn parse_model(s: &str) -> Result<Model, String> {
let s = s.trim();
match s {
"EMPTY" => return Ok(Model::Empty),
"ANY" => return Ok(Model::Any),
_ => {}
}
if s.contains("#PCDATA") {
let t = tokens(&s.replace("#PCDATA", " #PCDATA "));
return Ok(Model::Mixed(t.into_iter().filter(|x| !"()|*".contains(x.as_str()) && x != "#PCDATA").collect()));
}
let t = tokens(s);
let mut i = 0;
let cp = particle(&t, &mut i)?;
if i != t.len() {
return Err(format!("unexpected `{}` after the content model", t[i]));
}
Ok(Model::Children(cp))
}
/// Parse a DTD (its element and attribute-list declarations; parameter entities are expanded).
pub fn parse(src: &str) -> Result<Dtd, String> {
// Parameter entities first, then expand references and read the declarations again.
let mut ents: Vec<(String, String)> = Vec::new();
for d in declarations(src) {
if let Some(rest) = d.strip_prefix("ENTITY") {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('%') {
let rest = rest.trim_start();
let name: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
let val = rest[name.len()..].trim();
let q = val.chars().next().unwrap_or('"');
if let Some(v) = val.strip_prefix(q).and_then(|v| v.find(q).map(|e| &v[..e])) {
ents.push((name, v.to_string()));
}
}
}
}
let mut text = src.to_string();
for _ in 0..8 {
let before = text.clone();
for (n, v) in &ents {
text = text.replace(&format!("%{n};"), v);
}
if text == before {
break;
}
}
let mut dtd = Dtd::default();
for d in declarations(&text) {
if let Some(rest) = d.strip_prefix("ELEMENT") {
let rest = rest.trim_start();
let name: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
let model = parse_model(&rest[name.len()..]).map_err(|e| format!("<!ELEMENT {name}>: {e}"))?;
dtd.elements.push((name, model));
} else if let Some(rest) = d.strip_prefix("ATTLIST") {
// name (attr type default)*; only `#REQUIRED` matters here.
let mut words = Vec::new();
let mut cur = String::new();
let mut quote = None;
let mut group = 0;
for c in rest.chars() {
match (quote, c) {
(Some(q), c) if c == q => {
quote = None;
cur.push(c);
}
(Some(_), c) => cur.push(c),
(None, '"' | '\'') => {
quote = Some(c);
cur.push(c);
}
(None, '(') => {
group += 1;
cur.push(c);
}
(None, ')') => {
group -= 1;
cur.push(c);
}
(None, c) if c.is_whitespace() && group == 0 => {
if !cur.is_empty() {
words.push(std::mem::take(&mut cur));
}
}
(None, c) => cur.push(c),
}
}
if !cur.is_empty() {
words.push(cur);
}
let Some((el, attrs)) = words.split_first() else { continue };
let mut k = 0;
while k + 2 < attrs.len() {
let name = &attrs[k];
let default = attrs.get(k + 2).map(String::as_str).unwrap_or("");
if default == "#REQUIRED" {
dtd.required.entry(el.clone()).or_default().push(name.clone());
}
k += if default == "#FIXED" { 4 } else { 3 };
}
}
}
if dtd.elements.is_empty() {
return Err("no element declarations".into());
}
Ok(dtd)
}
/// End positions after `cp` matches a prefix of `seq[i..]`.
fn ends(cp: &Cp, seq: &[&str], i: usize) -> Vec<usize> {
let mut out: Vec<usize> = match cp {
Cp::Name(n) => {
if seq.get(i) == Some(&n.as_str()) {
vec![i + 1]
} else {
vec![]
}
}
Cp::Seq(items) => items.iter().fold(vec![i], |at, c| at.into_iter().flat_map(|p| ends(c, seq, p)).collect()),
Cp::Choice(items) => items.iter().flat_map(|c| ends(c, seq, i)).collect(),
Cp::Rep(c, r) => {
let mut all = if *r == '+' { vec![] } else { vec![i] };
let mut frontier = ends(c, seq, i);
if *r == '?' {
all.extend(frontier);
} else {
while !frontier.is_empty() {
let new: Vec<usize> = frontier.iter().copied().filter(|p| !all.contains(p)).collect();
all.extend(&new);
frontier = new.into_iter().filter(|&p| p > i).flat_map(|p| ends(c, seq, p)).filter(|p| !all.contains(p)).collect();
}
}
all
}
};
out.sort_unstable();
out.dedup();
out
}
fn show(cp: &Cp) -> String {
match cp {
Cp::Name(n) => n.clone(),
Cp::Seq(v) => format!("({})", v.iter().map(show).collect::<Vec<_>>().join(", ")),
Cp::Choice(v) => format!("({})", v.iter().map(show).collect::<Vec<_>>().join(" | ")),
Cp::Rep(c, r) => format!("{}{r}", show(c)),
}
}
/// An element of the document being validated.
struct Node {
name: String,
attrs: Vec<String>,
children: Vec<Node>,
text: bool,
}
fn read_tree(xml: &str) -> Result<Node, String> {
use quick_xml::events::Event;
let mut r = quick_xml::Reader::from_str(xml);
let mut stack: Vec<Node> = Vec::new();
let node = |e: &quick_xml::events::BytesStart| Node {
name: String::from_utf8_lossy(e.name().as_ref()).to_string(),
attrs: e.attributes().flatten().map(|a| String::from_utf8_lossy(a.key.as_ref()).to_string()).collect(),
children: vec![],
text: false,
};
loop {
match r.read_event().map_err(|e| e.to_string())? {
Event::Start(e) => stack.push(node(&e)),
Event::Empty(e) => {
let n = node(&e);
match stack.last_mut() {
Some(p) => p.children.push(n),
None => return Ok(n),
}
}
Event::End(_) => {
let n = stack.pop().ok_or("unbalanced end tag")?;
match stack.last_mut() {
Some(p) => p.children.push(n),
None => return Ok(n),
}
}
Event::Text(t) => {
if let Some(p) = stack.last_mut()
&& !t.iter().all(u8::is_ascii_whitespace)
{
p.text = true;
}
}
Event::CData(_) | Event::GeneralRef(_) => {
if let Some(p) = stack.last_mut() {
p.text = true;
}
}
Event::Eof => return Err("no root element".into()),
_ => {}
}
}
}
fn check(dtd: &Dtd, n: &Node, path: &str, out: &mut Vec<Problem>) {
let mut problem = |m: String| out.push(Problem { path: path.to_string(), message: m });
match dtd.model(&n.name) {
None => problem(format!("`{}` isn't declared in the DTD", n.name)),
Some(Model::Any) => {}
Some(Model::Empty) => {
if n.text || !n.children.is_empty() {
problem(format!("`{}` should be empty", n.name));
}
}
Some(Model::Mixed(allowed)) => {
for c in &n.children {
if !allowed.contains(&c.name) {
problem(format!("`{}` isn't allowed in `{}`", c.name, n.name));
}
}
}
Some(Model::Children(cp)) => {
if n.text {
problem(format!("`{}` can't contain text", n.name));
}
let seq: Vec<&str> = n.children.iter().map(|c| c.name.as_str()).collect();
if !ends(cp, &seq, 0).contains(&seq.len()) {
let got = if seq.is_empty() { "nothing".to_string() } else { seq.join(", ") };
problem(format!("`{}` contains {got}; the DTD asks for {}", n.name, show(cp)));
}
}
}
for a in dtd.required.get(&n.name).into_iter().flatten() {
if !n.attrs.contains(a) {
problem(format!("`{}` is missing the required attribute `{a}`", n.name));
}
}
let mut seen: HashMap<&str, usize> = HashMap::new();
for c in &n.children {
let k = seen.entry(c.name.as_str()).or_default();
*k += 1;
check(dtd, c, &format!("{path}/{}[{k}]", c.name), out);
}
}
/// Validate `xml` against `dtd`; an empty list when it's valid.
pub fn validate(dtd: &Dtd, xml: &str) -> Vec<Problem> {
match read_tree(xml) {
Ok(root) => {
let mut out = Vec::new();
let path = root.name.clone();
check(dtd, &root, &path, &mut out);
out
}
Err(e) => vec![Problem { path: String::new(), message: format!("not well-formed XML: {e}") }],
}
}
#[cfg(test)]
mod tests {
use super::*;
const DTD: &str = r##"<!-- an article -->
<!ENTITY % inline "#PCDATA | em | term">
<!ELEMENT Root (story+, figure*)>
<!ELEMENT story (title, (para | list)*)>
<!ELEMENT title (%inline;)*>
<!ELEMENT para (%inline;)*>
<!ELEMENT list (para+)>
<!ELEMENT em (#PCDATA)>
<!ELEMENT term (#PCDATA)>
<!ELEMENT figure EMPTY>
<!ATTLIST figure href CDATA #REQUIRED alt CDATA "a > b" kind (photo|chart) "photo">"##;
#[test]
fn content_models_and_required_attributes() {
let dtd = parse(DTD).unwrap();
assert_eq!(dtd.element_names().count(), 8);
let ok = r#"<Root><story><title>T <em>x</em></title><para>a</para><list><para>b</para></list></story><figure href="a.png"/></Root>"#;
assert_eq!(validate(&dtd, ok), vec![]);
let bad = r#"<Root><story><para>no title</para></story><figure/><figure href="b"><em>x</em></figure><aside/></Root>"#;
let p = validate(&dtd, bad);
let msgs: Vec<String> = p.iter().map(|p| format!("{}: {}", p.path, p.message)).collect();
assert!(msgs.iter().any(|m| m.starts_with("Root/story[1]: `story` contains para; the DTD asks for (title, (para | list)*)")), "{msgs:?}");
assert!(msgs.iter().any(|m| m == "Root/figure[1]: `figure` is missing the required attribute `href`"), "{msgs:?}");
assert!(msgs.iter().any(|m| m == "Root/figure[2]: `figure` should be empty"), "{msgs:?}");
assert!(msgs.iter().any(|m| m == "Root/aside[1]: `aside` isn't declared in the DTD"), "{msgs:?}");
// The root's model fails too (aside isn't in it).
assert!(msgs.iter().any(|m| m.starts_with("Root: ")), "{msgs:?}");
// Text where only elements are allowed; an element not in a mixed model.
let p = validate(&dtd, "<Root><story>loose<title><list/></title></story></Root>");
assert!(p.iter().any(|p| p.message == "`story` can't contain text"), "{p:?}");
assert!(p.iter().any(|p| p.message == "`list` isn't allowed in `title`"), "{p:?}");
assert!(parse("<!ELEMENT a (b, c | d)>").is_err());
assert!(parse("just text").is_err());
}
#[test]
fn repetition_matches_like_a_regular_expression() {
let dtd = parse("<!ELEMENT r ((a, b?)+, c*)><!ELEMENT a EMPTY><!ELEMENT b EMPTY><!ELEMENT c EMPTY>").unwrap();
for (xml, ok) in [
("<r><a/></r>", true),
("<r><a/><b/><a/><c/><c/></r>", true),
("<r><a/><a/><b/></r>", true),
("<r><b/></r>", false),
("<r><a/><b/><b/></r>", false),
("<r><a/><c/><a/></r>", false),
("<r/>", false),
] {
assert_eq!(validate(&dtd, xml).is_empty(), ok, "{xml}");
}
}
}

View File

@ -7,6 +7,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod dtd;
pub mod links;
pub mod math;
pub mod recovery;

View File

@ -170,6 +170,55 @@ pub fn tags(app: &mut DesignApp, ui: &mut egui::Ui) {
let _ = app.run("selection.set", json!({"ids": [el["id"]]}));
}
}
// DTD: load, validate (problems listed until the next validation), delete.
let vid = egui::Id::new("dtd_problems");
let mut problems: Option<Vec<String>> = ui.data(|d| d.get_temp(vid));
ui.horizontal(|ui| {
if ui.button("Load DTD…").clicked()
&& let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("dtd"))
{
match app.run("xml.loadDtd", json!({"path": path})) {
Ok(_) => problems = None,
Err(e) => app.status(format!("Load DTD: {e}")),
}
}
let has_dtd = info["dtd"].as_bool().unwrap_or(false);
if ui.add_enabled(has_dtd, egui::Button::new("Validate")).clicked()
&& let Ok(v) = app.session.execute("xml.validate", &json!({}))
{
problems = Some(
v["problems"]
.as_array()
.into_iter()
.flatten()
.map(|p| format!("{}: {}", p["path"].as_str().unwrap_or(""), p["message"].as_str().unwrap_or("")))
.collect(),
);
}
if has_dtd && ui.small_button("Delete DTD").clicked() {
let _ = app.run("xml.deleteDtd", json!({}));
problems = None;
}
});
match &problems {
Some(list) if list.is_empty() => {
ui.label(egui::RichText::new("The structure is valid.").size(11.0).color(t.text_dim));
}
Some(list) => {
for p in list {
ui.label(egui::RichText::new(format!("⚠ {p}")).size(11.0));
}
}
None => {}
}
ui.data_mut(|d| match problems {
Some(p) => {
d.insert_temp(vid, p);
}
None => {
d.remove::<Vec<String>>(vid);
}
});
ui.separator();
ui.horizontal(|ui| {
if ui.button("Export XML…").clicked() {

View File

@ -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 | 96 | 1 | 0 | 99% |
| P2 | 76 | 70 | 6 | 0 | 96% |
| P2 | 76 | 71 | 5 | 0 | 97% |
| Area | Parity |
|---|---|
@ -30,7 +30,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Long documents | 100% |
| Interactivity & digital | 100% |
| Output & production | 92% |
| XML & automation | 94% |
| XML & automation | 100% |
| View & navigation | 100% |
| Undo, history, saving | 100% |
| Accessibility | 100% |
@ -289,7 +289,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
| Output & production | Export XML | P2 | D |
| Output & production | Separations / Flattener / Ink limit preview | P2 | D |
| XML & automation | Tags panel, Structure | P2 | D |
| XML & automation | Import/Export XML | P2 | P |
| XML & automation | Import/Export XML | P2 | D |
| XML & automation | Data Merge | P2 | D |
| XML & automation | Scripts panel | P2 | D |
| XML & automation | Script Label | P2 | D |