From 2769d0b88f69b4b37bc1b1daa93e0f6fda827f07 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 04:13:55 -0500 Subject: [PATCH] Move DTD and raster encoding to craft-libs --- Cargo.lock | 69 ++-- Cargo.toml | 9 +- crates/engine/Cargo.toml | 2 +- crates/engine/src/dtd.rs | 747 +-------------------------------------- crates/images/Cargo.toml | 1 + crates/mcp/Cargo.toml | 2 +- crates/pdf/Cargo.toml | 1 + crates/pptx/Cargo.toml | 3 +- crates/pptx/src/slide.rs | 6 +- crates/render/Cargo.toml | 4 +- 10 files changed, 58 insertions(+), 786 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb8d919c4..18d8ca455 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,7 +1245,7 @@ dependencies = [ [[package]] name = "craft-image-codecs" version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=0d1854f1c8a5df0301604098c898bee16349151f#0d1854f1c8a5df0301604098c898bee16349151f" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43#f7d76eec548a77a79fd2e056e7846a8655ee3f43" dependencies = [ "craft-jpeg", "exr", @@ -1275,7 +1275,7 @@ dependencies = [ [[package]] name = "craft-jpeg" version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=0d1854f1c8a5df0301604098c898bee16349151f#0d1854f1c8a5df0301604098c898bee16349151f" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43#f7d76eec548a77a79fd2e056e7846a8655ee3f43" dependencies = [ "thiserror 2.0.21", ] @@ -1411,7 +1411,7 @@ dependencies = [ [[package]] name = "craft-psd" version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=0d1854f1c8a5df0301604098c898bee16349151f#0d1854f1c8a5df0301604098c898bee16349151f" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43#f7d76eec548a77a79fd2e056e7846a8655ee3f43" dependencies = [ "encoding_rs", "flate2", @@ -1433,21 +1433,6 @@ name = "craft-sheet" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" -[[package]] -name = "craft-svg" -version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=0d1854f1c8a5df0301604098c898bee16349151f#0d1854f1c8a5df0301604098c898bee16349151f" -dependencies = [ - "craft-image-codecs", - "craft-xml 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=0d1854f1c8a5df0301604098c898bee16349151f)", - "flate2", - "kurbo", - "resvg 0.48.1", - "roxmltree 0.21.2", - "thiserror 2.0.21", - "usvg 0.48.1", -] - [[package]] name = "craft-svg" version = "0.1.0" @@ -1473,6 +1458,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-svg" +version = "0.1.0" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43#f7d76eec548a77a79fd2e056e7846a8655ee3f43" +dependencies = [ + "craft-image-codecs", + "craft-xml 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43)", + "flate2", + "kurbo", + "resvg 0.48.1", + "roxmltree 0.21.2", + "thiserror 2.0.21", + "usvg 0.48.1", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1566,15 +1566,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "craft-xml" -version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=0d1854f1c8a5df0301604098c898bee16349151f#0d1854f1c8a5df0301604098c898bee16349151f" -dependencies = [ - "quick-xml 0.41.0", - "thiserror 2.0.21", -] - [[package]] name = "craft-xml" version = "0.1.0" @@ -1602,6 +1593,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f23b0f33f0d00b157476533a43cbbcfd901aa1f1#f23b0f33f0d00b157476533a43cbbcfd901aa1f1" +dependencies = [ + "quick-xml 0.41.0", + "serde", + "thiserror 2.0.21", +] + [[package]] name = "craft-xml" version = "0.1.0" @@ -1611,6 +1612,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43#f7d76eec548a77a79fd2e056e7846a8655ee3f43" +dependencies = [ + "quick-xml 0.41.0", + "thiserror 2.0.21", +] + [[package]] name = "craft-zip" version = "0.1.0" @@ -1885,6 +1895,7 @@ dependencies = [ "craft-prepress", "craft-print", "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf)", + "craft-xml 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=f23b0f33f0d00b157476533a43cbbcfd901aa1f1)", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -1904,7 +1915,6 @@ dependencies = [ "hayro-syntax", "kurbo", "log", - "quick-xml 0.41.0", "same-file", "serde", "serde_json", @@ -1989,7 +1999,7 @@ dependencies = [ "craft-file-formats", "craft-image-codecs", "craft-psd", - "craft-svg 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=0d1854f1c8a5df0301604098c898bee16349151f)", + "craft-svg 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43)", "designcraft-color", "designcraft-fonts", "flate2", @@ -2044,6 +2054,7 @@ name = "designcraft-pptx" version = "0.20.1" dependencies = [ "chrono", + "craft-image-codecs", "craft-opc 0.1.0 (git+https://github.com/storytold/craft-libs?rev=8abfa9d0f14bafbfc3449fc552ffeafb9ca6a551)", "craft-pptx", "designcraft-color", diff --git a/Cargo.toml b/Cargo.toml index e073298f2..a35967aca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,15 +18,16 @@ craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435 craft-epub = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-idml = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-indd = { git = "https://github.com/storytold/craft-libs", rev = "47bf5650b0d503b97bfb5e841746c4957897d11f", version = "=0.1.0" } -craft-svg = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "0d1854f1c8a5df0301604098c898bee16349151f", version = "=0.1.0", features = ["tree-render", "tree-text"] } +craft-svg = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "f7d76eec548a77a79fd2e056e7846a8655ee3f43", version = "=0.1.0", features = ["tree-render", "tree-text"] } craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-xml-dtd = { package = "craft-xml", git = "https://github.com/dylanmtaylor/craft-libs", rev = "f23b0f33f0d00b157476533a43cbbcfd901aa1f1", version = "=0.1.0" } craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-xlsx = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-docx = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-csv = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "44d7ac8d942dbd87048f82b3efc3e0d14483cc8d", version = "=0.1.0" } -craft-image-codecs = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "0d1854f1c8a5df0301604098c898bee16349151f", version = "=0.1.0" } -craft-psd = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "0d1854f1c8a5df0301604098c898bee16349151f", version = "=0.1.0" } +craft-image-codecs = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "f7d76eec548a77a79fd2e056e7846a8655ee3f43", version = "=0.1.0" } +craft-psd = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "f7d76eec548a77a79fd2e056e7846a8655ee3f43", version = "=0.1.0" } craft-file-formats = { git = "https://github.com/storytold/craft-libs", rev = "8abfa9d0f14bafbfc3449fc552ffeafb9ca6a551", version = "=0.1.0" } craft-opc = { git = "https://github.com/storytold/craft-libs", rev = "8abfa9d0f14bafbfc3449fc552ffeafb9ca6a551", version = "=0.1.0" } craft-pptx = { git = "https://github.com/storytold/craft-libs", rev = "f6cab1980", version = "=0.1.0" } @@ -81,7 +82,7 @@ skrifa = "0.47" harfrust = "0.13" vello_cpu = "0.2" vello_common = { version = "0.2", default-features = false } -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } +image = { version = "0.25", default-features = false } # CMYK TIFF samples for PDF export (`image` decodes them to RGB); the version `image` uses. tiff = "0.11" # Boolean operations on Bézier paths (Pathfinder). diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 57d196a62..6aa2e62df 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -33,8 +33,8 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } -quick-xml = { workspace = true } craft-xml = { workspace = true } +craft-xml-dtd = { workspace = true } caseless = { workspace = true } unicode-segmentation = { workspace = true } diff --git a/crates/engine/src/dtd.rs b/crates/engine/src/dtd.rs index 6b48ba3ab..6eda1fff5 100644 --- a/crates/engine/src/dtd.rs +++ b/crates/engine/src/dtd.rs @@ -1,746 +1,3 @@ -//! Document Type Definitions: element declarations (content models) and required attributes, and -//! validation of an XML document against them (Structure ▸ Validate). +//! XML DTD command adapter backed by craft-xml. -use std::collections::HashMap; - -/// How much text parameter entities may add during expansion (a pass count is not an output -/// bound: each pass replaces whole strings, so references can inflate multiplicatively). -const MAX_EXPANDED_BYTES: usize = 1024 * 1024; - -/// A content particle: a name, a sequence, a choice, or one of those repeated (`?`, `*`, `+`). -#[derive(Clone, Debug, PartialEq)] -enum Cp { - Name(String), - Seq(Vec), - Choice(Vec), - Rep(Box, char), -} - -#[derive(Clone, Debug, PartialEq)] -enum Model { - Empty, - Any, - /// `(#PCDATA | a | b)*`: text and these elements in any order. - Mixed(Vec), - Children(Cp), -} - -/// A parsed DTD. -#[derive(Clone, Debug, Default)] -pub struct Dtd { - elements: Vec<(String, Model)>, - /// Element → its `#REQUIRED` attributes. - required: HashMap>, -} - -/// 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 { - 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 { - let mut out = Vec::new(); - let mut rest = src; - while let Some(i) = 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 { - 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 -} - -/// How deeply groups may nest in a content model; parsing, matching (`ends`) and printing (`show`) -/// recurse once per level. -const MAX_CONTENT_MODEL_DEPTH: usize = 64; - -/// Parse a content particle from `t[*i..]`, inside `depth` enclosing groups. -fn particle(t: &[String], i: &mut usize, depth: usize) -> Result { - let base = match t.get(*i).map(String::as_str) { - Some("(") => { - if depth >= MAX_CONTENT_MODEL_DEPTH { - return Err(format!("content model nested more than {MAX_CONTENT_MODEL_DEPTH} levels")); - } - *i += 1; - let mut items = vec![particle(t, i, depth + 1)?]; - 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, depth + 1)?); - } - 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 { - 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, 0)?; - if i != t.len() { - return Err(format!("unexpected `{}` after the content model", t[i])); - } - Ok(Model::Children(cp)) -} - -/// How deeply parameter entities may nest: a reference in the DTD is level 1, a reference in its -/// value level 2, and so on. -const MAX_PARAMETER_ENTITY_DEPTH: usize = 64; - -/// Expand `%name;` references, one level per round, until a round changes nothing. -fn expand_parameter_entities(src: &str, ents: &[(String, String)]) -> Result { - let mut entities = HashMap::new(); - for (name, value) in ents { - entities.entry(name.as_str()).or_insert(value.as_str()); - } - let limit = src.len().saturating_add(MAX_EXPANDED_BYTES); - let append = |out: &mut String, value: &str| -> Result<(), String> { - if value.len() > limit.saturating_sub(out.len()) { - return Err(format!("parameter entities expand the DTD beyond {limit} bytes")); - } - out.push_str(value); - Ok(()) - }; - let too_deep = || format!("parameter entities refer to themselves or nest deeper than {MAX_PARAMETER_ENTITY_DEPTH} levels"); - let mut text = src.to_string(); - // The round after the deepest level changes nothing, hence one more round than levels. - for _ in 0..=MAX_PARAMETER_ENTITY_DEPTH { - let mut out = String::with_capacity(text.len()); - let mut rest = text.as_str(); - let mut changed = false; - while let Some(i) = rest.find('%') { - let (prefix, reference) = rest.split_at(i); - append(&mut out, prefix)?; - // `find` points to an ASCII `%`, so both split positions are UTF-8 boundaries. - rest = reference.split_at(1).1; - // A name has no whitespace and no `%` (`parse` ends a name at whitespace), whatever its - // length: the search for the closing `;` stops at the first of those, so the searches of - // a round never overlap and a round stays linear in the text. - let end = - rest.bytes().position(|b| matches!(b, b';' | b'%') || b.is_ascii_whitespace()).filter(|&k| rest.as_bytes().get(k) == Some(&b';')); - if let Some(end) = end - && let Some(value) = rest.get(..end).and_then(|name| entities.get(name)) - { - // A value that is its own reference (``) never gets anywhere. - if reference.get(..end + 2) == Some(*value) { - return Err(too_deep()); - } - append(&mut out, value)?; - changed = true; - rest = rest.split_at(end + 1).1; - continue; - } - append(&mut out, "%")?; - } - append(&mut out, rest)?; - text = out; - if !changed { - return Ok(text); - } - } - Err(too_deep()) -} - -/// Parse a DTD (its element and attribute-list declarations; parameter entities are expanded). -pub fn parse(src: &str) -> Result { - // 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 text = expand_parameter_entities(src, &ents)?; - 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!(": {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) -} - -/// What `ends` has worked out for one sequence of children: the end positions of a group (by the -/// address of its node, which lives as long as the model) from a position. -type EndsMemo = HashMap<(usize, usize), Vec>; - -/// End positions after `cp` matches a prefix of `seq[i..]`. -/// -/// The positions of each group are worked out once per start position (`memo`): nested repetitions -/// such as `((a*)*)*` ask the inner group for the same positions again and again, which is -/// exponential in their depth without it. -fn ends(cp: &Cp, seq: &[&str], i: usize, memo: &mut EndsMemo) -> Vec { - if let Cp::Name(n) = cp { - return if seq.get(i) == Some(&n.as_str()) { vec![i + 1] } else { vec![] }; - } - let key = (std::ptr::from_ref(cp) as usize, i); - if let Some(done) = memo.get(&key) { - return done.clone(); - } - let mut out: Vec = match cp { - Cp::Name(_) => vec![], - // Keep the positions after each item a set: as a list of every path, `(a?, a?, …)` doubles per item. - Cp::Seq(items) => { - let mut at = vec![i]; - for c in items { - let mut next = Vec::new(); - for p in at { - next.extend(ends(c, seq, p, memo)); - } - next.sort_unstable(); - next.dedup(); - at = next; - } - at - } - Cp::Choice(items) => { - let mut all = Vec::new(); - for c in items { - all.extend(ends(c, seq, i, memo)); - } - all - } - Cp::Rep(c, r) => { - let mut all = if *r == '+' { vec![] } else { vec![i] }; - let mut frontier = ends(c, seq, i, memo); - if *r == '?' { - all.extend(frontier); - } else { - while !frontier.is_empty() { - let new: Vec = frontier.iter().copied().filter(|p| !all.contains(p)).collect(); - all.extend(&new); - let mut next = Vec::new(); - for p in new.into_iter().filter(|&p| p > i) { - next.extend(ends(c, seq, p, memo).into_iter().filter(|p| !all.contains(p))); - } - // Two paths to one position are one position: kept apart they multiply per round. - next.sort_unstable(); - next.dedup(); - frontier = next; - } - } - all - } - }; - out.sort_unstable(); - out.dedup(); - memo.insert(key, out.clone()); - out -} - -fn show(cp: &Cp) -> String { - match cp { - Cp::Name(n) => n.clone(), - Cp::Seq(v) => format!("({})", v.iter().map(show).collect::>().join(", ")), - Cp::Choice(v) => format!("({})", v.iter().map(show).collect::>().join(" | ")), - Cp::Rep(c, r) => format!("{}{r}", show(c)), - } -} - -/// An element of the document being validated. -struct Node { - name: String, - attrs: Vec, - children: Vec, - text: bool, -} - -fn read_tree(xml: &str) -> Result { - use quick_xml::events::Event; - let mut r = quick_xml::Reader::from_str(xml); - let mut stack: Vec = 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) { - 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, &mut EndsMemo::new()).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 { - 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##" - - - - - - - - - - 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#"T <em>x</em>ab
"#; - assert_eq!(validate(&dtd, ok), vec![]); - let bad = r#"no title
x