mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 15:24:42 +00:00
Almost every Craft app carries its own small XML helper: a bounded element tree on a pull parser and an escaping string writer. This is the shared one, so each app can drop its copy. - An owned tree (El, Node) read from bytes with limits on input size, depth, node count and attributes per element. A limit exceeded is an error. No DTD is processed and no entity is expanded beyond the five predefined ones and numeric character references. UTF-8 and UTF-16. - Names as written, by local name, or rewritten to the caller's canonical prefixes by namespace URI, with hooks for a prefix that depends on the parent and for attribute values that hold prefixes. - Lenient and strict reading; streaming of repeated elements to a callback; first_start; a pull reader with byte ranges. - Accessors, typed attribute getters, mutation and a builder. Where the apps disagreed (whole or local names, own or deep text, exact or lossy numbers) there are two spellings. - esc, esc_attr, compact and pretty serialising, and a Writer. A name that is not an XML name is never written. Built on quick-xml 0.41's tokenizer (MIT), the version the apps use. The apps' own tests for their helpers are ported in tests/apps.rs. No app is switched here.
459 lines
15 KiB
Rust
459 lines
15 KiB
Rust
//! Writing: escaping, serialising a tree, and a writer for documents built without a tree.
|
|
|
|
use std::fmt::{self, Display, Write as _};
|
|
|
|
use crate::tree::{El, Node, Step, walk};
|
|
|
|
/// The XML declaration, with a newline.
|
|
pub const DECL: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
|
|
/// The XML declaration the office formats use (`standalone="yes"`), with a newline.
|
|
pub const DECL_STANDALONE: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n";
|
|
|
|
/// May `c` appear in an XML 1.0 document? Control characters other than tab, LF and CR, and
|
|
/// U+FFFE and U+FFFF, may not, even as character references.
|
|
pub fn is_xml_char(c: char) -> bool {
|
|
matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
|
|
}
|
|
|
|
fn is_name_start(c: char) -> bool {
|
|
matches!(c,
|
|
':' | '_' | 'A'..='Z' | 'a'..='z'
|
|
| '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}'
|
|
| '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}'
|
|
| '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}')
|
|
}
|
|
|
|
/// Is `s` an XML 1.0 name (an element, attribute or processing-instruction target name)?
|
|
pub fn is_name(s: &str) -> bool {
|
|
let mut chars = s.chars();
|
|
chars.next().is_some_and(is_name_start)
|
|
&& chars.all(|c| is_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}'))
|
|
}
|
|
|
|
/// `s` as element content: `&`, `<` and `>` escaped, a carriage return written as ` ` (a
|
|
/// literal one would be read back as a line feed), characters XML cannot carry dropped.
|
|
pub fn esc(s: &str) -> String {
|
|
let mut o = String::with_capacity(s.len());
|
|
push_esc(&mut o, s);
|
|
o
|
|
}
|
|
|
|
/// Appends [`esc`]`(s)` to `o`.
|
|
pub fn push_esc(o: &mut String, s: &str) {
|
|
// Most text is plain ASCII with nothing to escape.
|
|
if s.bytes().all(|b| matches!(b, b'\t' | b'\n' | 0x20..=0x7E) && !matches!(b, b'&' | b'<' | b'>')) {
|
|
o.push_str(s);
|
|
return;
|
|
}
|
|
o.reserve(s.len());
|
|
for c in s.chars() {
|
|
match c {
|
|
'&' => o.push_str("&"),
|
|
'<' => o.push_str("<"),
|
|
'>' => o.push_str(">"),
|
|
'\r' => o.push_str(" "),
|
|
c if is_xml_char(c) => o.push(c),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `s` as a double-quoted attribute value: `&`, `<`, `>` and `"` escaped, tab, LF and CR written
|
|
/// as character references (literal ones would be read back as spaces), characters XML cannot
|
|
/// carry dropped.
|
|
pub fn esc_attr(s: &str) -> String {
|
|
let mut o = String::with_capacity(s.len());
|
|
push_esc_attr(&mut o, s);
|
|
o
|
|
}
|
|
|
|
/// Appends [`esc_attr`]`(s)` to `o`.
|
|
pub fn push_esc_attr(o: &mut String, s: &str) {
|
|
for c in s.chars() {
|
|
match c {
|
|
'&' => o.push_str("&"),
|
|
'<' => o.push_str("<"),
|
|
'>' => o.push_str(">"),
|
|
'"' => o.push_str("""),
|
|
'\t' => o.push_str("	"),
|
|
'\n' => o.push_str(" "),
|
|
'\r' => o.push_str(" "),
|
|
c if is_xml_char(c) => o.push(c),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Escapes whatever is formatted into it as an attribute value.
|
|
struct AttrSink<'a>(&'a mut String);
|
|
|
|
impl fmt::Write for AttrSink<'_> {
|
|
fn write_str(&mut self, s: &str) -> fmt::Result {
|
|
push_esc_attr(self.0, s);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn push_attr(o: &mut String, name: &str, value: impl Display) {
|
|
o.push(' ');
|
|
o.push_str(name);
|
|
o.push_str("=\"");
|
|
let _ = write!(AttrSink(o), "{value}");
|
|
o.push('"');
|
|
}
|
|
|
|
fn push_pi(o: &mut String, target: &str, data: &str) {
|
|
// A target that is not a name, the reserved target `xml`, or data that would end the
|
|
// instruction early cannot be written.
|
|
if !is_name(target) || target.eq_ignore_ascii_case("xml") || data.contains("?>") {
|
|
return;
|
|
}
|
|
o.push_str("<?");
|
|
o.push_str(target);
|
|
if !data.is_empty() {
|
|
o.push(' ');
|
|
o.extend(data.chars().filter(|c| is_xml_char(*c)));
|
|
}
|
|
o.push_str("?>");
|
|
}
|
|
|
|
fn push_cdata(o: &mut String, text: &str) {
|
|
o.push_str("<![CDATA[");
|
|
// `]]>` cannot appear in a section: end the section inside it and start another.
|
|
for (i, part) in text.split("]]>").enumerate() {
|
|
if i > 0 {
|
|
o.push_str("]]]]><![CDATA[>");
|
|
}
|
|
o.extend(part.chars().filter(|c| is_xml_char(*c)));
|
|
}
|
|
o.push_str("]]>");
|
|
}
|
|
|
|
fn push_leaf(o: &mut String, node: &Node) {
|
|
match node {
|
|
Node::El(_) => {}
|
|
Node::Text(t) => push_esc(o, t),
|
|
Node::CData(t) => push_cdata(o, t),
|
|
Node::Pi { target, data } => push_pi(o, target, data),
|
|
// `--` cannot appear in a comment and a comment cannot end with `-`.
|
|
Node::Comment(c) if c.contains("--") || c.ends_with('-') => {}
|
|
Node::Comment(c) => {
|
|
o.push_str("<!--");
|
|
o.extend(c.chars().filter(|ch| is_xml_char(*ch)));
|
|
o.push_str("-->");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn push_start(o: &mut String, e: &El) {
|
|
o.push('<');
|
|
o.push_str(&e.name);
|
|
for (k, v) in e.attrs.iter().filter(|(k, _)| is_name(k)) {
|
|
push_attr(o, k, v);
|
|
}
|
|
}
|
|
|
|
fn push_end(o: &mut String, e: &El) {
|
|
o.push_str("</");
|
|
o.push_str(&e.name);
|
|
o.push('>');
|
|
}
|
|
|
|
fn push_el(o: &mut String, root: &El) {
|
|
// Where each open element's content starts: one that wrote none is closed as `<a/>`, so
|
|
// that what is written reads back as the same text however much of it was left out.
|
|
let mut content = Vec::new();
|
|
walk(root, |step| {
|
|
match step {
|
|
Step::Enter(e) => {
|
|
if !is_name(&e.name) {
|
|
return false;
|
|
}
|
|
push_start(o, e);
|
|
if e.children.is_empty() {
|
|
o.push_str("/>");
|
|
return false;
|
|
}
|
|
o.push('>');
|
|
content.push(o.len());
|
|
}
|
|
Step::Leave(e) => {
|
|
if content.pop() == Some(o.len()) {
|
|
o.pop();
|
|
o.push_str("/>");
|
|
} else {
|
|
push_end(o, e);
|
|
}
|
|
}
|
|
Step::Leaf(n) => push_leaf(o, n),
|
|
}
|
|
true
|
|
});
|
|
}
|
|
|
|
fn is_blank(n: &Node) -> bool {
|
|
matches!(n, Node::Text(t) if t.chars().all(|c| matches!(c, ' ' | '\t' | '\n' | '\r')))
|
|
}
|
|
|
|
/// Children that are all elements (with at most blank text between them) go one per line.
|
|
fn is_block(e: &El) -> bool {
|
|
let mut any = false;
|
|
for n in &e.children {
|
|
match n {
|
|
Node::El(c) => any |= is_name(&c.name),
|
|
n if is_blank(n) => {}
|
|
_ => return false,
|
|
}
|
|
}
|
|
any
|
|
}
|
|
|
|
fn push_pretty(o: &mut String, root: &El, indent: &str, base: usize) {
|
|
let mut depth = base;
|
|
let pad = |o: &mut String, depth: usize| (0..depth).for_each(|_| o.push_str(indent));
|
|
walk(root, |step| {
|
|
match step {
|
|
Step::Enter(e) => {
|
|
if !is_name(&e.name) {
|
|
return false;
|
|
}
|
|
pad(o, depth);
|
|
if !is_block(e) {
|
|
push_el(o, e);
|
|
o.push('\n');
|
|
return false;
|
|
}
|
|
push_start(o, e);
|
|
o.push_str(">\n");
|
|
depth += 1;
|
|
}
|
|
Step::Leave(e) => {
|
|
depth = depth.saturating_sub(1);
|
|
pad(o, depth);
|
|
push_end(o, e);
|
|
o.push('\n');
|
|
}
|
|
// Blank text between the children of a block.
|
|
Step::Leaf(_) => {}
|
|
}
|
|
true
|
|
});
|
|
}
|
|
|
|
impl El {
|
|
/// The element as XML on one line, with no declaration. See [`El::write_xml`].
|
|
pub fn to_xml(&self) -> String {
|
|
let mut s = String::new();
|
|
self.write_xml(&mut s);
|
|
s
|
|
}
|
|
|
|
/// Appends the element as XML. Text is escaped with [`esc`] and attribute values with
|
|
/// [`esc_attr`]; an element without children is written `<a/>`. An element or attribute
|
|
/// whose name is not an XML name ([`is_name`]) is left out with everything inside it, so a
|
|
/// name can never inject markup. Reading the result back gives an equal tree, apart from
|
|
/// such names and characters XML cannot carry.
|
|
pub fn write_xml(&self, out: &mut String) {
|
|
push_el(out, self);
|
|
}
|
|
|
|
/// The element as indented XML, ending with a newline. See [`El::write_xml_pretty`].
|
|
pub fn to_xml_pretty(&self, indent: &str) -> String {
|
|
let mut s = String::new();
|
|
self.write_xml_pretty(&mut s, indent);
|
|
s
|
|
}
|
|
|
|
/// Appends the element as indented XML: an element whose children are all elements has them
|
|
/// one per line, `indent` deeper each level, and any other element is written on one line
|
|
/// exactly as [`El::write_xml`] writes it. Character data is therefore never changed, but
|
|
/// whitespace-only text between the children of an element-only element is not kept.
|
|
pub fn write_xml_pretty(&self, out: &mut String, indent: &str) {
|
|
push_pretty(out, self, indent, 0);
|
|
}
|
|
}
|
|
|
|
/// Builds XML text without a tree. Element names are closed for the caller, attribute values and
|
|
/// text are escaped, and with [`Writer::pretty`] each element goes on its own line.
|
|
///
|
|
/// Names are written as given: they are the caller's own constants, not checked.
|
|
///
|
|
/// ```
|
|
/// let mut w = craft_xml::Writer::document();
|
|
/// w.tag("w:p").attr("w:id", 7).open();
|
|
/// w.leaf("w:t", &[("xml:space", "preserve")], " a < b ");
|
|
/// w.empty("w:br", &[]);
|
|
/// assert_eq!(
|
|
/// w.finish(),
|
|
/// "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:p w:id=\"7\"><w:t xml:space=\"preserve\"> a < b </w:t><w:br/></w:p>"
|
|
/// );
|
|
/// ```
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Writer {
|
|
out: String,
|
|
/// The names of the open elements, one after another, and where each starts.
|
|
names: String,
|
|
open: Vec<usize>,
|
|
indent: Option<String>,
|
|
}
|
|
|
|
/// A start tag being written: add attributes, then end it with [`Tag::open`], [`Tag::empty`] or
|
|
/// [`Tag::text`].
|
|
#[must_use = "end the tag with `open`, `empty` or `text`"]
|
|
pub struct Tag<'w> {
|
|
w: &'w mut Writer,
|
|
name: &'w str,
|
|
}
|
|
|
|
impl Tag<'_> {
|
|
/// An attribute; `value` is formatted and escaped.
|
|
pub fn attr(self, name: &str, value: impl Display) -> Self {
|
|
push_attr(&mut self.w.out, name, value);
|
|
self
|
|
}
|
|
/// An attribute when there is a value.
|
|
pub fn attr_opt(self, name: &str, value: Option<impl Display>) -> Self {
|
|
match value {
|
|
Some(v) => self.attr(name, v),
|
|
None => self,
|
|
}
|
|
}
|
|
/// Several attributes.
|
|
pub fn attrs(self, attrs: &[(&str, &str)]) -> Self {
|
|
attrs.iter().fold(self, |tag, (k, v)| tag.attr(k, v))
|
|
}
|
|
/// `<name …>`: the element stays open until [`Writer::close`].
|
|
pub fn open(self) {
|
|
self.w.out.push('>');
|
|
self.w.open.push(self.w.names.len());
|
|
self.w.names.push_str(self.name);
|
|
self.w.newline();
|
|
}
|
|
/// `<name …/>`.
|
|
pub fn empty(self) {
|
|
self.w.out.push_str("/>");
|
|
self.w.newline();
|
|
}
|
|
/// `<name …>text</name>`.
|
|
pub fn text(self, text: &str) {
|
|
self.w.out.push('>');
|
|
push_esc(&mut self.w.out, text);
|
|
self.w.out.push_str("</");
|
|
self.w.out.push_str(self.name);
|
|
self.w.out.push('>');
|
|
self.w.newline();
|
|
}
|
|
}
|
|
|
|
impl Writer {
|
|
/// An empty writer, for a fragment.
|
|
pub fn new() -> Writer {
|
|
Writer::default()
|
|
}
|
|
/// A writer that starts with [`DECL_STANDALONE`].
|
|
pub fn document() -> Writer {
|
|
Writer { out: String::from(DECL_STANDALONE), ..Writer::default() }
|
|
}
|
|
/// A writer that puts each element on its own line, `indent` deeper per open element.
|
|
/// Elements written with [`Writer::leaf`] or [`Tag::text`] stay on one line. Text written
|
|
/// with [`Writer::text`] is not indented.
|
|
pub fn pretty(indent: &str) -> Writer {
|
|
Writer { indent: Some(indent.to_string()), ..Writer::default() }
|
|
}
|
|
|
|
fn pad(&mut self) {
|
|
if let Some(indent) = &self.indent {
|
|
(0..self.open.len()).for_each(|_| self.out.push_str(indent));
|
|
}
|
|
}
|
|
fn newline(&mut self) {
|
|
if self.indent.is_some() {
|
|
self.out.push('\n');
|
|
}
|
|
}
|
|
|
|
/// Writes the XML declaration: [`DECL_STANDALONE`] or [`DECL`].
|
|
pub fn decl(&mut self, standalone: bool) {
|
|
self.out.push_str(if standalone { DECL_STANDALONE } else { DECL });
|
|
}
|
|
/// Writes `<!DOCTYPE name>` and a newline. Nothing is written if `name` is not a name.
|
|
pub fn doctype(&mut self, name: &str) {
|
|
if is_name(name) {
|
|
let _ = writeln!(self.out, "<!DOCTYPE {name}>");
|
|
}
|
|
}
|
|
/// Writes a processing instruction, and a newline when no element is open. Nothing is
|
|
/// written if `target` is not a name or `data` holds `?>`.
|
|
pub fn pi(&mut self, target: &str, data: &str) {
|
|
let before = self.out.len();
|
|
push_pi(&mut self.out, target, data);
|
|
if self.open.is_empty() && self.out.len() > before {
|
|
self.out.push('\n');
|
|
}
|
|
}
|
|
|
|
/// Starts a tag: `w.tag("a:off").attr("x", 1).attr("y", 2).empty()`.
|
|
pub fn tag<'w>(&'w mut self, name: &'w str) -> Tag<'w> {
|
|
self.pad();
|
|
self.out.push('<');
|
|
self.out.push_str(name);
|
|
Tag { w: self, name }
|
|
}
|
|
/// `<name attrs>`.
|
|
pub fn open(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
|
self.tag(name).attrs(attrs).open();
|
|
}
|
|
/// `<name attrs/>`.
|
|
pub fn empty(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
|
self.tag(name).attrs(attrs).empty();
|
|
}
|
|
/// `<name attrs>text</name>`.
|
|
pub fn leaf(&mut self, name: &str, attrs: &[(&str, &str)], text: &str) {
|
|
self.tag(name).attrs(attrs).text(text);
|
|
}
|
|
/// Closes the innermost open element. Nothing happens when none is open.
|
|
pub fn close(&mut self) {
|
|
let Some(start) = self.open.pop() else { return };
|
|
self.pad();
|
|
self.out.push_str("</");
|
|
self.out.push_str(self.names.get(start..).unwrap_or_default());
|
|
self.out.push('>');
|
|
self.names.truncate(start);
|
|
self.newline();
|
|
}
|
|
/// Escaped text.
|
|
pub fn text(&mut self, text: &str) {
|
|
push_esc(&mut self.out, text);
|
|
}
|
|
/// A CDATA section.
|
|
pub fn cdata(&mut self, text: &str) {
|
|
push_cdata(&mut self.out, text);
|
|
}
|
|
/// Markup the caller has already written, as it is.
|
|
pub fn raw(&mut self, xml: &str) {
|
|
self.out.push_str(xml);
|
|
}
|
|
/// A tree: on one line, or indented to the current depth with [`Writer::pretty`].
|
|
pub fn tree(&mut self, el: &El) {
|
|
match &self.indent {
|
|
Some(indent) => push_pretty(&mut self.out, el, indent, self.open.len()),
|
|
None => push_el(&mut self.out, el),
|
|
}
|
|
}
|
|
/// What has been written so far.
|
|
pub fn as_str(&self) -> &str {
|
|
&self.out
|
|
}
|
|
/// Closes every element still open and returns the text.
|
|
pub fn finish(mut self) -> String {
|
|
while !self.open.is_empty() {
|
|
self.close();
|
|
}
|
|
self.out
|
|
}
|
|
/// [`Writer::finish`], as bytes.
|
|
pub fn into_bytes(self) -> Vec<u8> {
|
|
self.finish().into_bytes()
|
|
}
|
|
}
|