Files
wordcraft/crates/docx/tests/it/alt_chunks.rs
T
NaxvogandClaude Opus 5.5 868fffa6fe Files: a Word chunk that asks to keep its source formatting keeps it (w:matchSrc)
An alternative format chunk holding a Word document with w:matchSrc keeps its
own styles: a style the document already has comes along under a new id, the
chunk's document defaults (and Word's, where they say nothing) go into its
base styles, and its unstyled paragraphs use its own Normal. Without
w:matchSrc the document's styles win, as before. Adopting another document
now also renumbers table styles and the revisions of table, row and cell
property changes.

A document whose chunk shares style names with it (Normal with an exact line
height, an italic Subtitle) ran a page longer than in Word: the chunk's tables
took the document's styles. It now keeps its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:04:31 +01:00

366 lines
19 KiB
Rust

//! Alternative format chunks (`w:altChunk`, ECMA-376 §17.17.2.1): content in another format that
//! is brought in where the chunk stands. All markup here is synthetic.
use std::io::Write;
use wordcraft_doc::{Block, Document, InlineObject, StoryRef};
use wordcraft_docx::{AltChunk, ReadOptions, read, read_with};
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
const DOCX_MAIN: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
fn p(text: &str) -> String {
format!(r#"<w:p><w:r><w:t xml:space="preserve">{text}</w:t></w:r></w:p>"#)
}
/// A package whose body is `body`, with the chunk `afchunk.{ext}` (content type `ct`) as `rIdC`.
fn package(body: &str, chunk: Option<(&str, &str, &[u8])>) -> Vec<u8> {
package_styled(body, chunk, None)
}
/// [`package`] with a styles part holding `styles`.
fn package_styled(body: &str, chunk: Option<(&str, &str, &[u8])>, styles: Option<&str>) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body>{body}<w:sectPr/></w:body></w:document>"#);
let mut files: Vec<(String, Vec<u8>)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())];
let mut types = String::from(
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>"#,
);
let mut rels = String::new();
if let Some((ext, ct, bytes)) = chunk {
types.push_str(&format!(r#"<Default Extension="{ext}" ContentType="{ct}"/>"#));
rels.push_str(&format!(r#"<Relationship Id="rIdC" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="/word/afchunk.{ext}"/>"#));
files.push((format!("word/afchunk.{ext}"), bytes.to_vec()));
}
if let Some(styles) = styles {
rels.push_str(
r#"<Relationship Id="rIdS" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#,
);
files.push(("word/styles.xml".into(), format!(r#"<w:styles {NS}>{styles}</w:styles>"#).into_bytes()));
}
if !rels.is_empty() {
files.push((
"word/_rels/document.xml.rels".into(),
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"#).into_bytes(),
));
}
types.push_str(r#"<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#);
files.push(("[Content_Types].xml".into(), types.into_bytes()));
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in files {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(&bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
const CHUNK: &str = r#"<w:altChunk r:id="rIdC"/>"#;
fn body_lines(d: &Document) -> Vec<String> {
d.plain_text(StoryRef::Body).split('\n').map(str::to_string).collect()
}
#[test]
fn a_chunk_comes_in_through_the_importer_where_it_stands() {
let html = b"<p>Chunk one</p><p>Chunk two</p>";
let bytes = package(&format!("{}{CHUNK}{}", p("Before"), p("After")), Some(("htm", "application/xhtml+xml", html)));
let seen = std::cell::RefCell::new(Vec::new());
let import = |c: &AltChunk| {
seen.borrow_mut().push((c.content_type.to_string(), c.part.to_string(), c.bytes.to_vec()));
Some(Document::from_text("Chunk one\nChunk two"))
};
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
assert_eq!(body_lines(&d), ["Before", "Chunk one", "Chunk two", "After"]);
assert_eq!(*seen.borrow(), [("application/xhtml+xml".to_string(), "word/afchunk.htm".to_string(), html.to_vec())]);
// Without an importer the chunk is left out.
assert_eq!(body_lines(&read(&bytes).unwrap()), ["Before", "After"]);
}
#[test]
fn a_bookmark_opened_before_a_chunk_at_the_start_of_the_body_is_kept() {
let body = format!(r#"<w:bookmarkStart w:id="0" w:name="Here"/>{CHUNK}<w:bookmarkEnd w:id="0"/>{}"#, p("After"));
let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"<p>Chunk</p>")));
let import = |_: &AltChunk| Some(Document::from_text("Chunk"));
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
assert_eq!(body_lines(&d), ["Chunk", "After"]);
let marks = d.bookmarks();
assert_eq!(marks.len(), 1);
assert_eq!((marks[0].0.as_str(), marks[0].1.path.last(), marks[0].1.off), ("Here", 0, 0));
}
#[test]
fn a_bookmark_opened_between_a_table_and_a_chunk_starts_the_chunk() {
let table = format!("<w:tbl><w:tr><w:tc>{}</w:tc></w:tr></w:tbl>", p("Cell"));
let body = format!(r#"{table}<w:bookmarkStart w:id="0" w:name="Here"/>{CHUNK}<w:bookmarkEnd w:id="0"/>{}"#, p("After"));
let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"<p>Chunk</p>")));
let import = |_: &AltChunk| Some(Document::from_text("Chunk"));
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
let marks = d.bookmarks();
assert_eq!(marks.len(), 1);
assert_eq!((marks[0].1.path.clone(), marks[0].1.off), (wordcraft_doc::Path::top(1), 0), "not in the table's cell");
}
#[test]
fn permission_ranges_of_chunks_and_their_host_stay_apart() {
let inner = package(r#"<w:p><w:permStart w:id="0" w:ed="them"/><w:r><w:t>Inner</w:t></w:r><w:permEnd w:id="0"/></w:p>"#, None);
// The host's range 0 is open across two chunks whose own ranges are 0 too.
let body = format!(
r#"<w:p><w:permStart w:id="0" w:edGrp="everyone"/><w:r><w:t>Host</w:t></w:r></w:p>{CHUNK}{CHUNK}<w:p><w:r><w:t>After</w:t></w:r><w:permEnd w:id="0"/></w:p>"#
);
let d = read(&package(&body, Some(("docx", DOCX_MAIN, &inner)))).unwrap();
assert_eq!(body_lines(&d), ["Host", "Inner", "Inner", "After"]);
let check = |d: &Document| {
let ranges = d.perm_ranges();
let mut ids: Vec<u32> = ranges.iter().map(|r| r.id).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 3, "{ranges:?}");
let host = ranges.iter().find(|r| r.group == "everyone").unwrap();
assert_eq!((host.start.path.last(), host.end.path.last()), (0, 3));
assert!(ranges.iter().filter(|r| r.editor == "them").all(|r| r.start.path == r.end.path));
};
check(&d);
check(&read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
/// A package whose body is `body`, with story parts (relationship type, part name, XML) and the
/// Word document `chunk` as `rIdC` from the main part and from each story part.
fn package_with_stories(body: &str, stories: &[(&str, &str, String)], chunk: Option<&[u8]>) -> Vec<u8> {
let rel = |id: &str, ty: &str, target: &str| {
format!(r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{ty}" Target="{target}"/>"#)
};
let rels =
|inner: String| format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{inner}</Relationships>"#);
let chunk_rel = if chunk.is_some() { rel("rIdC", "aFChunk", "/word/afchunk.docx") } else { String::new() };
let doc = format!(r#"<w:document {NS}><w:body>{body}<w:sectPr/></w:body></w:document>"#);
let mut main_rels = chunk_rel.clone();
let mut files: Vec<(String, Vec<u8>)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())];
for (i, (ty, name, xml)) in stories.iter().enumerate() {
main_rels.push_str(&rel(&format!("rIdS{i}"), ty, name));
files.push((format!("word/{name}"), xml.clone().into_bytes()));
files.push((format!("word/_rels/{name}.rels"), rels(chunk_rel.clone()).into_bytes()));
}
files.push(("word/_rels/document.xml.rels".into(), rels(main_rels).into_bytes()));
if let Some(c) = chunk {
files.push(("word/afchunk.docx".into(), c.to_vec()));
}
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in files {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(&bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// A paragraph `text` that comment `id` (in the file) is anchored around.
fn commented(id: u32, text: &str) -> String {
format!(
r#"<w:p><w:commentRangeStart w:id="{id}"/><w:r><w:t>{text}</w:t></w:r><w:commentRangeEnd w:id="{id}"/><w:r><w:commentReference w:id="{id}"/></w:r></w:p>"#
)
}
/// A comments part: (file id, author, content).
fn comments_part(list: &[(u32, &str, String)]) -> (&'static str, &'static str, String) {
let body: String = list.iter().map(|(id, who, content)| format!(r#"<w:comment w:id="{id}" w:author="{who}">{content}</w:comment>"#)).collect();
("comments", "comments.xml", format!("<w:comments {NS}>{body}</w:comments>"))
}
/// The authors of the comments anchored in the stories whose text contains `text`, and whether
/// every comment has text of its own and a distinct id.
fn anchored(d: &Document, text: &str) -> Vec<String> {
let mut stories: Vec<&[std::sync::Arc<Block>]> = vec![&d.body];
stories.extend(d.parts.values().map(|p| p.blocks.as_slice()));
let mut out = Vec::new();
for blocks in stories {
for b in blocks {
let Block::Para(p) = &**b else { continue };
if !p.text.contains(text) {
continue;
}
for o in &p.objects {
if let InlineObject::CommentStart { id } = o {
out.push(d.comments.get(id).map_or("?".to_string(), |c| c.author.clone()));
}
}
}
}
out
}
/// A Word document with one comment by "Them" around "Inner".
fn commented_chunk() -> Vec<u8> {
package_with_stories(&commented(0, "Inner"), &[comments_part(&[(0, "Them", p("Theirs"))])], None)
}
#[test]
fn a_chunk_in_a_footnote_keeps_its_comments_beside_the_documents() {
let notes = format!(r#"<w:footnotes {NS}><w:footnote w:id="1"><w:altChunk r:id="rIdC"/></w:footnote></w:footnotes>"#);
let body = format!(r#"{}<w:p><w:r><w:footnoteReference w:id="1"/></w:r></w:p>"#, commented(0, "Host"));
let stories = [("footnotes", "footnotes.xml", notes), comments_part(&[(0, "Us", p("Ours"))])];
let d = read(&package_with_stories(&body, &stories, Some(&commented_chunk()))).unwrap();
assert_eq!(d.comments.len(), 2, "{:?}", d.comments);
assert_eq!(anchored(&d, "Host"), ["Us"]);
assert_eq!(anchored(&d, "Inner"), ["Them"]);
}
#[test]
fn a_chunk_in_a_comment_keeps_its_comments_beside_the_documents() {
let inside = format!(r#"{}<w:altChunk r:id="rIdC"/>"#, p("Ours"));
let body = format!("{}{}", commented(0, "Host"), commented(1, "Second"));
let stories = [comments_part(&[(0, "Us", inside), (1, "Us too", p("Ours too"))])];
let d = read(&package_with_stories(&body, &stories, Some(&commented_chunk()))).unwrap();
assert_eq!(d.comments.len(), 3, "{:?}", d.comments);
assert_eq!(anchored(&d, "Host"), ["Us"]);
assert_eq!(anchored(&d, "Second"), ["Us too"]);
assert_eq!(anchored(&d, "Inner"), ["Them"]);
}
#[test]
fn a_wordprocessing_chunk_is_read_without_an_importer_and_flows_in_its_section() {
let inner = package(&format!("{}{}", p("Inner one"), p("Inner two")), None);
let bytes = package(&format!("{}{CHUNK}{}", p("Before"), p("After")), Some(("docx", DOCX_MAIN, &inner)));
let d = read(&bytes).unwrap();
assert_eq!(body_lines(&d), ["Before", "Inner one", "Inner two", "After"]);
// The inner document's own section doesn't come along.
assert_eq!(d.sections().len(), 1);
}
/// Styles a matchSrc chunk brings under new ids get distinct ones: with `A` and `A1` ours and
/// `A` to `A10` the chunk's, its `A` and `A1` both renamed don't land on one id (the second then
/// lost, its paragraphs taking the first's formatting), and keep their formatting through a save.
#[test]
fn match_src_gives_each_renamed_style_its_own_id() {
let style = |id: &str, half_points: u32| {
format!(r#"<w:style w:type="paragraph" w:styleId="{id}"><w:name w:val="{id}"/><w:rPr><w:sz w:val="{half_points}"/></w:rPr></w:style>"#)
};
let ours: String = ["A", "A1"].iter().map(|id| style(id, 22)).collect();
let mut theirs = style("A", 20) + &style("A1", 28);
for n in 2..=10 {
theirs += &style(&format!("A{n}"), 16);
}
let para = |id: &str, text: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{id}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
let inner = package_styled(&(para("A", "ten") + &para("A1", "fourteen")), None, Some(&theirs));
let body = format!(r#"{}<w:altChunk r:id="rIdC"><w:altChunkPr><w:matchSrc/></w:altChunkPr></w:altChunk>"#, p("Ours"));
let d = read(&package_styled(&body, Some(("docx", DOCX_MAIN, &inner)), Some(&ours))).unwrap();
let check = |d: &Document| {
let look = |text: &str| {
d.body
.iter()
.find_map(|b| match &**b {
Block::Para(p) if p.text == text => {
Some((p.props.style.clone(), d.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size))
}
_ => None,
})
.unwrap()
};
let ((ten, ten_size), (fourteen, fourteen_size)) = (look("ten"), look("fourteen"));
assert_ne!(ten, fourteen);
assert_eq!((ten_size, fourteen_size), (10.0, 14.0));
};
check(&d);
check(&read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
#[test]
fn match_src_keeps_a_word_chunks_own_formatting() {
// Both define Normal and Subtitle differently, and the chunk's defaults differ from ours.
let ours = r#"<w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="22"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="200"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:pPr><w:spacing w:after="248" w:line="248" w:lineRule="exact"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:rPr><w:i/></w:rPr></w:style>"#;
let theirs = r#"<w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="24"/></w:rPr></w:rPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:basedOn w:val="Normal"/></w:style>"#;
let inner = package_styled(
&format!(r#"{}<w:p><w:pPr><w:pStyle w:val="Subtitle"/></w:pPr><w:r><w:t>Sub</w:t></w:r></w:p>"#, p("Plain")),
None,
Some(theirs),
);
for match_src in [true, false] {
let pr = if match_src { "<w:altChunkPr><w:matchSrc/></w:altChunkPr>" } else { "" };
let body = format!(r#"{}<w:altChunk r:id="rIdC">{pr}</w:altChunk>"#, p("Ours"));
let d = read(&package_styled(&body, Some(("docx", DOCX_MAIN, &inner)), Some(ours))).unwrap();
let look = |i: usize| match &*d.body[i] {
Block::Para(p) => {
let para = d.styles.resolve_para(&p.props);
let chr = d.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0));
(para.space_after, chr.size, chr.italic)
}
b => panic!("{b:?}"),
};
// Ours stays ours either way.
assert_eq!(look(0), (12.4, 11.0, false));
if match_src {
// The chunk's own Normal (its 12 pt, Word's no space after) and its Subtitle (not italic).
assert_eq!(look(1), (0.0, 12.0, false));
assert_eq!(look(2), (0.0, 12.0, false));
} else {
// Without it the chunk takes our styles (our Subtitle stands on our defaults).
assert_eq!(look(1), (12.4, 11.0, false));
assert_eq!(look(2), (10.0, 11.0, true));
}
}
}
#[test]
fn nested_wordprocessing_chunks_stop_at_a_depth() {
let mut bytes = package(&p("Deepest"), None);
for level in 0..8 {
bytes = package(&format!("{}{CHUNK}", p(&format!("Level {level}"))), Some(("docx", DOCX_MAIN, &bytes)));
}
let lines = body_lines(&read(&bytes).unwrap());
assert_eq!(lines.first().map(String::as_str), Some("Level 7"));
assert!(!lines.iter().any(|l| l == "Deepest"), "{lines:?}");
assert!(lines.len() >= 4, "{lines:?}");
}
#[test]
fn a_chunks_pictures_come_with_it() {
let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("htm", "text/html", b"<p>x</p>")));
let import = |_: &AltChunk| {
let mut d = Document::new();
let key = d.add_media(vec![1, 2, 3], "png");
let mut para = wordcraft_doc::Paragraph::new();
let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
para.insert_object(0, image, &Default::default()).unwrap();
d.body = vec![std::sync::Arc::new(Block::Para(para))];
Some(d)
};
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
let keys: Vec<String> = d
.body
.iter()
.filter_map(|b| match &**b {
Block::Para(p) => p.objects.iter().find_map(|o| match o {
InlineObject::Image { media, .. } => Some(media.clone()),
_ => None,
}),
_ => None,
})
.collect();
assert_eq!(keys.len(), 1);
assert_eq!(d.media.get(&keys[0]).map(|m| m.as_slice()), Some(&[1u8, 2, 3][..]));
}
#[test]
fn a_file_brings_in_a_bounded_number_of_chunks() {
let bytes = package(&CHUNK.repeat(300), Some(("htm", "text/html", b"<p>x</p>")));
let calls = std::cell::Cell::new(0);
let import = |_: &AltChunk| {
calls.set(calls.get() + 1);
Some(Document::from_text("x"))
};
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
assert_eq!(calls.get(), 256);
assert_eq!(body_lines(&d).len(), 256);
}
#[test]
fn hostile_chunks_never_panic() {
for (ext, ct, bytes) in [("docx", DOCX_MAIN, &b"PK\x03\x04 not a zip"[..]), ("htm", "text/html", b""), ("bin", "", b"\x00\xff")] {
let bytes = package(&format!("{CHUNK}{CHUNK}{}", p("x")), Some((ext, ct, bytes)));
let import = |_: &AltChunk| None;
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
assert_eq!(body_lines(&d), ["x"]);
}
// A chunk naming a missing part or another relationship type is left out.
let bytes = package(&format!(r#"<w:altChunk r:id="nope"/><w:altChunk/>{}"#, p("y")), None);
assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]);
}