Files
craft-libs/crates/docx/tests/it/content_control_save_cost.rs
Dylan M. Taylor e3e31693c7 Add craft-docx: WordCraft's DOCX reader and writer on craft-wordmodel, craft-opc and craft-xml (#1015)
* Add craft-wordmodel: WordCraft's document model without its editing code

WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.

Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.

* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs

The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.

* craft-wordmodel: keep the document operations (edit.rs) with the model

The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.

base64 0.23, as WordCraft and craft-offcrypto use.

* craft-docx: WordCraft's crates/docx as it is at wordcraft 07f97737

The source, unchanged, so the next commit shows only what the move changes.
This commit does not build on its own (it still names wordcraft-doc and
wordcraft-geom, and is not a workspace member yet).

* craft-docx: on craft-wordmodel, craft-opc, craft-xml and craft-offcrypto

- The package (zip reading, part names, relationships, content types,
  zip writing) is craft-opc's; the module keeps WordCraft's bookkeeping
  (passthrough keys, the embedded-part manifest) and its own Rel/Rels shape.
- Parts are parsed with craft-xml's canonical names (the Strict drawing
  names and mc:Choice/@Requires through its hooks), then given the tree
  shape the reader was written against.
- Encryption and Restrict Editing hashes are craft-offcrypto's; this crate
  maps its errors and the w:documentProtection attributes.
- wordcraft-doc is craft-wordmodel; wordcraft-geom's helpers are its geom.

Tests that covered what now lives in craft-opc and craft-offcrypto are left to
those crates; the DIFAT regression is craft-cfb's. Test-only zip use follows
zip 9's fallible names. The attribute-cap test times the read only.
2026-11-11 20:31:40 -05:00

229 lines
10 KiB
Rust

//! What saving a document with many content controls costs (#13).
//!
//! A form-shaped document: a control per paragraph, inline, or a control around two paragraphs.
//! The writer used to look for each paragraph's markers in the whole set of markers, once per
//! paragraph, and to compare every control with every other one, so the save was quadratic in the
//! number of controls.
use std::time::Instant;
use craft_docx::write;
use craft_wordmodel::control::{ContentControl, ControlKind};
use craft_wordmodel::para::InlineObject;
use craft_wordmodel::props::CharProps;
use craft_wordmodel::{Document, Paragraph, para_block};
/// One paragraph per control, the control inline in it.
fn inline_controls(n: usize) -> Document {
let mut doc = Document::new();
doc.body.clear();
let props = CharProps::default();
for _ in 0..n {
let mut p = Paragraph::with_text("value: ", props.clone());
let off = p.len();
p.insert_object(off, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
.expect("insert the start marker");
let off = p.len();
p.insert_object(off, InlineObject::ControlEnd, &props).expect("insert the end marker");
doc.body.push(para_block(p));
}
doc
}
/// Two paragraphs per control, the control written around both of them.
fn block_controls(n: usize) -> Document {
let mut doc = Document::new();
doc.body.clear();
let props = CharProps::default();
for _ in 0..n {
let mut open = Paragraph::new();
let off = open.len();
open.insert_object(off, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
.expect("insert the start marker");
doc.body.push(para_block(open));
let mut close = Paragraph::with_text("value", props.clone());
let off = close.len();
close.insert_object(off, InlineObject::ControlEnd, &props).expect("insert the end marker");
doc.body.push(para_block(close));
}
doc
}
/// How much more saving the bigger document costs than the smaller one, for the bigger one
/// `scale` times the size of the smaller one's: ~`scale` when linear, ~`scale`² when quadratic.
/// Each is the cheapest of several runs after a warm-up, the runs alternating so a busy spell
/// slows both sizes alike; the smaller document is saved `scale` times over per run, so both
/// timings are long against the clock's resolution.
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
small();
big();
let mut small_scaled = || (0..scale).for_each(|_| small());
let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY);
for _ in 0..5 {
let a = Instant::now();
small_scaled();
t1 = t1.min(a.elapsed().as_secs_f64() / scale as f64);
let b = Instant::now();
big();
tk = tk.min(b.elapsed().as_secs_f64());
}
(t1, tk, tk / t1.max(1e-9))
}
/// A document with many inline content controls saves in the time the controls take: the writer
/// looked for each paragraph's markers in the whole set of markers, once per paragraph, so a
/// document with 32,000 controls took four seconds to save where it takes a tenth of one now.
#[test]
fn a_document_with_many_inline_content_controls_saves_in_linear_time() {
let scale = 4;
let small = inline_controls(8_000);
let big = inline_controls(8_000 * scale);
let (t1, tk, ratio) = cost_ratio(
scale,
|| {
write(&small).expect("write");
},
|| {
write(&big).expect("write");
},
);
eprintln!("t(n) {t1:.4} s, t(4n) {tk:.4} s, ratio {ratio:.1}");
assert!(ratio < 8.0, "four times the controls took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
}
/// The same for the controls written around whole paragraphs: the writer compared every control
/// with every other one to find the ones that clash, so the save was quadratic in their number.
#[test]
fn a_document_with_many_block_content_controls_saves_in_linear_time() {
let scale = 4;
let small = block_controls(8_000);
let big = block_controls(8_000 * scale);
let (t1, tk, ratio) = cost_ratio(
scale,
|| {
write(&small).expect("write");
},
|| {
write(&big).expect("write");
},
);
eprintln!("t(n) {t1:.4} s, t(4n) {tk:.4} s, ratio {ratio:.1}");
assert!(ratio < 8.0, "four times the controls took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
}
/// The controls are written where they were: the plan that decides whether a control is written
/// inline or around whole paragraphs gives the same answer as it did before it was made faster.
#[test]
fn the_controls_are_written_where_they_were() {
// Inline controls: both markers in the same paragraph, so the control is written in it.
let bytes = write(&inline_controls(50)).expect("write");
let back = craft_docx::read(&bytes).expect("read back");
let ranges = back.control_ranges(craft_wordmodel::StoryRef::Body);
assert_eq!(ranges.len(), 50, "every control survives the round trip");
for r in &ranges {
assert_eq!(r.start.path, r.end.path, "the control is written inline: {:?}", r.start.path);
}
// Block controls: the markers in different paragraphs, so the control is written around them.
let bytes = write(&block_controls(50)).expect("write");
let back = craft_docx::read(&bytes).expect("read back");
let ranges = back.control_ranges(craft_wordmodel::StoryRef::Body);
assert_eq!(ranges.len(), 50, "every control survives the round trip");
for r in &ranges {
assert_ne!(r.start.path, r.end.path, "the control is written around the paragraphs");
}
}
/// The names of the parts of a package, in the order they are in it.
fn part_names(bytes: &[u8]) -> Vec<String> {
let z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
z.file_names().map(|n| n.unwrap().to_string()).collect()
}
/// The body of the document part of a package.
fn document_body(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
{
use std::io::Read;
z.by_name("word/document.xml").expect("the document part").read_to_string(&mut xml).expect("read");
}
let rest = xml.split_once("<w:body>").expect("the body").1;
let body = rest.split_once("</w:body>").expect("the end of the body").0;
format!("<w:body>{body}</w:body>")
}
/// A document whose controls are written in an order and a place worth pinning: two inline
/// controls in one paragraph, and a control around a whole paragraph.
fn pinned_doc() -> Document {
let props = CharProps::default();
let mut doc = Document::new();
doc.body.clear();
// "before [a] between [b] after", the two controls inline in one paragraph.
let mut p = Paragraph::with_text("before ", props.clone());
let mut at = p.len();
p.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::Text { multi_line: false })) }, &props)
.expect("the first marker");
at = p.len();
p.insert_text(at, "inside a", &props).expect("text");
at = p.len();
p.insert_object(at, InlineObject::ControlEnd, &props).expect("the first end marker");
at = p.len();
p.insert_text(at, " between ", &props).expect("text");
at = p.len();
p.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
.expect("the second marker");
at = p.len();
p.insert_text(at, "inside b", &props).expect("text");
at = p.len();
p.insert_object(at, InlineObject::ControlEnd, &props).expect("the second end marker");
at = p.len();
p.insert_text(at, " after", &props).expect("text");
doc.body.push(para_block(p));
// A control around a whole paragraph.
let mut open = Paragraph::new();
let at = open.len();
open.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
.expect("the block marker");
doc.body.push(para_block(open));
let mut inside = Paragraph::with_text("in the block", props.clone());
let at = inside.len();
inside.insert_object(at, InlineObject::ControlEnd, &props).expect("the block end marker");
doc.body.push(para_block(inside));
doc
}
/// The body of the document part the writer writes for [`pinned_doc`], byte for byte: what the
/// writer wrote before it was made faster. The counts and the paragraphs in the test above say
/// that the controls are where they belong; this says that the rest is where it was too, down to
/// the order and the place of the markers inside a paragraph.
///
/// The body is what the plan decides: which controls are written inline and which around whole
/// paragraphs, and where. Nothing outside it can change with the plan, so it is what is pinned.
///
/// Regenerate it when the writer's output changes on purpose: write [`pinned_doc`] and take the
/// body of the document part.
#[test]
fn the_document_body_is_written_as_it_was() {
let bytes = write(&pinned_doc()).expect("write");
assert_eq!(document_body(&bytes), PINNED_BODY);
// The parts are the same ones, so nothing is added to the package and nothing is lost.
assert_eq!(part_names(&bytes), PINNED_PARTS);
}
/// The body of the document part of [`pinned_doc`], as the writer wrote it before it was made
/// faster.
const PINNED_PARTS: &[&str] = &[
"[Content_Types].xml",
"_rels/.rels",
"word/document.xml",
"word/_rels/document.xml.rels",
"word/styles.xml",
"word/settings.xml",
"word/theme/theme1.xml",
"docProps/core.xml",
"docProps/app.xml",
];
const PINNED_BODY: &str = r#"<w:body><w:p><w:r><w:t xml:space="preserve">before </w:t></w:r><w:sdt><w:sdtPr><w:text/></w:sdtPr><w:sdtContent><w:r><w:t xml:space="preserve">inside a</w:t></w:r></w:sdtContent></w:sdt><w:r><w:t xml:space="preserve"> between </w:t></w:r><w:sdt><w:sdtPr></w:sdtPr><w:sdtContent><w:r><w:t xml:space="preserve">inside b</w:t></w:r></w:sdtContent></w:sdt><w:r><w:t xml:space="preserve"> after</w:t></w:r></w:p><w:sdt><w:sdtPr></w:sdtPr><w:sdtContent><w:p></w:p><w:p><w:r><w:t xml:space="preserve">in the block</w:t></w:r></w:p></w:sdtContent></w:sdt><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/><w:cols w:space="720" w:num="1"/></w:sectPr></w:body>"#;