mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
The container every OpenDocument file shares, moved from WordCraft (crates/formats/src/odt/crypt.rs, odt/flat.rs, the package reading and writing in odt/import.rs and odt/package.rs, crates/doc/src/zip_guard.rs at 06c2ae6f) so that the ODT and ODS readers build on one copy. - Package and PackageWriter: the ZIP with its mimetype and manifest, within Limits (entries, bytes per file, bytes per package). - Manifest: read by namespace whatever the prefix, as GridCraft's ODS reader did; written as LibreOffice lays it out. - crypt: AES-256-CBC with PBKDF2, read and written, with the per-file and per-package key-derivation budgets. - flat: a flat document to a package and back, for any media type (the WordCraft code wrote the text one only). - zip_guard: the entry-count check before zip parses a directory. No document content is read here.
447 lines
21 KiB
Rust
447 lines
21 KiB
Rust
//! Flat OpenDocument files (`.fodt`, `.fods`, `.fodp`, `.fodg`; ODF 1.3 Part 3 §2.1.2 and
|
|
//! §3.1.2): the whole document in one `office:document` element, its pictures inline
|
|
//! (`office:binary-data`, base64) and its embedded objects inline (`math:math` or another
|
|
//! `office:document` inside `draw:object`). [`to_package`] takes one apart into a package's
|
|
//! files; [`from_package`] puts a package's files together. Neither looks at what the document
|
|
//! says.
|
|
|
|
use base64::Engine as _;
|
|
use base64::engine::general_purpose::STANDARD;
|
|
|
|
use crate::{Error, Limits, Package, PackageWriter};
|
|
|
|
/// Deepest nesting of one element name followed (deeper is malformed).
|
|
const MAX_XML_DEPTH: usize = 512;
|
|
|
|
/// The top-level parts of a flat document, in the order a package's files take them.
|
|
const META: &str = "office:meta";
|
|
const SETTINGS: &str = "office:settings";
|
|
const FONTS: &str = "office:font-face-decls";
|
|
const STYLES: &str = "office:styles";
|
|
const AUTOMATIC: &str = "office:automatic-styles";
|
|
const MASTERS: &str = "office:master-styles";
|
|
const BODY: &str = "office:body";
|
|
|
|
/// Most pictures and formulas taken out of a flat document (hostile files).
|
|
const MAX_INLINE: usize = 10_000;
|
|
|
|
/// Element `name` in `xml` (its first one, start to end, or self-closed); "" when there is none.
|
|
fn element<'a>(xml: &'a str, name: &str) -> &'a str {
|
|
let open = format!("<{name}");
|
|
let Some(start) = xml
|
|
.match_indices(&open)
|
|
.map(|(i, _)| i)
|
|
.find(|&i| xml.get(i + open.len()..).and_then(|r| r.chars().next()).is_some_and(|c| c == '>' || c == '/' || c.is_whitespace()))
|
|
else {
|
|
return "";
|
|
};
|
|
let Some(tag_end) = xml.get(start..).and_then(|r| r.find('>')).map(|e| start + e) else { return "" };
|
|
if xml.get(..tag_end).is_some_and(|t| t.ends_with('/')) {
|
|
return xml.get(start..=tag_end).unwrap_or("");
|
|
}
|
|
// Its end: the close tag at its own depth (a chart object inside holds an `office:body`,
|
|
// an `office:document` of its own).
|
|
let close = format!("</{name}>");
|
|
let mut depth = 0usize;
|
|
let mut at = tag_end;
|
|
// Every step moves past a tag, so this ends; nesting is bounded like any XML here (deeper is
|
|
// malformed), however many elements of the name sit side by side within.
|
|
loop {
|
|
let rest = xml.get(at..).unwrap_or("");
|
|
let next_close = rest.find(&close);
|
|
// Only opens before that close count (and only they are looked for).
|
|
let before = next_close.and_then(|c| rest.get(..c)).unwrap_or(rest);
|
|
let next_open = before.match_indices(&open).map(|(i, _)| i).find(|&i| {
|
|
let after = rest.get(i + open.len()..).unwrap_or("");
|
|
after.chars().next().is_some_and(|c| c == '>' || c == '/' || c.is_whitespace())
|
|
// Not self-closed.
|
|
&& after.find('>').and_then(|e| after.get(..e)).is_some_and(|t| !t.ends_with('/'))
|
|
});
|
|
match (next_open, next_close) {
|
|
(Some(o), Some(c)) if o < c => {
|
|
depth += 1;
|
|
if depth > MAX_XML_DEPTH {
|
|
return xml.get(start..).unwrap_or("");
|
|
}
|
|
at += o + open.len();
|
|
}
|
|
(_, Some(c)) if depth > 0 => {
|
|
depth -= 1;
|
|
at += c + close.len();
|
|
}
|
|
(_, Some(c)) => return xml.get(start..at + c + close.len()).unwrap_or(""),
|
|
// Unclosed (malformed): to the end.
|
|
(_, None) => return xml.get(start..).unwrap_or(""),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What is inside element `el` (an element string as [`element`] gives).
|
|
fn inner(el: &str) -> &str {
|
|
let Some(tag_end) = el.find('>') else { return "" };
|
|
if el.get(..tag_end).is_some_and(|t| t.ends_with('/')) {
|
|
return "";
|
|
}
|
|
let end = el.rfind("</").unwrap_or(el.len());
|
|
el.get(tag_end + 1..end.max(tag_end + 1)).unwrap_or("")
|
|
}
|
|
|
|
/// The extension of picture `data`, by its first bytes.
|
|
fn picture_ext(data: &[u8]) -> &'static str {
|
|
if data.starts_with(b"\x89PNG") {
|
|
"png"
|
|
} else if data.starts_with(b"\xff\xd8") {
|
|
"jpg"
|
|
} else if data.starts_with(b"GIF8") {
|
|
"gif"
|
|
} else if data.starts_with(b"<svg") || data.starts_with(b"<?xml") {
|
|
"svg"
|
|
} else {
|
|
"bin"
|
|
}
|
|
}
|
|
|
|
/// Where the first `draw:object` holding its object inline (no `xlink:href`, not self-closed)
|
|
/// starts in `xml`.
|
|
fn inline_object(xml: &str) -> Option<usize> {
|
|
let mut from = 0;
|
|
while let Some(i) = xml.get(from..).and_then(|r| r.find("<draw:object")).map(|i| from + i) {
|
|
let tag = xml.get(i..).and_then(|r| r.find('>').and_then(|e| r.get(..e))).unwrap_or("");
|
|
let next = tag.get("<draw:object".len()..).and_then(|r| r.chars().next());
|
|
if next.is_none_or(|c| c.is_whitespace()) && !tag.contains("xlink:href") && !tag.ends_with('/') {
|
|
return Some(i);
|
|
}
|
|
from = i + "<draw:object".len();
|
|
}
|
|
None
|
|
}
|
|
|
|
/// `xml` with its inline pictures and formulas moved into `files` (named as a package names
|
|
/// them) and pointed at there, as a package's content holds them.
|
|
fn take_out(xml: &str, files: &mut Vec<(String, Vec<u8>)>, folders: &mut Vec<(String, String)>, max_entry: u64) -> String {
|
|
let mut out = String::with_capacity(xml.len());
|
|
let mut rest = xml;
|
|
loop {
|
|
let pic = rest.find("<office:binary-data>");
|
|
let obj = inline_object(rest);
|
|
let (at, picture) = match (pic, obj) {
|
|
(Some(p), Some(o)) if o < p => (o, false),
|
|
(Some(p), _) => (p, true),
|
|
(None, Some(o)) => (o, false),
|
|
(None, None) => break,
|
|
};
|
|
let (before, from) = rest.split_at(at);
|
|
let close = if picture { "</office:binary-data>" } else { "</draw:object>" };
|
|
let Some(end) = from.find(close) else {
|
|
out.push_str(before);
|
|
rest = from;
|
|
break;
|
|
};
|
|
let open_len = if picture { "<office:binary-data>".len() } else { from.find('>').map_or(0, |e| e + 1) };
|
|
let body = from.get(open_len..end).unwrap_or("");
|
|
let after = from.get(end + close.len()..).unwrap_or("");
|
|
let n = files.len() + 1;
|
|
if files.len() >= MAX_INLINE {
|
|
out.push_str(before);
|
|
rest = after;
|
|
continue;
|
|
}
|
|
if picture {
|
|
// The picture's `draw:image` start tag (just before) gets the package's reference.
|
|
let b64: String = body.chars().filter(|c| !c.is_whitespace()).collect();
|
|
let data = STANDARD.decode(b64.as_bytes()).ok().filter(|d| d.len() as u64 <= max_entry).unwrap_or_default();
|
|
// The element holding the data: the start tag just before it.
|
|
let holder = before.rfind('<').filter(|_| !data.is_empty()).and_then(|i| {
|
|
let tag = before.get(i..).unwrap_or("");
|
|
["<draw:image", "<draw:object-ole"]
|
|
.into_iter()
|
|
.find(|el| tag.starts_with(el) && tag.get(el.len()..).is_some_and(|r| r.starts_with([' ', '>', '\n', '\t', '\r'])))
|
|
.map(|el| (i, el))
|
|
});
|
|
match holder {
|
|
Some((at, el)) => {
|
|
let name = if el == "<draw:image" { format!("Pictures/flat{n}.{}", picture_ext(&data)) } else { format!("Object flat{n}") };
|
|
let (head, tag) = before.split_at(at);
|
|
out.push_str(head);
|
|
out.push_str(&format!("{el} xlink:href=\"{}\"", if el == "<draw:image" { name.clone() } else { format!("./{name}") }));
|
|
out.push_str(tag.get(el.len()..).unwrap_or(""));
|
|
files.push((name, data));
|
|
}
|
|
None => out.push_str(before),
|
|
}
|
|
} else {
|
|
let dir = format!("Object flat{n}");
|
|
out.push_str(before);
|
|
out.push_str(&format!("<draw:object xlink:href=\"./{dir}\"/>"));
|
|
// A sub-document (a chart, a spreadsheet) goes back into its files; a formula is
|
|
// its `content.xml`.
|
|
match package_parts(body.trim()) {
|
|
Some(parts) => {
|
|
if let Some(mime) = mimetype_of(body.trim()) {
|
|
folders.push((format!("{dir}/"), mime));
|
|
}
|
|
for (name, xml) in ["content.xml", "styles.xml", "meta.xml", "settings.xml"].into_iter().zip(parts) {
|
|
files.push((format!("{dir}/{name}"), xml.into_bytes()));
|
|
}
|
|
}
|
|
None => files.push((format!("{dir}/content.xml"), body.as_bytes().to_vec())),
|
|
}
|
|
}
|
|
rest = after;
|
|
}
|
|
out.push_str(rest);
|
|
out
|
|
}
|
|
|
|
/// A flat document taken apart.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct FlatDocument {
|
|
/// The root's `office:mimetype`.
|
|
pub mimetype: Option<String>,
|
|
/// `content.xml`: fonts, automatic styles and the body.
|
|
pub content: String,
|
|
/// `styles.xml`: fonts, styles, automatic styles and master styles.
|
|
pub styles: String,
|
|
/// `meta.xml`.
|
|
pub meta: String,
|
|
/// `settings.xml`.
|
|
pub settings: String,
|
|
/// The pictures and embedded objects that were inline, named as a package names them
|
|
/// (`Pictures/flat1.png`, `Object flat2/content.xml`); `content` and `styles` point at them.
|
|
pub files: Vec<(String, Vec<u8>)>,
|
|
/// The folders of embedded sub-documents and their media types (`Object flat2/`).
|
|
pub folders: Vec<(String, String)>,
|
|
}
|
|
|
|
/// Takes a flat document apart. At most 10,000 pictures and objects are taken out (the rest
|
|
/// are dropped), and a picture larger than [`Limits::max_entry`] is left out.
|
|
pub fn split(bytes: &[u8], limits: &Limits) -> Result<FlatDocument, Error> {
|
|
let xml = std::str::from_utf8(bytes).map_err(|_| Error::Flat("not UTF-8 text"))?;
|
|
let root = element(xml, "office:document");
|
|
let [content, styles, meta, settings] = package_parts(root).ok_or(Error::Flat("no office:document"))?;
|
|
let (mut files, mut folders) = (Vec::new(), Vec::new());
|
|
let content = take_out(&content, &mut files, &mut folders, limits.max_entry);
|
|
let styles = take_out(&styles, &mut files, &mut folders, limits.max_entry);
|
|
Ok(FlatDocument { mimetype: mimetype_of(root), content, styles, meta, settings, files, folders })
|
|
}
|
|
|
|
/// A flat document as a package, of the media type its root declares, else `default_mime`.
|
|
/// The files are stored, not deflated: the package is for a reader to open, not to keep.
|
|
pub fn to_package(bytes: &[u8], default_mime: &str, limits: &Limits) -> Result<Vec<u8>, Error> {
|
|
let doc = split(bytes, limits)?;
|
|
let mut w = PackageWriter::new(doc.mimetype.as_deref().unwrap_or(default_mime));
|
|
for (name, xml) in [("content.xml", &doc.content), ("styles.xml", &doc.styles), ("meta.xml", &doc.meta), ("settings.xml", &doc.settings)] {
|
|
w.stored(name, "text/xml", xml.as_bytes());
|
|
}
|
|
for (path, mime) in &doc.folders {
|
|
w.folder(path, mime);
|
|
}
|
|
for (name, data) in &doc.files {
|
|
w.stored(name, if name.ends_with(".xml") { "text/xml" } else { "" }, data);
|
|
}
|
|
w.finish()
|
|
}
|
|
|
|
/// A package as a flat document: its files in one `office:document`, the automatic styles of
|
|
/// its content and its styles together, pictures and embedded objects inline.
|
|
pub fn from_package(package: &[u8], limits: &Limits) -> Result<Vec<u8>, Error> {
|
|
let mut z = Package::open(package, limits)?;
|
|
let manifest = z.manifest();
|
|
let mime = z
|
|
.mimetype()
|
|
.filter(|m| !m.is_empty())
|
|
.or_else(|| manifest.media_type("/").map(str::to_string))
|
|
.ok_or(Error::Flat("the package has no media type"))?;
|
|
let mut bytes_of = |name: &str| -> Vec<u8> { z.read(name).ok().flatten().unwrap_or_default() };
|
|
let mut read = |name: &str| -> String { String::from_utf8_lossy(&bytes_of(name)).into_owned() };
|
|
let (content, styles, meta, settings) = (read("content.xml"), read("styles.xml"), read("meta.xml"), read("settings.xml"));
|
|
let body = flat_parts(&content, &styles, &meta, &settings);
|
|
// Pictures and formulas in the document, not beside it.
|
|
let mut flat = String::with_capacity(body.len());
|
|
let mut rest = body.as_str();
|
|
while let Some(at) = rest.find("xlink:href=\"") {
|
|
let (before, from) = rest.split_at(at);
|
|
let target_start = "xlink:href=\"".len();
|
|
let Some(len) = from.get(target_start..).and_then(|r| r.find('"')) else { break };
|
|
let target = from.get(target_start..target_start + len).unwrap_or("");
|
|
let after = from.get(target_start + len + 1..).unwrap_or("");
|
|
let tag = before.rfind('<').and_then(|i| before.get(i..)).unwrap_or("");
|
|
// A picture, or an embedded object's replacement picture.
|
|
let picture = target.starts_with("Pictures/") || target.trim_start_matches("./").starts_with("ObjectReplacements/");
|
|
// The rest of the linking element's start tag, whether it closes itself, and what follows
|
|
// the tag (markup kept as read may have `<draw:image …></draw:image>`).
|
|
let gt = after.find('>').unwrap_or(after.len());
|
|
let closed = after.get(..gt).is_some_and(|t| t.ends_with('/'));
|
|
let tag_rest = after.get(..gt).unwrap_or("").trim_end_matches('/');
|
|
let past_tag = after.get(gt + 1..).unwrap_or("");
|
|
// What follows the element: past its tag, or past its end tag.
|
|
let past = |el: &str| {
|
|
if closed { past_tag } else { past_tag.find(&format!("</{el}>")).and_then(|e| past_tag.get(e + el.len() + 3..)).unwrap_or("") }
|
|
};
|
|
// The link's other attributes go with it.
|
|
let attrs: String = tag_rest.split_whitespace().filter(|a| !a.starts_with("xlink:")).map(|a| format!(" {a}")).collect();
|
|
if tag.starts_with("<draw:image") && picture {
|
|
// `<draw:image xlink:href="…" …/>` → `<draw:image …><office:binary-data>…`, before
|
|
// anything else it holds.
|
|
let data = bytes_of(target.trim_start_matches("./"));
|
|
flat.push_str(before.trim_end());
|
|
flat.push_str(&attrs);
|
|
flat.push_str(&format!("><office:binary-data>{}</office:binary-data>", STANDARD.encode(&data)));
|
|
if closed {
|
|
flat.push_str("</draw:image>");
|
|
}
|
|
rest = past_tag;
|
|
} else if tag.starts_with("<draw:object-ole") && target.starts_with("./Object ") {
|
|
// An OLE object's file inline (§10.4.7 allows `office:binary-data` in it).
|
|
let data = bytes_of(target.trim_start_matches("./"));
|
|
flat.push_str(before.rfind("<draw:object-ole").and_then(|i| before.get(..i)).unwrap_or(before));
|
|
flat.push_str(&format!("<draw:object-ole><office:binary-data>{}</office:binary-data></draw:object-ole>", STANDARD.encode(&data)));
|
|
rest = past("draw:object-ole");
|
|
} else if tag.starts_with("<draw:object") && target.starts_with("./Object ") {
|
|
let dir = target.trim_start_matches("./");
|
|
let mut text = |name: &str| String::from_utf8_lossy(&bytes_of(&format!("{dir}/{name}"))).into_owned();
|
|
let content = text("content.xml");
|
|
// A sub-document (a chart, a spreadsheet) goes in as an `office:document` (§3.1.2)
|
|
// of its media type, its files together; a formula as its `math:math`.
|
|
let math = if content.contains("<office:document-content") {
|
|
let mime = manifest.media_type(&format!("{dir}/")).unwrap_or(crate::mime::GRAPHICS);
|
|
let (styles, meta, settings) = (text("styles.xml"), text("meta.xml"), text("settings.xml"));
|
|
flat_document(mime, &content, &styles, &meta, &settings)
|
|
} else {
|
|
content.split_once("?>").map_or(content.as_str(), |(_, m)| m).trim().to_string()
|
|
};
|
|
// A plain `draw:object` around it.
|
|
flat.push_str(before.rfind("<draw:object").and_then(|i| before.get(..i)).unwrap_or(before));
|
|
flat.push_str(&format!("<draw:object>{math}</draw:object>"));
|
|
rest = past("draw:object");
|
|
} else {
|
|
flat.push_str(before);
|
|
flat.push_str(from.get(..target_start + len + 1).unwrap_or(""));
|
|
rest = after;
|
|
}
|
|
}
|
|
flat.push_str(rest);
|
|
Ok(format!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document {} office:version=\"1.3\" office:mimetype=\"{}\">{flat}</office:document>\n",
|
|
merged_namespaces(&[&content, &styles, &meta, &settings]),
|
|
craft_xml::esc_attr(&mime)
|
|
)
|
|
.into_bytes())
|
|
}
|
|
|
|
/// Every namespace the parts declare (the settings' `config`, say), each prefix once.
|
|
fn merged_namespaces(parts: &[&str]) -> String {
|
|
let mut ns: Vec<&str> = Vec::new();
|
|
for part in parts {
|
|
for decl in namespaces(part) {
|
|
let prefix = decl.split('=').next().unwrap_or("");
|
|
if !ns.iter().any(|d| d.split('=').next() == Some(prefix)) {
|
|
ns.push(decl);
|
|
}
|
|
}
|
|
}
|
|
ns.join(" ")
|
|
}
|
|
|
|
/// What a flat document holds, from a package's parts in the order §3.1.2 gives them: one
|
|
/// font declaration list and one automatic styles list for its content and its styles together.
|
|
fn flat_parts(content: &str, styles: &str, meta: &str, settings: &str) -> String {
|
|
let both = |name: &str| {
|
|
let (a, b) = (element(styles, name), element(content, name));
|
|
if a.is_empty() && b.is_empty() { String::new() } else { format!("<{name}>{}{}</{name}>", inner(a), inner(b)) }
|
|
};
|
|
[
|
|
element(meta, META).to_string(),
|
|
element(settings, SETTINGS).to_string(),
|
|
both(FONTS),
|
|
element(styles, STYLES).to_string(),
|
|
both(AUTOMATIC),
|
|
element(styles, MASTERS).to_string(),
|
|
element(content, BODY).to_string(),
|
|
]
|
|
.concat()
|
|
}
|
|
|
|
/// A sub-document's parts as one `office:document` of media type `mime`.
|
|
fn flat_document(mime: &str, content: &str, styles: &str, meta: &str, settings: &str) -> String {
|
|
format!(
|
|
"<office:document {} office:version=\"1.3\" office:mimetype=\"{}\">{}</office:document>",
|
|
merged_namespaces(&[content, styles, meta, settings]),
|
|
craft_xml::esc_attr(mime),
|
|
flat_parts(content, styles, meta, settings)
|
|
)
|
|
}
|
|
|
|
/// An `office:document` element taken apart into a package's `content.xml`, `styles.xml`,
|
|
/// `meta.xml` and `settings.xml` (each part's root with the document's attributes but its
|
|
/// media type). `None` when `root` is no `office:document`.
|
|
fn package_parts(root: &str) -> Option<[String; 4]> {
|
|
if !root.starts_with("<office:document") || root.starts_with("<office:document-") {
|
|
return None;
|
|
}
|
|
let attrs = root.find('>').and_then(|e| root.get("<office:document".len()..e)).map(|a| a.trim_end_matches('/'))?;
|
|
// Without its media type, which only the flat root has.
|
|
let attrs = match attrs.find(" office:mimetype=\"") {
|
|
Some(i) => {
|
|
let from = i + " office:mimetype=\"".len();
|
|
let end = attrs.get(from..).and_then(|r| r.find('"')).map_or(attrs.len(), |e| from + e + 1);
|
|
format!("{}{}", attrs.get(..i).unwrap_or(""), attrs.get(end..).unwrap_or(""))
|
|
}
|
|
None => attrs.to_string(),
|
|
};
|
|
let doc = |name: &str, parts: &[&str]| -> String {
|
|
let body: String = parts.iter().map(|p| element(root, p)).collect();
|
|
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:{name}{attrs}>{body}</office:{name}>")
|
|
};
|
|
Some([
|
|
doc("document-content", &[FONTS, AUTOMATIC, BODY]),
|
|
doc("document-styles", &[FONTS, STYLES, AUTOMATIC, MASTERS]),
|
|
doc("document-meta", &[META]),
|
|
doc("document-settings", &[SETTINGS]),
|
|
])
|
|
}
|
|
|
|
/// The `office:mimetype` of an `office:document` element.
|
|
fn mimetype_of(root: &str) -> Option<String> {
|
|
let tag = root.find('>').and_then(|e| root.get(..e))?;
|
|
let from = tag.find(" office:mimetype=\"")? + " office:mimetype=\"".len();
|
|
let value = tag.get(from..)?;
|
|
Some(craft_xml::unescape(value.get(..value.find('"')?)?))
|
|
}
|
|
|
|
/// The namespace declarations (`xmlns:p="…"`) on the root element of `xml`.
|
|
fn namespaces(xml: &str) -> Vec<&str> {
|
|
let start = xml.find("<office:").unwrap_or(0);
|
|
let tag = xml.get(start..).and_then(|r| r.find('>').and_then(|e| r.get(..e))).unwrap_or("");
|
|
let mut out = Vec::new();
|
|
let mut rest = tag;
|
|
while let Some(i) = rest.find("xmlns:") {
|
|
let from = rest.get(i..).unwrap_or("");
|
|
// `xmlns:p="…"`: up to the closing quote.
|
|
let Some(q1) = from.find('"') else { break };
|
|
let Some(q2) = from.get(q1 + 1..).and_then(|r| r.find('"')) else { break };
|
|
out.push(from.get(..q1 + 1 + q2 + 1).unwrap_or(""));
|
|
rest = from.get(q1 + 1 + q2 + 1..).unwrap_or("");
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod element_tests {
|
|
use super::element;
|
|
|
|
/// An element ends at its own close tag however many elements of its name it holds side by
|
|
/// side (inline objects' own `office:body`s), not after a count of tags.
|
|
#[test]
|
|
fn an_element_holding_many_of_its_name_ends_at_its_own_close() {
|
|
let inner = "<office:body><text:p>x</text:p></office:body>".repeat(1200);
|
|
let outer = format!("<office:body>{inner}</office:body>");
|
|
let xml = format!("<office:document>{outer}<office:settings/></office:document>");
|
|
assert_eq!(element(&xml, "office:body"), outer);
|
|
// Nested deeper than any XML here: malformed, to the end, as an unclosed one.
|
|
let deep = format!("{}{}", "<office:body>".repeat(600), "</office:body>".repeat(600));
|
|
assert_eq!(element(&deep, "office:body"), deep);
|
|
assert_eq!(element("<office:body><x/>", "office:body"), "<office:body><x/>");
|
|
}
|
|
}
|