# craft-xml
Bounded XML for hostile input: an owned element tree, streaming of repeated elements, a pull
reader, escaping and a writer. It replaces the small XML helper each Craft app carried.
It is built on `quick-xml`'s tokenizer (MIT). Everything above the tokens is here: limits,
references, namespaces, recovery, the tree and the writer.
```rust
use craft_xml::{Local, Options};
let root = craft_xml::parse(b"one & two").unwrap();
assert_eq!(root.attr_u32("id"), Some(5));
assert_eq!(root.child(Local("t")).map(|t| t.deep_text()).as_deref(), Some("one & two"));
// Rows go to the callback one at a time; the tree keeps the rest.
let part = b"|
";
let mut rows = 0;
let sheet = Options::local().parse_streaming(part, &["row"], |row| {
rows += row.attr_u32("r").unwrap_or(0);
Ok::<(), craft_xml::Error>(())
});
assert_eq!((rows, sheet.map(|s| s.children.len())), (3, Ok(0)));
```
## Reading
`parse(bytes)` reads with `Options::default()`. `Options` is a plain struct; set what differs.
| Option | Default | Meaning |
| --- | --- | --- |
| `limits` | see below | What the reader refuses to exceed. |
| `names` | `AsWritten` | `AsWritten` keeps `p:sp`. `Local` keeps `sp`. `Canonical(&c)` rewrites to the caller's prefix for the namespace URI. |
| `ns_decls` | `true` | Keep `xmlns` declarations as attributes. `Options::local()` and `Options::canonical(..)` turn it off. |
| `strict` | `false` | See below. `.strict()` also makes an undefined entity an error. |
| `unknown_entity` | `Drop` | `Drop`, `Literal` (keep `&name;` as text) or `Error`. |
| `doctype` | `Ignore` | `Ignore`, `RejectInternalSubset` or `Reject`. Never processed, whichever is chosen. |
| `cdata`, `pis`, `comments` | `false` | Keep these as their own nodes. CDATA content is otherwise joined to the text around it. |
| `drop_blank_text` | `false` | Drop the text of an element with child elements when all of it is whitespace (indentation). |
| `split_depth` | `2` | For streaming: how far below the root an element may be and still be handed out. |
### Limits
| Limit | Default | Error |
| --- | --- | --- |
| `max_input` | 256 MiB | `TooLarge` |
| `max_depth` | 256 levels (the root is 1) | `TooDeep` |
| `max_nodes` | 2,000,000 nodes kept in the tree: elements, text stretches, kept CDATA, instructions and comments | `TooManyNodes` |
| `max_attrs` | 1024 on one start tag, namespace declarations and malformed ones included; with canonical names, also the declarations in scope at once | `TooManyAttrs` |
| `max_attrs_total` | 4,000,000 attributes kept in the tree, over all its elements | `TooManyAttrsTotal` |
A limit exceeded is an error, never a panic and never a truncated tree. `parse_streaming_partial`
returns the error together with what had been read, for a caller that wants the part's other
children when its rows cannot all be read; that tree is marked as partial by the error beside it. Elements handed to a
streaming callback leave the node and attribute counts, so a streamed part may be of any length. Text is not
counted separately: nothing expands, so it is never more than three times the input (the worst
case is input that is not UTF-8, where each bad byte becomes U+FFFD).
### What is never done
- **No DTD processing.** A `` is skipped or refused; its entity and element declarations are not read.
- **No entity expansion** beyond `&` `<` `>` `"` `'` and numeric character references. A reference to a declared entity is an undefined entity. Nothing external is ever fetched.
- No validation, no XPath, no XInclude, no schema.
- No encodings other than UTF-8 and UTF-16. The encoding an XML declaration names is not consulted.
- Names are not checked on reading. The writer checks them (below).
### Lenient and strict
A lenient read keeps what a damaged document had:
- an end tag closes the innermost open element, whatever its name; an end tag with nothing open is skipped;
- elements still open at the end of the input are closed (a truncated part);
- reading stops when the root element ends: nothing after it is looked at;
- a malformed attribute is skipped; bytes that are not UTF-8 become U+FFFD.
A strict read makes each of those an error, and also a second root element and text outside the
root, in the pull reader as in the tree. Markup the tokenizer cannot read (`` escaped; CR written `
`.
- `esc_attr`, `push_esc_attr`: a double-quoted attribute value. Also `"`, and tab, LF, CR as ` ` `
` `
`.
- Both drop characters XML 1.0 cannot carry (`is_xml_char`).
- `El::to_xml`, `write_xml`: one line. `El::to_xml_pretty(indent)`, `write_xml_pretty`: an element whose children are all elements has them one per line; any other element is on one line exactly as `to_xml` writes it.
- An element or attribute whose name is not an XML name (`is_name`) is not written, with everything inside it. A name can never inject markup.
- `Writer`: `Writer::document()` (with the `standalone="yes"` declaration), `Writer::new()` (a fragment), `Writer::pretty(indent)`; `decl`, `doctype`, `pi`; `tag(name).attr(k, v).attr_opt(k, v).open()` / `.empty()` / `.text(t)`; `open`, `empty`, `leaf` with a slice of attributes; `close()` (the writer knows the name); `text`, `cdata`, `raw`, `tree`; `finish` closes what is open.
### Round trip
Reading what `to_xml` wrote gives an equal tree. Reading a document and writing it keeps the
elements, attributes in order, text, and with the options on, CDATA sections, processing
instructions and comments. It does not keep:
- the XML declaration, the DOCTYPE, and anything outside the root element;
- quoting and spacing inside tags, and how a character was written (`A` against `A`);
- `` against ``;
- comments, processing instructions and CDATA sections as sections, unless the options keep them;
- a literal CR; literal tab, LF and CR in attribute values (they were read as spaces);
- with `to_xml_pretty`, whitespace-only text between the children of an element-only element;
- namespace declarations, when names are read as `Local` or `Canonical`.
## Migration
### WordCraft `crates/docx/src/xml.rs`
| WordCraft | craft-xml |
| --- | --- |
| `MAX_DEPTH`, `MAX_ELEMENTS`, `MAX_ATTRS` | `Limits`. `max_nodes` counts text nodes too: use twice the element count. |
| `NAMESPACES`, `OTHER_NAMESPACES`, `STRICT`, `prefix_for` | The tables stay in WordCraft, as `Canonical::namespaces`; `Canonical::prefix`. |
| `element_prefix`, `drawing_prefix` | `Canonical::element`. The rules stay in WordCraft. |
| `requirement_prefix` and the `Requires` rewrite | `Canonical::attr_value` with `Scope::uri`. |
| `parse`, `parse_ns(extra)`, `parse_strict` | `Options::canonical(&c).parse`; extra entries first in the table; `.strict()`. |
| `Node`, `El { name, attrs, kids }` | `Node`, `El { name, attrs, children }` |
| `attr` | `attr_or_plain` |
| `els`, `child`, `children`, `find`, `deep_text`, `local`, `to_xml` | `elements`, then the same names |
| `text` | `text` (a `Cow`) |
| `child_val(n)` | `child(n).and_then(\|c\| c.attr_or_plain("w:val"))` |
| `utf16_to_utf8` | `to_utf8` |
| `esc` | `esc` for text, `esc_attr` for attribute values |
| `W::new`, `open`, `empty`, `leaf`, `text`, `raw`, `into_bytes` | `Writer::document`, then the same names |
| `W::close(name)`, `W::val(name, v)`, `w.s` | `close()`, `empty(name, &[("w:val", v)])`, `as_str()` |
| `is_rel_attr`, `REL_*`, `DECORATIVE_*`, `body_ns` | Stay in WordCraft. |
### GridCraft `crates/xlsx/src/xml.rs`
| GridCraft | craft-xml |
| --- | --- |
| `MAX_DEPTH`, `MAX_XML_NODES` | `Limits` |
| `parse` | `Options { unknown_entity: Literal, ..Options::local() }.parse` |
| `parse_streaming(xml, split, cb)` | `.parse_streaming(xml, split, cb)`; `cb` returns any error that is `From` |
| `first_start(xml, name)` | `Options::local().first_start(xml, name)` |
| `El.children` (elements), `El.text` | `elements()`, `text()` |
| `attr`, `attr_u32`, `attr_i64`, `attr_f64`, `attr_bool`, `flag`, `child`, `path` | The same names |
| `kids(n)`, `child_val(n)` | `children(n)`, `child_attr(n, "val")` |
| `to_utf8`, `esc`, `push_esc`, `esc_attr` | The same names |
| `push_u64`, `push_a1`, `num`, `push_num`, `rich_text`, `phonetic_data`, `encode_xstring`, `decode_xstring`, `t_el`, `si_body` | Stay in GridCraft. |
### DeckCraft `crates/pptx/src/xml.rs`
| DeckCraft | craft-xml |
| --- | --- |
| `MAX_DEPTH`, `MAX_NODES` | `Limits` |
| `parse(bytes)?.root`, `Doc::ns_decls` | `craft_xml::parse(bytes)?`, `root.ns_decls()` (prefix and URI) |
| `attr(n)`, `has_attr(n)` | `attr_local(n)`, `attr_local(n).is_some()` |
| `i64(n)`, `i32(n)`, `u32(n)` | `attr_local(n).and_then(value::to_i64_lossy)` and `to_i32_lossy`, `to_u32_lossy` |
| `f64(n)`, `bool(n)` | `attr_local(n).and_then(value::to_f64)` and `to_bool` |
| `is(l)`, `child(l)`, `children_named(l)`, `path(ls)`, `find(l)` | `is(Local(l))`, `child(Local(l))`, `children(Local(l))`, `path(&[Local(..)])`, `find(Local(l))` |
| `find_all(l, &mut out)` | `out.extend(e.find_all(Local(l)))` |
| `text()` | `deep_text()` |
| `child_val(l)` | `child(Local(l)).and_then(\|c\| c.attr_local("val"))` |
| `local`, `prefix`, `set_attr`, `elements`, `elements_mut`, `to_xml`, `esc`, `esc_attr` | The same names |
| `W::new`, `W::frag`, `finish` | `Writer::document`, `Writer::new`, `into_bytes` |
| `A::new().a(k, v)`, `.o(k, opt)` | `tag(name).attr(k, v)`, `.attr_opt(k, opt)` |
| `A::b(k, opt_bool)`, `A::t(k, bool)` | `.attr_opt(k, opt.map(u8::from))`, `.attr_opt(k, flag.then_some(1))` |
| `open(name, a)`, `open0`, `empty(name, a)`, `empty0` | `tag(name)….open()` / `.empty()`, or `open(name, &[])`, `empty(name, &[])` |
| `close(name)`, `val(name, v)`, `elt(name, t)` | `close()`, `tag(name).attr("val", v).empty()`, `leaf(name, &[], t)` |
### DesignCraft `crates/idml/src/xml.rs`, `crates/pptx/src/xml.rs`, `crates/engine/src/cmd/xml.rs`
| DesignCraft | craft-xml |
| --- | --- |
| `parse` | `Options { strict: true, pis: true, ..Options::default() }.parse` |
| `Node::Pi(target, content)` | `Node::Pi { target, data }` |
| `El::new(n).attr(k, v).child(c).text(t)` | `El::new(n).with_attr(k, v).with_child(c).with_text(t)` |
| `set`, `push`, `get`, `local`, `elements` | `set_attr`, `push`, `attr`, `local`, `elements` |
| `find(l)`, `find_all(l)` (direct children) | `child(Local(l))`, `children(Local(l))` |
| `text_content` | `deep_text` |
| `write(out, depth)` | `write_xml_pretty(out, "\t")` |
| `escape_into(out, s, attr)` | `push_esc(out, s)`, `push_esc_attr(out, s)` |
| `document(root)`, `document_with_pi(root, pi)` | `Writer::pretty("\t")`, then `decl(true)`, `pi(target, data)` or `raw(pi)`, `tree(root)`, `into_bytes()` |
| `prop`, `prop_el`, `num`, `boolean` (IDML's `` form) | Stay in DesignCraft, on `attr` and `child`. |
| pptx `esc` | `esc` for text, `esc_attr` for attribute values. `emu`, `emu_len`, `angle`, `pct`, `hex` stay. |
| cmd `esc`, `valid_name` | `esc`/`esc_attr`; `is_name` (which also accepts `:`). The rest is the app's structure commands and stays. |
### The others
| App helper | craft-xml |
| --- | --- |
| WordCraft `formats/src/odt/xml.rs`: `events`, `events_at`, `Ev`, `Attrs`, `get`, `local` | `Options::events(utf8)`, `Events::next`, `Events::span`, `Event`. `Event::End` names the start tag. An error is returned, not swallowed. `length` stays. |
| AniCraft `crates/xml`: `parse` | `Options { strict: true, drop_blank_text: true, unknown_entity: Literal, ..Options::default() }.parse` |
| AniCraft `parse_mixed`, `TEXT_NODE` | Not needed: text nodes keep their place among the children. |
| AniCraft `Element::attr`, `u32`, `child`, `f32`, `list(n)` | `attr`, `attr_u32`, `child`, `attr_f64(n) as f32`, `child(n)` then `elements()` |
| LightCraft `lensdb/src/xml.rs`: `parse`, `Node`, `attr`, `num`, `kids`, `kid`, `kid_text`, `entity`, `unescape` | `Options::parse` with its limits (`max_depth: 32`), `El`, `attr`, `attr_f64`, `children`, `child`, `child_text` (trim it), `entity`, `unescape` |
| FilmCraft `interchange/src/xml.rs`: `escape`, `escape_attr`, `XmlWriter` | `esc`, `esc_attr`, `Writer::pretty(" ")` with `decl(false)` and `doctype(name)`; `text_attrs` is `leaf` |
| FilmCraft `parse`, `child`, `children`, `elements`, `text`, `path_text`, `child_text`, `child_i64`, `child_f64` | `Options { doctype: RejectInternalSubset, .. }.parse` and the tree's `child`, `children`, `elements`, `text`, `path`, `child_text`, `value::to_i64_lossy`, `value::to_f64`. The result is an `El`, not a `roxmltree::Document`. |
| FigCraft `vectordrawable/src/xml.rs`: `esc` | `esc_attr` |
| MailCraft `dav`, `ews` readers | `Options { doctype: Reject, .. }.strict()`, with `Canonical` for the DAV and EWS namespaces |
### Behaviour that changes on migration
- **Bytes written.** `esc` no longer escapes quotes in text; WordCraft, FilmCraft, FigCraft and DesignCraft's pptx writer escaped `'` and this crate never does (values are double-quoted); DesignCraft's IDML writer wrote ` ` where this writes ` `, and wrote a literal CR in text where this writes `
`. All read back the same, except the CR, which now survives.
- **Bytes that are not UTF-8** in text were an error in WordCraft, GridCraft and DesignCraft's lenient readers. Here a lenient read replaces them and a strict read refuses them.
- **GridCraft** kept an attribute value raw when it held an undefined entity; here the rest of the value is still resolved. It did not normalise line ends in CDATA; here they are.
- **DeckCraft** kept reading after the root element and could fail on what followed; a lenient read here stops.
- **Node counts** include text, so an element limit becomes about twice the number.
- **LightCraft** did not normalise literal tabs and line ends in attribute values to spaces; here they are.
- **FilmCraft** rounded a fractional `child_i64`; `value::to_i64_lossy` truncates. Its `child_f64` accepted `NaN` and infinities; `value::to_f64` does not.
- **AniCraft** kept attributes in a map (the last of a repeated name won); here they are a list and `attr` returns the first.
### Left to the apps
- FigCraft's `Out` layout (one attribute per line, as Android Studio formats drawables).
- EffectCraft's `xml_project.rs`: a project codec with its own reader. It writes control characters as `N;`, which XML 1.0 forbids and this crate does not read.
- VectorCraft's `xml_id.rs`: object names as XML IDs (`_xHH_` escapes), a naming rule and not XML syntax.
- PhotoCraft's `io/src/xml.rs` and FilmCraft's `parse` guard `roxmltree` and return its document. Code that moves to `El` gets the same guards from `Limits` and `Doctype`; code that stays on `roxmltree` keeps its guard.
- Booleans that are not `xsd:boolean`: FilmCraft's `TRUE`/`FALSE`, AniCraft's "anything but `true` is false".
- Number formatting (`num`), units, colours, and every format's own vocabulary.