mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
* CI: line-table debug info for dev builds, none for dependencies * CI: one integration-test binary per crate * CI: nextest with doctests, test build without examples, one wasm check * CI: the interface audit reports missing catalog entries instead of failing * Docs: the interface audit, not CI as a whole, passes with missing catalog entries
3950 lines
252 KiB
Rust
3950 lines
252 KiB
Rust
//! Hand-written minimal WordprocessingML fixtures (written for these tests from the spec).
|
||
|
||
use std::io::Write;
|
||
|
||
use wordcraft_doc::fill::Fill;
|
||
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, Underline, VMerge};
|
||
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, RevisionKind};
|
||
|
||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
|
||
|
||
const ROOT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><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>"#;
|
||
|
||
pub fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||
for (name, bytes) in entries {
|
||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||
zw.write_all(bytes).unwrap();
|
||
}
|
||
zw.finish().unwrap().into_inner()
|
||
}
|
||
|
||
fn rels(list: &[(&str, &str, &str)]) -> String {
|
||
let mut s = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||
for (id, ty, target) in list {
|
||
let ext = if target.starts_with("http") { r#" TargetMode="External""# } else { "" };
|
||
s.push_str(&format!(
|
||
r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{ty}" Target="{target}"{ext}/>"#
|
||
));
|
||
}
|
||
s.push_str("</Relationships>");
|
||
s
|
||
}
|
||
|
||
/// A package with `body` as the document body and extra (path, content) parts.
|
||
fn docx(body: &str, doc_rels: &[(&str, &str, &str)], extra: &[(&str, &str)]) -> Vec<u8> {
|
||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||
let r = rels(doc_rels);
|
||
let mut entries: Vec<(&str, &[u8])> =
|
||
vec![("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("word/_rels/document.xml.rels", r.as_bytes())];
|
||
for (p, c) in extra {
|
||
entries.push((p, c.as_bytes()));
|
||
}
|
||
zip(&entries)
|
||
}
|
||
|
||
fn read_body(body: &str) -> Document {
|
||
wordcraft_docx::read(&docx(body, &[], &[])).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn paragraph_default_presence_survives_repeated_saves() {
|
||
// Omission leaves paragraph defaults application-defined (ISO 29500 pPrDefault).
|
||
// An explicitly empty container must therefore remain distinct from omission.
|
||
for (defaults, present, after) in [
|
||
("", false, None),
|
||
("<w:docDefaults/>", false, None),
|
||
("<w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val=\"22\"/></w:rPr></w:rPrDefault></w:docDefaults>", false, None),
|
||
("<w:docDefaults><w:pPrDefault/></w:docDefaults>", true, None),
|
||
("<w:docDefaults><w:pPrDefault><w:pPr/></w:pPrDefault></w:docDefaults>", true, None),
|
||
("<w:docDefaults><w:pPrDefault><w:pPr><w:spacing w:after=\"160\"/></w:pPr></w:pPrDefault></w:docDefaults>", true, Some(8.0)),
|
||
] {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}>{defaults}<w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#
|
||
);
|
||
let mut d = wordcraft_docx::read(&docx(
|
||
"<w:p><w:r><w:t>First</w:t></w:r></w:p><w:p><w:r><w:t>Second</w:t></w:r></w:p>",
|
||
&[("rId1", "styles", "styles.xml")],
|
||
&[("word/styles.xml", &styles)],
|
||
))
|
||
.unwrap();
|
||
for _ in 0..2 {
|
||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||
let xml = text(&unzip(&bytes), "word/styles.xml");
|
||
assert_eq!(xml.contains("<w:pPrDefault>"), present, "{defaults}");
|
||
d = wordcraft_docx::read(&bytes).unwrap();
|
||
assert_eq!(d.styles.default_para.space_after, after);
|
||
assert_eq!(paras(&d).iter().map(|p| p.text.as_str()).collect::<Vec<_>>(), ["First", "Second"]);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn omitted_paragraph_defaults_can_be_edited() {
|
||
let styles =
|
||
format!(r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#);
|
||
let mut d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||
assert!(!d.styles.default_para_present);
|
||
d.styles.default_para.space_after = Some(12.0);
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
assert!(again.styles.default_para_present);
|
||
assert_eq!(again.styles.default_para.space_after, Some(12.0));
|
||
let new = Document::new();
|
||
assert_eq!(wordcraft_docx::read(&wordcraft_docx::write(&new).unwrap()).unwrap().styles.default_para, new.styles.default_para);
|
||
}
|
||
|
||
fn paras(d: &Document) -> Vec<&Paragraph> {
|
||
d.body.iter().filter_map(|b| b.as_para()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn ref_links_keep_cached_results() {
|
||
let d = read_body(
|
||
r#"
|
||
<w:p><w:fldSimple w:instr="REF destination \h" w:fldLock="1"><w:r><w:rPr><w:b/></w:rPr><w:t>Cached simple</w:t></w:r></w:fldSimple></w:p>
|
||
<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve">REF destination </w:instrText></w:r><w:r><w:instrText>\w \h</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>Cached complex</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>
|
||
<w:p><w:bookmarkStart w:id="0" w:name="destination"/><w:r><w:t>Changed destination</w:t></w:r><w:bookmarkEnd w:id="0"/></w:p>"#,
|
||
);
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
for doc in [&d, &again] {
|
||
let p = paras(doc);
|
||
for (index, instr, cached, locked) in
|
||
[(0, r"REF destination \h", "Cached simple", true), (1, r"REF destination \w \h", "Cached complex", false)]
|
||
{
|
||
assert!(matches!(&p[index].objects[0], InlineObject::Field { instr: actual, result, locked: lock, .. }
|
||
if actual == instr && result == cached && *lock == locked));
|
||
}
|
||
assert_eq!(p[0].props_of_char(0).bold, Some(true));
|
||
assert!(doc.bookmarks().iter().any(|(name, _)| name == "destination"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn single_entry_toc_keeps_formatted_result_and_nested_page_field() {
|
||
let d = read_body(
|
||
r#"
|
||
<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>
|
||
<w:r><w:fldChar w:fldCharType="begin" w:fldLock="true"/></w:r>
|
||
<w:r><w:instrText> TOC \o "1-1" \h </w:instrText></w:r>
|
||
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
|
||
<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:b/></w:rPr><w:t>Intro</w:t></w:r>
|
||
<w:r><w:tab/></w:r><w:fldSimple w:instr=" PAGEREF _Toc1 \h "><w:r><w:t>1</w:t></w:r></w:fldSimple></w:hyperlink>
|
||
<w:r><w:fldChar w:fldCharType="end"/></w:r>
|
||
</w:p>"#,
|
||
);
|
||
let p = paras(&d);
|
||
assert_eq!(p[0].plain_text(), "Contents");
|
||
assert_eq!(
|
||
p[0].objects,
|
||
vec![InlineObject::Field {
|
||
instr: r#"TOC \o "1-1" \h"#.into(),
|
||
result: String::new(),
|
||
locked: true,
|
||
dirty: false,
|
||
code: false,
|
||
form: String::new()
|
||
}]
|
||
);
|
||
assert_eq!(p[1].plain_text(), "Intro\t1");
|
||
assert_eq!(p[1].props_of_char(0).bold, Some(true));
|
||
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc1"));
|
||
assert_eq!(
|
||
p[1].objects,
|
||
vec![InlineObject::Field {
|
||
instr: r#"PAGEREF _Toc1 \h"#.into(),
|
||
result: "1".into(),
|
||
locked: false,
|
||
dirty: false,
|
||
code: false,
|
||
form: String::new()
|
||
}]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn toc_complex_field_spanning_paragraphs() {
|
||
let body = r#"
|
||
<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>
|
||
<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>
|
||
<w:hyperlink w:anchor="_Toc1"><w:r><w:t>Intro</w:t></w:r><w:r><w:tab/></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGEREF _Toc1 \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:hyperlink>
|
||
</w:p>
|
||
<w:p><w:hyperlink w:anchor="_Toc2"><w:r><w:t>Body</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>2</w:t></w:r></w:hyperlink></w:p>
|
||
<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>
|
||
<w:p><w:bookmarkStart w:id="0" w:name="_Toc1"/><w:r><w:t>Intro</w:t></w:r><w:bookmarkEnd w:id="0"/></w:p>
|
||
<w:p><w:r><w:t xml:space="preserve">Page </w:t></w:r><w:fldSimple w:instr=" PAGE "><w:r><w:t>7</w:t></w:r></w:fldSimple></w:p>"#;
|
||
let d = read_body(body);
|
||
let p = paras(&d);
|
||
assert_eq!(p.len(), 5);
|
||
assert_eq!(p[0].text, "\u{FFFC}Intro\t\u{FFFC}");
|
||
assert_eq!(p[0].objects[0], InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false, dirty: false });
|
||
assert_eq!(
|
||
p[0].objects[1],
|
||
InlineObject::Field {
|
||
instr: r#"PAGEREF _Toc1 \h"#.into(),
|
||
result: "1".into(),
|
||
locked: false,
|
||
dirty: false,
|
||
code: false,
|
||
form: String::new()
|
||
}
|
||
);
|
||
assert_eq!(p[0].props_of_char(3).link.as_deref(), Some("#_Toc1"));
|
||
assert_eq!(p[0].props.style.as_deref(), Some("TOC1"));
|
||
assert_eq!(p[1].text, "Body\t2");
|
||
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc2"));
|
||
assert_eq!(p[2].objects, vec![InlineObject::FieldEnd]);
|
||
assert_eq!(p[3].objects, vec![InlineObject::BookmarkStart { name: "_Toc1".into() }, InlineObject::BookmarkEnd { name: "_Toc1".into() }]);
|
||
assert_eq!(p[4].plain_text(), "Page 7");
|
||
assert_eq!(
|
||
p[4].objects[0],
|
||
InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() }
|
||
);
|
||
// And it writes back out and reads the same.
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
assert_eq!(again.body, d.body);
|
||
}
|
||
|
||
#[test]
|
||
fn hyperlink_runs_and_wrappers() {
|
||
let body = r#"
|
||
<w:p>
|
||
<w:hyperlink r:id="rId5" w:history="1"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>site</w:t></w:r></w:hyperlink>
|
||
<w:hyperlink r:id="rId5" w:anchor="frag"><w:r><w:t>X</w:t></w:r></w:hyperlink>
|
||
<w:smartTag><w:r><w:t>A</w:t></w:r></w:smartTag><w:customXml><w:r><w:t>B</w:t></w:r></w:customXml>
|
||
<w:sdt><w:sdtPr/><w:sdtContent><w:r><w:t>C</w:t></w:r></w:sdtContent></w:sdt>
|
||
<w:r><w:sym w:font="Wingdings" w:char="F04A"/><w:noBreakHyphen/><w:softHyphen/><w:cr/><w:br w:type="page"/><w:br w:type="column"/><w:t xml:space="preserve"> e&</w:t></w:r>
|
||
<w:proofErr w:type="spellStart"/><w:r><w:rPr><w:b w:val="false"/><w:i w:val="0"/><w:caps w:val="off"/><w:strike w:val="true"/><w:color w:val="auto"/></w:rPr><w:t>T</w:t></w:r>
|
||
</w:p>
|
||
<w:sdt><w:sdtContent><w:p><w:r><w:t>in block sdt</w:t></w:r></w:p></w:sdtContent></w:sdt>"#;
|
||
let bytes = docx(body, &[("rId5", "hyperlink", "https://example.org/")], &[]);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let p = paras(&d);
|
||
// The content control around "C" is kept (zero-width markers in the text).
|
||
assert_eq!(p[0].plain_text(), "siteXABC\u{F04A}\u{2011}\u{AD}\n\u{C}\u{E} e&T");
|
||
assert!(matches!(p[0].objects.first(), Some(InlineObject::ControlStart { .. })));
|
||
assert_eq!(p[0].props_of_char(0).link.as_deref(), Some("https://example.org/"));
|
||
assert_eq!(p[0].props_of_char(0).style.as_deref(), Some("Hyperlink"));
|
||
assert_eq!(p[0].props_of_char(4).link.as_deref(), Some("https://example.org/#frag"));
|
||
let sym_off = p[0].text.find('\u{F04A}').unwrap();
|
||
assert_eq!(p[0].props_of_char(sym_off).sym_font.as_deref(), Some("Wingdings"), "the symbol keeps its own font");
|
||
let t = p[0].props_of_char(p[0].text.len() - 1);
|
||
assert_eq!((t.bold, t.italic, t.caps, t.strike, t.color), (Some(false), Some(false), Some(false), Some(true), Some(TextColor::Auto)));
|
||
assert_eq!(p[1].plain_text(), "in block sdt");
|
||
}
|
||
|
||
#[test]
|
||
fn symbol_font_text_reads_as_unicode_and_round_trips_unchanged() {
|
||
// Wingdings stored both ways Word writes it: `w:sym` (private-use code) and a plain code in a
|
||
// run whose font is Wingdings; plus Symbol and Webdings.
|
||
let body = r#"
|
||
<w:p>
|
||
<w:r><w:t xml:space="preserve">Done </w:t></w:r>
|
||
<w:r><w:sym w:font="Wingdings" w:char="F0FC"/></w:r>
|
||
<w:r><w:rPr><w:rFonts w:ascii="Wingdings" w:hAnsi="Wingdings"/></w:rPr><w:t>J</w:t></w:r>
|
||
<w:r><w:sym w:font="Symbol" w:char="F061"/><w:sym w:font="Webdings" w:char="F061"/></w:r>
|
||
</w:p>"#;
|
||
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||
let p = paras(&d);
|
||
// Stored as Word stores it…
|
||
assert_eq!(p[0].plain_text(), "Done \u{F0FC}J\u{F061}\u{F061}");
|
||
// …read (copied, exported as text) as the symbols shown.
|
||
assert_eq!(p[0].shown_text(), "Done \u{2713}\u{263A}\u{3B1}\u{2714}");
|
||
assert_eq!(d.shown_text(wordcraft_doc::StoryRef::Body), "Done \u{2713}\u{263A}\u{3B1}\u{2714}");
|
||
// A round trip keeps the original characters and fonts.
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
let q = paras(&again);
|
||
assert_eq!(q[0].plain_text(), p[0].plain_text());
|
||
assert_eq!(q[0].shown_text(), p[0].shown_text());
|
||
}
|
||
|
||
/// The `w:sym` elements of a document's main part, as written.
|
||
fn sym_elements(bytes: &[u8]) -> Vec<String> {
|
||
let parts = unzip(bytes);
|
||
let xml = String::from_utf8_lossy(parts.get("word/document.xml").unwrap()).to_string();
|
||
xml.match_indices("<w:sym ").map(|(i, _)| xml[i..].split("/>").next().unwrap().to_string() + "/>").collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_sym_character_keeps_its_symbol_font_when_the_text_font_changes() {
|
||
// #2036: ECMA-376 §17.3.3.30 — a `w:sym` character's font is its own `w:font`, whatever the
|
||
// run's font. Setting the whole paragraph in Times New Roman keeps the check mark.
|
||
let body = r#"<w:p><w:r><w:t>a</w:t></w:r><w:r><w:sym w:font="Wingdings" w:char="F0FC"/></w:r><w:r><w:t>b</w:t></w:r></w:p>"#;
|
||
let mut d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||
let len = paras(&d)[0].text.len();
|
||
d.format_range(&wordcraft_doc::Pos::body(0, 0), &wordcraft_doc::Pos::body(0, len), &|c| c.font = Some("Times New Roman".into())).unwrap();
|
||
let p = paras(&d)[0];
|
||
assert_eq!(p.plain_text(), "a\u{F0FC}b", "the stored character is unchanged");
|
||
assert_eq!(p.props_of_char(1).font.as_deref(), Some("Times New Roman"), "the run font did change");
|
||
assert_eq!(p.shown_text(), "a\u{2713}b");
|
||
assert_eq!(d.shown_text(wordcraft_doc::StoryRef::Body), "a\u{2713}b");
|
||
// Written back as a `w:sym` in its symbol font, so Word shows the symbol too.
|
||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||
assert_eq!(sym_elements(&bytes), vec![r#"<w:sym w:font="Wingdings" w:char="F0FC"/>"#.to_string()]);
|
||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||
let q = paras(&again)[0];
|
||
assert_eq!(q.plain_text(), "a\u{F0FC}b");
|
||
assert_eq!(q.shown_text(), "a\u{2713}b");
|
||
assert_eq!(q.props_of_char(1).font.as_deref(), Some("Times New Roman"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_sym_character_round_trips_as_w_sym() {
|
||
let body = r#"<w:p><w:r><w:t xml:space="preserve">Done </w:t></w:r><w:r><w:sym w:font="Wingdings" w:char="F0FC"/><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p>"#;
|
||
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||
assert_eq!(
|
||
sym_elements(&bytes),
|
||
vec![r#"<w:sym w:font="Wingdings" w:char="F0FC"/>"#.to_string(), r#"<w:sym w:font="Symbol" w:char="F061"/>"#.to_string()]
|
||
);
|
||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||
assert_eq!(paras(&again)[0].shown_text(), "Done \u{2713}\u{3B1}");
|
||
assert_eq!(sym_elements(&wordcraft_docx::write(&again).unwrap()), sym_elements(&bytes));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_sym_fonts_never_panic() {
|
||
// Built in the model (an XML file couldn't even hold some of these names).
|
||
let huge = "W".repeat(100_000);
|
||
for font in ["", " ", "\u{1}", huge.as_str(), "Wingdings\u{FFFF}", "<&\"'>"] {
|
||
let mut d = Document::from_text("a\u{F0FC}\u{1}\u{1F600}b");
|
||
let len = paras(&d)[0].text.len();
|
||
let f = font.to_string();
|
||
d.format_range(&wordcraft_doc::Pos::body(0, 0), &wordcraft_doc::Pos::body(0, len), &|c| c.sym_font = Some(f.clone())).unwrap();
|
||
let _ = d.shown_text(wordcraft_doc::StoryRef::Body);
|
||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||
let _ = wordcraft_docx::read(&bytes).unwrap();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn only_printable_characters_are_written_as_w_sym() {
|
||
// `w:sym` holds a character code from U+0020 (the reader drops lower ones), like `w:t`.
|
||
let mut d = Document::from_text("a\u{7}");
|
||
d.format_range(&wordcraft_doc::Pos::body(0, 0), &wordcraft_doc::Pos::body(0, 2), &|c| c.sym_font = Some("Wingdings".into())).unwrap();
|
||
assert_eq!(sym_elements(&wordcraft_docx::write(&d).unwrap()), vec![r#"<w:sym w:font="Wingdings" w:char="0061"/>"#.to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn pcl6_symbol_roundtrip() {
|
||
let body = r#"<w:p><w:r><w:sym w:font="Symbol (PCL6)" w:char="F0B3"/></w:r></w:p>"#;
|
||
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
for doc in [&d, &again] {
|
||
let p = paras(doc)[0];
|
||
assert_eq!(p.text, "\u{F0B3}");
|
||
assert_eq!(p.props_of_char(0).sym_font.as_deref(), Some("Symbol (PCL6)"));
|
||
assert_eq!(p.shown_text(), "≥");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn alternate_content_choice_and_fallback() {
|
||
let body = r#"
|
||
<w:p><w:r><mc:AlternateContent>
|
||
<mc:Choice Requires="wps"><w:drawing><wp:anchor behindDoc="0" distL="12700"><wp:positionH relativeFrom="page"><wp:posOffset>127000</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:wrapSquare wrapText="bothSides"/><wp:docPr id="1" name="Text Box 1"/>
|
||
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:cNvSpPr txBox="1"/><wps:spPr><a:prstGeom prst="rect"/></wps:spPr><wps:txbx><w:txbxContent><w:p><w:r><w:t>boxed</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic>
|
||
</wp:anchor></w:drawing></mc:Choice>
|
||
<mc:Fallback><w:pict><v:shape style="width:72pt;height:36pt"><v:textbox><w:txbxContent><w:p><w:r><w:t>old</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict></mc:Fallback>
|
||
</mc:AlternateContent></w:r></w:p>
|
||
<w:p><mc:AlternateContent><mc:Choice Requires="w99"><w:r><w:t>future</w:t></w:r></mc:Choice><mc:Fallback><w:r><w:t>fallback text</w:t></w:r></mc:Fallback></mc:AlternateContent></w:p>"#;
|
||
let d = read_body(body);
|
||
let p = paras(&d);
|
||
match &p[0].objects[0] {
|
||
InlineObject::Shape { kind, w, h, float, story, .. } => {
|
||
assert_eq!((*kind, *w, *h), (ShapeKind::TextBox, 72.0, 36.0));
|
||
assert_eq!(float.wrap, Wrap::Square);
|
||
assert_eq!(float.x, 10.0);
|
||
assert_eq!(float.dist, 1.0);
|
||
let s = d.parts.get(&story.unwrap()).unwrap();
|
||
assert_eq!(s.blocks[0].as_para().unwrap().text, "boxed");
|
||
}
|
||
o => panic!("{o:?}"),
|
||
}
|
||
assert_eq!(p[1].text, "fallback text");
|
||
}
|
||
|
||
#[test]
|
||
fn table_look_bitmask_and_legacy_merges() {
|
||
let body = r#"
|
||
<w:tbl>
|
||
<w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="5000" w:type="pct"/><w:jc w:val="center"/><w:tblLook w:val="04A0"/></w:tblPr>
|
||
<w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
|
||
<w:tr><w:tc><w:tcPr><w:hMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>wide</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:hMerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:vMerge w:val="restart"/><w:shd w:val="clear" w:fill="FF0000"/></w:tcPr><w:p/></w:tc></w:tr>
|
||
<w:tr><w:trPr><w:trHeight w:val="400"/></w:trPr><w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p/></w:tc><w:sdt><w:sdtContent><w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc></w:sdtContent></w:sdt></w:tr>
|
||
</w:tbl>
|
||
<w:tbl><w:tblPr><w:tblLook w:firstRow="0" w:lastRow="1" w:firstColumn="1" w:lastColumn="0" w:noHBand="1" w:noVBand="0"/></w:tblPr><w:tr><w:tc><w:p/></w:tc></w:tr></w:tbl>
|
||
<w:p/>"#;
|
||
let d = read_body(body);
|
||
let Block::Table(t) = &*d.body[0] else { panic!() };
|
||
assert_eq!(t.props.width_pct, Some(100.0));
|
||
assert_eq!(t.props.align, Some(Align::Center));
|
||
let l = t.props.look;
|
||
assert!(l.header_row && l.first_column && l.banded_rows && !l.banded_columns && !l.total_row && !l.last_column);
|
||
assert_eq!(t.rows[0].cells.len(), 2);
|
||
assert_eq!(t.rows[0].cells[0].span(), 2);
|
||
assert_eq!(t.rows[0].cells[1].props.vmerge, VMerge::Restart);
|
||
assert_eq!(t.rows[1].cells[1].props.vmerge, VMerge::Continue);
|
||
assert_eq!(t.rows[1].props.height, Some(20.0));
|
||
assert_eq!(t.grid, vec![100.0, 100.0, 100.0]);
|
||
let Block::Table(t2) = &*d.body[1] else { panic!() };
|
||
let l = t2.props.look;
|
||
assert!(!l.header_row && l.total_row && l.first_column && !l.last_column && !l.banded_rows && l.banded_columns);
|
||
}
|
||
|
||
#[test]
|
||
fn styles_numbering_settings_notes_comments_from_parts() {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}>
|
||
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi"/><w:sz w:val="22"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="160" w:line="259" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>
|
||
<w:style w:type="paragraph" w:default="1" w:styleId="a"><w:name w:val="Normal"/><w:qFormat/></w:style>
|
||
<w:style w:type="paragraph" w:styleId="1"><w:name w:val="heading 1"/><w:basedOn w:val="a"/><w:next w:val="a"/><w:uiPriority w:val="9"/><w:qFormat/><w:pPr><w:keepNext/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:rFonts w:asciiTheme="majorHAnsi"/><w:b/><w:sz w:val="32"/></w:rPr></w:style>
|
||
<w:style w:type="paragraph" w:customStyle="1" w:styleId="Fancy"><w:name w:val="Fancy"/><w:basedOn w:val="Fancy"/></w:style>
|
||
<w:style w:type="table" w:styleId="Grid"><w:name w:val="Grid"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4"/></w:tblBorders></w:tblPr><w:tblStylePr w:type="firstRow"><w:rPr><w:b/></w:rPr><w:tcPr><w:shd w:val="clear" w:fill="4472C4"/></w:tcPr></w:tblStylePr></w:style>
|
||
</w:styles>"#
|
||
);
|
||
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:clrScheme name="T"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:accent1><a:srgbClr val="4472C4"/></a:accent1></a:clrScheme><a:fontScheme name="T"><a:majorFont><a:latin typeface="Major Face"/></a:majorFont><a:minorFont><a:latin typeface="Minor Face"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
|
||
let numbering = format!(
|
||
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="7"><w:name w:val="L"/><w:lvl w:ilvl="0"><w:start w:val="3"/><w:numFmt w:val="upperLetter"/><w:lvlText w:val="%1)"/><w:lvlJc w:val="right"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:suff w:val="space"/><w:lvlText w:val="o"/><w:rPr><w:rFonts w:ascii="Courier New"/></w:rPr></w:lvl></w:abstractNum><w:num w:numId="2"><w:abstractNumId w:val="7"/><w:lvlOverride w:ilvl="0"><w:startOverride w:val="5"/></w:lvlOverride></w:num></w:numbering>"#
|
||
);
|
||
let settings = format!(
|
||
r#"<w:settings {W_NS}><w:trackRevisions/><w:defaultTabStop w:val="708"/><w:evenAndOddHeaders w:val="1"/><w:footnotePr><w:pos w:val="sideways"/><w:numFmt w:val="lowerRoman"/><w:numStart w:val="-99999999999"/><w:numRestart w:val="eachPage"/></w:footnotePr></w:settings>"#
|
||
);
|
||
let footnotes = format!(
|
||
r#"<w:footnotes {W_NS}><w:footnote w:type="separator" w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></w:footnote><w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> Note text</w:t></w:r></w:p></w:footnote></w:footnotes>"#
|
||
);
|
||
let comments = format!(
|
||
r#"<w:comments {W_NS}><w:comment w:id="5" w:author="Zed" w:initials="Z" w:date="2026-01-01T00:00:00Z"><w:p><w:r><w:annotationRef/></w:r><w:r><w:t>Look</w:t></w:r></w:p></w:comment></w:comments>"#
|
||
);
|
||
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:t>H</w:t></w:r></w:p></w:hdr>"#);
|
||
let core = r#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Doc Title</dc:title><cp:revision>3</cp:revision></cp:coreProperties>"#;
|
||
let body = r#"
|
||
<w:p><w:pPr><w:pStyle w:val="1"/><w:numPr><w:ilvl w:val="1"/><w:numId w:val="2"/></w:numPr></w:pPr><w:commentRangeStart w:id="5"/><w:r><w:rPr><w:rFonts w:asciiTheme="majorHAnsi"/></w:rPr><w:t>Heading</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:commentReference w:id="5"/></w:r></w:p>
|
||
<w:p><w:pPr><w:pStyle w:val="a"/></w:pPr><w:ins w:id="1" w:author="I" w:date="2026-01-01T00:00:00Z"><w:r><w:t>new</w:t></w:r></w:ins><w:del w:id="2" w:author="I"><w:r><w:delText>old</w:delText></w:r></w:del></w:p>
|
||
<w:sectPr><w:headerReference w:type="default" r:id="rId9"/><w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/><w:cols w:space="708"/><w:titlePg w:val="0"/></w:sectPr>"#;
|
||
let doc_rels = [
|
||
("rId1", "styles", "styles.xml"),
|
||
("rId2", "theme", "theme/theme1.xml"),
|
||
("rId3", "numbering", "numbering.xml"),
|
||
("rId4", "settings", "/word/settings.xml"),
|
||
("rId5", "footnotes", "footnotes.xml"),
|
||
("rId6", "comments", "comments.xml"),
|
||
("rId9", "header", "header1.xml"),
|
||
];
|
||
let root_rels = 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"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>"#;
|
||
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||
let r = rels(&doc_rels);
|
||
let bytes = zip(&[
|
||
("_rels/.rels", root_rels.as_bytes()),
|
||
("word/document.xml", doc.as_bytes()),
|
||
("word/_rels/document.xml.rels", r.as_bytes()),
|
||
("word/styles.xml", styles.as_bytes()),
|
||
("word/theme/theme1.xml", theme.as_bytes()),
|
||
("word/numbering.xml", numbering.as_bytes()),
|
||
("word/settings.xml", settings.as_bytes()),
|
||
("word/footnotes.xml", footnotes.as_bytes()),
|
||
("word/comments.xml", comments.as_bytes()),
|
||
("word/header1.xml", header.as_bytes()),
|
||
("docProps/core.xml", core.as_bytes()),
|
||
]);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
// Styles: localized default id renamed to Normal; display names mapped.
|
||
let h = d.styles.get("1").unwrap();
|
||
assert_eq!(h.name, "Heading 1");
|
||
assert_eq!(h.based_on.as_deref(), Some("Normal"));
|
||
assert!(h.builtin && h.quick);
|
||
assert_eq!(h.chr.font.as_deref(), Some("Major Face"));
|
||
assert!(d.styles.get("Normal").is_some());
|
||
assert_eq!(d.styles.get("Fancy").unwrap().based_on, None, "self-reference dropped");
|
||
assert!(!d.styles.get("Fancy").unwrap().builtin);
|
||
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Minor Face"));
|
||
assert_eq!(d.styles.default_chr.size, Some(11.0));
|
||
let g = d.styles.get("Grid").unwrap().table.as_ref().unwrap();
|
||
assert_eq!(g.header_fill, Some(wordcraft_doc::Rgb(0x44, 0x72, 0xC4)));
|
||
assert_eq!(g.header_chr.bold, Some(true));
|
||
assert_eq!(d.settings.theme_colors[4], wordcraft_doc::Rgb(0x44, 0x72, 0xC4));
|
||
assert_eq!(d.settings.major_font, "Major Face");
|
||
// Numbering.
|
||
let lv = d.numbering.level(2, 0).unwrap();
|
||
assert_eq!((lv.start, lv.text.as_str(), lv.indent, lv.hanging, lv.align), (3, "%1)", 36.0, 18.0, Align::Right));
|
||
let lv1 = d.numbering.level(2, 1).unwrap();
|
||
assert_eq!(lv1.chr.font.as_deref(), Some("Courier New"));
|
||
assert_eq!(lv1.suffix, wordcraft_doc::numbering::LevelSuffix::Space);
|
||
assert_eq!(d.numbering.num(2).unwrap().start_overrides, vec![(0, 5)]);
|
||
// Settings.
|
||
assert!(d.settings.track_changes && d.settings.even_odd_headers);
|
||
assert_eq!(d.settings.default_tab, 35.4);
|
||
assert_eq!(d.settings.footnote_format, wordcraft_doc::section::NumFormat::LowerRoman);
|
||
// A hostile start is clamped, an unknown position left out.
|
||
let fp = d.settings.footnote_pr;
|
||
assert_eq!((fp.pos, fp.num_start, fp.num_restart), (None, Some(1), Some(wordcraft_doc::section::NoteRestart::EachPage)));
|
||
// Body.
|
||
let p = paras(&d);
|
||
assert_eq!(p[0].props.style.as_deref(), Some("1"));
|
||
assert_eq!(p[0].props.numbering, Some(wordcraft_doc::props::NumRef { num: 2, level: 1 }));
|
||
assert_eq!(p[0].props_of_char(4).font.as_deref(), Some("Major Face"));
|
||
match &p[0].objects[..] {
|
||
[InlineObject::CommentStart { id: 5 }, InlineObject::NoteRef { kind: NoteKind::Footnote, id, .. }, InlineObject::CommentEnd { id: 5 }] => {
|
||
let note = d.parts.get(id).unwrap().blocks[0].as_para().unwrap();
|
||
assert_eq!(note.text, format!("{} Note text", wordcraft_doc::para::OBJ));
|
||
assert_eq!(note.objects, vec![InlineObject::NoteRef { kind: NoteKind::Footnote, id: *id, custom: String::new() }]);
|
||
}
|
||
o => panic!("{o:?}"),
|
||
}
|
||
let c = d.comments.get(&5).unwrap();
|
||
assert_eq!((c.author.as_str(), c.initials.as_str()), ("Zed", "Z"));
|
||
assert_eq!(d.parts.get(&c.part).unwrap().blocks[0].as_para().unwrap().text, "Look");
|
||
assert_eq!(p[1].props.style.as_deref(), Some("Normal"));
|
||
assert_eq!(p[1].text, "newold");
|
||
assert_eq!(d.revisions.len(), 2);
|
||
assert_eq!(p[1].props_of_char(0).ins, Some(0));
|
||
assert_eq!(p[1].props_of_char(3).del, Some(1));
|
||
// Section + header.
|
||
let s = &d.last_section;
|
||
assert!(s.landscape);
|
||
assert_eq!((s.page_w, s.page_h), (841.9, 595.3));
|
||
assert!(!s.title_page);
|
||
let hid = s.headers.default.unwrap();
|
||
assert_eq!(d.parts.get(&hid).unwrap().blocks[0].as_para().unwrap().text, "H");
|
||
assert_eq!(d.core.title, "Doc Title");
|
||
assert_eq!(d.core.revision, 3);
|
||
}
|
||
|
||
/// A table style's own pPr/rPr, its `wholeTable` region (which covers every cell) and its table
|
||
/// shading and cell margins; a derived style keeps only what it sets itself.
|
||
#[test]
|
||
fn table_style_whole_table_shading_and_margins() {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}>
|
||
<w:style w:type="table" w:styleId="Base"><w:name w:val="Base"/><w:pPr><w:spacing w:after="0" w:line="240" w:lineRule="auto"/></w:pPr><w:tblPr><w:shd w:val="clear" w:color="auto" w:fill="EEEEEE"/><w:tblCellMar><w:top w:w="20" w:type="dxa"/><w:left w:w="288" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="288" w:type="dxa"/></w:tblCellMar></w:tblPr></w:style>
|
||
<w:style w:type="table" w:styleId="Whole"><w:name w:val="Whole"/><w:basedOn w:val="Base"/><w:rPr><w:b/></w:rPr><w:tblStylePr w:type="wholeTable"><w:pPr><w:jc w:val="center"/></w:pPr><w:rPr><w:color w:val="1F4E79"/></w:rPr><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="DDEBF7"/></w:tcPr></w:tblStylePr><w:tblStylePr w:type="bogus"/><w:tblStylePr/></w:style>
|
||
</w:styles>"#
|
||
);
|
||
let bytes = docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)]);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let base = d.styles.get("Base").unwrap();
|
||
let parts = base.table.as_ref().unwrap();
|
||
assert_eq!(parts.fill, Some(wordcraft_doc::Rgb(0xEE, 0xEE, 0xEE)));
|
||
assert_eq!(parts.cell_margins, Some([1.0, 14.4, 0.0, 14.4]));
|
||
let whole = d.styles.get("Whole").unwrap();
|
||
assert_eq!(whole.para.align, Some(Align::Center));
|
||
assert_eq!(whole.chr.bold, Some(true));
|
||
assert_eq!(whole.chr.color, Some(TextColor::Rgb(wordcraft_doc::Rgb(0x1F, 0x4E, 0x79))));
|
||
assert_eq!(whole.table.as_ref().unwrap().fill, Some(wordcraft_doc::Rgb(0xDD, 0xEB, 0xF7)));
|
||
assert_eq!(whole.table.as_ref().unwrap().cell_margins, None);
|
||
let merged = d.styles.table_style("Whole").unwrap();
|
||
assert_eq!(merged.para.space_after, Some(0.0), "from Base");
|
||
assert_eq!(merged.para.align, Some(Align::Center));
|
||
assert_eq!(merged.parts.fill, Some(wordcraft_doc::Rgb(0xDD, 0xEB, 0xF7)));
|
||
assert_eq!(merged.parts.cell_margins, Some([1.0, 14.4, 0.0, 14.4]), "from Base");
|
||
}
|
||
|
||
fn gallery_ids(styles: &str) -> Vec<String> {
|
||
let bytes = docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", styles)]);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
d.styles.gallery().iter().map(|s| s.id.clone()).collect()
|
||
}
|
||
|
||
/// LibreOffice writes no `w:qFormat`: the Styles gallery still shows Word's usual quick styles.
|
||
#[test]
|
||
fn styles_without_qformat_get_the_default_gallery() {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}>
|
||
<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="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/></w:style>
|
||
<w:style w:type="character" w:styleId="Emphasis"><w:name w:val="Emphasis"/></w:style>
|
||
</w:styles>"#
|
||
);
|
||
let ids = gallery_ids(&styles);
|
||
for id in ["Normal", "NoSpacing", "Heading1", "Heading2", "Title", "Subtitle"] {
|
||
assert!(ids.iter().any(|i| i == id), "{id} in {ids:?}");
|
||
}
|
||
}
|
||
|
||
/// A custom style called "Title" is not the built-in one: it stays out of the gallery, and the
|
||
/// built-in Title is not added over its name.
|
||
#[test]
|
||
fn custom_style_named_like_a_quick_builtin_is_not_quick() {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}>
|
||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||
<w:style w:type="paragraph" w:customStyle="1" w:styleId="MyTitle"><w:name w:val="Title"/></w:style>
|
||
</w:styles>"#
|
||
);
|
||
let ids = gallery_ids(&styles);
|
||
assert!(ids.iter().any(|i| i == "Normal"), "{ids:?}");
|
||
assert!(!ids.iter().any(|i| i == "MyTitle" || i == "Title"), "{ids:?}");
|
||
}
|
||
|
||
/// A file that uses `w:qFormat` (Word's own) keeps exactly the gallery it says.
|
||
#[test]
|
||
fn styles_with_qformat_keep_their_gallery() {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}>
|
||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style>
|
||
</w:styles>"#
|
||
);
|
||
assert_eq!(gallery_ids(&styles), vec!["Normal".to_string()]);
|
||
}
|
||
|
||
/// Word starts every note with a `w:footnoteRef` / `w:endnoteRef` mark: the note's own number.
|
||
/// It reads as a reference to the note itself, so the note text shows its number.
|
||
#[test]
|
||
fn note_marks_reference_their_own_note() {
|
||
let note = |tag: &str, mark: &str, style: &str, id: u32, text: &str| {
|
||
format!(
|
||
r#"<w:{tag} w:id="{id}"><w:p><w:pPr><w:pStyle w:val="{style}Text"/></w:pPr><w:r><w:rPr><w:rStyle w:val="{style}Reference"/></w:rPr><w:{mark}/></w:r><w:r><w:t xml:space="preserve"> {text}</w:t></w:r></w:p></w:{tag}>"#
|
||
)
|
||
};
|
||
let sep = |tag: &str| {
|
||
format!(
|
||
r#"<w:{tag} w:type="separator" w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></w:{tag}><w:{tag} w:type="continuationSeparator" w:id="0"><w:p><w:r><w:continuationSeparator/></w:r></w:p></w:{tag}>"#
|
||
)
|
||
};
|
||
// The second footnote has a stray endnote mark, which is not its number.
|
||
let footnotes = format!(
|
||
r#"<w:footnotes {W_NS}>{}{}<w:footnote w:id="2"><w:p><w:r><w:endnoteRef/><w:t>Stray</w:t></w:r></w:p></w:footnote>{}</w:footnotes>"#,
|
||
sep("footnote"),
|
||
note("footnote", "footnoteRef", "Footnote", 1, "First."),
|
||
note("footnote", "footnoteRef", "Footnote", 3, "Third."),
|
||
);
|
||
let endnotes = format!(r#"<w:endnotes {W_NS}>{}{}</w:endnotes>"#, sep("endnote"), note("endnote", "endnoteRef", "Endnote", 1, "End."));
|
||
// A mark outside a note (here in the body) refers to nothing.
|
||
let body = r#"<w:p><w:r><w:footnoteRef/><w:t>A</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:footnoteReference w:id="2"/></w:r><w:r><w:footnoteReference w:id="3"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r></w:p>"#;
|
||
let bytes = docx(
|
||
body,
|
||
&[("rId1", "footnotes", "footnotes.xml"), ("rId2", "endnotes", "endnotes.xml")],
|
||
&[("word/footnotes.xml", &footnotes), ("word/endnotes.xml", &endnotes)],
|
||
);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let p = paras(&d)[0];
|
||
assert_eq!(p.text.chars().filter(|c| *c == wordcraft_doc::para::OBJ).count(), 4, "{:?}", p.objects);
|
||
let refs: Vec<(NoteKind, u32)> = p
|
||
.objects
|
||
.iter()
|
||
.map(|o| match o {
|
||
InlineObject::NoteRef { kind, id, .. } => (*kind, *id),
|
||
o => panic!("{o:?}"),
|
||
})
|
||
.collect();
|
||
let first_para = |id: u32| d.parts.get(&id).unwrap().blocks[0].as_para().unwrap();
|
||
for (kind, id) in [refs[0], refs[2], refs[3]] {
|
||
let n = first_para(id);
|
||
assert_eq!(n.objects, vec![InlineObject::NoteRef { kind, id, custom: String::new() }], "note {id}");
|
||
assert!(n.text.starts_with(wordcraft_doc::para::OBJ), "{:?}", n.text);
|
||
let style = if kind == NoteKind::Footnote { "FootnoteReference" } else { "EndnoteReference" };
|
||
assert_eq!(n.props_of_char(0).style.as_deref(), Some(style));
|
||
}
|
||
assert_eq!(first_para(refs[0].1).plain_text(), " First.");
|
||
assert_eq!(first_para(refs[3].1).plain_text(), " End.");
|
||
let stray = first_para(refs[1].1);
|
||
assert!(stray.objects.is_empty(), "{:?}", stray.objects);
|
||
assert_eq!(stray.text, "Stray");
|
||
}
|
||
|
||
#[test]
|
||
fn empty_tbl_borders_elements_leave_sides_unset() {
|
||
// Style TS: single borders on all six sides. Document: table styled TS with an empty
|
||
// paired <w:tblBorders></w:tblBorders> and empty <w:tcBorders></w:tcBorders> per cell.
|
||
// (Hand-written from ECMA-376 §17.4.39/§17.4.43; do not use files produced by Word.)
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}><w:style w:type="table" w:styleId="TS"><w:name w:val="TS"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="2" w:color="000000"/><w:left w:val="single" w:sz="2" w:color="000000"/><w:bottom w:val="single" w:sz="2" w:color="000000"/><w:right w:val="single" w:sz="2" w:color="000000"/><w:insideH w:val="single" w:sz="2" w:color="000000"/><w:insideV w:val="single" w:sz="2" w:color="000000"/></w:tblBorders></w:tblPr></w:style></w:styles>"#
|
||
);
|
||
let body = r#"<w:tbl><w:tblPr><w:tblStyle w:val="TS"/><w:tblBorders></w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcBorders></w:tcBorders></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcBorders></w:tcBorders></w:tcPr><w:p/></w:tc></w:tr></w:tbl>"#;
|
||
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||
assert_eq!(t.props.borders, Some(wordcraft_doc::props::Borders::default()));
|
||
assert_eq!(t.rows[0].cells[0].props.borders, Some(wordcraft_doc::props::Borders::default()));
|
||
let parts = d.styles.get("TS").unwrap().table.clone().unwrap();
|
||
let b = parts.borders.unwrap();
|
||
assert!(b.top.is_some() && b.between.is_some() && b.inside_v.is_some());
|
||
}
|
||
|
||
/// A style ID defined twice is its last definition, whole (Word drops the earlier one rather
|
||
/// than merging: observed with synthetic documents). Generators that write a placeholder
|
||
/// Heading 1 and then their own made headings come out in the placeholder's font and size.
|
||
#[test]
|
||
fn a_style_defined_twice_is_its_last_definition() {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}>
|
||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:pPr><w:jc w:val="center"/></w:pPr><w:rPr><w:i/><w:color w:val="FF0000"/><w:sz w:val="40"/></w:rPr></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:b/><w:color w:val="0000FF"/><w:sz w:val="24"/></w:rPr></w:style>
|
||
</w:styles>"#
|
||
);
|
||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Probe</w:t></w:r></w:p>"#;
|
||
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||
assert_eq!(d.styles.styles.iter().filter(|s| s.id == "Heading1").count(), 1);
|
||
let h = d.styles.get("Heading1").unwrap();
|
||
assert_eq!((h.chr.bold, h.chr.italic, h.chr.size), (Some(true), None, Some(12.0)));
|
||
assert_eq!(h.chr.color, Some(TextColor::Rgb(Rgb(0, 0, 0xFF))));
|
||
assert_eq!(h.para.align, None, "nothing kept from the first definition");
|
||
}
|
||
|
||
#[test]
|
||
fn vml_image_and_inline_drawing() {
|
||
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
|
||
let body = r#"
|
||
<w:p><w:r><w:pict><v:shape style="width:1in;height:36pt" alt="vml pic"><v:imagedata r:id="rIdImg" o:title="t"/></v:shape></w:pict></w:r>
|
||
<w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="3" name="P" descr="alt text"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg"/><a:srcRect l="10000"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>
|
||
<w:r><w:drawing><wp:inline><wp:extent cx="1" cy="1"/><a:graphic><a:graphicData><pic:pic><pic:blipFill><a:blip r:embed="rIdMissing"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
|
||
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||
let r = rels(&[("rIdImg", "image", "media/pic.png"), ("rIdMissing", "image", "media/nope.png")]);
|
||
let bytes = zip(&[
|
||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||
("word/document.xml", doc.as_bytes()),
|
||
("word/_rels/document.xml.rels", r.as_bytes()),
|
||
("word/media/pic.png", png),
|
||
]);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let p = paras(&d);
|
||
assert_eq!(p[0].objects.len(), 2);
|
||
match (&p[0].objects[0], &p[0].objects[1]) {
|
||
(InlineObject::Image { media: m1, w: w1, h: h1, alt: a1, .. }, InlineObject::Image { media: m2, w: w2, h: h2, alt: a2, crop, .. }) => {
|
||
assert_eq!((m1.as_str(), *w1, *h1, a1.as_str()), ("pic.png", 72.0, 36.0, "vml pic"));
|
||
assert_eq!((m2.as_str(), *w2, *h2, a2.as_str()), ("pic.png", 100.0, 50.0, "alt text"));
|
||
assert_eq!(crop[0], 0.1);
|
||
}
|
||
o => panic!("{o:?}"),
|
||
}
|
||
assert_eq!(d.media.get("pic.png").unwrap().as_slice(), png);
|
||
}
|
||
|
||
#[test]
|
||
fn chart_and_diagram_drawings_are_graphic_objects() {
|
||
// A diagram's data holds a blip: the URI decides first, so no picture is read from it.
|
||
let drawing = |uri: &str, data: &str| {
|
||
format!(
|
||
r#"<w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1828800"/><wp:docPr id="5" name="G" descr="sales"/><a:graphic><a:graphicData uri="{uri}">{data}</a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||
)
|
||
};
|
||
let body = format!(
|
||
"<w:p>{}{}</w:p>",
|
||
drawing("http://schemas.openxmlformats.org/drawingml/2006/chart", ""),
|
||
drawing("http://schemas.openxmlformats.org/drawingml/2006/diagram", r#"<a:blip r:embed="rIdImg"/>"#)
|
||
);
|
||
let d = read_body(&body);
|
||
let p = paras(&d);
|
||
assert_eq!(p[0].objects.len(), 2);
|
||
match (&p[0].objects[0], &p[0].objects[1]) {
|
||
(InlineObject::Graphic { w: w1, h: h1, alt, graphic: g1, .. }, InlineObject::Graphic { graphic: g2, .. }) => {
|
||
assert_eq!((*w1, *h1, alt.as_str()), (216.0, 144.0, "sales"));
|
||
assert_eq!(g1.kind, wordcraft_doc::graphic::GraphicKind::Chart);
|
||
assert_eq!(g2.kind, wordcraft_doc::graphic::GraphicKind::Diagram);
|
||
assert!(g1.items.is_empty() && g2.items.is_empty());
|
||
}
|
||
o => panic!("{o:?}"),
|
||
}
|
||
}
|
||
|
||
/// A text box shape's style text colour (`wps:style/a:fontRef`) is kept on its story, as what
|
||
/// automatic text colour means inside it; a box without one has none.
|
||
#[test]
|
||
fn a_text_box_s_style_font_colour_is_read() {
|
||
let shape = |style: &str| {
|
||
format!(
|
||
r#"<w:r><w:drawing><wp:anchor behindDoc="0"><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="1270000" cy="635000"/><wp:wrapNone/><wp:docPr id="1" name="S"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr>{style}<wps:txbx><w:txbxContent><w:p><w:r><w:t>In the box</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
|
||
)
|
||
};
|
||
let style = r#"<wps:style><a:lnRef idx="2"><a:srgbClr val="000000"/></a:lnRef><a:fillRef idx="1"><a:srgbClr val="203040"/></a:fillRef><a:effectRef idx="0"><a:srgbClr val="000000"/></a:effectRef><a:fontRef idx="minor"><a:srgbClr val="FFFFFF"/></a:fontRef></wps:style>"#;
|
||
let d = read_body(&format!("<w:p>{}{}</w:p>", shape(style), shape("")));
|
||
let boxes: Vec<_> =
|
||
paras(&d)[0].objects.iter().filter_map(|o| if let InlineObject::Shape { story: Some(id), .. } = o { Some(*id) } else { None }).collect();
|
||
let [styled, plain] = boxes.as_slice() else { panic!("{boxes:?}") };
|
||
assert_eq!(d.parts[styled].body.auto_color, Some(Rgb(0xFF, 0xFF, 0xFF)));
|
||
assert_eq!(d.parts[plain].body.auto_color, None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_chart_part_is_built_once_and_only_through_a_chart_relationship() {
|
||
let chart = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
|
||
let drawing = |id: &str| {
|
||
format!(
|
||
r#"<w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1828800"/><wp:docPr id="5" name="G"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="{id}"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||
)
|
||
};
|
||
let body = format!("<w:p>{}{}{}</w:p>", drawing("rIdChart"), drawing("rIdChart"), drawing("rIdNotChart"));
|
||
let bytes = docx(
|
||
&body,
|
||
&[("rIdChart", "chart", "charts/chart1.xml"), ("rIdNotChart", "image", "charts/chart1.xml")],
|
||
&[("word/charts/chart1.xml", chart)],
|
||
);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let graphics: Vec<_> = paras(&d)[0]
|
||
.objects
|
||
.iter()
|
||
.filter_map(|o| if let InlineObject::Graphic { graphic, .. } = o { Some(graphic.clone()) } else { None })
|
||
.collect();
|
||
assert_eq!(graphics.len(), 3);
|
||
assert!(!graphics[0].items.is_empty(), "the chart is drawn");
|
||
assert!(std::sync::Arc::ptr_eq(&graphics[0], &graphics[1]), "one build for both references");
|
||
assert!(graphics[2].items.is_empty(), "an image relationship is not a chart");
|
||
}
|
||
|
||
#[test]
|
||
fn strict_namespace_and_bad_numbers() {
|
||
let doc = r#"<w:document xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main"><w:body>
|
||
<w:p><w:pPr><w:jc w:val="end"/><w:ind w:start="1in" w:hanging="abc"/><w:spacing w:before="-50" w:line="99999999999999999999" w:lineRule="exact"/><w:outlineLvl w:val="300"/><w:numPr><w:numId w:val="-4"/></w:numPr></w:pPr>
|
||
<w:r><w:rPr><w:sz w:val="NaN"/><w:w w:val="999999"/><w:position w:val="-1e30"/><w:color w:val="GGGGGG"/><w:highlight w:val="mauve"/><w:u w:val="squiggle"/></w:rPr><w:t>strict</w:t></w:r></w:p>
|
||
<w:tbl><w:tblGrid><w:gridCol w:w="-5"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:gridSpan w:val="99999"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
|
||
<w:sectPr><w:pgSz w:w="0" w:h="x"/><w:cols w:num="-3"/><w:pgNumType w:start="-1" w:fmt="klingon"/></w:sectPr>
|
||
</w:body></w:document>"#;
|
||
let bytes = zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())]);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let p = paras(&d);
|
||
assert_eq!(p[0].text, "strict");
|
||
assert_eq!(p[0].props.align, Some(Align::Right));
|
||
assert_eq!(p[0].props.indent_left, Some(72.0));
|
||
assert_eq!(p[0].props.space_before, Some(0.0));
|
||
assert_eq!(p[0].props.outline_level, Some(9));
|
||
let c = p[0].props_of_char(0);
|
||
assert_eq!(c.size, None);
|
||
assert_eq!(c.scale, Some(600.0));
|
||
assert_eq!(c.color, None);
|
||
let Block::Table(t) = &*d.body[1] else { panic!() };
|
||
assert_eq!(t.rows[0].cells[0].span(), 63);
|
||
assert_eq!(d.last_section.page_w, 612.0);
|
||
assert_eq!(d.last_section.columns.count, 1);
|
||
// The document can be written back.
|
||
wordcraft_docx::write(&d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn unbalanced_fields_and_markers() {
|
||
let body = r#"
|
||
<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>a</w:t></w:r><w:bookmarkEnd w:id="77"/><w:commentRangeEnd w:id="3"/></w:p>
|
||
<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>IF </w:instrText></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>MERGEFIELD x</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>X</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r><w:r><w:instrText> = "y"</w:instrText></w:r></w:p>
|
||
<w:bookmarkStart w:id="1" w:name="between"/>
|
||
<w:p><w:r><w:t>after</w:t></w:r></w:p>
|
||
<w:bookmarkEnd w:id="1"/>"#;
|
||
let d = read_body(body);
|
||
let p = paras(&d);
|
||
assert_eq!(p[0].text, "a");
|
||
assert_eq!(
|
||
p[1].objects,
|
||
vec![InlineObject::Field {
|
||
instr: "IF X = \"y\"".into(),
|
||
result: String::new(),
|
||
locked: false,
|
||
dirty: false,
|
||
code: false,
|
||
form: String::new()
|
||
}]
|
||
);
|
||
assert_eq!(p[2].objects, vec![InlineObject::BookmarkStart { name: "between".into() }, InlineObject::BookmarkEnd { name: "between".into() }]);
|
||
assert_eq!(p[2].plain_text(), "after");
|
||
}
|
||
|
||
#[test]
|
||
fn field_code_spans_paragraphs() {
|
||
let body = r#"
|
||
<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>IF</w:instrText></w:r></w:p>
|
||
<w:p><w:r><w:instrText> MERGEFIELD x</w:instrText><w:br/><w:instrText> = "y"</w:instrText></w:r></w:p>
|
||
<w:p><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>
|
||
<w:p><w:r><w:t>Visible</w:t></w:r></w:p>"#;
|
||
let doc = read_body(body);
|
||
let p = paras(&doc);
|
||
assert_eq!(p.len(), 4);
|
||
for code in &p[..2] {
|
||
assert!(code.plain_text().is_empty());
|
||
assert_eq!(code.mark.hidden, Some(true));
|
||
}
|
||
assert_eq!(p[2].mark.hidden, None);
|
||
assert_eq!(p[3].plain_text(), "Visible");
|
||
assert_eq!(p[3].mark.hidden, None);
|
||
}
|
||
|
||
#[test]
|
||
fn empty_field_results_keep_line() {
|
||
let body = r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:fldChar w:fldCharType="end"/></w:r></w:p>
|
||
<w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>IF</w:instrText></w:r></w:p>
|
||
<w:p/>
|
||
<w:p><w:r><w:instrText> x = y</w:instrText><w:fldChar w:fldCharType="separate"/><w:t>Result</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p>"#;
|
||
let doc = read_body(body);
|
||
let p = paras(&doc);
|
||
assert_eq!(p[0].mark.hidden, None);
|
||
assert!(matches!(&p[0].objects[0], InlineObject::Field { instr, .. } if instr == "PAGE"));
|
||
assert_eq!(p[1].mark.hidden, Some(true));
|
||
assert_eq!(p[2].mark.hidden, Some(true));
|
||
assert_eq!(p[3].mark.hidden, None);
|
||
assert_eq!(p[3].plain_text(), "Result");
|
||
}
|
||
|
||
#[test]
|
||
fn header_self_reference_and_escaping_targets() {
|
||
let hdr = format!(
|
||
r#"<w:hdr {W_NS}><w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rId1"/></w:sectPr></w:pPr><w:r><w:t>loop</w:t></w:r></w:p></w:hdr>"#
|
||
);
|
||
let hdr_rels = rels(&[("rId1", "header", "header1.xml")]);
|
||
let body = r#"<w:p><w:r><w:drawing><wp:inline><a:graphic><a:graphicData><pic:pic><pic:blipFill><a:blip r:embed="rIdEvil"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:headerReference w:type="even" r:id="rIdH"/><w:footerReference w:type="first" r:id="rIdNone"/></w:sectPr>"#;
|
||
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||
let r = rels(&[("rIdH", "header", "header1.xml"), ("rIdEvil", "image", "../../../../etc/passwd"), ("rIdNone", "footer", "missing.xml")]);
|
||
let bytes = zip(&[
|
||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||
("word/document.xml", doc.as_bytes()),
|
||
("word/_rels/document.xml.rels", r.as_bytes()),
|
||
("word/header1.xml", hdr.as_bytes()),
|
||
("word/_rels/header1.xml.rels", hdr_rels.as_bytes()),
|
||
]);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
assert_eq!(d.last_section.headers.default, d.last_section.headers.even);
|
||
assert!(d.media.is_empty());
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
wordcraft_docx::read(&out).unwrap();
|
||
}
|
||
|
||
/// A document whose settings part is `settings` (None: no settings part at all).
|
||
fn with_settings(settings: Option<&str>) -> Document {
|
||
let body = "<w:p><w:r><w:t>x</w:t></w:r></w:p>";
|
||
match settings {
|
||
None => read_body(body),
|
||
Some(inner) => {
|
||
let s = format!(r#"<w:settings {W_NS}>{inner}</w:settings>"#);
|
||
wordcraft_docx::read(&docx(body, &[("rId1", "settings", "settings.xml")], &[("word/settings.xml", &s)])).unwrap()
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn table_contextual_spacing_compatibility_flag_is_read() {
|
||
assert!(!with_settings(None).settings.allow_space_of_same_style_in_table);
|
||
for (flag, expected) in [
|
||
("", false),
|
||
(r#"<w:allowSpaceOfSameStyleInTable w:val="0"/>"#, false),
|
||
(r#"<w:allowSpaceOfSameStyleInTable/>"#, true),
|
||
(r#"<w:allowSpaceOfSameStyleInTable w:val="1"/>"#, true),
|
||
] {
|
||
let settings = format!("<w:compat>{flag}</w:compat>");
|
||
assert_eq!(with_settings(Some(&settings)).settings.allow_space_of_same_style_in_table, expected, "{flag}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn compatibility_mode_is_read_and_defaults_to_word_2007() {
|
||
let compat = |v: &str| {
|
||
format!(r#"<w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="{v}"/></w:compat>"#)
|
||
};
|
||
assert_eq!(with_settings(Some(&compat("15"))).settings.compat_mode, 15);
|
||
assert_eq!(with_settings(Some(&compat("14"))).settings.compat_mode, 14);
|
||
// Unnamed, out of range or junk values.
|
||
assert_eq!(with_settings(Some("<w:compat/>")).settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
|
||
assert_eq!(with_settings(None).settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
|
||
assert_eq!(with_settings(Some(&compat("-3"))).settings.compat_mode, 11);
|
||
assert_eq!(with_settings(Some(&compat("99999"))).settings.compat_mode, 99);
|
||
assert_eq!(with_settings(Some(&compat("abc"))).settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
|
||
// Another vendor's setting of the same name is ignored.
|
||
let other = r#"<w:compat><w:compatSetting w:name="compatibilityMode" w:uri="urn:example" w:val="15"/></w:compat>"#;
|
||
assert_eq!(with_settings(Some(other)).settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
|
||
}
|
||
|
||
#[test]
|
||
fn line_rule_is_read_case_insensitively() {
|
||
// Word accepts `atleast` as well as the spec's `atLeast`.
|
||
let d = read_body(
|
||
r#"<w:p><w:pPr><w:spacing w:line="280" w:lineRule="atleast"/></w:pPr><w:r><w:t>x</w:t></w:r></w:p><w:p><w:pPr><w:spacing w:line="300" w:lineRule="EXACT"/></w:pPr></w:p>"#,
|
||
);
|
||
let p = paras(&d);
|
||
assert_eq!(p[0].props.line_spacing, Some(wordcraft_doc::props::LineSpacing::AtLeast(14.0)));
|
||
assert_eq!(p[1].props.line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(15.0)));
|
||
}
|
||
|
||
/// #2329: Word 2024 saves strict OOXML with the line height written out with a unit
|
||
/// (`w:line="13.90pt"`). Under `auto` it reads through twips, as Word renders it (13.90 × 20 =
|
||
/// 278; the 1.1583 multiple), not as 240ths of a line like the bare number — a Word file with
|
||
/// a `line="13.90pt"` default came in at 13.9 lines per line of text.
|
||
#[test]
|
||
fn strict_line_heights_with_a_unit_are_read_as_point_multiples() {
|
||
let d = read_body(
|
||
r#"<w:p><w:pPr><w:spacing w:after="8pt" w:line="13.90pt" w:lineRule="auto"/></w:pPr><w:r><w:t>x</w:t></w:r></w:p><w:p><w:pPr><w:spacing w:line="13.90pt" w:lineRule="exact"/></w:pPr></w:p>"#,
|
||
);
|
||
let p = paras(&d);
|
||
let Some(wordcraft_doc::props::LineSpacing::Multiple(m)) = p[0].props.line_spacing else {
|
||
panic!("auto line spacing: {:?}", p[0].props.line_spacing)
|
||
};
|
||
assert!((m - 278.0 / 240.0).abs() < 1e-5, "{m}");
|
||
assert_eq!(p[0].props.space_after, Some(8.0), "the unit form of w:after too");
|
||
assert_eq!(p[1].props.line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(13.9)), "exact keeps reading it as points");
|
||
// Saves as the transitional 240ths form and reads back the same multiple.
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
let Some(wordcraft_doc::props::LineSpacing::Multiple(m2)) = paras(&again)[0].props.line_spacing else { panic!("multiple after a save") };
|
||
assert!((m2 - m).abs() < 1e-5, "{m2} vs {m}");
|
||
}
|
||
|
||
#[test]
|
||
fn char_border_reads_and_nil_resolves_off() {
|
||
let d = read_body(
|
||
r#"<w:p><w:r><w:rPr><w:bdr w:val="single" w:sz="8" w:space="1" w:color="FF0000"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:bdr w:val="nil"/></w:rPr><w:t>b</w:t></w:r></w:p>"#,
|
||
);
|
||
let p = paras(&d)[0];
|
||
assert_eq!(p.props_of_char(0).border, Some(Border { style: BorderStyle::Single, width: 1.0, color: Some(Rgb(0xFF, 0, 0)), space: 1.0 }));
|
||
let nil = p.props_of_char(1);
|
||
assert_eq!(nil.border.map(|b| b.style), Some(BorderStyle::None));
|
||
assert_eq!(d.styles.resolve_char(None, nil).border, None);
|
||
}
|
||
|
||
#[test]
|
||
fn anchor_alignment_reference_areas_and_distances() {
|
||
let shape = |pos: &str, dist: &str| {
|
||
format!(
|
||
r#"<w:r><w:drawing><wp:anchor behindDoc="0" {dist}>{pos}<wp:extent cx="127000" cy="127000"/><wp:wrapTopAndBottom/><wp:docPr id="1" name="S"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="ellipse"/></wps:spPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
|
||
)
|
||
};
|
||
let body = format!(
|
||
"<w:p>{}{}</w:p>",
|
||
shape(
|
||
r#"<wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="topMargin"><wp:posOffset>-12700</wp:posOffset></wp:positionV>"#,
|
||
r#"distT="25400" distB="50800" distL="0" distR="114300""#
|
||
),
|
||
shape(
|
||
r#"<wp:positionH relativeFrom="leftMargin"><wp:posOffset>63500</wp:posOffset></wp:positionH><wp:positionV relativeFrom="line"><wp:align>bottom</wp:align></wp:positionV>"#,
|
||
""
|
||
),
|
||
);
|
||
let d = read_body(&body);
|
||
let p = paras(&d);
|
||
let floats: Vec<_> =
|
||
p[0].objects.iter().filter_map(|o| if let InlineObject::Shape { float, .. } = o { Some(float.clone()) } else { None }).collect();
|
||
let [a, b] = floats.as_slice() else { panic!("{floats:?}") };
|
||
assert_eq!((a.h_rel, a.h_align, a.v_rel, a.v_align, a.y), (Anchor::Margin, Some(FloatAlign::Center), Anchor::TopMargin, None, -1.0));
|
||
assert_eq!((a.dist, a.dist_top, a.dist_bottom), (9.0, 2.0, 4.0));
|
||
assert_eq!((b.h_rel, b.h_align, b.x, b.v_rel, b.v_align), (Anchor::LeftMargin, None, 5.0, Anchor::Line, Some(FloatAlign::End)));
|
||
assert_eq!((b.dist, b.dist_top, b.dist_bottom), (0.0, 0.0, 0.0));
|
||
}
|
||
|
||
#[test]
|
||
fn anchor_stacking_order_is_read_and_kept() {
|
||
let shape = |z: &str| {
|
||
format!(
|
||
r#"<w:r><w:drawing><wp:anchor behindDoc="0" {z}><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="127000" cy="127000"/><wp:wrapNone/><wp:docPr id="1" name="S"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
|
||
)
|
||
};
|
||
let body = format!("<w:p>{}{}{}</w:p>", shape(r#"relativeHeight="251660288""#), shape(r#"relativeHeight="251657214""#), shape(""));
|
||
let zs = |d: &Document| -> Vec<u32> {
|
||
paras(d)[0].objects.iter().filter_map(|o| if let InlineObject::Shape { float, .. } = o { Some(float.z) } else { None }).collect()
|
||
};
|
||
let d = read_body(&body);
|
||
assert_eq!(zs(&d), [251660288, 251657214, 0]);
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
let z = zs(&again);
|
||
assert_eq!(&z[..2], &[251660288, 251657214]);
|
||
assert!(z[2] > 0, "an object without one gets an order on saving: {z:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() {
|
||
assert_eq!(read_body("<w:p/>").settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
|
||
}
|
||
|
||
#[test]
|
||
fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
|
||
// Issue #93: Normal names only East Asian / complex-script fonts, so Latin text keeps the
|
||
// docDefaults font. A run hinted as East Asian still uses its East Asian font.
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}>
|
||
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:eastAsia="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/></w:rPr></w:rPrDefault><w:pPrDefault/></w:docDefaults>
|
||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/><w:rPr><w:rFonts w:eastAsia="Times New Roman" w:cs="Times New Roman"/></w:rPr></w:style>
|
||
</w:styles>"#
|
||
);
|
||
let body = r#"<w:p><w:r><w:t>Plain</w:t></w:r></w:p><w:p><w:r><w:rPr><w:rFonts w:hint="eastAsia" w:eastAsia="SimSun"/></w:rPr><w:t>漢字</w:t></w:r></w:p>"#;
|
||
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||
assert_eq!(d.styles.get("Normal").unwrap().chr.font, None);
|
||
let ps = paras(&d);
|
||
let font = |p: &Paragraph| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).font;
|
||
assert_eq!(font(ps[0]), "Calibri");
|
||
assert_eq!(font(ps[1]), "SimSun");
|
||
}
|
||
|
||
#[test]
|
||
fn bare_break_directly_in_paragraph() {
|
||
let body = r#"<w:p><w:r><w:t>First</w:t></w:r><w:br/><w:r><w:t>Second</w:t></w:r><w:cr/><w:br w:type="page"/><w:r><w:t>Third</w:t></w:r></w:p>"#;
|
||
let d = read_body(body);
|
||
assert_eq!(paras(&d)[0].text, "First\nSecond\n\u{C}Third");
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let d2 = wordcraft_docx::read(&out).unwrap();
|
||
assert_eq!(paras(&d2)[0].text, "First\nSecond\n\u{C}Third");
|
||
}
|
||
|
||
/// Issue #1290: Word's text watermark, a VML shape with a `v:textpath` in a header or footer,
|
||
/// becomes the document's watermark (drawn on every page) instead of being dropped; other WordArt
|
||
/// is not a watermark, and hostile values don't panic.
|
||
#[test]
|
||
fn vml_textpath_in_header_is_the_watermark() {
|
||
let read = |shape: &str, in_header: bool| {
|
||
let pict = format!("<w:p><w:r><w:t>Header label</w:t></w:r><w:r><w:pict>{shape}</w:pict></w:r></w:p>");
|
||
let header = format!(r#"<w:hdr {W_NS}>{}</w:hdr>"#, if in_header { pict.as_str() } else { "<w:p/>" });
|
||
let body = format!(
|
||
r#"{}<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#,
|
||
if in_header { "" } else { pict.as_str() }
|
||
);
|
||
let d = wordcraft_docx::read(&docx(&body, &[("rIdH", "header", "header1.xml")], &[("word/header1.xml", &header)])).unwrap();
|
||
let hid = d.last_section.headers.default.unwrap();
|
||
let header_objects = d.parts.get(&hid).unwrap().blocks.iter().filter_map(|b| b.as_para()).map(|p| p.objects.len()).sum::<usize>();
|
||
let header_text = d.parts.get(&hid).unwrap().blocks.first().and_then(|b| b.as_para()).map(|p| p.text.clone()).unwrap_or_default();
|
||
(d.settings.watermark.clone(), header_objects, header_text)
|
||
};
|
||
|
||
// Word's Design › Watermark shape: turned 315°, silver, semitransparent, quoted font name.
|
||
let word = r##"<v:shape id="PowerPlusWaterMarkObject1" o:spid="_x0000_s1025" type="#_x0000_t136" style="position:absolute;margin-left:0;margin-top:0;width:412.4pt;height:247.4pt;rotation:315;z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" o:allowincell="f" fillcolor="silver" stroked="f"><v:fill opacity=".5"/><v:textpath style="font-family:"Calibri Light";font-size:1pt" string="CONFIDENTIAL"/></v:shape>"##;
|
||
let (wm, objects, text) = read(word, true);
|
||
let wm = wm.expect("watermark");
|
||
assert_eq!((wm.text.as_str(), wm.font.as_str(), wm.color), ("CONFIDENTIAL", "Calibri Light", Rgb(0xC0, 0xC0, 0xC0)));
|
||
assert!(wm.diagonal && wm.semitransparent, "{wm:?}");
|
||
// Consumed: not also drawn as a shape in the header, whose text stays.
|
||
assert_eq!((objects, text.as_str()), (0, "Header label"));
|
||
|
||
// A page-centred, gray, opaque shape turned -40° (no `v:fill`).
|
||
let gray = r##"<v:shape id="WaterMark" type="#_x0000_t136" style="width:500pt;height:100pt;margin-top:0;margin-left:0;mso-position-horizontal:center;mso-position-horizontal-relative:page;mso-position-vertical:center;mso-position-vertical-relative:page;position:absolute;rotation:-40" fillcolor="gray" strokecolor="gray"><v:textpath style="font-family:Arial" string="BOZZA"/></v:shape>"##;
|
||
let wm = read(gray, true).0.expect("watermark");
|
||
assert_eq!(
|
||
(wm.text.as_str(), wm.font.as_str(), wm.color, wm.diagonal, wm.semitransparent),
|
||
("BOZZA", "Arial", Rgb(0x80, 0x80, 0x80), true, false)
|
||
);
|
||
|
||
// Horizontal, centred both ways without Word's id, opacity in 65536ths.
|
||
let flat = r##"<v:shape type="#_x0000_t136" style="position:absolute;width:300pt;height:60pt;mso-position-horizontal:center;mso-position-vertical:center" fillcolor="#336699"><v:fill opacity="32768f"/><v:textpath string="ASAP"/></v:shape>"##;
|
||
let wm = read(flat, true).0.expect("watermark");
|
||
assert_eq!((wm.color, wm.diagonal, wm.semitransparent), (Rgb(0x33, 0x66, 0x99), false, true));
|
||
|
||
// Not watermarks: the same shape in the body (WordArt); header WordArt that isn't centred
|
||
// and isn't Word's watermark (a logo); a textpath turned off; a watermark-like shape inside a
|
||
// header's text box.
|
||
assert_eq!(read(gray, false).0, None);
|
||
let logo = r##"<v:shape type="#_x0000_t136" style="position:absolute;margin-left:0;margin-top:0;width:120pt;height:30pt;mso-position-horizontal:left;mso-position-horizontal-relative:margin" fillcolor="navy"><v:textpath string="ACME"/></v:shape>"##;
|
||
assert_eq!(read(logo, true).0, None);
|
||
assert_eq!(read(&word.replace(r#"<v:textpath "#, r#"<v:textpath on="f" "#), true).0, None);
|
||
let boxed = format!(
|
||
r##"<v:shape type="#_x0000_t202" style="width:200pt;height:100pt"><v:textbox><w:txbxContent><w:p><w:r><w:pict>{word}</w:pict></w:r></w:p></w:txbxContent></v:textbox></v:shape>"##
|
||
);
|
||
assert_eq!(read(&boxed, true).0, None);
|
||
|
||
// A footer's watermark counts too, and the first one found wins.
|
||
let footer = format!(r#"<w:ftr {W_NS}><w:p><w:r><w:pict>{gray}</w:pict></w:r></w:p></w:ftr>"#);
|
||
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:pict>{word}</w:pict></w:r></w:p></w:hdr>"#);
|
||
let body = r#"<w:p/><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:footerReference w:type="default" r:id="rIdF"/></w:sectPr>"#;
|
||
let only_footer = r#"<w:p/><w:sectPr><w:footerReference w:type="default" r:id="rIdF"/></w:sectPr>"#;
|
||
let parts = [("word/header1.xml", header.as_str()), ("word/footer1.xml", footer.as_str())];
|
||
let rels = [("rIdH", "header", "header1.xml"), ("rIdF", "footer", "footer1.xml")];
|
||
let wm = |body: &str| wordcraft_docx::read(&docx(body, &rels, &parts)).unwrap().settings.watermark.map(|w| w.text);
|
||
assert_eq!(wm(only_footer).as_deref(), Some("BOZZA"));
|
||
assert_eq!(wm(body).as_deref(), Some("CONFIDENTIAL"));
|
||
|
||
// Hostile values: an oversized string is capped, garbage rotation/opacity/colour don't panic,
|
||
// an empty string is no watermark.
|
||
let long = "W".repeat(10_000);
|
||
let hostile = format!(
|
||
r##"<v:shape id="PowerPlusWaterMarkObject9" style="rotation:NaNfd;width:-1e40pt" fillcolor="#zz" ><v:fill opacity="1e99f"/><v:textpath style="font-family:;" string="{long}"/></v:shape>"##
|
||
);
|
||
let wm = read(&hostile, true).0.expect("watermark");
|
||
assert_eq!(wm.text.chars().count(), 200);
|
||
assert!(!wm.font.is_empty());
|
||
assert_eq!(read(r#"<v:shape id="PowerPlusWaterMarkObject2"><v:textpath string=" "/></v:shape>"#, true).0, None);
|
||
}
|
||
|
||
/// Issue #2875: Word 2007 WordArt, a body `v:shape` with a `v:textpath`, is shown as a text shape
|
||
/// holding its string in the textpath's font and the shape's colour, bent by the warp of its WordArt
|
||
/// shape, not dropped; a textpath turned off or with an empty string draws nothing.
|
||
#[test]
|
||
fn vml_textpath_in_body_is_shown() {
|
||
let read = |shape: &str| {
|
||
let body = format!("<w:p><w:r><w:t>Before</w:t></w:r><w:r><w:pict>{shape}</w:pict></w:r></w:p>");
|
||
wordcraft_docx::read(&docx(&body, &[], &[])).unwrap()
|
||
};
|
||
let art = r##"<v:shape type="#_x0000_t136" style="position:absolute;width:300pt;height:60pt" fillcolor="#336699" stroked="f"><v:textpath style="font-family:"Impact";font-size:1pt" string="Sale"/></v:shape>"##;
|
||
let d = read(art);
|
||
let objs = ¶s(&d)[0].objects;
|
||
assert_eq!(objs.len(), 1);
|
||
let InlineObject::Shape { story: Some(id), w, h, .. } = &objs[0] else { panic!("not a text shape: {:?}", objs[0]) };
|
||
assert_eq!((*w, *h), (300.0, 60.0));
|
||
let p = d.parts.get(id).unwrap().blocks[0].as_para().unwrap();
|
||
assert_eq!(p.text, "Sale");
|
||
assert_eq!(p.runs[0].props.font.as_deref(), Some("Impact"));
|
||
assert_eq!(p.runs[0].props.color, Some(TextColor::Rgb(Rgb(0x33, 0x66, 0x99))));
|
||
|
||
// Its WordArt shape bends the text: shapetype 144 (Arch Up) is DrawingML's `textArchUp`.
|
||
let warp = |shape: &str| match ¶s(&read(shape))[0].objects[..] {
|
||
[InlineObject::Shape { extra, .. }] => extra.warp.as_ref().map(|w| w.preset.clone()),
|
||
other => panic!("not one shape: {other:?}"),
|
||
};
|
||
assert_eq!(warp(&art.replace("_x0000_t136", "_x0000_t144")).as_deref(), Some("textArchUp"));
|
||
assert_eq!(warp(&art.replace("_x0000_t136", "_x0000_t175")).as_deref(), Some("textCanDown"));
|
||
assert_eq!(warp(art).as_deref(), Some("textPlain"));
|
||
|
||
assert!(paras(&read(&art.replace("<v:textpath ", r#"<v:textpath on="f" "#)))[0].objects.is_empty());
|
||
assert!(paras(&read(&art.replace(r#"string="Sale""#, r#"string=" ""#)))[0].objects.is_empty());
|
||
}
|
||
|
||
/// Issue #1290: a file with Word's watermark keeps it through save and reopen: the same
|
||
/// watermark, the same headers with the same text and paragraphs, one shape per header written;
|
||
/// header WordArt that isn't a watermark doesn't become one on the way.
|
||
#[test]
|
||
fn word_watermark_survives_save() {
|
||
let shape = |id: &str, text: &str| {
|
||
format!(
|
||
r##"<w:r><w:pict><v:shapetype id="_x0000_t136" coordsize="21600,21600" o:spt="136" path="m0,0l21600,0m0,21600l21600,21600e"><v:textpath on="t" fitshape="t"/></v:shapetype><v:shape id="{id}" type="#_x0000_t136" style="position:absolute;margin-left:0;margin-top:0;width:412pt;height:247pt;rotation:315;z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" o:allowincell="f" fillcolor="silver" stroked="f"><v:fill opacity=".5"/><v:textpath style="font-family:"Calibri Light";font-size:1pt" string="{text}"/></v:shape></w:pict></w:r>"##
|
||
)
|
||
};
|
||
let hdr = |inner: &str| {
|
||
format!(r#"<w:hdr {W_NS}><w:p>{inner}<w:r><w:t>Header label</w:t></w:r></w:p><w:p><w:r><w:t>Second line</w:t></w:r></w:p></w:hdr>"#)
|
||
};
|
||
let h1 = hdr(&shape("PowerPlusWaterMarkObject1", "CONFIDENTIAL"));
|
||
let h2 = hdr(&shape("PowerPlusWaterMarkObject2", "CONFIDENTIAL"));
|
||
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:headerReference w:type="first" r:id="rIdF"/><w:titlePg/></w:sectPr>"#;
|
||
let rels = [("rIdH", "header", "header1.xml"), ("rIdF", "header", "header2.xml")];
|
||
let d = wordcraft_docx::read(&docx(body, &rels, &[("word/header1.xml", &h1), ("word/header2.xml", &h2)])).unwrap();
|
||
let wm = d.settings.watermark.clone().expect("watermark");
|
||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||
let d2 = wordcraft_docx::read(&bytes).unwrap();
|
||
assert_eq!(d2.settings.watermark, Some(wm));
|
||
let headers = |d: &Document| {
|
||
let mut v: Vec<Vec<String>> = [d.last_section.headers.default, d.last_section.headers.first]
|
||
.iter()
|
||
.map(|id| d.parts[&id.unwrap()].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect())
|
||
.collect();
|
||
v.push(vec![d.parts.len().to_string()]);
|
||
v
|
||
};
|
||
assert_eq!(headers(&d2), headers(&d));
|
||
assert_eq!(headers(&d2)[0], vec!["Header label".to_string(), "Second line".to_string()]);
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||
for name in ["word/header1.xml", "word/header2.xml"] {
|
||
let mut xml = String::new();
|
||
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut xml).unwrap();
|
||
assert_eq!(xml.matches("<v:shape ").count(), 1, "{name}");
|
||
}
|
||
|
||
// A header logo in WordArt isn't a watermark, so none is written.
|
||
let logo = r##"<w:r><w:pict><v:shape type="#_x0000_t136" style="position:absolute;width:120pt;height:30pt;mso-position-horizontal:left" fillcolor="navy"><v:textpath string="ACME"/></v:shape></w:pict></w:r>"##;
|
||
let d = wordcraft_docx::read(&docx(body, &rels, &[("word/header1.xml", &hdr(logo)), ("word/header2.xml", &hdr(""))])).unwrap();
|
||
assert_eq!(d.settings.watermark, None);
|
||
let d2 = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
assert_eq!(d2.settings.watermark, None);
|
||
}
|
||
|
||
/// Issue #1775: a picture watermark in a header that starts with a table is anchored in a cell
|
||
/// but placed on the page: any VML shape in a header (Word's picture watermark, `o:allowincell="f"`; user-checked
|
||
/// in Word for Mac, the attribute makes no difference) or DrawingML `layoutInCell="0"`. That survives save and reopen; a shape laid out in its cell stays so.
|
||
#[test]
|
||
fn header_cell_picture_placed_on_the_page() {
|
||
let vml = |allow: &str| {
|
||
format!(
|
||
r##"<w:r><w:pict><v:shape id="WordPictureWatermark1" o:spid="_x0000_s2049" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:300pt;height:200pt;z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" {allow}><v:imagedata r:id="rIdP" o:title="" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r>"##
|
||
)
|
||
};
|
||
let anchor = |in_cell: &str| {
|
||
format!(
|
||
r#"<w:r><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" behindDoc="1" locked="0" layoutInCell="{in_cell}" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>center</wp:align></wp:positionV><wp:extent cx="3810000" cy="2540000"/><wp:wrapNone/><wp:docPr id="1" name="Picture 1"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="p.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdP"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="3810000" cy="2540000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
|
||
)
|
||
};
|
||
let read = |run: &str| {
|
||
let hdr = format!(
|
||
r#"<w:hdr {W_NS}><w:tbl><w:tblGrid><w:gridCol w:w="4680"/><w:gridCol w:w="4680"/></w:tblGrid><w:tr><w:tc><w:p><w:r><w:t>Left</w:t></w:r></w:p></w:tc><w:tc><w:p>{run}<w:r><w:t>Right</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/></w:hdr>"#
|
||
);
|
||
let hrels = rels(&[("rIdP", "image", "media/p.png")]);
|
||
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
|
||
let parts = [("word/header1.xml", hdr.as_str()), ("word/_rels/header1.xml.rels", hrels.as_str()), ("word/media/p.png", "\u{89}PNG stand-in")];
|
||
wordcraft_docx::read(&docx(body, &[("rIdH", "header", "header1.xml")], &parts)).unwrap()
|
||
};
|
||
let floats = |d: &Document| -> Vec<(bool, Wrap)> {
|
||
let hid = d.last_section.headers.default.unwrap();
|
||
let mut out = Vec::new();
|
||
for b in &d.parts[&hid].blocks {
|
||
if let Block::Table(t) = &**b {
|
||
for c in &t.rows[0].cells {
|
||
for p in c.blocks.iter().filter_map(|b| b.as_para()) {
|
||
out.extend(p.objects.iter().filter_map(|o| o.frame()).map(|(_, _, f)| (f.outside_cell, f.wrap)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
};
|
||
for (run, outside) in [
|
||
(vml(r#"o:allowincell="f""#), true),
|
||
(vml(r#"o:allowincell="false""#), true),
|
||
// Word for Mac draws every VML picture in a header cell on the page, ticked or not.
|
||
(vml(""), true),
|
||
(vml(r#"o:allowincell="t""#), true),
|
||
(anchor("0"), true),
|
||
(anchor("false"), true),
|
||
(anchor("1"), false),
|
||
] {
|
||
let d = read(&run);
|
||
assert_eq!(floats(&d), vec![(outside, Wrap::BehindText)], "{run}");
|
||
let d2 = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
assert_eq!(floats(&d2), vec![(outside, Wrap::BehindText)], "saved: {run}");
|
||
}
|
||
}
|
||
|
||
/// Issue #1775: only header and footer shapes are placed on the page from a cell (what was checked
|
||
/// in Word); a VML picture in a body table cell stays laid out in its cell.
|
||
#[test]
|
||
fn body_cell_vml_picture_stays_in_its_cell() {
|
||
let pic = r##"<w:r><w:pict><v:shape type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:100pt;height:80pt;z-index:-1;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin"><v:imagedata r:id="rIdP"/></v:shape></w:pict></w:r>"##;
|
||
let body = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4680"/></w:tblGrid><w:tr><w:tc><w:p>{pic}</w:p></w:tc></w:tr></w:tbl><w:p/>"#);
|
||
let parts = [("word/media/p.png", "\u{89}PNG stand-in")];
|
||
let d = wordcraft_docx::read(&docx(&body, &[("rIdP", "image", "media/p.png")], &parts)).unwrap();
|
||
let Block::Table(t) = &*d.body[0] else { panic!("table first") };
|
||
let floats: Vec<bool> = t.rows[0].cells[0]
|
||
.blocks
|
||
.iter()
|
||
.filter_map(|b| b.as_para())
|
||
.flat_map(|p| p.objects.iter().filter_map(|o| o.frame()).map(|(_, _, f)| f.outside_cell))
|
||
.collect();
|
||
assert_eq!(floats, vec![false]);
|
||
}
|
||
|
||
/// Issue #149: an absolutely positioned VML text box in a header floats where its style puts it
|
||
/// (relative to the page here, behind the text at a negative z-index, no wrapping) instead of
|
||
/// sitting inline in the header's flow, and it keeps that placement when saved and read back.
|
||
#[test]
|
||
fn absolute_vml_text_box_in_header_floats() {
|
||
let w10 = r#"xmlns:w10="urn:schemas-microsoft-com:office:word""#;
|
||
let boxes = r##"<w:p><w:r><w:pict><v:shape id="Box" type="#_x0000_t202" style="position:absolute;left:0;margin-left:0pt;margin-top:80pt;width:405pt;height:491.4pt;z-index:-1;mso-position-horizontal-relative:page;mso-position-vertical-relative:page"><v:textbox><w:txbxContent><w:p><w:r><w:t>WATERMARK</w:t></w:r></w:p></w:txbxContent></v:textbox><w10:wrap type="none"/></v:shape></w:pict></w:r></w:p>
|
||
<w:p><w:r><w:pict><v:rect style="position:absolute;margin-left:12pt;margin-top:-6pt;width:1in;height:36pt;z-index:3;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical-relative:top-margin-area;mso-wrap-distance-left:9pt;mso-wrap-distance-bottom:4pt"><v:textbox><w:txbxContent><w:p><w:r><w:t>square</w:t></w:r></w:p></w:txbxContent></v:textbox><w10:wrap type="square"/></v:rect></w:pict></w:r>
|
||
<w:r><w:pict><v:shape style="position:absolute;margin-left:1in;margin-top:2pt;width:72pt;height:20pt;z-index:5"><v:textbox><w:txbxContent><w:p><w:r><w:t>front</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>
|
||
<w:r><w:pict><v:shape style="width:72pt;height:20pt"><v:textbox><w:txbxContent><w:p><w:r><w:t>inline</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>""##;
|
||
let header = format!(r#"<w:hdr {W_NS} {w10}><w:p><w:r><w:t>Header label</w:t></w:r></w:p>{boxes}</w:hdr>"#);
|
||
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
|
||
let bytes = docx(body, &[("rIdH", "header", "header1.xml")], &[("word/header1.xml", &header)]);
|
||
let check = |d: &Document| {
|
||
let hid = d.last_section.headers.default.unwrap();
|
||
let blocks = &d.parts.get(&hid).unwrap().blocks;
|
||
let shapes: Vec<_> = blocks
|
||
.iter()
|
||
.filter_map(|b| b.as_para())
|
||
.flat_map(|p| &p.objects)
|
||
.filter_map(|o| {
|
||
if let InlineObject::Shape { kind, w, h, float, story, .. } = o { Some((*kind, *w, *h, float.clone(), *story)) } else { None }
|
||
})
|
||
.collect();
|
||
let [(k0, w0, h0, f0, s0), (_, w1, h1, f1, _), (_, _, _, f2, _), (_, _, _, f3, _)] = shapes.as_slice() else { panic!("{shapes:?}") };
|
||
assert_eq!(*k0, ShapeKind::TextBox);
|
||
assert!((w0 - 405.0).abs() < 0.01 && (h0 - 491.4).abs() < 0.01, "{w0} x {h0}");
|
||
assert_eq!((f0.wrap, f0.h_rel, f0.v_rel, f0.h_align, f0.v_align), (Wrap::BehindText, Anchor::Page, Anchor::Page, None, None));
|
||
assert!(f0.x.abs() < 0.01 && (f0.y - 80.0).abs() < 0.01, "offset {} {}", f0.x, f0.y);
|
||
let text = d.parts.get(&s0.unwrap()).unwrap().blocks[0].as_para().unwrap().text.clone();
|
||
assert_eq!(text, "WATERMARK");
|
||
assert_eq!((*w1, *h1), (72.0, 36.0));
|
||
assert_eq!(
|
||
(f1.wrap, f1.h_rel, f1.h_align, f1.v_rel, f1.v_align),
|
||
(Wrap::Square, Anchor::Margin, Some(FloatAlign::Center), Anchor::TopMargin, None)
|
||
);
|
||
assert!((f1.y + 6.0).abs() < 0.01 && (f1.dist - 9.0).abs() < 0.01 && (f1.dist_bottom - 4.0).abs() < 0.01, "{f1:?}");
|
||
// No wrap element: in front of the text, relative to the column and paragraph.
|
||
assert_eq!((f2.wrap, f2.h_rel, f2.v_rel), (Wrap::InFrontOfText, Anchor::Column, Anchor::Paragraph));
|
||
assert!((f2.x - 72.0).abs() < 0.01 && (f2.y - 2.0).abs() < 0.01, "{f2:?}");
|
||
// Not absolutely positioned: still inline.
|
||
assert_eq!(f3.wrap, Wrap::Inline);
|
||
};
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
check(&d);
|
||
// Saved (as DrawingML anchors) and read back, the boxes keep their placement.
|
||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_vml_style_values_stay_finite() {
|
||
let body = r#"<w:p><w:r><w:pict><v:shape style="position:ABSOLUTE;margin-left:1e39pt;left:-1e39pt;margin-top:NaNpt;top:;width:-5pt;height:1e30in;z-index:99999999999999999999999;mso-position-horizontal:bogus;mso-wrap-distance-left:-3pt;mso-wrap-distance-top:1e9pt;;:;position"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
|
||
let d = read_body(body);
|
||
let p = paras(&d);
|
||
let Some(InlineObject::Shape { w, h, float, .. }) = p[0].objects.first() else { panic!("{:?}", p[0].objects) };
|
||
assert_eq!(float.wrap, Wrap::InFrontOfText);
|
||
assert!([*w, *h, float.x, float.y, float.dist, float.dist_top].iter().all(|v| v.is_finite()), "{w} {h} {float:?}");
|
||
assert_eq!((float.h_align, float.dist, float.dist_top), (None, 0.0, 1584.0));
|
||
}
|
||
|
||
// ---- charts and OLE objects written back (#319) ----
|
||
|
||
const CHART_XML: &str = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart><c:externalData r:id="rId1"><c:autoUpdate val="0"/></c:externalData></c:chartSpace>"#;
|
||
/// Stands in for the chart's workbook: opaque bytes, never opened.
|
||
const WORKBOOK: &str = "PK\u{3}\u{4} not really a workbook";
|
||
const COLORS_XML: &str = r#"<cs:colorStyle xmlns:cs="http://schemas.microsoft.com/office/drawing/2012/chartStyle" meth="cycle" id="10"/>"#;
|
||
const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
|
||
const CT_XLSX: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||
|
||
/// A package holding one inline chart (3 × 1.5 in) with its workbook and colour style parts.
|
||
fn chart_docx() -> Vec<u8> {
|
||
let body = r#"<w:p><w:r><w:t>Sales</w:t></w:r><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="rId7"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
|
||
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
|
||
let types = format!(
|
||
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"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chart1.xml" ContentType="{CT_CHART}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
|
||
);
|
||
docx(
|
||
body,
|
||
&[("rId7", "chart", "charts/chart1.xml")],
|
||
&[
|
||
("[Content_Types].xml", &types),
|
||
("word/charts/chart1.xml", CHART_XML),
|
||
("word/charts/_rels/chart1.xml.rels", chart_rels),
|
||
("word/charts/colors1.xml", COLORS_XML),
|
||
("word/embeddings/Microsoft_Excel_Worksheet.xlsx", WORKBOOK),
|
||
],
|
||
)
|
||
}
|
||
|
||
/// The entries of a zip, by name.
|
||
fn unzip(bytes: &[u8]) -> std::collections::BTreeMap<String, Vec<u8>> {
|
||
use std::io::Read;
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||
let mut out = std::collections::BTreeMap::new();
|
||
for i in 0..z.len() {
|
||
let mut f = z.by_index(i).unwrap();
|
||
let mut b = Vec::new();
|
||
f.read_to_end(&mut b).unwrap();
|
||
out.insert(f.name().to_string(), b);
|
||
}
|
||
out
|
||
}
|
||
|
||
fn text(files: &std::collections::BTreeMap<String, Vec<u8>>, name: &str) -> String {
|
||
String::from_utf8(files.get(name).unwrap_or_else(|| panic!("no {name} in {:?}", files.keys())).clone()).unwrap()
|
||
}
|
||
|
||
/// Values of attribute `attr` on the elements named `el` (in written order).
|
||
fn attr_values(xml: &str, el: &str, attr: &str) -> Vec<String> {
|
||
let start = |s: &str| format!(" {}", s.split('>').next().unwrap_or(""));
|
||
xml.split(&format!("<{el} "))
|
||
.skip(1)
|
||
.filter_map(|s| start(s).split(&format!(" {attr}=\"")).nth(1)?.split('"').next().map(str::to_string))
|
||
.collect()
|
||
}
|
||
|
||
/// (type, target) of relationship `id` in a relationships part.
|
||
fn rel(rels: &str, id: &str) -> (String, String) {
|
||
let r = rels.split("<Relationship ").find(|r| r.contains(&format!("Id=\"{id}\""))).unwrap_or_else(|| panic!("no {id} in {rels}"));
|
||
let get = |a: &str| r.split(&format!("{a}=\"")).nth(1).unwrap().split('"').next().unwrap().to_string();
|
||
(get("Type"), get("Target"))
|
||
}
|
||
|
||
/// Package part a relationship target of `from_dir` names.
|
||
fn part_at(from_dir: &str, target: &str) -> String {
|
||
let mut parts: Vec<&str> = from_dir.split('/').filter(|s| !s.is_empty()).collect();
|
||
for seg in target.split('/') {
|
||
match seg {
|
||
".." => {
|
||
parts.pop();
|
||
}
|
||
"." | "" => {}
|
||
s => parts.push(s),
|
||
}
|
||
}
|
||
parts.join("/")
|
||
}
|
||
|
||
/// Checks a written chart: its part and the parts it relates to are all in the package, with the
|
||
/// original bytes and content types. Returns the chart's part name.
|
||
fn check_chart(files: &std::collections::BTreeMap<String, Vec<u8>>, rid: &str) -> String {
|
||
let (kind, target) = rel(&text(files, "word/_rels/document.xml.rels"), rid);
|
||
assert!(kind.ends_with("/chart"), "{kind}");
|
||
let chart = part_at("word", &target);
|
||
assert_eq!(text(files, &chart), CHART_XML);
|
||
let (dir, file) = chart.rsplit_once('/').unwrap();
|
||
let chart_rels = text(files, &format!("{dir}/_rels/{file}.rels"));
|
||
let (kind, wb) = rel(&chart_rels, "rId1");
|
||
assert!(kind.ends_with("/package"), "{kind}");
|
||
let wb = part_at(dir, &wb);
|
||
assert_eq!(files.get(&wb).map(Vec::as_slice), Some(WORKBOOK.as_bytes()), "{wb}");
|
||
let (kind, colors) = rel(&chart_rels, "rId2");
|
||
assert!(kind.ends_with("/chartColorStyle"), "{kind}");
|
||
assert_eq!(text(files, &part_at(dir, &colors)), COLORS_XML);
|
||
let types = text(files, "[Content_Types].xml");
|
||
assert!(types.contains(&format!(r#"PartName="/{chart}" ContentType="{CT_CHART}""#)), "{types}");
|
||
assert!(types.contains(&format!(r#"PartName="/{wb}" ContentType="{CT_XLSX}""#)), "{types}");
|
||
chart
|
||
}
|
||
|
||
fn graphics(d: &Document) -> Vec<InlineObject> {
|
||
paras(d).iter().flat_map(|p| p.objects.iter()).filter(|o| matches!(o, InlineObject::Graphic { .. })).cloned().collect()
|
||
}
|
||
|
||
#[test]
|
||
fn chart_round_trips_with_its_parts() {
|
||
let d = wordcraft_docx::read(&chart_docx()).unwrap();
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let files = unzip(&out);
|
||
let body = text(&files, "word/document.xml");
|
||
let ids = attr_values(&body, "c:chart", "r:id");
|
||
assert_eq!(ids.len(), 1, "{body}");
|
||
assert_eq!(check_chart(&files, &ids[0]), "word/charts/chart1.xml");
|
||
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["2743200"]);
|
||
|
||
// Read back: still drawn, and a second save still carries it.
|
||
let back = wordcraft_docx::read(&out).unwrap();
|
||
let g = graphics(&back);
|
||
let [InlineObject::Graphic { w, graphic, .. }] = g.as_slice() else { panic!("{g:?}") };
|
||
assert_eq!(*w, 216.0);
|
||
assert!(!graphic.items.is_empty() && graphic.source.is_some());
|
||
let again = unzip(&wordcraft_docx::write(&back).unwrap());
|
||
let ids = attr_values(&text(&again, "word/document.xml"), "c:chart", "r:id");
|
||
check_chart(&again, &ids[0]);
|
||
}
|
||
|
||
// ---- Office 2016 charts (cx:chart) written back with their fallback picture (#997) ----
|
||
|
||
const CHARTEX_NS: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex";
|
||
const CHARTEX_XML: &str = r#"<cx:chartSpace xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><cx:chartData><cx:externalData r:id="rId1" cx:autoUpdate="0"/></cx:chartData><cx:chart><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="boxWhisker"/></cx:plotAreaRegion></cx:plotArea></cx:chart></cx:chartSpace>"#;
|
||
const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
|
||
|
||
/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an
|
||
/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`.
|
||
fn chartex_docx(requires: &str, requires_ns: &str) -> Vec<u8> {
|
||
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
|
||
let body = format!(
|
||
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
|
||
);
|
||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
|
||
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
|
||
let types = format!(
|
||
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"/><Default Extension="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
|
||
);
|
||
zip(&[
|
||
("[Content_Types].xml", types.as_bytes()),
|
||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||
("word/document.xml", doc.as_bytes()),
|
||
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
|
||
("word/charts/chartEx1.xml", CHARTEX_XML.as_bytes()),
|
||
("word/charts/_rels/chartEx1.xml.rels", chart_rels.as_bytes()),
|
||
("word/charts/colors1.xml", COLORS_XML.as_bytes()),
|
||
("word/embeddings/Microsoft_Excel_Worksheet.xlsx", WORKBOOK.as_bytes()),
|
||
("word/media/image1.png", b"fallback picture bytes"),
|
||
])
|
||
}
|
||
|
||
#[test]
|
||
fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() {
|
||
// Word binds `cx1`… to the chart type's own namespace; Apache POI's sample binds `cx`.
|
||
for (requires, requires_ns) in [("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex"), ("cx", CHARTEX_NS)] {
|
||
let mut d = wordcraft_docx::read(&chartex_docx(requires, requires_ns)).unwrap();
|
||
for _ in 0..2 {
|
||
let g = graphics(&d);
|
||
let [InlineObject::Graphic { w, graphic, .. }] = g.as_slice() else { panic!("{requires}: {g:?}") };
|
||
assert_eq!(*w, 216.0);
|
||
assert!(
|
||
graphic.items.iter().any(|i| matches!(i, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media))),
|
||
"{requires}: the chart shows its fallback picture"
|
||
);
|
||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
let body = text(&files, "word/document.xml");
|
||
assert_eq!(attr_values(&body, "mc:Choice", "Requires"), [requires], "{body}");
|
||
assert!(body.contains(&format!(r#"xmlns:{requires}="{requires_ns}""#)), "{requires} is declared: {body}");
|
||
let doc_rels = text(&files, "word/_rels/document.xml.rels");
|
||
let [cx_id] = attr_values(&body, "cx:chart", "r:id").try_into().unwrap_or_else(|_| panic!("chart lost: {body}"));
|
||
let (kind, target) = rel(&doc_rels, &cx_id);
|
||
assert!(kind.ends_with("/chartEx"), "{kind}");
|
||
let chart = part_at("word", &target);
|
||
assert_eq!(text(&files, &chart), CHARTEX_XML);
|
||
let types = text(&files, "[Content_Types].xml");
|
||
assert!(types.contains(&format!(r#"PartName="/{chart}" ContentType="{CT_CHARTEX}""#)), "{types}");
|
||
let (dir, file) = chart.rsplit_once('/').unwrap();
|
||
let (_, wb) = rel(&text(&files, &format!("{dir}/_rels/{file}.rels")), "rId1");
|
||
assert_eq!(files.get(&part_at(dir, &wb)).map(Vec::as_slice), Some(WORKBOOK.as_bytes()));
|
||
let fallback = body.split("<mc:Fallback>").nth(1).unwrap_or_else(|| panic!("no fallback: {body}"));
|
||
let [pic_id] = attr_values(fallback, "a:blip", "r:embed").try_into().unwrap_or_else(|_| panic!("fallback picture lost: {body}"));
|
||
assert_eq!(files.get(&part_at("word", &rel(&doc_rels, &pic_id).1)).map(Vec::as_slice), Some(&b"fallback picture bytes"[..]));
|
||
assert!(attr_values(&body, "wp:docPr", "name").iter().all(|n| n.starts_with("Chart ")), "{body}");
|
||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||
d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn resized_office_2016_chart_resizes_its_fallback_too() {
|
||
let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap();
|
||
if let Some(Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut)
|
||
&& let Some(InlineObject::Graphic { w, h, .. }) = p.objects.first_mut()
|
||
{
|
||
(*w, *h) = (288.0, 144.0);
|
||
}
|
||
let body = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/document.xml");
|
||
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"], "{body}");
|
||
}
|
||
|
||
#[test]
|
||
fn moved_and_copied_chart_keeps_its_parts() {
|
||
let d = wordcraft_docx::read(&chart_docx()).unwrap();
|
||
let g = graphics(&d);
|
||
let [InlineObject::Graphic { alt, graphic, .. }] = g.as_slice() else { panic!("{g:?}") };
|
||
// Resized and dragged to float on the page in WordCraft, then copied and pasted.
|
||
let float = wordcraft_doc::para::Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 36.0, y: 18.0, ..Default::default() };
|
||
let moved = InlineObject::Graphic { w: 288.0, h: 144.0, alt: alt.clone(), float, graphic: graphic.clone() };
|
||
let mut p = Paragraph::new();
|
||
p.insert_object(0, moved.clone(), &Default::default()).unwrap();
|
||
p.insert_object(p.len(), moved, &Default::default()).unwrap();
|
||
let mut doc = d.clone();
|
||
doc.body = vec![wordcraft_doc::para_block(p)];
|
||
|
||
let out = wordcraft_docx::write(&doc).unwrap();
|
||
let files = unzip(&out);
|
||
let body = text(&files, "word/document.xml");
|
||
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"], "the new size");
|
||
assert_eq!(body.matches("<wp:posOffset>457200</wp:posOffset>").count(), 2, "the new position");
|
||
let ids = attr_values(&body, "c:chart", "r:id");
|
||
assert_eq!(ids.len(), 2);
|
||
let charts: Vec<String> = ids.iter().map(|id| check_chart(&files, id)).collect();
|
||
assert_ne!(charts[0], charts[1], "each copy has its own chart part");
|
||
|
||
let back = wordcraft_docx::read(&out).unwrap();
|
||
let g = graphics(&back);
|
||
assert_eq!(g.len(), 2);
|
||
for o in &g {
|
||
let InlineObject::Graphic { w, float, graphic, .. } = o else { panic!() };
|
||
assert_eq!((*w, float.wrap, float.x), (288.0, Wrap::Square, 36.0));
|
||
assert!(!graphic.items.is_empty() && graphic.source.is_some());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ole_object_round_trips() {
|
||
let body = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata r:id="rId8" o:title=""/></v:shape><o:OLEObject Type="Embed" ProgID="Package" ShapeID="_x0000_i1025" DrawAspect="Content" ObjectID="_1000000001" r:id="rId9"/></w:object></w:r></w:p>"##;
|
||
let picture = "\u{89}PNG stand-in";
|
||
let ole = "\u{d0}\u{cf} opaque OLE storage";
|
||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.oleObject"/></Types>"#;
|
||
let bytes = docx(
|
||
body,
|
||
&[("rId8", "image", "media/image1.png"), ("rId9", "oleObject", "embeddings/oleObject1.bin")],
|
||
&[("[Content_Types].xml", types), ("word/media/image1.png", picture), ("word/embeddings/oleObject1.bin", ole)],
|
||
);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let Some(InlineObject::Image { w, ole: Some(_), .. }) = paras(&d)[0].objects.first() else { panic!("{:?}", paras(&d)[0].objects) };
|
||
assert_eq!(*w, 72.0);
|
||
|
||
// Resized in WordCraft.
|
||
let mut doc = d.clone();
|
||
if let Some(wordcraft_doc::Block::Para(p)) = doc.body.first_mut().map(std::sync::Arc::make_mut)
|
||
&& let Some(InlineObject::Image { w, h, .. }) = p.objects.first_mut()
|
||
{
|
||
(*w, *h) = (144.0, 72.0);
|
||
}
|
||
let out = wordcraft_docx::write(&doc).unwrap();
|
||
let files = unzip(&out);
|
||
let body = text(&files, "word/document.xml");
|
||
let rels = text(&files, "word/_rels/document.xml.rels");
|
||
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:144pt;height:72pt"]);
|
||
let [ole_id] = attr_values(&body, "o:OLEObject", "r:id").try_into().unwrap();
|
||
let (kind, target) = rel(&rels, &ole_id);
|
||
assert!(kind.ends_with("/oleObject"), "{kind}");
|
||
let ole_part = part_at("word", &target);
|
||
assert_eq!(files.get(&ole_part).map(Vec::as_slice), Some(ole.as_bytes()));
|
||
assert!(
|
||
text(&files, "[Content_Types].xml")
|
||
.contains(&format!(r#"PartName="/{ole_part}" ContentType="application/vnd.openxmlformats-officedocument.oleObject""#))
|
||
);
|
||
let [pic_id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
|
||
let (kind, target) = rel(&rels, &pic_id);
|
||
assert!(kind.ends_with("/image"), "{kind}");
|
||
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes()));
|
||
assert!(!body.contains("<w:drawing>"), "the object, not a plain picture");
|
||
|
||
let back = wordcraft_docx::read(&out).unwrap();
|
||
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn an_unqualified_relationship_attribute_round_trips() {
|
||
// A producer that left the relationship prefix out: the VML picture names its image with an
|
||
// unqualified `id`. The reader resolves it (the alias `El::attr` accepts), the writer keeps
|
||
// the attribute and gives it the id the new package holds the image under.
|
||
let body = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata id="rId8" o:title=""/></v:shape><o:OLEObject Type="Embed" ProgID="Package" ShapeID="_x0000_i1025" DrawAspect="Content" ObjectID="_1000000001" r:id="rId9"/></w:object></w:r></w:p>"##;
|
||
let picture = "\u{89}PNG stand-in";
|
||
let ole = "\u{d0}\u{cf} opaque OLE storage";
|
||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.oleObject"/></Types>"#;
|
||
let bytes = docx(
|
||
body,
|
||
&[("rId8", "image", "media/image1.png"), ("rId9", "oleObject", "embeddings/oleObject1.bin")],
|
||
&[("[Content_Types].xml", types), ("word/media/image1.png", picture), ("word/embeddings/oleObject1.bin", ole)],
|
||
);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let Some(InlineObject::Image { ole: Some(_), .. }) = paras(&d)[0].objects.first() else { panic!("{:?}", paras(&d)[0].objects) };
|
||
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let files = unzip(&out);
|
||
let body = text(&files, "word/document.xml");
|
||
let rels = text(&files, "word/_rels/document.xml.rels");
|
||
// The attribute is still unqualified, and names the image the new package holds.
|
||
let [pic_id] = attr_values(&body, "v:imagedata", "id").try_into().unwrap();
|
||
let (kind, target) = rel(&rels, &pic_id);
|
||
assert!(kind.ends_with("/image"), "{kind}");
|
||
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes()));
|
||
// The object itself is kept whole, as before.
|
||
let [ole_id] = attr_values(&body, "o:OLEObject", "r:id").try_into().unwrap();
|
||
let (kind, target) = rel(&rels, &ole_id);
|
||
assert!(kind.ends_with("/oleObject"), "{kind}");
|
||
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(ole.as_bytes()));
|
||
}
|
||
|
||
#[test]
|
||
fn signature_line_round_trips() {
|
||
// Insert › Signature Line: a VML picture (the line, its X and the signer drawn in a metafile)
|
||
// with an `o:signatureline` that makes it signable in Word. Saving keeps it a signature line,
|
||
// not a plain picture; a VML picture without one still becomes a DrawingML picture.
|
||
let body = r##"<w:p><w:r><w:pict><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"><v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"/><o:lock v:ext="edit" aspectratio="t"/></v:shapetype><v:shape id="_x0000_i1025" type="#_x0000_t75" alt="Signature line" style="width:192pt;height:96pt"><v:imagedata r:id="rId8" o:title=""/><o:lock v:ext="edit" ungrouping="t" rotation="t" cropping="t" verticies="t" text="t" grouping="t"/><o:signatureline v:ext="edit" id="{00000000-0000-0000-0000-000000000001}" provid="{00000000-0000-0000-0000-000000000000}" o:suggestedsigner="A. Example" o:suggestedsigner2="Director" showsigndate="f" issignatureline="t"/></v:shape></w:pict></w:r><w:r><w:pict><v:shape style="width:72pt;height:36pt" alt="plain"><v:imagedata r:id="rId8" o:title=""/></v:shape></w:pict></w:r></w:p>"##;
|
||
let picture = "\u{89}PNG stand-in";
|
||
let types =
|
||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/></Types>"#;
|
||
let bytes = docx(body, &[("rId8", "image", "media/image1.png")], &[("[Content_Types].xml", types), ("word/media/image1.png", picture)]);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let objects = ¶s(&d)[0].objects;
|
||
let [InlineObject::Image { w, alt, ole: Some(_), .. }, InlineObject::Image { ole: None, .. }] = objects.as_slice() else { panic!("{objects:?}") };
|
||
assert_eq!((*w, alt.as_str()), (192.0, "Signature line"));
|
||
|
||
// Resized in WordCraft.
|
||
let mut doc = d.clone();
|
||
if let Some(wordcraft_doc::Block::Para(p)) = doc.body.first_mut().map(std::sync::Arc::make_mut)
|
||
&& let Some(InlineObject::Image { w, h, .. }) = p.objects.first_mut()
|
||
{
|
||
(*w, *h) = (144.0, 72.0);
|
||
}
|
||
let out = wordcraft_docx::write(&doc).unwrap();
|
||
let files = unzip(&out);
|
||
let body = text(&files, "word/document.xml");
|
||
let rels = text(&files, "word/_rels/document.xml.rels");
|
||
assert_eq!(attr_values(&body, "o:signatureline", "o:suggestedsigner"), ["A. Example"]);
|
||
assert_eq!(attr_values(&body, "o:signatureline", "o:suggestedsigner2"), ["Director"]);
|
||
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:144pt;height:72pt"]);
|
||
assert_eq!(attr_values(&body, "v:shapetype", "id"), ["_x0000_t75"]);
|
||
let [pic_id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
|
||
let (kind, target) = rel(&rels, &pic_id);
|
||
assert!(kind.ends_with("/image"), "{kind}");
|
||
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes()));
|
||
assert_eq!(body.matches("<w:drawing>").count(), 1, "only the plain picture becomes a DrawingML picture");
|
||
|
||
let back = wordcraft_docx::read(&out).unwrap();
|
||
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_embedded_relationships_stay_bounded() {
|
||
// The chart relates to itself, the main document, a relationships part and a missing part;
|
||
// another drawing uses an id with no relationship. Reading and saving neither loop nor carry
|
||
// those along, and the chart is still written back.
|
||
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="x/package" Target="chart1.xml"/><Relationship Id="rId2" Type="x/a" Target="../document.xml"/><Relationship Id="rId3" Type="x/b" Target="../_rels/document.xml.rels"/><Relationship Id="rId4" Type="x/c" Target="gone.bin"/><Relationship Id="rId4" Type="x/d" Target="chart1.xml"/></Relationships>"#;
|
||
let drawing = |id: &str| {
|
||
format!(
|
||
r#"<w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="C"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="{id}"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||
)
|
||
};
|
||
let body = format!("<w:p>{}{}</w:p>", drawing("rId7"), drawing("rIdNowhere"));
|
||
let bytes = docx(
|
||
&body,
|
||
&[("rId7", "chart", "charts/chart1.xml")],
|
||
&[("word/charts/chart1.xml", CHART_XML), ("word/charts/_rels/chart1.xml.rels", chart_rels)],
|
||
);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let g = graphics(&d);
|
||
assert_eq!(g.len(), 2);
|
||
let sources: Vec<bool> = g.iter().map(|o| matches!(o, InlineObject::Graphic { graphic, .. } if graphic.source.is_some())).collect();
|
||
assert_eq!(sources, [true, false], "a dangling id can't be written back");
|
||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
let body = text(&files, "word/document.xml");
|
||
let [id] = attr_values(&body, "c:chart", "r:id").try_into().unwrap();
|
||
let chart = part_at("word", &rel(&text(&files, "word/_rels/document.xml.rels"), &id).1);
|
||
let rels = text(&files, "word/charts/_rels/chart1.xml.rels");
|
||
assert_eq!(rel(&rels, "rId1").1, "chart1.xml", "its relationship to itself, to the same written part");
|
||
assert!(!rels.contains("document.xml") && !rels.contains("gone.bin") && rels.matches("Id=\"rId4\"").count() <= 1, "{rels}");
|
||
assert_eq!(text(&files, &chart), CHART_XML);
|
||
assert_eq!(files.keys().filter(|k| k.starts_with("word/charts/") && !k.contains("_rels")).count(), 1);
|
||
}
|
||
|
||
/// A group as Word saves one (in `mc:AlternateContent`, needing `wpg`): its members' space
|
||
/// starts at `a:chOff`, and a nested group is flattened into it through its own transform.
|
||
#[test]
|
||
fn word_group_with_nested_group() {
|
||
let body = r#"<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor behindDoc="0" distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="2540000" cy="1270000"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapSquare wrapText="bothSides"/><wp:docPr id="1" name="Group 1"/><wp:cNvGraphicFramePr/>
|
||
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2540000" cy="1270000"/><a:chOff x="127000" y="127000"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
|
||
<wps:wsp><wps:cNvPr id="2" name="Rectangle 2"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="127000" y="127000"/><a:ext cx="1270000" cy="635000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
|
||
<wpg:grpSp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="1397000" y="762000"/><a:ext cx="1270000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
|
||
<wps:wsp><wps:cNvPr id="3" name="Oval 3"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="254000" y="254000"/><a:ext cx="508000" cy="254000"/></a:xfrm><a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
|
||
</wpg:grpSp></wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict/></mc:Fallback></mc:AlternateContent></w:r></w:p>"#;
|
||
let d = read_body(body);
|
||
let p = paras(&d);
|
||
let InlineObject::Group { w, h, float, ch_w, ch_h, children, .. } = &p[0].objects[0] else { panic!("{:?}", p[0].objects) };
|
||
assert_eq!((*w, *h, *ch_w, *ch_h), (200.0, 100.0, 200.0, 100.0));
|
||
assert_eq!(float.wrap, Wrap::Square);
|
||
let got: Vec<(f32, f32, ShapeKind, f32, f32)> = children
|
||
.iter()
|
||
.map(|c| match &c.obj {
|
||
InlineObject::Shape { kind, w, h, .. } => (c.x, c.y, *kind, *w, *h),
|
||
o => panic!("{o:?}"),
|
||
})
|
||
.collect();
|
||
assert_eq!(got, [(0.0, 0.0, ShapeKind::Rectangle, 100.0, 50.0), (110.0, 60.0, ShapeKind::Ellipse, 20.0, 10.0)]);
|
||
}
|
||
|
||
/// The first object of the first paragraph, made mutable.
|
||
fn first_object(doc: &mut Document) -> &mut InlineObject {
|
||
let Some(wordcraft_doc::Block::Para(p)) = doc.body.first_mut().map(std::sync::Arc::make_mut) else { panic!("a paragraph") };
|
||
p.objects.first_mut().expect("an object")
|
||
}
|
||
|
||
/// #319 with #332: an OLE object turned or flipped in WordCraft is written back turned, in its VML
|
||
/// shape (`rotation`, `flip`) and in a DrawingML picture (`a:xfrm` rot/flipH/flipV, with the
|
||
/// effect extent covering the rotated bounds like other drawings).
|
||
#[test]
|
||
fn rotated_ole_objects_round_trip() {
|
||
let picture = "\u{89}PNG stand-in";
|
||
let ole = "\u{d0}\u{cf} opaque OLE storage";
|
||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.oleObject"/></Types>"#;
|
||
let parts = [("[Content_Types].xml", types), ("word/media/image1.png", picture), ("word/embeddings/oleObject1.bin", ole)];
|
||
let doc_rels = [("rId8", "image", "media/image1.png"), ("rId9", "oleObject", "embeddings/oleObject1.bin")];
|
||
let spin = |o: &InlineObject| o.frame().map(|f| f.2.spin()).unwrap();
|
||
|
||
// VML: read turned, written with the new turn.
|
||
let vml = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt;rotation:30;flip:x" o:ole=""><v:imagedata r:id="rId8" o:title=""/></v:shape><o:OLEObject Type="Embed" ProgID="Package" ShapeID="_x0000_i1025" DrawAspect="Content" ObjectID="_1000000001" r:id="rId9"/></w:object></w:r></w:p>"##;
|
||
let mut d = wordcraft_docx::read(&docx(vml, &doc_rels, &parts)).unwrap();
|
||
assert_eq!(spin(¶s(&d)[0].objects[0]), wordcraft_geom::Spin::new(30.0, true, false));
|
||
if let Some(f) = first_object(&mut d).float_mut() {
|
||
f.set_spin(wordcraft_geom::Spin::new(45.0, false, true));
|
||
}
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let body = text(&unzip(&out), "word/document.xml");
|
||
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:72pt;height:36pt;rotation:45;flip:y"]);
|
||
let back = wordcraft_docx::read(&out).unwrap();
|
||
assert!(matches!(¶s(&back)[0].objects[0], InlineObject::Image { ole: Some(_), .. }));
|
||
assert_eq!(spin(¶s(&back)[0].objects[0]), wordcraft_geom::Spin::new(45.0, false, true));
|
||
|
||
// DrawingML: the picture's `a:xfrm` and the drawing's effect extent follow the turn.
|
||
let dml = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="914400" cy="457200"/><wp:docPr id="7" name="Object 7"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name=""/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rId8"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm flipH="1"><a:off x="0" y="0"/><a:ext cx="914400" cy="457200"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing><o:OLEObject Type="Embed" ProgID="Package" DrawAspect="Content" ObjectID="_1000000002" r:id="rId9"/></w:object></w:r></w:p>"##;
|
||
let mut d = wordcraft_docx::read(&docx(dml, &doc_rels, &parts)).unwrap();
|
||
assert_eq!(spin(¶s(&d)[0].objects[0]), wordcraft_geom::Spin::new(0.0, true, false));
|
||
if let Some(f) = first_object(&mut d).float_mut() {
|
||
f.set_spin(wordcraft_geom::Spin::new(90.0, true, false));
|
||
}
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let body = text(&unzip(&out), "word/document.xml");
|
||
assert_eq!(attr_values(&body, "a:xfrm", "rot"), ["5400000"]);
|
||
assert_eq!(attr_values(&body, "a:xfrm", "flipH"), ["1"]);
|
||
// 72 × 36 pt turned 90°: 18 pt narrower each side, 18 pt taller each side.
|
||
assert_eq!(attr_values(&body, "wp:effectExtent", "l"), ["-228600"]);
|
||
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["228600"]);
|
||
let back = wordcraft_docx::read(&out).unwrap();
|
||
let o = ¶s(&back)[0].objects[0];
|
||
assert!(matches!(o, InlineObject::Image { ole: Some(_), .. }), "{o:?}");
|
||
assert_eq!(spin(o), wordcraft_geom::Spin::new(90.0, true, false));
|
||
assert_eq!(o.frame().unwrap().2.effect, [0.0; 4], "the rotated overhang is not kept as effects room");
|
||
}
|
||
|
||
/// Issue #41: tracked formatting changes (ECMA-376 §17.13.5) survive open and save — run,
|
||
/// paragraph mark, paragraph, numbering, table, row, cell and section — each written last in
|
||
/// its parent, holding the properties before the change.
|
||
#[test]
|
||
fn formatting_revisions_round_trip() {
|
||
let body = r#"
|
||
<w:p><w:r><w:t xml:space="preserve">Plain, </w:t></w:r><w:r><w:rPr><w:b/><w:rPrChange w:id="5" w:author="Alice" w:date="2026-10-01T00:00:00Z"><w:rPr/></w:rPrChange></w:rPr><w:t>bold by Alice</w:t></w:r><w:r><w:rPr><w:sz w:val="28"/><w:rPrChange w:id="99999999999" w:author="Bob"><w:rPr><w:i/><w:sz w:val="24"/><w:rPrChange w:id="1" w:author="Nested"><w:rPr/></w:rPrChange></w:rPr></w:rPrChange></w:rPr><w:t xml:space="preserve"> end.</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/><w:numberingChange w:id="6" w:author="Alice" w:date="2026-10-01T00:00:00Z" w:original="1."/></w:numPr><w:jc w:val="center"/><w:rPr><w:i/><w:rPrChange w:id="7" w:author="Alice" w:date="2026-10-01T00:00:00Z"><w:rPr/></w:rPrChange></w:rPr><w:pPrChange w:id="8" w:author="Alice" w:date="2026-10-01T00:00:00Z"><w:pPr><w:ind w:left="720"/></w:pPr></w:pPrChange></w:pPr><w:r><w:t>Centred</w:t></w:r></w:p>
|
||
<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:jc w:val="center"/><w:tblPrChange w:id="9" w:author="Bob"><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr></w:tblPrChange></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
|
||
<w:tr><w:trPr><w:cantSplit/><w:trPrChange w:id="10" w:author="Bob"><w:trPr/></w:trPrChange></w:trPr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/><w:tcPrChange w:id="11" w:author="Bob"><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr></w:tcPrChange></w:tcPr><w:p><w:r><w:t>cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
|
||
<w:p/>
|
||
<w:sectPr><w:pgSz w:w="15840" w:h="12240" w:orient="landscape"/><w:sectPrChange w:id="12" w:author="Carol" w:date="2026-10-02T00:00:00Z"><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:sectPrChange></w:sectPr>"#;
|
||
let d = read_body(body);
|
||
let rev = |i: u32| d.revisions.get(i as usize).map(|r| (r.kind, r.author.as_str(), r.date.as_str()));
|
||
let p = paras(&d);
|
||
let runs: Vec<_> = p[0].run_ranges().map(|(r, c)| (r, c.clone())).collect();
|
||
assert_eq!(runs.len(), 3);
|
||
let alice = runs[1].1.fmt_change.as_deref().expect("Alice's rPrChange");
|
||
assert_eq!(rev(alice.rev), Some((wordcraft_doc::RevisionKind::Format, "Alice", "2026-10-01T00:00:00Z")));
|
||
assert_eq!((runs[1].1.bold, alice.old.bold), (Some(true), None));
|
||
let bob = runs[2].1.fmt_change.as_deref().expect("Bob's rPrChange");
|
||
assert_eq!((bob.old.italic, bob.old.size, bob.old.fmt_change.is_none()), (Some(true), Some(12.0), true), "a nested change is dropped");
|
||
assert!(runs[0].1.fmt_change.is_none());
|
||
let pc = p[1].props.fmt_change.as_deref().expect("pPrChange");
|
||
assert_eq!((p[1].props.align, pc.old.align, pc.old.indent_left), (Some(Align::Center), None, Some(36.0)));
|
||
assert_eq!(p[1].props.num_change.as_deref().map(|n| n.original.as_str()), Some("1."));
|
||
assert!(p[1].mark.fmt_change.is_some());
|
||
let Some(Block::Table(t)) = d.body.get(2).map(|b| &**b) else { panic!("table") };
|
||
assert_eq!(t.props.fmt_change.as_deref().map(|c| c.old.align), Some(None));
|
||
assert_eq!(t.rows[0].props.fmt_change.as_deref().map(|c| c.old.cant_split), Some(false));
|
||
assert_eq!(t.rows[0].cells[0].props.fmt_change.as_deref().map(|c| c.old.shading), Some(None));
|
||
let sc = d.last_section.fmt_change.as_deref().expect("sectPrChange");
|
||
assert_eq!((d.last_section.landscape, sc.old.landscape), (true, false));
|
||
assert_eq!(rev(sc.rev).map(|r| r.1), Some("Carol"));
|
||
|
||
// Written back: each change is the last child of its parent, and reads back the same.
|
||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||
let mut xml = String::new();
|
||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||
for (tag, parent) in [
|
||
("w:rPrChange", "</w:rPr></w:rPrChange></w:rPr>"),
|
||
("w:pPrChange", "</w:pPr></w:pPrChange></w:pPr>"),
|
||
("w:tblPrChange", "</w:tblPr></w:tblPrChange></w:tblPr>"),
|
||
("w:trPrChange", "</w:trPrChange></w:trPr>"),
|
||
("w:tcPrChange", "</w:tcPr></w:tcPrChange></w:tcPr>"),
|
||
("w:sectPrChange", "</w:sectPr></w:sectPrChange></w:sectPr>"),
|
||
] {
|
||
assert!(xml.contains(&format!("<{tag} w:id=")), "{tag}: {xml}");
|
||
assert!(xml.contains(parent), "{tag} last: {xml}");
|
||
}
|
||
assert!(xml.contains(r#"<w:numberingChange w:id="#) && xml.contains(r#"w:original="1."/></w:numPr>"#), "{xml}");
|
||
assert!(xml.contains(r#"<w:b/><w:rPrChange w:id="#), "{xml}");
|
||
assert!(!xml.contains("<w:bCs"), "Saving must not add an absent complex-script bold: {xml}");
|
||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||
assert_eq!(again.body, d.body);
|
||
assert_eq!(again.last_section, d.last_section);
|
||
assert_eq!(again.revisions, d.revisions);
|
||
}
|
||
|
||
/// A package with `styles`, a theme (accent1 4472C4, accent2 ED7D31, major font "Major Face") and,
|
||
/// when given, `settings`.
|
||
fn themed_docx(body: &str, styles: &str, settings: Option<&str>) -> Document {
|
||
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:clrScheme name="T"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="44546A"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="T"><a:majorFont><a:latin typeface="Major Face"/></a:majorFont><a:minorFont><a:latin typeface="Minor Face"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{styles}</w:styles>"#
|
||
);
|
||
let mut doc_rels = vec![("rId1", "styles", "styles.xml"), ("rId2", "theme", "theme/theme1.xml")];
|
||
let mut extra = vec![("word/styles.xml", styles.as_str()), ("word/theme/theme1.xml", theme)];
|
||
let settings = settings.map(|s| format!(r#"<w:settings {W_NS}>{s}</w:settings>"#));
|
||
if let Some(s) = &settings {
|
||
doc_rels.push(("rId3", "settings", "settings.xml"));
|
||
extra.push(("word/settings.xml", s.as_str()));
|
||
}
|
||
wordcraft_docx::read(&docx(body, &doc_rels, &extra)).unwrap()
|
||
}
|
||
|
||
/// Within one step per channel: Word's tint and shade round differently from ours in the last bit.
|
||
fn near(c: Option<TextColor>, want: Rgb) -> bool {
|
||
matches!(c, Some(TextColor::Rgb(Rgb(r, g, b))) if r.abs_diff(want.0) <= 1 && g.abs_diff(want.1) <= 1 && b.abs_diff(want.2) <= 1)
|
||
}
|
||
|
||
#[test]
|
||
fn theme_colour_wins_over_the_fixed_colour() {
|
||
// Heading 1 as Word writes it: the stale w:val is ignored, accent1 darkened 25% is 2F5496.
|
||
let styles = r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:color w:val="FF0000" w:themeColor="accent1" w:themeShade="BF"/><w:u w:val="single" w:color="FF0000" w:themeColor="accent2"/></w:rPr></w:style>"#;
|
||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>H</w:t></w:r></w:p>
|
||
<w:p><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="99"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="66"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="33"/></w:rPr><w:t>c</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeShade="7F"/></w:rPr><w:t>d</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent2" w:themeShade="BF"/></w:rPr><w:t>e</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="text2"/></w:rPr><w:t>f</w:t></w:r><w:r><w:rPr><w:color w:val="auto" w:themeColor="hyperlink"/></w:rPr><w:t>g</w:t></w:r><w:r><w:rPr><w:shd w:val="clear" w:color="auto" w:fill="000000" w:themeFill="accent2" w:themeFillTint="33"/></w:rPr><w:t>h</w:t></w:r></w:p>"#;
|
||
let d = themed_docx(body, styles, None);
|
||
let h = d.styles.get("Heading1").unwrap();
|
||
assert_eq!(h.chr.color, Some(TextColor::Rgb(Rgb(0x2F, 0x54, 0x96))));
|
||
assert_eq!(h.chr.underline_color, Some(Rgb(0xED, 0x7D, 0x31)));
|
||
let p = paras(&d);
|
||
let at = |i: usize| p[1].props_of_char(i);
|
||
// Colours Word writes into w:val next to these theme colours (Grid Table and heading styles).
|
||
assert!(near(at(0).color, Rgb(0x8E, 0xAA, 0xDB)), "{:?}", at(0).color);
|
||
assert!(near(at(1).color, Rgb(0xB4, 0xC6, 0xE7)), "{:?}", at(1).color);
|
||
assert!(near(at(2).color, Rgb(0xD9, 0xE2, 0xF3)), "{:?}", at(2).color);
|
||
assert!(near(at(3).color, Rgb(0x1F, 0x37, 0x63)), "{:?}", at(3).color);
|
||
assert!(near(at(4).color, Rgb(0xC4, 0x59, 0x11)), "{:?}", at(4).color);
|
||
// text2 maps to dk2 without a w:clrSchemeMapping; hyperlink to hlink.
|
||
assert_eq!(at(5).color, Some(TextColor::Rgb(Rgb(0x44, 0x54, 0x6A))));
|
||
assert_eq!(at(6).color, Some(TextColor::Rgb(Rgb(0x05, 0x63, 0xC1))));
|
||
let fill = at(7).shading.unwrap();
|
||
assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn theme_colours_follow_the_colour_scheme_mapping() {
|
||
// Styles are read before settings; the mapping still applies to them.
|
||
let styles =
|
||
r#"<w:style w:type="character" w:styleId="T1"><w:name w:val="T1"/><w:rPr><w:color w:val="000000" w:themeColor="text1"/></w:rPr></w:style>"#;
|
||
let body = r#"<w:p><w:r><w:rPr><w:rStyle w:val="T1"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="background1"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="dark1"/></w:rPr><w:t>c</w:t></w:r></w:p>"#;
|
||
let mapping = r#"<w:clrSchemeMapping w:bg1="dark1" w:t1="accent2" w:bg2="light2" w:t2="dark2" w:accent1="accent1" w:accent2="accent2" w:accent3="accent3" w:accent4="accent4" w:accent5="accent5" w:accent6="accent6" w:hyperlink="hyperlink" w:followedHyperlink="followedHyperlink"/>"#;
|
||
let d = themed_docx(body, styles, Some(mapping));
|
||
assert_eq!(d.styles.get("T1").unwrap().chr.color, Some(TextColor::Rgb(Rgb(0xED, 0x7D, 0x31))));
|
||
let p = paras(&d);
|
||
assert_eq!(p[0].props_of_char(1).color, Some(TextColor::Rgb(Rgb(0, 0, 0))), "background1 → dark1");
|
||
assert_eq!(p[0].props_of_char(2).color, Some(TextColor::Rgb(Rgb(0, 0, 0))), "dark1 isn't remapped");
|
||
}
|
||
|
||
#[test]
|
||
fn theme_colour_without_a_theme_keeps_the_fixed_colour() {
|
||
let d = read_body(r#"<w:p><w:r><w:rPr><w:color w:val="00FF00" w:themeColor="accent1" w:themeShade="BF"/></w:rPr><w:t>a</w:t></w:r></w:p>"#);
|
||
assert_eq!(paras(&d)[0].props_of_char(0).color, Some(TextColor::Rgb(Rgb(0, 0xFF, 0))));
|
||
}
|
||
|
||
#[test]
|
||
fn underline_without_a_val_draws_no_underline() {
|
||
// w:val has no schema default (ECMA-376 CT_Underline); Word leaves a bare w:u with a colour plain.
|
||
let d = read_body(
|
||
r#"<w:p><w:r><w:rPr><w:u w:val="single"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:u w:color="FF0000"/></w:rPr><w:t>b</w:t></w:r><w:r><w:t>c</w:t></w:r></w:p>"#,
|
||
);
|
||
let p = paras(&d);
|
||
assert_eq!(p[0].props_of_char(0).underline, Some(Underline::Single));
|
||
let b = p[0].props_of_char(1);
|
||
assert_eq!(b.underline, None);
|
||
assert_eq!(b.underline_color, Some(Rgb(0xFF, 0, 0)), "the colour is still read");
|
||
assert_eq!(p[0].props_of_char(2).underline, None);
|
||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
let b = paras(&back)[0].props_of_char(1);
|
||
assert_eq!((b.underline, b.underline_color), (None, Some(Rgb(0xFF, 0, 0))), "saving keeps the colour, not an underline");
|
||
}
|
||
|
||
#[test]
|
||
fn theme_font_wins_over_the_fixed_font() {
|
||
let styles = r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:rFonts w:ascii="Calibri Light" w:hAnsi="Calibri Light" w:asciiTheme="majorHAnsi" w:hAnsiTheme="majorHAnsi"/></w:rPr></w:style>"#;
|
||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>H</w:t></w:r></w:p>
|
||
<w:p><w:r><w:rPr><w:rFonts w:hAnsi="Arial" w:hAnsiTheme="minorHAnsi"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Arial" w:asciiTheme="minorHAnsi"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Arial" w:hAnsiTheme="majorHAnsi"/></w:rPr><w:t>c</w:t></w:r></w:p>"#;
|
||
let d = themed_docx(body, styles, None);
|
||
assert_eq!(d.styles.get("Heading1").unwrap().chr.font.as_deref(), Some("Major Face"));
|
||
let p = paras(&d);
|
||
assert_eq!(p[1].props_of_char(0).font.as_deref(), Some("Minor Face"));
|
||
assert_eq!(p[1].props_of_char(1).font.as_deref(), Some("Minor Face"));
|
||
// An explicit ascii font still beats a theme font given only for hAnsi.
|
||
assert_eq!(p[1].props_of_char(2).font.as_deref(), Some("Arial"));
|
||
}
|
||
|
||
/// Issue #400: moved text (ECMA-376 §17.13.5.21–28). `w:moveFrom` / `w:moveTo` inside named
|
||
/// move ranges are the two ends of one move, paragraph marks included; the ranges are written
|
||
/// back around them. A move missing an end, or whose range never ends, is a plain deletion and
|
||
/// insertion.
|
||
#[test]
|
||
fn move_revisions_round_trip() {
|
||
let body = r#"
|
||
<w:p><w:pPr><w:rPr><w:moveTo w:id="1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/></w:rPr></w:pPr><w:moveToRangeStart w:id="2" w:name="move1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/><w:moveTo w:id="3" w:author="Ana" w:date="2026-10-03T00:00:00Z"><w:r><w:t>Moved here.</w:t></w:r></w:moveTo></w:p>
|
||
<w:moveToRangeEnd w:id="2"/>
|
||
<w:p><w:r><w:t xml:space="preserve">Stays. </w:t></w:r><w:moveFromRangeStart w:id="4" w:name="move1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/><w:moveFrom w:id="5" w:author="Ana" w:date="2026-10-03T00:00:00Z"><w:r><w:t>Moved here.</w:t></w:r></w:moveFrom><w:moveFromRangeEnd w:id="4"/></w:p>
|
||
<w:p><w:moveFromRangeStart w:id="6" w:name="lonely" w:author="Bo"/><w:moveFrom w:id="7" w:author="Bo"><w:r><w:t>No destination</w:t></w:r></w:moveFrom><w:moveFromRangeEnd w:id="6"/><w:moveToRangeStart w:id="8" w:name="open" w:author="Bo"/><w:moveTo w:id="9" w:author="Bo"><w:r><w:t>never ends</w:t></w:r></w:moveTo><w:moveTo w:id="10" w:author="Cy"><w:r><w:t>no range</w:t></w:r></w:moveTo><w:moveFromRangeEnd w:id="404"/></w:p>"#;
|
||
let check = |d: &Document| {
|
||
let p = paras(d);
|
||
let name = |r: Option<u32>| d.move_name(r).map(str::to_string);
|
||
assert_eq!(name(p[0].mark.ins).as_deref(), Some("move1"), "a moved paragraph mark");
|
||
let to = p[0].runs[0].props.ins;
|
||
assert_eq!((name(to).as_deref(), d.revisions[to.unwrap() as usize].kind), (Some("move1"), RevisionKind::Insert));
|
||
let from = p[1].run_ranges().find(|(_, c)| c.del.is_some()).map(|(_, c)| c.del).unwrap();
|
||
assert_eq!((name(from).as_deref(), d.revisions[from.unwrap() as usize].kind), (Some("move1"), RevisionKind::Delete));
|
||
assert_eq!(d.revisions[from.unwrap() as usize].author, "Ana");
|
||
// Hostile ends: each is a plain revision, still tracked.
|
||
for (r, c) in p[2].run_ranges() {
|
||
assert!(c.ins.or(c.del).is_some(), "{r:?} keeps its revision");
|
||
assert_eq!(name(c.ins.or(c.del)), None, "{r:?} is no move");
|
||
}
|
||
};
|
||
let d = read_body(body);
|
||
check(&d);
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let xml = text(&unzip(&out), "word/document.xml");
|
||
assert_eq!(attr_values(&xml, "w:moveFromRangeStart", "w:name"), ["move1"]);
|
||
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:name"), ["move1"]);
|
||
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:author"), ["Ana"]);
|
||
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:id"), attr_values(&xml, "w:moveToRangeEnd", "w:id"));
|
||
assert_eq!(attr_values(&xml, "w:moveFromRangeStart", "w:id"), attr_values(&xml, "w:moveFromRangeEnd", "w:id"));
|
||
assert!(xml.contains("<w:moveFrom ") && !xml.contains("<w:delText xml:space=\"preserve\">Moved"), "moved-away text stays in w:t");
|
||
// The range around the moved paragraph includes its mark: it ends in the next paragraph.
|
||
let to_start = xml.find("<w:moveToRangeStart").unwrap();
|
||
let to_end = xml.find("<w:moveToRangeEnd").unwrap();
|
||
assert!(xml[to_start..to_end].contains("</w:p>"), "{xml}");
|
||
check(&wordcraft_docx::read(&out).unwrap());
|
||
}
|
||
|
||
/// A document's own footnote separators and continuation notice are kept (ECMA-376 §17.11), and
|
||
/// written back as Word numbers them: -1, 0 and 1, the notes after them.
|
||
#[test]
|
||
fn note_separators_and_continuation_notice_round_trip() {
|
||
let footnotes = format!(
|
||
r#"<w:footnotes {W_NS}><w:footnote w:type="separator" w:id="-1"><w:p><w:pPr><w:spacing w:after="0"/></w:pPr><w:r><w:separator/></w:r></w:p></w:footnote><w:footnote w:type="continuationSeparator" w:id="0"><w:p><w:r><w:t xml:space="preserve">More: </w:t></w:r><w:r><w:continuationSeparator/></w:r></w:p></w:footnote><w:footnote w:type="continuationNotice" w:id="1"><w:p><w:pPr><w:jc w:val="right"/></w:pPr><w:r><w:t>(continued)</w:t></w:r></w:p></w:footnote><w:footnote w:id="2"><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> The note.</w:t></w:r></w:p></w:footnote></w:footnotes>"#
|
||
);
|
||
let body = r#"<w:p><w:r><w:t>Text</w:t></w:r><w:r><w:footnoteReference w:id="2"/></w:r></w:p>"#;
|
||
let bytes = docx(body, &[("rId1", "footnotes", "footnotes.xml")], &[("word/footnotes.xml", &footnotes)]);
|
||
let check = |d: &Document| {
|
||
let s = &d.footnote_separators;
|
||
let first = |b: &Option<wordcraft_doc::Blocks>| b.as_ref().unwrap()[0].as_para().unwrap().clone();
|
||
let sep = first(&s.separator);
|
||
assert_eq!(sep.objects.iter().map(InlineObject::as_note_separator).collect::<Vec<_>>(), vec![Some(false)]);
|
||
assert_eq!(sep.props.space_after, Some(0.0));
|
||
let cont = first(&s.continuation_separator);
|
||
assert_eq!(cont.plain_text(), "More: ");
|
||
assert_eq!(cont.objects.iter().map(InlineObject::as_note_separator).collect::<Vec<_>>(), vec![Some(true)]);
|
||
let notice = first(&s.continuation_notice);
|
||
assert_eq!(notice.plain_text(), "(continued)");
|
||
assert_eq!(notice.props.align, Some(Align::Right));
|
||
assert!(d.endnote_separators.is_empty());
|
||
// The note itself is read as before.
|
||
let InlineObject::NoteRef { id, .. } = paras(d)[0].objects[0] else { panic!() };
|
||
assert_eq!(d.parts[&id].blocks[0].as_para().unwrap().plain_text(), " The note.");
|
||
assert_eq!(d.parts.len(), 1, "special notes aren't notes");
|
||
};
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
check(&d);
|
||
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let part = |name: &str| {
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&out)).unwrap();
|
||
let mut xml = String::new();
|
||
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut xml).unwrap();
|
||
xml
|
||
};
|
||
let notes = part("word/footnotes.xml");
|
||
for (kind, id) in [("separator", "-1"), ("continuationSeparator", "0"), ("continuationNotice", "1")] {
|
||
assert!(notes.contains(&format!(r#"<w:footnote w:type="{kind}" w:id="{id}">"#)), "{kind}: {notes}");
|
||
}
|
||
assert!(notes.contains(r#"<w:footnote w:id="2">"#), "the note follows the notice: {notes}");
|
||
assert!(part("word/document.xml").contains(r#"<w:footnoteReference w:id="2"/>"#));
|
||
let settings = part("word/settings.xml");
|
||
assert!(settings.contains(r#"<w:footnote w:id="-1"/><w:footnote w:id="0"/><w:footnote w:id="1"/>"#), "{settings}");
|
||
check(&wordcraft_docx::read(&out).unwrap());
|
||
}
|
||
|
||
/// Issue #534: East Asian number formats are read, labelled and written back as they were; a
|
||
/// value the format doesn't define is decimal, and a level without `w:ind` has no indent.
|
||
#[test]
|
||
fn east_asian_number_formats_read_label_and_round_trip() {
|
||
use wordcraft_doc::numbering::Counters;
|
||
use wordcraft_doc::section::NumFormat;
|
||
let lvl = |i: u8, fmt: &str, text: &str| {
|
||
format!(r#"<w:lvl w:ilvl="{i}"><w:start w:val="1"/><w:numFmt w:val="{fmt}"/><w:suff w:val="nothing"/><w:lvlText w:val="{text}"/></w:lvl>"#)
|
||
};
|
||
let levels = [
|
||
lvl(0, "chineseCounting", "%1、"),
|
||
lvl(1, "chineseCountingThousand", "第%2章"),
|
||
lvl(2, "japaneseLegal", "(%3)"),
|
||
lvl(3, "decimalEnclosedCircle", "%4"),
|
||
lvl(4, "hebrew1", "%5."),
|
||
lvl(5, "notAFormat", "%6."),
|
||
]
|
||
.concat();
|
||
let numbering = format!(
|
||
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="0">{levels}</w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
|
||
);
|
||
let item = |lv: u8| format!(r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="{lv}"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>x</w:t></w:r></w:p>"#);
|
||
let body: String = [0, 0, 0, 1, 2, 3, 4, 5].map(item).concat();
|
||
let bytes = docx(&body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]);
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
let formats = |d: &Document| (0..6).map(|i| d.numbering.level(1, i).map(|l| l.format)).collect::<Vec<_>>();
|
||
let want = [
|
||
NumFormat::ChineseCounting,
|
||
NumFormat::ChineseCountingThousand,
|
||
NumFormat::JapaneseLegal,
|
||
NumFormat::DecimalEnclosedCircle,
|
||
NumFormat::Hebrew1,
|
||
NumFormat::Decimal,
|
||
]
|
||
.map(Some);
|
||
assert_eq!(formats(&d), want);
|
||
assert_eq!(d.numbering.level(1, 0).map(|l| (l.indent, l.hanging)), Some((0.0, 0.0)));
|
||
let mut c = Counters::default();
|
||
let labels: Vec<String> =
|
||
paras(&d).iter().filter_map(|p| p.props.numbering).filter_map(|n| c.next_label(&d.numbering, n.num, n.level)).map(|l| l.0).collect();
|
||
assert_eq!(labels, ["一、", "二、", "三、", "第一章", "(壱)", "①", "1.", "1."]);
|
||
// Saved and read again, every format is the one the file had (hebrew1 too, which shows as
|
||
// decimal for now).
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let back = wordcraft_docx::read(&out).unwrap();
|
||
assert_eq!(formats(&back), want);
|
||
}
|
||
|
||
/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into
|
||
/// the table's last cell) and is saved as a matched pair.
|
||
#[test]
|
||
fn bookmark_around_final_table_keeps_its_end() {
|
||
let body = r#"<w:bookmarkStart w:id="7" w:name="TableRange"/><w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:bookmarkEnd w:id="7"/><w:sectPr/>"#;
|
||
let d = read_body(body);
|
||
let marks = |d: &Document| {
|
||
let Block::Table(t) = &*d.body[0] else { panic!("{:?}", d.body[0]) };
|
||
t.rows[0].cells[0].blocks[0].as_para().unwrap().objects.clone()
|
||
};
|
||
let want = vec![InlineObject::BookmarkStart { name: "TableRange".into() }, InlineObject::BookmarkEnd { name: "TableRange".into() }];
|
||
assert_eq!(marks(&d), want);
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
assert_eq!(marks(&again), want);
|
||
assert_eq!(again.bookmarks().len(), 1);
|
||
}
|
||
|
||
/// #465: a field whose cached result is an embedded picture (`INCLUDEPICTURE`, simple, complex or
|
||
/// locked) keeps the picture through reading and saving; the linked file is never read.
|
||
#[test]
|
||
fn includepicture_keeps_its_embedded_picture() {
|
||
let png: &[u8] = b"\x89PNG\r\n\x1a\nstand-in";
|
||
let pic = r#"<w:r><w:drawing><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
|
||
let cases = [
|
||
(format!(r#"<w:fldSimple w:instr="INCLUDEPICTURE "picture.png"">{pic}</w:fldSimple>"#), false),
|
||
(format!(r#"<w:fldSimple w:instr="INCLUDEPICTURE "picture.png"" w:fldLock="1">{pic}</w:fldSimple>"#), true),
|
||
(
|
||
format!(
|
||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> INCLUDEPICTURE "picture.png" </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{pic}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#
|
||
),
|
||
false,
|
||
),
|
||
];
|
||
for (field, locked) in cases {
|
||
let bytes = docx(&format!("<w:p>{field}</w:p><w:sectPr/>"), &[("rIdPic", "image", "media/picture.png")], &[]);
|
||
let bytes = {
|
||
// `docx` takes text parts; add the picture bytes as a binary part.
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||
for i in 0..z.len() {
|
||
let mut f = z.by_index(i).unwrap();
|
||
let mut v = Vec::new();
|
||
std::io::Read::read_to_end(&mut f, &mut v).unwrap();
|
||
entries.push((f.name().to_string(), v));
|
||
}
|
||
entries.push(("word/media/picture.png".into(), png.to_vec()));
|
||
zip(&entries.iter().map(|(n, v)| (n.as_str(), v.as_slice())).collect::<Vec<_>>())
|
||
};
|
||
let check = |d: &Document| {
|
||
let p = paras(d);
|
||
let objs = &p[0].objects;
|
||
assert_eq!(objs.len(), 3, "{objs:?}");
|
||
assert_eq!(objs[0], InlineObject::FieldStart { instr: "INCLUDEPICTURE \"picture.png\"".into(), locked, dirty: false });
|
||
let InlineObject::Image { media, .. } = &objs[1] else { panic!("{objs:?}") };
|
||
assert_eq!(d.media.get(media).unwrap().as_slice(), png);
|
||
assert_eq!(objs[2], InlineObject::FieldEnd);
|
||
};
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
check(&d);
|
||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||
}
|
||
}
|
||
|
||
/// One anchored Word shape (`wps:wsp` inside `mc:AlternateContent`, as Word writes them) with
|
||
/// shape properties `sppr`, style `style` and extra `wsp` content.
|
||
fn anchored_wsp(name: &str, cx: u32, cy: u32, sppr: &str, style: &str, extra: &str) -> String {
|
||
format!(
|
||
r#"<w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing><wp:anchor distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>12700</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>25400</wp:posOffset></wp:positionV><wp:extent cx="{cx}" cy="{cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="{name}"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp>{extra}<wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>{sppr}</wps:spPr>{style}<wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:line from="0,0" to="1pt,2pt"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"#
|
||
)
|
||
}
|
||
|
||
/// #611: diagrams drawn with Word's shapes were mostly missing. Connectors and boxes take their
|
||
/// outline and fill from the theme through their style references, or name preset and scheme
|
||
/// colours; all of these are read, with line widths and arrowheads.
|
||
#[test]
|
||
fn theme_styled_connectors_and_callout_boxes_keep_their_paint() {
|
||
let style = |ln: u8, fill: u8, clr: &str| {
|
||
format!(
|
||
r#"<wps:style><a:lnRef idx="{ln}"><a:schemeClr val="{clr}"/></a:lnRef><a:fillRef idx="{fill}"><a:schemeClr val="{clr}"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="{clr}"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></wps:style>"#
|
||
)
|
||
};
|
||
let body = [
|
||
// A connector with only a style: accent4, the theme's thinnest line, an arrowhead.
|
||
anchored_wsp("Arrow", 673100, 889000, r#"<a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom><a:ln><a:tailEnd type="triangle"/></a:ln>"#, &style(1, 0, "accent4"), "<wps:cNvCnPr/>"),
|
||
// A callout text box: scheme fill, preset black outline.
|
||
anchored_wsp(
|
||
"Text Box",
|
||
3073400,
|
||
673100,
|
||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln>"#,
|
||
"",
|
||
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Callout</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
|
||
),
|
||
// A filled rectangle from its style alone.
|
||
anchored_wsp("Rect", 292100, 647700, r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#, &style(2, 1, "accent1"), "<wps:cNvSpPr/>"),
|
||
// An elbow connector.
|
||
anchored_wsp("Elbow", 1270000, 635000, r#"<a:prstGeom prst="bentConnector3"><a:avLst/></a:prstGeom>"#, &style(1, 0, "accent2"), "<wps:cNvCnPr/>"),
|
||
// A rounded-rectangle callout.
|
||
anchored_wsp("Callout", 1270000, 635000, r#"<a:prstGeom prst="wedgeRoundRectCallout"><a:avLst/></a:prstGeom>"#, &style(2, 1, "accent1"), "<wps:cNvSpPr/>"),
|
||
]
|
||
.concat();
|
||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||
let theme = &d.settings.theme_colors;
|
||
let objs = ¶s(&d)[0].objects;
|
||
assert_eq!(objs.len(), 5);
|
||
let shape = |i: usize| match &objs[i] {
|
||
InlineObject::Shape { kind, fill, stroke, stroke_width, extra, story, .. } => {
|
||
(*kind, fill.as_ref().map(Fill::main_color), *stroke, *stroke_width, extra.arrow_end, story.is_some())
|
||
}
|
||
o => panic!("{o:?}"),
|
||
};
|
||
assert_eq!(shape(0), (ShapeKind::StraightConnector, None, Some(theme[7]), 0.5, true, false));
|
||
assert_eq!(shape(1), (ShapeKind::TextBox, Some(theme[1]), Some(Rgb(0, 0, 0)), 0.5, false, true));
|
||
assert_eq!(shape(2), (ShapeKind::Rectangle, Some(theme[4]), Some(theme[4]), 1.0, false, false));
|
||
assert_eq!(shape(3), (ShapeKind::ElbowConnector, None, Some(theme[5]), 0.5, false, false));
|
||
assert_eq!(shape(4).0, ShapeKind::RoundedRectangle);
|
||
// The themed outline and the arrowhead survive saving.
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
assert!(matches!(¶s(&again)[0].objects[0], InlineObject::Shape { stroke, extra, .. } if *stroke == Some(theme[7]) && extra.arrow_end));
|
||
}
|
||
|
||
/// A rounded rectangle's corner is its `adj` (100000ths of the shorter side), read, capped and
|
||
/// written back; without one, and for presets only drawn as rounded rectangles, the default.
|
||
#[test]
|
||
fn rounded_rectangle_corners_are_read_and_saved() {
|
||
let geom = |prst: &str, av: &str| {
|
||
format!(r#"<a:prstGeom prst="{prst}"><a:avLst>{av}</a:avLst></a:prstGeom><a:solidFill><a:srgbClr val="D9D9D9"/></a:solidFill>"#)
|
||
};
|
||
let gd = |fmla: &str| format!(r#"<a:gd name="adj" fmla="{fmla}"/>"#);
|
||
let body = [
|
||
geom("roundRect", &gd("val 3565")),
|
||
geom("roundRect", ""),
|
||
geom("roundRect", &gd("val 90000")),
|
||
geom("roundRect", &gd("val -20")),
|
||
geom("roundRect", &gd("*/ 1 2 3")),
|
||
geom("roundRect", r#"<a:gd name="adj1" fmla="val 100"/>"#),
|
||
geom("wedgeRoundRectCallout", &gd("val 3565")),
|
||
]
|
||
.iter()
|
||
.map(|sppr| anchored_wsp("Rounded", 2540000, 1270000, sppr, "", "<wps:cNvSpPr/>"))
|
||
.collect::<String>();
|
||
let corners = |d: &Document| -> Vec<(ShapeKind, Option<u32>)> {
|
||
paras(d)[0]
|
||
.objects
|
||
.iter()
|
||
.filter_map(|o| match o {
|
||
InlineObject::Shape { kind, extra, .. } => Some((*kind, extra.corner)),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
let rounded = ShapeKind::RoundedRectangle;
|
||
let expected =
|
||
vec![(rounded, Some(3565)), (rounded, None), (rounded, Some(50_000)), (rounded, Some(0)), (rounded, None), (rounded, None), (rounded, None)];
|
||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||
assert_eq!(corners(&d), expected);
|
||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||
let mut xml = String::new();
|
||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
|
||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||
assert!(xml.contains(r#"<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val 3565"/></a:avLst></a:prstGeom>"#), "{xml}");
|
||
assert_eq!(corners(&wordcraft_docx::read(&bytes).unwrap()), expected);
|
||
}
|
||
|
||
/// #611: legacy VML lines and rectangles (documents without DrawingML).
|
||
#[test]
|
||
fn vml_lines_and_rectangles() {
|
||
let body = r##"<w:p><w:r><w:pict><v:line style="position:absolute;flip:y;z-index:1" from="10pt,20pt" to="40pt,60pt" strokecolor="#4472c4 [3204]" strokeweight=".5pt"><v:stroke endarrow="block"/></v:line></w:pict></w:r>
|
||
<w:r><w:pict><v:rect style="position:absolute;margin-left:5pt;margin-top:6pt;width:30pt;height:20pt" fillcolor="#ff0000" stroked="f"/></w:pict></w:r></w:p>"##;
|
||
let d = read_body(body);
|
||
let objs = ¶s(&d)[0].objects;
|
||
assert_eq!(objs.len(), 2, "{objs:?}");
|
||
match &objs[0] {
|
||
InlineObject::Shape { kind: ShapeKind::Line, w, h, stroke, stroke_width, float, extra, .. } => {
|
||
assert_eq!((*w, *h, *stroke, *stroke_width), (30.0, 40.0, Some(Rgb(0x44, 0x72, 0xC4)), 0.5));
|
||
assert_eq!((float.x, float.y, float.flip_h, float.flip_v), (10.0, 20.0, false, true));
|
||
assert!(extra.arrow_end && !extra.arrow_start);
|
||
}
|
||
o => panic!("{o:?}"),
|
||
}
|
||
assert!(matches!(
|
||
&objs[1],
|
||
InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Fill::Solid { color: Rgb(255, 0, 0), .. }), stroke: None, w: 30.0, h: 20.0, .. }
|
||
));
|
||
}
|
||
|
||
/// The right arrow's shapetype as Word writes it (ECMA-376 Part 4 `v:shapetype`, `o:spt` 13).
|
||
const VML_RIGHT_ARROW_TYPE: &str = r#"<v:shapetype id="_x0000_t13" coordsize="21600,21600" o:spt="13" adj="16200,5400" path="m@0,l@0@1,0@1,0@2@0@2@0,21600,21600,10800xe"><v:stroke joinstyle="miter"/><v:formulas><v:f eqn="val #0"/><v:f eqn="val #1"/></v:formulas><v:path o:connecttype="custom"/></v:shapetype>"#;
|
||
|
||
/// #1377: a VML shape drawn from a preset shapetype (`type="#_x0000_t13"`) is the DrawingML
|
||
/// preset WordCraft draws, with its fill, outline and size: from the shapetype beside it, from
|
||
/// Word's shapetype id when the shapetype was written at an earlier use, or from its own `o:spt`.
|
||
#[test]
|
||
fn vml_preset_shapes_are_drawn() {
|
||
let pict = |inner: &str| format!("<w:p><w:r><w:pict>{inner}</w:pict></w:r></w:p>");
|
||
let body = [
|
||
pict(&format!(r##"{VML_RIGHT_ARROW_TYPE}<v:shape id="_x0000_i1026" type="#_x0000_t13" style="width:120pt;height:40pt" fillcolor="#ffa500"/>"##)),
|
||
pict(r##"<v:shape type="#_x0000_t13" style="position:absolute;margin-left:10pt;margin-top:5pt;width:60pt;height:20pt;z-index:2" fillcolor="#00ff00" strokecolor="#0000ff" strokeweight="2pt"/>"##),
|
||
pict(r##"<v:shape o:spt="74" style="width:30pt;height:30pt" fillcolor="red" stroked="f"/>"##),
|
||
pict(r##"<v:shapetype id="tri" o:spt="5" coordsize="21600,21600"/><v:shape type="#tri" style="width:20pt;height:10pt" filled="f"/>"##),
|
||
pict(r##"<v:shape type="#_x0000_t4" style="width:50pt;height:50pt" fillcolor="#ffff00"><v:textbox><w:txbxContent><w:p><w:r><w:t>yes?</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape>"##),
|
||
// A preset WordCraft has no shape for is drawn as the nearest one, as for DrawingML: a
|
||
// right triangle (`o:spt` 6) as a triangle.
|
||
pict(r##"<v:shape type="#_x0000_t6" style="width:20pt;height:20pt" fillcolor="red"/>"##),
|
||
// Its own path wins over the shapetype's: not the preset.
|
||
pict(r##"<v:shape type="#_x0000_t13" path="m,l21600,21600e" style="width:20pt;height:20pt"/>"##),
|
||
// Lines from a shapetype (`o:spt` 20 and Word's line tool's 32) take the size of their box.
|
||
pict(r##"<v:shape type="#_x0000_t20" style="width:40pt;height:10pt"/>"##),
|
||
pict(r##"<v:shapetype id="_x0000_t32" coordsize="21600,21600" o:spt="32" o:oned="t" path="m,l21600,21600e" filled="f"/><v:shape type="#_x0000_t32" style="position:absolute;width:30pt;height:15pt" strokecolor="#ff0000"><v:stroke endarrow="block"/></v:shape>"##),
|
||
// A text box's shapetype without text is still a text box.
|
||
pict(r##"<v:shape type="#_x0000_t202" style="width:30pt;height:15pt"/>"##),
|
||
// A shape that isn't a preset keeps its text, in a text box.
|
||
pict(r##"<v:shape o:spt="0" style="width:50pt;height:50pt"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape>"##),
|
||
// Text in a preset with neither fill nor outline is kept.
|
||
pict(r##"<v:shape type="#_x0000_t3" style="width:50pt;height:50pt" filled="f" stroked="f"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape>"##),
|
||
pict(r##"<v:oval style="width:50pt;height:40pt"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:oval>"##),
|
||
// Paint the shape leaves out comes from its shapetype.
|
||
pict(r##"<v:shapetype id="_x0000_t1" o:spt="1" fillcolor="#0000ff" stroked="f"/><v:shape type="#_x0000_t1" style="width:9pt;height:9pt"/>"##),
|
||
r##"<w:p><w:r><w:pict><v:shape type="#_x0000_t2" style="width:9pt;height:9pt"/></w:pict></w:r><w:r><w:pict><v:shape type="#_x0000_t12" style="width:9pt;height:9pt"/></w:pict></w:r></w:p>"##.to_string(),
|
||
pict(r##"<v:shape type="#_x0000_t109" style="width:9pt;height:9pt"/>"##),
|
||
pict(r##"<v:shape type="#_x0000_t110" style="width:9pt;height:9pt"/>"##),
|
||
pict(r##"<v:shape type="#_x0000_t120" style="width:9pt;height:9pt"/>"##),
|
||
// Nearest shapes: left arrow, rectangular callout, four-point star.
|
||
pict(r##"<v:shape type="#_x0000_t66" style="width:60pt;height:20pt" fillcolor="#ffa500"/>"##),
|
||
pict(r##"<v:shape type="#_x0000_t61" style="width:60pt;height:20pt"/>"##),
|
||
pict(r##"<v:shape type="#_x0000_t187" style="width:20pt;height:20pt"/>"##),
|
||
// Not drawn: WordArt text (136) and a picture frame without its picture (75).
|
||
pict(r##"<v:shape type="#_x0000_t136" style="width:60pt;height:20pt" fillcolor="silver"/>"##),
|
||
pict(r##"<v:shape type="#_x0000_t75" style="width:60pt;height:20pt"/>"##),
|
||
]
|
||
.concat();
|
||
let d = read_body(&body);
|
||
let ps = paras(&d);
|
||
let shape = |i: usize| match ps[i].objects.first() {
|
||
Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. }) => {
|
||
Some((*kind, *w, *h, fill.as_ref().map(Fill::main_color), *stroke, *stroke_width, float.wrap == Wrap::Inline, story.is_some()))
|
||
}
|
||
_ => None,
|
||
};
|
||
let kind = |i: usize| shape(i).map(|s| (s.0, s.7));
|
||
let (black, white) = (Some(Rgb::BLACK), Some(Rgb::WHITE));
|
||
assert_eq!(shape(0), Some((ShapeKind::Arrow, 120.0, 40.0, Some(Rgb(0xFF, 0xA5, 0x00)), black, 0.75, true, false)));
|
||
assert_eq!(shape(1), Some((ShapeKind::Arrow, 60.0, 20.0, Some(Rgb(0, 0xFF, 0)), Some(Rgb(0, 0, 0xFF)), 2.0, false, false)));
|
||
assert_eq!(shape(2), Some((ShapeKind::Heart, 30.0, 30.0, Some(Rgb(0xFF, 0, 0)), None, 0.75, true, false)));
|
||
assert_eq!(shape(3), Some((ShapeKind::Triangle, 20.0, 10.0, None, black, 0.75, true, false)));
|
||
// A preset holding text keeps its geometry around the text.
|
||
assert_eq!(shape(4), Some((ShapeKind::Diamond, 50.0, 50.0, Some(Rgb(0xFF, 0xFF, 0)), black, 0.75, true, true)));
|
||
assert_eq!(shape(5), Some((ShapeKind::RightTriangle, 20.0, 20.0, Some(Rgb(0xFF, 0, 0)), black, 0.75, true, false)));
|
||
// Its own path is drawn as a freeform, not as the shapetype's arrow.
|
||
assert_eq!(kind(6), Some((ShapeKind::Freeform, false)));
|
||
assert_eq!(shape(7), Some((ShapeKind::Line, 40.0, 10.0, None, black, 0.75, true, false)));
|
||
assert_eq!(shape(8), Some((ShapeKind::Line, 30.0, 15.0, None, Some(Rgb(0xFF, 0, 0)), 0.75, false, false)));
|
||
assert!(matches!(ps[8].objects.first(), Some(InlineObject::Shape { extra, .. }) if extra.arrow_end && !extra.arrow_start));
|
||
assert_eq!(shape(9), Some((ShapeKind::TextBox, 30.0, 15.0, white, black, 0.75, true, false)));
|
||
assert_eq!(kind(10), Some((ShapeKind::TextBox, true)));
|
||
assert_eq!(shape(11), Some((ShapeKind::Ellipse, 50.0, 50.0, None, None, 0.75, true, true)));
|
||
assert_eq!(kind(12), Some((ShapeKind::Ellipse, true)));
|
||
assert_eq!(shape(13), Some((ShapeKind::Rectangle, 9.0, 9.0, Some(Rgb(0, 0, 0xFF)), None, 0.75, true, false)));
|
||
let kinds: Vec<_> = ps[14].objects.iter().filter_map(|o| if let InlineObject::Shape { kind, .. } = o { Some(*kind) } else { None }).collect();
|
||
assert_eq!(kinds, [ShapeKind::RoundedRectangle, ShapeKind::Star]);
|
||
assert_eq!(kind(15), Some((ShapeKind::Rectangle, false)));
|
||
assert_eq!(kind(16), Some((ShapeKind::Diamond, false)));
|
||
assert_eq!(kind(17), Some((ShapeKind::Ellipse, false)));
|
||
assert_eq!(shape(18), Some((ShapeKind::Arrow, 60.0, 20.0, Some(Rgb(0xFF, 0xA5, 0x00)), black, 0.75, true, false)));
|
||
assert_eq!(kind(19), Some((ShapeKind::Rectangle, false)));
|
||
assert_eq!(kind(20), Some((ShapeKind::Star, false)));
|
||
assert_eq!(shape(21), None);
|
||
assert_eq!(shape(22), None);
|
||
}
|
||
|
||
/// #1377: hostile shape type numbers and references are skipped, never a panic.
|
||
#[test]
|
||
fn vml_preset_hostile_types_never_panic() {
|
||
let shapes = [
|
||
r##"<v:shape o:spt="99999999999999999999999" style="width:9pt;height:9pt"/>"##,
|
||
r##"<v:shape o:spt="-13" style="width:9pt;height:9pt"/>"##,
|
||
r##"<v:shape o:spt="" type="#_x0000_t" style="width:9pt;height:9pt"/>"##,
|
||
r##"<v:shape type="#_x0000_t13abc" style="width:9pt;height:9pt"/>"##,
|
||
r##"<v:shape type="#" style="width:9pt;height:9pt"/>"##,
|
||
r##"<v:shape type="#_x0000_tépée" style="width:9pt;height:9pt"/>"##,
|
||
r##"<v:shapetype id="x" o:spt="nope"/><v:shape type="#x" style="width:9pt;height:9pt"/>"##,
|
||
r##"<v:shape type="#_x0000_t13" style="width:-1e39pt;height:NaNpt" fillcolor="#zzz" strokeweight="1e30pt"/>"##,
|
||
];
|
||
let body: String = shapes.iter().map(|s| format!("<w:p><w:r><w:pict>{s}</w:pict></w:r></w:p>")).collect();
|
||
let d = read_body(&body);
|
||
for (i, p) in paras(&d).iter().enumerate().take(7) {
|
||
assert!(p.objects.is_empty(), "shape {i}: {:?}", p.objects);
|
||
}
|
||
match paras(&d)[7].objects.first() {
|
||
Some(InlineObject::Shape { kind: ShapeKind::Arrow, w, h, stroke_width, .. }) => {
|
||
assert!(w.is_finite() && h.is_finite() && *w >= 0.0 && *h >= 0.0 && *stroke_width <= 100.0)
|
||
}
|
||
o => panic!("{o:?}"),
|
||
}
|
||
}
|
||
|
||
/// #1377: a VML preset shape is kept on save, as its DrawingML preset with its fill.
|
||
#[test]
|
||
fn vml_preset_shape_survives_saving() {
|
||
let body = format!(
|
||
r##"<w:p><w:r><w:pict>{VML_RIGHT_ARROW_TYPE}<v:shape type="#_x0000_t13" style="width:120pt;height:40pt" fillcolor="#ffa500"/></w:pict></w:r></w:p>"##
|
||
);
|
||
let saved = wordcraft_docx::write(&read_body(&body)).unwrap();
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(saved.as_slice())).unwrap();
|
||
let mut xml = String::new();
|
||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||
assert!(xml.contains(r#"prst="rightArrow""#), "{xml}");
|
||
let again = wordcraft_docx::read(&saved).unwrap();
|
||
assert!(
|
||
matches!(
|
||
¶s(&again)[0].objects[..],
|
||
[InlineObject::Shape {
|
||
kind: ShapeKind::Arrow,
|
||
w: 120.0,
|
||
h: 40.0,
|
||
fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }),
|
||
stroke: Some(Rgb::BLACK),
|
||
..
|
||
}]
|
||
),
|
||
"{:?}",
|
||
paras(&again)[0].objects
|
||
);
|
||
}
|
||
|
||
/// #1377: a VML preset drawn as its nearest shape (a left arrow, `o:spt` 66) is kept on save as
|
||
/// that shape's DrawingML preset, as a DrawingML left arrow is.
|
||
#[test]
|
||
fn vml_nearest_preset_shape_survives_saving() {
|
||
let body = r##"<w:p><w:r><w:pict><v:shape type="#_x0000_t66" style="width:60pt;height:20pt" fillcolor="#ffa500"/></w:pict></w:r></w:p>"##;
|
||
let saved = wordcraft_docx::write(&read_body(body)).unwrap();
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(saved.as_slice())).unwrap();
|
||
let mut xml = String::new();
|
||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||
assert!(xml.contains(r#"prst="rightArrow""#), "{xml}");
|
||
let again = wordcraft_docx::read(&saved).unwrap();
|
||
assert!(
|
||
matches!(
|
||
¶s(&again)[0].objects[..],
|
||
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
|
||
),
|
||
"{:?}",
|
||
paras(&again)[0].objects
|
||
);
|
||
}
|
||
|
||
/// #743: a cover page from Word: a group holding a rectangle filled through its style, a
|
||
/// freeform band and a title box, beside a box that resizes to fit its two lines and has no
|
||
/// internal margins. The fills, the band's outline, the margins and the resizing are all read
|
||
/// and survive saving.
|
||
#[test]
|
||
fn cover_page_group_fills_freeforms_and_text_box_margins() {
|
||
let style = r#"<wps:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="50000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></wps:style>"#;
|
||
let member = |id: u32, txbox: bool, off: (u32, u32), ext: (u32, u32), geom: &str, paint: &str, inner: &str| {
|
||
let nv = if txbox { r#"<wps:cNvSpPr txBox="1"/>"# } else { "<wps:cNvSpPr/>" };
|
||
format!(
|
||
r#"<wps:wsp><wps:cNvPr id="{id}" name="Member {id}"/>{nv}<wps:spPr><a:xfrm><a:off x="{}" y="{}"/><a:ext cx="{}" cy="{}"/></a:xfrm>{geom}{paint}</wps:spPr>{style}{inner}</wps:wsp>"#,
|
||
off.0, off.1, ext.0, ext.1
|
||
)
|
||
};
|
||
let rect = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
|
||
let band = r#"<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="100"/></a:moveTo><a:lnTo><a:pt x="100" y="0"/></a:lnTo><a:lnTo><a:pt x="100" y="100"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>"#;
|
||
let title = r#"<wps:txbx><w:txbxContent><w:p><w:r><w:t>Cover Title</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr rot="0" vert="horz" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t"><a:noAutofit/></wps:bodyPr>"#;
|
||
let members = [
|
||
member(11, false, (0, 0), (5486400, 5486400), rect, r#"<a:ln><a:noFill/></a:ln>"#, "<wps:bodyPr/>"),
|
||
member(
|
||
12,
|
||
false,
|
||
(914400, 4572000),
|
||
(4572000, 914400),
|
||
band,
|
||
r#"<a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:ln><a:noFill/></a:ln>"#,
|
||
"<wps:bodyPr/>",
|
||
),
|
||
member(13, true, (457200, 457200), (4572000, 1828800), rect, r#"<a:noFill/><a:ln><a:noFill/></a:ln>"#, title),
|
||
]
|
||
.concat();
|
||
let group = format!(
|
||
r##"<w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" behindDoc="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>top</wp:align></wp:positionV><wp:extent cx="5486400" cy="5486400"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="Group 1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="5486400" cy="5486400"/><a:chOff x="0" y="0"/><a:chExt cx="5486400" cy="5486400"/></a:xfrm></wpg:grpSpPr>{members}</wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:rect style="width:10pt;height:10pt" fillcolor="#156082 [3204]" stroked="f"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"##
|
||
);
|
||
let subtitle = anchored_wsp(
|
||
"Subtitle",
|
||
5753100,
|
||
420000,
|
||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>"#,
|
||
"",
|
||
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Plan</w:t></w:r></w:p><w:p><w:r><w:t>Author</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
|
||
)
|
||
// `anchored_wsp` writes an empty `wps:bodyPr`: give this one margins and Resize to fit.
|
||
.replace("<wps:bodyPr/>", r#"<wps:bodyPr lIns="0" tIns="12700" rIns="0" bIns="0"><a:spAutoFit/></wps:bodyPr>"#);
|
||
let check = |d: &Document| {
|
||
let theme = &d.settings.theme_colors;
|
||
let objs = ¶s(d)[0].objects;
|
||
let InlineObject::Group { children, .. } = &objs[0] else { panic!("{:?}", objs[0]) };
|
||
assert_eq!(children.len(), 3);
|
||
let shape = |i: usize| match &children[i].obj {
|
||
InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, fill.as_ref().map(Fill::main_color), freeform.is_some(), *story),
|
||
o => panic!("{o:?}"),
|
||
};
|
||
let (kind, fill, free, _) = shape(0);
|
||
assert_eq!((kind, fill, free), (ShapeKind::Rectangle, Some(theme[4]), false), "the style's accent fill");
|
||
let (kind, fill, free, _) = shape(1);
|
||
assert_eq!((kind, fill, free), (ShapeKind::Freeform, Some(theme[1]), true), "a freeform in a group keeps its outline");
|
||
let (kind, _, _, story) = shape(2);
|
||
assert_eq!(kind, ShapeKind::TextBox);
|
||
let body = d.parts[&story.unwrap()].body;
|
||
assert_eq!((body.inset, body.auto_fit), (Some([0; 4]), false), "the title box has no margins");
|
||
let InlineObject::Shape { story: Some(sub), .. } = &objs[1] else { panic!("{:?}", objs[1]) };
|
||
let body = d.parts[sub].body;
|
||
assert_eq!((body.inset, body.auto_fit), (Some([0, 12700, 0, 0]), true), "the subtitle resizes to fit its text");
|
||
};
|
||
let d = read_body(&format!("<w:p>{group}{subtitle}</w:p>"));
|
||
check(&d);
|
||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||
}
|
||
|
||
/// #886: saving dropped Word's DrawingML shapes (rectangles, ellipses, triangles, arrows,
|
||
/// stars, connectors and their theme fills). Each kind the report lists is read, saved and read
|
||
/// back with the same kind, fill, outline, size and position, and the saved part still holds a
|
||
/// `wps:wsp` with its preset geometry.
|
||
#[test]
|
||
fn drawingml_shapes_survive_saving() {
|
||
let style = r#"<wps:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></wps:style>"#;
|
||
let geom = |prst: &str| format!(r#"<a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>"#);
|
||
let presets = ["rect", "ellipse", "triangle", "rightArrow", "star5"];
|
||
let mut body: String = presets.iter().map(|p| anchored_wsp(p, 2009775, 819150, &geom(p), style, "<wps:cNvSpPr/>")).collect();
|
||
body.push_str(&anchored_wsp(
|
||
"Connector",
|
||
2362200,
|
||
390525,
|
||
r#"<a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom><a:ln><a:tailEnd type="triangle"/></a:ln>"#,
|
||
style,
|
||
"<wps:cNvCnPr/>",
|
||
));
|
||
body.push_str(&anchored_wsp(
|
||
"Text Box",
|
||
2324100,
|
||
571500,
|
||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="9525"><a:solidFill><a:srgbClr val="7F7F7F"/></a:solidFill></a:ln>"#,
|
||
"",
|
||
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Floating text box</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
|
||
));
|
||
let shapes = |d: &Document| {
|
||
paras(d)[0]
|
||
.objects
|
||
.iter()
|
||
.map(|o| match o {
|
||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, extra, .. } => {
|
||
(*kind, *w, *h, fill.clone(), *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end)
|
||
}
|
||
o => panic!("{o:?}"),
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||
let accent = d.settings.theme_colors[4];
|
||
let read = shapes(&d);
|
||
let kinds: Vec<ShapeKind> = read.iter().map(|s| s.0).collect();
|
||
assert_eq!(
|
||
kinds,
|
||
[
|
||
ShapeKind::Rectangle,
|
||
ShapeKind::Ellipse,
|
||
ShapeKind::Triangle,
|
||
ShapeKind::Arrow,
|
||
ShapeKind::Star,
|
||
ShapeKind::StraightConnector,
|
||
ShapeKind::TextBox
|
||
]
|
||
);
|
||
for s in &read[..5] {
|
||
assert_eq!(s.3, Some(wordcraft_doc::fill::Fill::solid(accent)), "{:?} is filled from the theme", s.0);
|
||
assert!(s.4.is_some() && s.5 > 0.0, "{:?} has its outline", s.0);
|
||
assert_eq!((s.6, s.7, s.8), (1.0, 2.0, Wrap::InFrontOfText), "{:?} keeps its place", s.0);
|
||
}
|
||
assert!(read[5].9, "the connector keeps its arrowhead");
|
||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||
assert_eq!(shapes(&again), read, "every shape survives saving");
|
||
assert_eq!(part_text_box(&again), "Floating text box");
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
|
||
let mut xml = String::new();
|
||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||
assert_eq!(xml.matches("<wps:wsp>").count(), 7);
|
||
for p in presets.iter().chain(&["straightConnector1"]) {
|
||
assert!(xml.contains(&format!(r#"<a:prstGeom prst="{p}">"#)), "{p} is written");
|
||
}
|
||
}
|
||
|
||
/// The text of the first text box in `d`.
|
||
fn part_text_box(d: &Document) -> String {
|
||
let story = paras(d)[0].objects.iter().find_map(|o| match o {
|
||
InlineObject::Shape { kind: ShapeKind::TextBox, story, .. } => *story,
|
||
_ => None,
|
||
});
|
||
story.and_then(|id| d.parts.get(&id)).map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).unwrap_or_default()
|
||
}
|
||
|
||
/// #743: Word's cover pages place their shapes relative to the page's size, wrapped in
|
||
/// `mc:AlternateContent` with an offset as the Fallback, and draw their freeforms back to the
|
||
/// start without `a:close`. Both shapes are read: placed at the offset, and filled.
|
||
#[test]
|
||
fn cover_page_relative_position_and_freeform_drawn_back_to_its_start() {
|
||
let geom = r#"<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="0" y="90"/></a:lnTo><a:cubicBezTo><a:pt x="30" y="100"/><a:pt x="70" y="100"/><a:pt x="100" y="90"/></a:cubicBezTo><a:lnTo><a:pt x="100" y="0"/></a:lnTo><a:lnTo><a:pt x="0" y="0"/></a:lnTo></a:path><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="50"/></a:moveTo><a:lnTo><a:pt x="100" y="50"/></a:lnTo></a:path></a:pathLst></a:custGeom><a:solidFill><a:srgbClr val="0E2841"/></a:solidFill>"#;
|
||
let pct = r#"<mc:AlternateContent><mc:Choice Requires="wp14"><wp:positionV relativeFrom="page"><wp14:pctPosVOffset>4500</wp14:pctPosVOffset></wp:positionV></mc:Choice><mc:Fallback><wp:positionV relativeFrom="page"><wp:posOffset>480695</wp:posOffset></wp:positionV></mc:Fallback></mc:AlternateContent>"#;
|
||
let shape = anchored_wsp("Freeform 1", 5486400, 5334000, geom, "", "<wps:cNvSpPr/>")
|
||
.replace(r#"<wp:positionV relativeFrom="paragraph"><wp:posOffset>25400</wp:posOffset></wp:positionV>"#, pct);
|
||
let check = |d: &Document| {
|
||
let objs = ¶s(d)[0].objects;
|
||
let InlineObject::Shape { kind, float, freeform: Some(f), .. } = &objs[0] else { panic!("{:?}", objs[0]) };
|
||
assert_eq!(*kind, ShapeKind::Freeform);
|
||
assert_eq!((float.v_rel, float.v_align, float.y), (Anchor::Page, None, 480695.0 / 12700.0), "the Fallback's offset");
|
||
let closed: Vec<bool> = f.paths.iter().map(|p| p.closed).collect();
|
||
assert_eq!(closed, [true, false], "a path back at its start is closed and filled; an open line isn't");
|
||
};
|
||
let d = read_body(&format!("<w:p>{shape}</w:p>"));
|
||
check(&d);
|
||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn embedded_fonts_survive_a_round_trip() {
|
||
let table = r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:font w:name="Roboto"><w:embedRegular r:id="rId1"/><w:embedBold r:id="rId2"/></w:font></w:fonts>"#;
|
||
let table_rels = rels(&[("rId1", "font", "fonts/Roboto-regular.ttf"), ("rId2", "font", "fonts/Roboto-bold.ttf")]);
|
||
let bytes = docx(
|
||
"<w:p><w:r><w:t>Hi</w:t></w:r></w:p>",
|
||
&[("rId9", "fontTable", "fontTable.xml")],
|
||
&[
|
||
("word/fontTable.xml", table),
|
||
("word/_rels/fontTable.xml.rels", &table_rels),
|
||
("word/fonts/Roboto-regular.ttf", "regular font bytes"),
|
||
("word/fonts/Roboto-bold.ttf", "bold font bytes"),
|
||
],
|
||
);
|
||
let out = wordcraft_docx::write(&wordcraft_docx::read(&bytes).unwrap()).unwrap();
|
||
let files = unzip(&out);
|
||
assert_eq!(text(&files, "word/fontTable.xml"), table);
|
||
assert_eq!(text(&files, "word/fonts/Roboto-regular.ttf"), "regular font bytes");
|
||
assert_eq!(text(&files, "word/fonts/Roboto-bold.ttf"), "bold font bytes");
|
||
let table_rels = text(&files, "word/_rels/fontTable.xml.rels");
|
||
assert!(table_rels.contains(r#"Id="rId1""#) && table_rels.contains("fonts/Roboto-regular.ttf"), "{table_rels}");
|
||
assert!(text(&files, "word/_rels/document.xml.rels").contains("fontTable.xml"));
|
||
// A second save keeps them too.
|
||
let again = unzip(&wordcraft_docx::write(&wordcraft_docx::read(&out).unwrap()).unwrap());
|
||
assert_eq!(text(&again, "word/fonts/Roboto-bold.ttf"), "bold font bytes");
|
||
}
|
||
|
||
// ---- native Word ink: opaque preservation, never re-encoded as WordCraft freeforms ----
|
||
|
||
const INK_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
|
||
const INK_XML: &str = r##"<ink xmlns="http://www.w3.org/2003/InkML"><definitions><context xml:id="ctx"><inkSource xml:id="src"><traceFormat><channel name="X" type="integer" units="cm"/><channel name="Y" type="integer" units="cm"/></traceFormat></inkSource></context><brush xml:id="blue"><brushProperty name="color" value="#2563EB"/><brushProperty name="width" value="0.08" units="cm"/></brush></definitions><trace contextRef="#ctx" brushRef="#blue">0 0, 1 1, 2 0</trace></ink>"##;
|
||
const INK_TYPES: &str = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/ink/ink1.xml" ContentType="application/inkml+xml"/><Default Extension="png" ContentType="image/png"/></Types>"#;
|
||
|
||
fn ink_docx(fallback: bool, alias: bool) -> Vec<u8> {
|
||
let content = r#"<q:contentPart xmlns:q="http://schemas.microsoft.com/office/word/2010/wordml" r:id="rInk"><q:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></q:xfrm></q:contentPart>"#;
|
||
let drawing = format!(
|
||
r#"<w:drawing><wp:inline><wp:extent cx="1828800" cy="914400"/><wp:docPr id="1" name="Study ink"/><a:graphic><a:graphicData uri="{INK_NS}">{content}</a:graphicData></a:graphic></wp:inline></w:drawing>"#
|
||
);
|
||
let prefix = if alias { "pen" } else { "wpi" };
|
||
let markup = if fallback {
|
||
format!(
|
||
r##"<mc:AlternateContent><mc:Choice xmlns:{prefix}="{INK_NS}" Requires="{prefix}">{drawing}</mc:Choice><mc:Fallback><w:pict><v:shape id="preview" style="width:144pt;height:72pt"><v:imagedata r:id="rPreview"/></v:shape></w:pict></mc:Fallback></mc:AlternateContent>"##
|
||
)
|
||
} else {
|
||
drawing
|
||
};
|
||
let body = format!("<w:p><w:r><w:t>Original</w:t></w:r></w:p><w:p><w:r>{markup}</w:r></w:p>");
|
||
let mut relationships = vec![("rInk", "customXml", "ink/ink1.xml")];
|
||
let mut extra = vec![("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)];
|
||
if fallback {
|
||
relationships.push(("rPreview", "image", "media/preview.png"));
|
||
extra.push(("word/media/preview.png", "original preview bytes"));
|
||
}
|
||
docx(&body, &relationships, &extra)
|
||
}
|
||
|
||
fn check_ink(files: &std::collections::BTreeMap<String, Vec<u8>>) -> String {
|
||
let body = text(files, "word/document.xml");
|
||
let [id] = attr_values(&body, "w14:contentPart", "r:id").try_into().unwrap_or_else(|_| panic!("ink reference lost: {body}"));
|
||
let (kind, target) = rel(&text(files, "word/_rels/document.xml.rels"), &id);
|
||
assert!(kind.ends_with("/customXml"));
|
||
let part = part_at("word", &target);
|
||
assert_eq!(text(files, &part), INK_XML);
|
||
assert!(text(files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#)));
|
||
part
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_survives_unrelated_edit_and_two_saves() {
|
||
let mut d = wordcraft_docx::read(&ink_docx(false, false)).unwrap();
|
||
let g = graphics(&d);
|
||
assert_eq!(g.len(), 1, "native ink must remain a model object");
|
||
let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() };
|
||
assert_eq!(graphic.kind.noun(), "ink");
|
||
assert!(graphic.source.is_some());
|
||
assert!(!graphic.items.is_empty(), "a missing preview must not silently leave a blank frame");
|
||
let p = std::sync::Arc::make_mut(&mut d.body[0]);
|
||
let Block::Para(p) = p else { panic!() };
|
||
p.delete(0, p.len()).unwrap();
|
||
p.insert_text(0, "Edited", &Default::default()).unwrap();
|
||
for _ in 0..2 {
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let files = unzip(&out);
|
||
check_ink(&files);
|
||
assert!(text(&files, "word/document.xml").contains("Edited"));
|
||
d = wordcraft_docx::read(&out).unwrap();
|
||
assert_eq!(graphics(&d).len(), 1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn direct_native_ink_content_part_survives_unrelated_edit_and_two_saves() {
|
||
let body = r#"<w:p><w:r><w:t>Original</w:t></w:r><w:r><w:contentPart r:id="rInk"/></w:r></w:p>"#;
|
||
let mut d = wordcraft_docx::read(&docx(
|
||
body,
|
||
&[("rInk", "customXml", "ink/ink1.xml")],
|
||
&[("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)],
|
||
))
|
||
.unwrap();
|
||
assert_eq!(graphics(&d).len(), 1, "direct native ink must remain a model object");
|
||
let p = std::sync::Arc::make_mut(&mut d.body[0]);
|
||
let Block::Para(p) = p else { panic!() };
|
||
p.delete(0, 8).unwrap();
|
||
p.insert_text(0, "Edited", &Default::default()).unwrap();
|
||
for _ in 0..2 {
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let files = unzip(&out);
|
||
let body = text(&files, "word/document.xml");
|
||
assert!(body.contains("<w:contentPart"), "direct content part must stay direct: {body}");
|
||
let [id] = attr_values(&body, "w:contentPart", "r:id").try_into().unwrap();
|
||
let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);
|
||
assert!(kind.ends_with("/customXml"));
|
||
let part = part_at("word", &target);
|
||
assert_eq!(text(&files, &part), INK_XML);
|
||
assert!(text(&files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#)));
|
||
d = wordcraft_docx::read(&out).unwrap();
|
||
assert_eq!(graphics(&d).len(), 1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_keeps_alternate_content_and_preview_with_renamed_prefixes() {
|
||
for alias in [false, true] {
|
||
let mut d = wordcraft_docx::read(&ink_docx(true, alias)).unwrap();
|
||
let g = graphics(&d);
|
||
assert_eq!(g.len(), 1, "fallback must not replace native ink");
|
||
let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() };
|
||
assert!(
|
||
graphic.items.iter().any(|item| matches!(item, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media)))
|
||
);
|
||
for _ in 0..2 {
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let files = unzip(&out);
|
||
check_ink(&files);
|
||
let body = text(&files, "word/document.xml");
|
||
assert!(body.contains("<mc:AlternateContent>") && body.contains("<mc:Fallback>"));
|
||
let [id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
|
||
let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);
|
||
assert!(kind.ends_with("/image"));
|
||
assert_eq!(text(&files, &part_at("word", &target)), "original preview bytes");
|
||
d = wordcraft_docx::read(&out).unwrap();
|
||
assert_eq!(graphics(&d).len(), 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_missing_part_uses_available_fallback() {
|
||
let bytes = ink_docx(true, true);
|
||
let mut files = unzip(&bytes);
|
||
files.remove("word/ink/ink1.xml");
|
||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||
assert!(graphics(&d).is_empty());
|
||
assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. })));
|
||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
assert!(!text(&files, "word/document.xml").contains("w14:contentPart"));
|
||
assert!(files.values().any(|bytes| bytes == b"original preview bytes"));
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_missing_optional_preview_keeps_native_part() {
|
||
let mut files = unzip(&ink_docx(true, false));
|
||
files.remove("word/media/preview.png");
|
||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||
assert_eq!(graphics(&d).len(), 1);
|
||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
check_ink(&files);
|
||
assert!(!text(&files, "word/document.xml").contains("<mc:Fallback>"));
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_requires_the_bound_namespace_and_ink_content_type() {
|
||
for wrong_namespace in [true, false] {
|
||
let mut files = unzip(&ink_docx(true, false));
|
||
let name = if wrong_namespace { "word/document.xml" } else { "[Content_Types].xml" };
|
||
let old = text(&files, name);
|
||
let changed = if wrong_namespace {
|
||
old.replace(&format!(r#"xmlns:wpi="{INK_NS}""#), r#"xmlns:wpi="urn:future:unsupported""#)
|
||
} else {
|
||
old.replace("application/inkml+xml", "application/xml")
|
||
};
|
||
files.insert(name.into(), changed.into_bytes());
|
||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||
assert!(graphics(&d).is_empty());
|
||
assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. })));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_copies_have_independent_parts_and_current_frames() {
|
||
let mut d = wordcraft_docx::read(&ink_docx(true, false)).unwrap();
|
||
let [mut object] = graphics(&d).try_into().unwrap();
|
||
let InlineObject::Graphic { w, h, float, .. } = &mut object else { panic!() };
|
||
(*w, *h) = (288.0, 144.0);
|
||
*float = wordcraft_doc::para::Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 36.0, ..Default::default() };
|
||
let mut p = Paragraph::new();
|
||
p.insert_object(0, object.clone(), &Default::default()).unwrap();
|
||
p.insert_object(p.len(), object, &Default::default()).unwrap();
|
||
d.body = vec![wordcraft_doc::para_block(p)];
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let files = unzip(&out);
|
||
let body = text(&files, "word/document.xml");
|
||
let ids = attr_values(&body, "w14:contentPart", "r:id");
|
||
assert_eq!(ids.len(), 2);
|
||
let parts: Vec<_> = ids.iter().map(|id| part_at("word", &rel(&text(&files, "word/_rels/document.xml.rels"), id).1)).collect();
|
||
assert_ne!(parts[0], parts[1]);
|
||
for part in parts {
|
||
assert_eq!(text(&files, &part), INK_XML);
|
||
}
|
||
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"]);
|
||
assert_eq!(attr_values(&body, "a:ext", "cx"), ["3657600", "3657600"]);
|
||
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:288pt;height:144pt", "width:288pt;height:144pt"]);
|
||
assert_eq!(body.matches("<wp:posOffset>914400</wp:posOffset>").count(), 2);
|
||
let back = wordcraft_docx::read(&out).unwrap();
|
||
let g = graphics(&back);
|
||
assert_eq!(g.len(), 2);
|
||
for object in g {
|
||
let InlineObject::Graphic { w, h, float, .. } = object else { panic!() };
|
||
assert_eq!((w, h, float.x, float.y), (288.0, 144.0, 72.0, 36.0));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_markup_limit_does_not_write_a_dangling_reference() {
|
||
let mut files = unzip(&ink_docx(false, false));
|
||
let body = text(&files, "word/document.xml").replace("<a:graphicData ", &format!(r#"<a:graphicData padding="{}" "#, "x".repeat(1024 * 1024 + 1)));
|
||
files.insert("word/document.xml".into(), body.into_bytes());
|
||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
assert!(!text(&files, "word/document.xml").contains("w14:contentPart"));
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_preserves_inner_transform_and_rotation_padding_on_unrelated_saves() {
|
||
let mut files = unzip(&ink_docx(false, false));
|
||
let body = text(&files, "word/document.xml")
|
||
.replace("<wp:docPr", r#"<wp:effectExtent l="55468" t="512668" r="55468" b="512668"/><wp:docPr"#)
|
||
.replace("<q:xfrm>", r#"<q:xfrm rot="2700000">"#)
|
||
.replace(r#"<a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/>"#, r#"<a:off x="127000" y="254000"/><a:ext cx="914400" cy="457200"/>"#);
|
||
files.insert("word/document.xml".into(), body.into_bytes());
|
||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||
let mut d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||
for _ in 0..2 {
|
||
let [object] = graphics(&d).try_into().unwrap();
|
||
let InlineObject::Graphic { float, .. } = object else { panic!() };
|
||
assert_eq!(float.spin().deg, 45.0);
|
||
assert!(float.effect.iter().all(|v| v.abs() < 0.001), "rotation padding is not counted twice: {:?}", float.effect);
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let files = unzip(&out);
|
||
check_ink(&files);
|
||
let body = text(&files, "word/document.xml");
|
||
assert_eq!(attr_values(&body, "a:off", "x"), ["127000"]);
|
||
assert_eq!(attr_values(&body, "a:ext", "cx"), ["914400"]);
|
||
assert_eq!(attr_values(&body, "w14:xfrm", "rot"), ["2700000"]);
|
||
d = wordcraft_docx::read(&out).unwrap();
|
||
}
|
||
}
|
||
|
||
// ---- native Word ink as a member of a group or a drawing canvas (gap 2) ----
|
||
|
||
const GROUP_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
|
||
const CANVAS_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
|
||
const W14_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordml";
|
||
|
||
/// A rectangle member `name` at `x` (EMU) in its group's space.
|
||
fn rect_member(name: &str, x: u32) -> String {
|
||
format!(
|
||
r#"<wps:wsp><wps:cNvPr id="{x}" name="{name}"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="{x}" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>"#
|
||
)
|
||
}
|
||
|
||
/// A native-ink member (`w14:contentPart`) at `x` (EMU), optionally inside `mc:AlternateContent`
|
||
/// with a picture fallback.
|
||
fn ink_member(x: u32, fallback: bool) -> String {
|
||
let content = format!(
|
||
r#"<w14:contentPart xmlns:w14="{W14_NS}" bwMode="auto" r:id="rInk"><w14:nvContentPartPr><w14:cNvPr id="9" name="Ink 9"/><w14:cNvContentPartPr/></w14:nvContentPartPr><w14:xfrm><a:off x="{x}" y="0"/><a:ext cx="914400" cy="457200"/></w14:xfrm></w14:contentPart>"#
|
||
);
|
||
if !fallback {
|
||
return content;
|
||
}
|
||
format!(
|
||
r#"<mc:AlternateContent><mc:Choice xmlns:wpi="{INK_NS}" Requires="wpi">{content}</mc:Choice><mc:Fallback><pic:pic><pic:nvPicPr><pic:cNvPr id="9" name="Ink 9"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rPreview"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="{x}" y="0"/><a:ext cx="914400" cy="457200"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></mc:Fallback></mc:AlternateContent>"#
|
||
)
|
||
}
|
||
|
||
/// A document whose second paragraph holds a group (or canvas) of a rectangle, `ink` and another
|
||
/// rectangle, in that order.
|
||
fn nested_ink_docx(canvas: bool, ink: &str, fallback: bool) -> Vec<u8> {
|
||
let members = format!("{}{ink}{}", rect_member("Before", 0), rect_member("After", 1828800));
|
||
let graphic = if canvas {
|
||
format!(r#"<a:graphicData uri="{CANVAS_URI}"><wpc:wpc xmlns:wpc="{CANVAS_URI}"><wpc:bg/><wpc:whole/>{members}</wpc:wpc></a:graphicData>"#)
|
||
} else {
|
||
format!(
|
||
r#"<a:graphicData uri="{GROUP_URI}"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="2743200" cy="914400"/></a:xfrm></wpg:grpSpPr>{members}</wpg:wgp></a:graphicData>"#
|
||
)
|
||
};
|
||
let body = format!(
|
||
r#"<w:p><w:r><w:t>Original</w:t></w:r></w:p><w:p><w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="914400"/><wp:docPr id="1" name="Group 1"/><a:graphic>{graphic}</a:graphic></wp:inline></w:drawing></w:r></w:p>"#
|
||
);
|
||
let mut relationships = vec![("rInk", "customXml", "ink/ink1.xml")];
|
||
let mut extra = vec![("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)];
|
||
if fallback {
|
||
relationships.push(("rPreview", "image", "media/preview.png"));
|
||
extra.push(("word/media/preview.png", "original preview bytes"));
|
||
}
|
||
docx(&body, &relationships, &extra)
|
||
}
|
||
|
||
/// The members of the one group or canvas in `d`.
|
||
fn group_children(d: &Document) -> Vec<wordcraft_doc::para::GroupChild> {
|
||
let groups: Vec<_> = paras(d)
|
||
.iter()
|
||
.flat_map(|p| p.objects.iter())
|
||
.filter_map(|o| match o {
|
||
InlineObject::Group { children, .. } => Some(children.clone()),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let [children] = groups.try_into().unwrap_or_else(|g: Vec<_>| panic!("expected one group, found {}", g.len()));
|
||
children
|
||
}
|
||
|
||
fn assert_ink_between_rects(d: &Document) {
|
||
let c = group_children(d);
|
||
let kinds: Vec<_> = c
|
||
.iter()
|
||
.map(|c| match &c.obj {
|
||
InlineObject::Shape { .. } => "shape",
|
||
InlineObject::Graphic { graphic, .. } if graphic.kind.noun() == "ink" => "ink",
|
||
_ => "other",
|
||
})
|
||
.collect();
|
||
assert_eq!(kinds, ["shape", "ink", "shape"], "the ink member keeps its place among the members");
|
||
let ink = &c[1];
|
||
assert_eq!((ink.x, ink.y), (72.0, 0.0));
|
||
let InlineObject::Graphic { w, h, .. } = &ink.obj else { panic!() };
|
||
assert_eq!((*w, *h), (72.0, 36.0));
|
||
}
|
||
|
||
fn edit_first_para(d: &mut Document) {
|
||
let p = std::sync::Arc::make_mut(&mut d.body[0]);
|
||
let Block::Para(p) = p else { panic!() };
|
||
p.delete(0, p.len()).unwrap();
|
||
p.insert_text(0, "Edited", &Default::default()).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_in_a_group_or_canvas_survives_unrelated_edit_and_two_saves() {
|
||
for canvas in [false, true] {
|
||
for fallback in [false, true] {
|
||
let what = format!("canvas {canvas}, fallback {fallback}");
|
||
let mut d = wordcraft_docx::read(&nested_ink_docx(canvas, &ink_member(914400, fallback), fallback)).unwrap();
|
||
assert_ink_between_rects(&d);
|
||
edit_first_para(&mut d);
|
||
for _ in 0..2 {
|
||
let out = wordcraft_docx::write(&d).unwrap();
|
||
let files = unzip(&out);
|
||
check_ink(&files);
|
||
let body = text(&files, "word/document.xml");
|
||
assert!(body.contains("Edited"));
|
||
let at = |s: &str| body.find(s).unwrap_or_else(|| panic!("{s} missing ({what}): {body}"));
|
||
let ink = at("<w14:contentPart");
|
||
assert!(at("<wps:wsp") < ink && body.rfind("<wps:wsp").unwrap() > ink, "member order lost ({what}): {body}");
|
||
assert!(body.contains("<w14:nvContentPartPr>") && body.contains(r#"name="Ink 9""#), "{what}: {body}");
|
||
let xfrm = &body[ink..];
|
||
let xfrm = &xfrm[xfrm.find("<w14:xfrm").unwrap()..];
|
||
assert_eq!(attr_values(xfrm, "a:off", "x").first().map(String::as_str), Some("914400"), "{what}");
|
||
assert_eq!(attr_values(xfrm, "a:ext", "cx").first().map(String::as_str), Some("914400"), "{what}");
|
||
if fallback {
|
||
assert!(body.contains("<mc:Fallback>"), "{what}: {body}");
|
||
let [id] = attr_values(&body, "a:blip", "r:embed").try_into().unwrap();
|
||
let (_, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);
|
||
assert_eq!(text(&files, &part_at("word", &target)), "original preview bytes");
|
||
}
|
||
d = wordcraft_docx::read(&out).unwrap();
|
||
assert_ink_between_rects(&d);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_in_a_group_shows_its_fallback_picture() {
|
||
let d = wordcraft_docx::read(&nested_ink_docx(false, &ink_member(914400, true), true)).unwrap();
|
||
let c = group_children(&d);
|
||
let InlineObject::Graphic { graphic, .. } = &c[1].obj else { panic!() };
|
||
assert!(
|
||
graphic.items.iter().any(|item| matches!(item, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media))),
|
||
"{:?}",
|
||
graphic.items
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn native_ink_in_a_nested_group_is_written_at_its_place_in_the_outer_group() {
|
||
// The inner group doubles its members: 1 inch of its space is 2 inches of the outer group's.
|
||
let inner = format!(
|
||
r#"<wpg:grpSp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="914400" y="0"/><a:ext cx="1828800" cy="1828800"/><a:chOff x="0" y="0"/><a:chExt cx="914400" cy="914400"/></a:xfrm></wpg:grpSpPr>{}</wpg:grpSp>"#,
|
||
ink_member(0, false)
|
||
);
|
||
let d = wordcraft_docx::read(&nested_ink_docx(false, &inner, false)).unwrap();
|
||
let c = group_children(&d);
|
||
let ink = c.iter().find(|c| matches!(c.obj, InlineObject::Graphic { .. })).expect("ink member kept");
|
||
let InlineObject::Graphic { w, h, .. } = &ink.obj else { panic!() };
|
||
assert_eq!((ink.x, ink.y, *w, *h), (72.0, 0.0, 144.0, 72.0));
|
||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
check_ink(&files);
|
||
let body = text(&files, "word/document.xml");
|
||
let xfrm = &body[body.find("<w14:xfrm").unwrap()..];
|
||
assert_eq!(attr_values(xfrm, "a:off", "x").first().map(String::as_str), Some("914400"));
|
||
assert_eq!(attr_values(xfrm, "a:ext", "cx").first().map(String::as_str), Some("1828800"));
|
||
assert_eq!(attr_values(xfrm, "a:ext", "cy").first().map(String::as_str), Some("914400"));
|
||
}
|
||
|
||
/// Ink parts share the custom XML relationship type with real custom XML stores: the store is
|
||
/// carried as before, the ink part only with the ink that refers to it.
|
||
#[test]
|
||
fn custom_xml_store_beside_native_ink_is_kept_and_ink_leaves_no_orphan() {
|
||
const STORE: &str = r#"<?xml version="1.0" encoding="UTF-8"?><data xmlns="urn:example:store"><item>kept</item></data>"#;
|
||
let types = INK_TYPES.replace("<Default ", r#"<Override PartName="/customXml/item1.xml" ContentType="application/xml"/><Default "#);
|
||
let mut files = unzip(&nested_ink_docx(false, &ink_member(914400, false), false));
|
||
let rels = text(&files, "word/_rels/document.xml.rels").replace(
|
||
"</Relationships>",
|
||
r#"<Relationship Id="rStore" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="../customXml/item1.xml"/></Relationships>"#,
|
||
);
|
||
files.insert("word/_rels/document.xml.rels".into(), rels.into_bytes());
|
||
files.insert("[Content_Types].xml".into(), types.into_bytes());
|
||
files.insert("customXml/item1.xml".into(), STORE.as_bytes().to_vec());
|
||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||
let mut d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||
let out = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
check_ink(&out);
|
||
assert!(out.values().any(|b| b == STORE.as_bytes()), "the store is kept");
|
||
assert_eq!(out.values().filter(|b| *b == INK_XML.as_bytes()).count(), 1, "one ink part, not a second copy as a store");
|
||
// Without the ink the store stays and the ink part goes.
|
||
d.body.truncate(1);
|
||
let out = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
assert!(out.values().any(|b| b == STORE.as_bytes()), "the store is kept");
|
||
assert!(!out.values().any(|b| b == INK_XML.as_bytes()), "orphan ink part: {:?}", out.keys().collect::<Vec<_>>());
|
||
assert!(!text(&out, "[Content_Types].xml").contains("inkml"));
|
||
assert_eq!(text(&out, "word/_rels/document.xml.rels").matches("/customXml\"").count(), 1, "only the store's relationship");
|
||
}
|
||
|
||
#[test]
|
||
fn deleting_drawing_level_native_ink_writes_no_orphan_ink_part() {
|
||
for fallback in [false, true] {
|
||
let mut d = wordcraft_docx::read(&ink_docx(fallback, false)).unwrap();
|
||
assert_eq!(graphics(&d).len(), 1);
|
||
d.body.truncate(1);
|
||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
assert!(!files.values().any(|b| b == INK_XML.as_bytes()), "orphan ink part: {:?}", files.keys().collect::<Vec<_>>());
|
||
assert!(!text(&files, "[Content_Types].xml").contains("inkml"));
|
||
assert!(!text(&files, "word/_rels/document.xml.rels").contains("customXml"));
|
||
}
|
||
}
|
||
|
||
/// A group member's ink taken out of its group (not something WordCraft's commands do: Ungroup
|
||
/// refuses such a group) can't be written as a stand-alone drawing: it is left out whole, never
|
||
/// written in the wrong place, and its parts aren't written either.
|
||
#[test]
|
||
fn group_member_ink_on_its_own_is_left_out_without_orphans() {
|
||
for fallback in [false, true] {
|
||
let d = wordcraft_docx::read(&nested_ink_docx(false, &ink_member(914400, fallback), fallback)).unwrap();
|
||
let ink = group_children(&d).into_iter().find(|c| matches!(c.obj, InlineObject::Graphic { .. })).expect("ink member").obj;
|
||
let mut d = d;
|
||
d.body.truncate(1);
|
||
let mut p = Paragraph::new();
|
||
p.insert_object(0, ink, &Default::default()).unwrap();
|
||
d.body.push(wordcraft_doc::para_block(p));
|
||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
let body = text(&files, "word/document.xml");
|
||
assert!(!body.contains("contentPart"), "fallback {fallback}: {body}");
|
||
assert!(!files.values().any(|b| b == INK_XML.as_bytes()), "orphan ink part: {:?}", files.keys().collect::<Vec<_>>());
|
||
assert!(!text(&files, "word/_rels/document.xml.rels").contains("customXml"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn deleting_a_group_with_native_ink_writes_no_orphan_ink_part() {
|
||
let mut d = wordcraft_docx::read(&nested_ink_docx(false, &ink_member(914400, true), true)).unwrap();
|
||
d.body.truncate(1);
|
||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
assert!(!text(&files, "word/document.xml").contains("contentPart"));
|
||
assert!(!files.values().any(|b| b == INK_XML.as_bytes()), "orphan ink part: {:?}", files.keys().collect::<Vec<_>>());
|
||
assert!(!text(&files, "[Content_Types].xml").contains("inkml"));
|
||
assert!(!text(&files, "word/_rels/document.xml.rels").contains("customXml"));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_native_ink_in_a_group_never_panics() {
|
||
// A dangling relationship, a missing part, or a part that isn't InkML: the ink member is
|
||
// dropped (as drawing-level ink is), the other members stay.
|
||
for fallback in [false, true] {
|
||
for case in ["dangling", "missing", "wrong type"] {
|
||
let mut files = unzip(&nested_ink_docx(false, &ink_member(914400, fallback), fallback));
|
||
match case {
|
||
"dangling" => {
|
||
let body = text(&files, "word/document.xml").replace(r#"r:id="rInk""#, r#"r:id="rNowhere""#);
|
||
files.insert("word/document.xml".into(), body.into_bytes());
|
||
}
|
||
"missing" => {
|
||
files.remove("word/ink/ink1.xml");
|
||
}
|
||
_ => {
|
||
let types = text(&files, "[Content_Types].xml").replace("application/inkml+xml", "application/xml");
|
||
files.insert("[Content_Types].xml".into(), types.into_bytes());
|
||
}
|
||
}
|
||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||
let c = group_children(&d);
|
||
assert!(!c.iter().any(|c| matches!(c.obj, InlineObject::Graphic { .. })), "{case}");
|
||
// With a picture fallback the picture takes the ink's place.
|
||
let pictures = c.iter().filter(|c| matches!(c.obj, InlineObject::Image { .. })).count();
|
||
assert_eq!(c.len(), if fallback { 3 } else { 2 }, "{case}, fallback {fallback}");
|
||
assert_eq!(pictures, usize::from(fallback), "{case}");
|
||
let out = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
assert!(!text(&out, "word/document.xml").contains("contentPart"), "{case}");
|
||
}
|
||
}
|
||
// A garbled ink part is kept as it is: InkML stays opaque.
|
||
let mut files = unzip(&nested_ink_docx(true, &ink_member(914400, false), false));
|
||
files.insert("word/ink/ink1.xml".into(), vec![0xff, 0x00, b'<', 0xfe]);
|
||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||
let out = unzip(&wordcraft_docx::write(&d).unwrap());
|
||
assert!(out.values().any(|b| b == &[0xff, 0x00, b'<', 0xfe]));
|
||
}
|
||
|
||
/// Word draws a table of contents' entry links without the Hyperlink character style (measured in
|
||
/// Word for Windows); the import drops the style from them, keeping the link. A Hyperlink-styled run
|
||
/// outside a hyperlink, and a hyperlink in another field, keep it.
|
||
#[test]
|
||
fn toc_entry_links_lose_the_hyperlink_style() {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
|
||
<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0066FF"/><w:u w:val="single"/></w:rPr></w:style></w:styles>"#
|
||
);
|
||
let field = |instr: &str, inner: &str| {
|
||
format!(
|
||
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve">{instr}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{inner}</w:p><w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
|
||
)
|
||
};
|
||
let link =
|
||
|t: &str| format!(r#"<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>{t}</w:t></w:r></w:hyperlink>"#);
|
||
let body = [
|
||
field(r#" TOC \o "1-3" \h \z \u "#, &link("entry")),
|
||
field(" TOC ", r#"<w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>styled</w:t></w:r>"#),
|
||
field(" QUOTE x ", &link("quoted")),
|
||
format!("<w:p>{}</w:p>", link("plain")),
|
||
r#"<w:p><w:bookmarkStart w:id="1" w:name="_Toc1"/><w:r><w:t>Target</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p>"#.to_string(),
|
||
]
|
||
.concat();
|
||
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||
let run = |word: &str| {
|
||
paras(&d).into_iter().find_map(|p| p.text.find(word).map(|at| p.props_of_char(at).clone())).unwrap_or_else(|| panic!("no run with {word}"))
|
||
};
|
||
let entry = run("entry");
|
||
assert_eq!((entry.style.as_deref(), entry.link.as_deref()), (None, Some("#_Toc1")), "a TOC entry: plain, still a link");
|
||
assert_eq!(run("styled").style.as_deref(), Some("Hyperlink"), "not in a hyperlink: kept");
|
||
assert_eq!(run("quoted").style.as_deref(), Some("Hyperlink"), "another field: kept");
|
||
assert_eq!(run("plain").style.as_deref(), Some("Hyperlink"), "outside fields: kept");
|
||
}
|
||
|
||
/// Localized Word (Spanish: `Hipervnculo`) and older files (`Hyperlnk`) give the built-in
|
||
/// Hyperlink character style another id; its name stays "Hyperlink". TOC entries carrying it are
|
||
/// plain too (#3239: entries showed blue).
|
||
#[test]
|
||
fn toc_entry_links_lose_a_localized_hyperlink_style() {
|
||
for id in ["Hipervnculo", "Hyperlnk"] {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
|
||
<w:style w:type="character" w:styleId="{id}"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style></w:styles>"#
|
||
);
|
||
let link = |t: &str| {
|
||
format!(
|
||
r#"<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:rStyle w:val="{id}"/></w:rPr><w:t>{t}</w:t></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGEREF _Toc1 \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:hyperlink>"#
|
||
)
|
||
};
|
||
let body = format!(
|
||
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent><w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}</w:p><w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:sdtContent></w:sdt><w:p>{}</w:p><w:p><w:bookmarkStart w:id="1" w:name="_Toc1"/><w:r><w:t>Target</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p>"#,
|
||
link("entry"),
|
||
link("plain")
|
||
);
|
||
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||
let run = |word: &str| {
|
||
paras(&d)
|
||
.into_iter()
|
||
.find_map(|p| p.text.find(word).map(|at| p.props_of_char(at).clone()))
|
||
.unwrap_or_else(|| panic!("no run with {word}"))
|
||
};
|
||
let entry = run("entry");
|
||
assert_eq!((entry.style.as_deref(), entry.link.as_deref()), (None, Some("#_Toc1")), "{id}: a TOC entry: plain, still a link");
|
||
assert_eq!(run("plain").style.as_deref(), Some(id), "{id}: outside fields: kept");
|
||
}
|
||
}
|
||
|
||
/// A style or paragraph whose `w:numPr` gives only a level (`w:ilvl`) takes the list from its
|
||
/// style chain (ECMA-376 §17.3.1.19): a Heading 3 based on a numbered Heading 2 is level 3 of the
|
||
/// same list. WordCraft dropped the level, so Heading 3s numbered as Heading 2s (6.15 where Word
|
||
/// shows 6.4.5 in NIH's monitoring-plan template).
|
||
#[test]
|
||
fn a_list_level_without_a_list_takes_the_style_s_list() {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}>
|
||
<w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="15"/></w:numPr></w:pPr></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Heading2"/><w:pPr><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Heading4"><w:name w:val="heading 4"/><w:basedOn w:val="Heading3"/><w:pPr><w:numPr><w:ilvl w:val="3"/></w:numPr></w:pPr></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Loose"><w:name w:val="Loose"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="4"/></w:numPr></w:pPr></w:style>
|
||
</w:styles>"#
|
||
);
|
||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading3"/></w:pPr><w:r><w:t>three</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:pStyle w:val="Heading2"/><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr><w:r><w:t>direct level</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/></w:numPr></w:pPr><w:r><w:t>no list anywhere</w:t></w:r></w:p>"#;
|
||
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||
let num = |id: &str| d.styles.get(id).and_then(|s| s.para.numbering).map(|n| (n.num, n.level));
|
||
assert_eq!(num("Heading3"), Some((15, 2)), "Heading 3 is level 3 of Heading 2's list");
|
||
assert_eq!(num("Heading4"), Some((15, 3)), "through two level-only styles");
|
||
assert_eq!(num("Loose"), None, "no list in the chain: no numbering");
|
||
let p = paras(&d);
|
||
assert_eq!(d.styles.resolve_para(&p[0].props).numbering.map(|n| (n.num, n.level)), Some((15, 2)));
|
||
assert_eq!(p[1].props.numbering.map(|n| (n.num, n.level)), Some((15, 2)), "a paragraph's own level, its style's list");
|
||
assert_eq!(p[2].props.numbering, None);
|
||
// Saved and read again it is an ordinary list reference.
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
assert_eq!(again.styles.get("Heading3").and_then(|s| s.para.numbering).map(|n| (n.num, n.level)), Some((15, 2)));
|
||
}
|
||
|
||
/// `w:hideMark` on a cell is read and written back (it was dropped, and the empty rows NIH's
|
||
/// tables collapse with it showed as blank lines).
|
||
#[test]
|
||
fn cell_hide_mark_reads_and_round_trips() {
|
||
let body = r#"<w:tbl><w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:hideMark/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl><w:p/>"#;
|
||
let hides = |d: &Document| -> Vec<bool> {
|
||
d.body
|
||
.iter()
|
||
.find_map(|b| {
|
||
if let wordcraft_doc::Block::Table(t) = &**b { Some(t.rows[0].cells.iter().map(|c| c.props.hide_mark).collect()) } else { None }
|
||
})
|
||
.unwrap()
|
||
};
|
||
let d = read_body(body);
|
||
assert_eq!(hides(&d), [true, false]);
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
assert_eq!(hides(&again), [true, false]);
|
||
}
|
||
|
||
/// Text frames (`w:framePr` without a drop cap), as NIH's templates put a footer's page number in
|
||
/// one: read with their size, anchors, alignment and wrap, and written back (they were dropped on
|
||
/// save).
|
||
#[test]
|
||
fn text_frames_read_and_round_trip() {
|
||
let body = r#"<w:p><w:pPr><w:framePr w:wrap="around" w:vAnchor="text" w:hAnchor="margin" w:xAlign="center" w:y="1"/></w:pPr><w:r><w:t>3</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:framePr w:w="2880" w:h="720" w:hRule="exact" w:hSpace="180" w:wrap="notBeside" w:hAnchor="page" w:vAnchor="page" w:x="1440" w:y="2880"/></w:pPr><w:r><w:t>boxed</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:framePr w:wrap="sideways" w:hAnchor="nowhere" w:w="-5" w:x="NaN"/></w:pPr><w:r><w:t>junk</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="2" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:t>D</w:t></w:r></w:p>
|
||
<w:p><w:r><w:t>rop cap text</w:t></w:r></w:p>"#;
|
||
let d = read_body(body);
|
||
let p = paras(&d);
|
||
let f = p[0].props.frame.clone().expect("a frame");
|
||
assert_eq!(
|
||
(f.wrap.as_str(), f.v_anchor.as_str(), f.h_anchor.as_str(), f.x_align.as_deref(), f.y),
|
||
("around", "text", "margin", Some("center"), 0.05)
|
||
);
|
||
let g = p[1].props.frame.clone().expect("a frame");
|
||
assert_eq!((g.w, g.h, g.h_exact, g.h_space, g.wrap.as_str(), g.x, g.y), (Some(144.0), Some(36.0), true, 9.0, "notBeside", 72.0, 144.0));
|
||
let cap = p.iter().find(|q| q.props.drop_cap.is_some()).expect("the drop cap");
|
||
assert!(cap.props.frame.is_none(), "a drop cap stays a drop cap");
|
||
let junk = p.iter().find(|q| q.text.contains("junk")).and_then(|q| q.props.frame.clone()).expect("a frame");
|
||
assert_eq!((junk.wrap.as_str(), junk.h_anchor.as_str(), junk.w, junk.x), ("auto", "page", None, 0.0), "unknown values fall back");
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
let q = paras(&again);
|
||
assert_eq!(q[0].props.frame, p[0].props.frame);
|
||
assert_eq!(q[1].props.frame, p[1].props.frame);
|
||
assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none()));
|
||
}
|
||
|
||
/// #2360: a `w:framePr` in the document defaults, the sizeless top-left one Apple Pages writes,
|
||
/// frames no paragraph, as in Word: a header's title and the body's text stay in the text instead
|
||
/// of moving to the page's corner, and the default isn't written back. The defaults' other
|
||
/// properties are kept, and a style's own frame still applies.
|
||
#[test]
|
||
fn a_frame_in_the_paragraph_defaults_frames_nothing() {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}><w:docDefaults><w:pPrDefault><w:pPr><w:framePr w:anchorLock="0" w:w="0" w:h="0" w:vSpace="0" w:hSpace="0" w:xAlign="left" w:y="0" w:hRule="exact" w:vAnchor="margin"/><w:spacing w:after="120"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Framed"><w:name w:val="Framed"/><w:pPr><w:framePr w:wrap="around" w:hAnchor="margin" w:vAnchor="text" w:xAlign="right" w:y="1"/></w:pPr></w:style></w:styles>"#
|
||
);
|
||
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:t>Title</w:t></w:r></w:p></w:hdr>"#);
|
||
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Framed"/></w:pPr><w:r><w:t>Boxed</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
|
||
let bytes = docx(
|
||
body,
|
||
&[("rIdS", "styles", "styles.xml"), ("rIdH", "header", "header1.xml")],
|
||
&[("word/styles.xml", &styles), ("word/header1.xml", &header)],
|
||
);
|
||
let check = |d: &Document| {
|
||
assert!(d.styles.default_para.frame.is_none(), "{:?}", d.styles.default_para.frame);
|
||
assert_eq!(d.styles.default_para.space_after, Some(6.0), "the defaults' spacing is kept");
|
||
let frame = |p: &Paragraph| d.styles.resolve_para(&p.props).frame;
|
||
let body = paras(d);
|
||
assert_eq!(body[0].text, "Body");
|
||
assert!(frame(body[0]).is_none(), "body text is not a frame");
|
||
let boxed = frame(body[1]).expect("a style's frame still applies");
|
||
assert_eq!((boxed.h_anchor.as_str(), boxed.x_align.as_deref()), ("margin", Some("right")));
|
||
let hid = d.last_section.headers.default.expect("a header");
|
||
let title = d.parts.get(&hid).unwrap().blocks.iter().find_map(|b| b.as_para()).expect("the title");
|
||
assert_eq!(title.text, "Title");
|
||
assert!(frame(title).is_none(), "the header's title is not a frame");
|
||
};
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
check(&d);
|
||
let saved = wordcraft_docx::write(&d).unwrap();
|
||
let xml = text(&unzip(&saved), "word/styles.xml");
|
||
let defaults = xml.split("</w:docDefaults>").next().unwrap_or_default();
|
||
assert!(!defaults.contains("w:framePr"), "the default frame isn't written back: {defaults}");
|
||
check(&wordcraft_docx::read(&saved).unwrap());
|
||
}
|
||
|
||
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4): a rule across a header,
|
||
/// drawn in a 1000 × 1000 coordinate space, outlined, its fill transparent. It is read as a
|
||
/// freeform of the shape's size and kept when saved (as DrawingML custom geometry) and read back.
|
||
#[test]
|
||
fn vml_freeform_rule_in_header() {
|
||
let w10 = r#"xmlns:w10="urn:schemas-microsoft-com:office:word""#;
|
||
let rule = r##"<w:p><w:r><w:pict><v:shape style="position:absolute;margin-left:0;margin-top:30pt;width:360pt;height:12pt;z-index:2;mso-position-horizontal-relative:margin;mso-position-vertical-relative:margin" coordsize="1000,1000" path="m,500l1000,500xe" fillcolor="this" stroked="t" strokecolor="#3366cc" strokeweight="1.5pt"><v:fill opacity="0"/><w10:wrap anchorx="margin"/></v:shape></w:pict></w:r>
|
||
<w:r><w:pict><v:shape style="width:20pt;height:10pt" coordorigin="100,100" coordsize="200,100" path="t100,100r100,0,0,100nfx e" strokecolor="red"><v:stroke opacity="32768f"/></v:shape></w:pict></w:r></w:p>"##;
|
||
let header = format!(r#"<w:hdr {W_NS} {w10}><w:p><w:r><w:t>Header</w:t></w:r></w:p>{rule}</w:hdr>"#);
|
||
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
|
||
let bytes = docx(body, &[("rIdH", "header", "header1.xml")], &[("word/header1.xml", &header)]);
|
||
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
|
||
let check = |d: &Document| {
|
||
let hid = d.last_section.headers.default.unwrap();
|
||
let shapes: Vec<&InlineObject> = d.parts.get(&hid).unwrap().blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).collect();
|
||
let [rule, tri] = shapes.as_slice() else { panic!("{shapes:?}") };
|
||
let InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, freeform: Some(f), .. } = rule else { panic!("{rule:?}") };
|
||
assert_eq!((*kind, fill.as_ref().map(Fill::main_color), *stroke), (ShapeKind::Freeform, None, Some(Rgb(0x33, 0x66, 0xCC))));
|
||
assert!(near(*w, 360.0) && near(*h, 12.0) && near(*stroke_width, 1.5), "{w} x {h}, {stroke_width}");
|
||
assert_eq!((float.h_rel, float.v_rel, float.wrap), (Anchor::Margin, Anchor::Margin, Wrap::InFrontOfText));
|
||
assert!(near(float.y, 30.0), "{float:?}");
|
||
let [p] = f.paths.as_slice() else { panic!("{f:?}") };
|
||
let pts = f.placed(0.0, 0.0, *w, *h);
|
||
let [(pts, _)] = pts.as_slice() else { panic!("{pts:?}") };
|
||
assert!(p.closed && pts.len() == 2, "{pts:?}");
|
||
assert!(near(pts[0][0], 0.0) && near(pts[0][1], 6.0) && near(pts[1][0], 360.0) && near(pts[1][1], 6.0), "{pts:?}");
|
||
// Relative moves and lines from the origin, `nf`: an outlined triangle, half opaque.
|
||
let InlineObject::Shape { kind: ShapeKind::Freeform, fill: None, stroke: Some(Rgb(255, 0, 0)), w, h, freeform: Some(f), .. } = tri else {
|
||
panic!("{tri:?}")
|
||
};
|
||
let placed = f.placed(0.0, 0.0, *w, *h);
|
||
let [(pts, true)] = placed.as_slice() else { panic!("{placed:?}") };
|
||
let want = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]];
|
||
assert!(pts.len() == 3 && pts.iter().zip(want).all(|(a, b)| near(a[0], b[0]) && near(a[1], b[1])), "{pts:?}");
|
||
assert!(near(f.alpha, 0.5), "{}", f.alpha);
|
||
};
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
check(&d);
|
||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||
}
|
||
|
||
/// Hostile VML paths and coordinate spaces read without panicking and stay finite.
|
||
#[test]
|
||
fn hostile_vml_paths_stay_finite() {
|
||
let paths = [
|
||
"m,",
|
||
"l",
|
||
"mxe",
|
||
"m99999999999999999999999999,0l-99999999999999999999,1e",
|
||
"minf,nanlNaN,-inf",
|
||
"m@1,@2l@3,@4nfnse",
|
||
"wa1",
|
||
"c1",
|
||
"m١٢,٣l٤,٥",
|
||
"m0,0c",
|
||
"m0,0v1,2,3",
|
||
"ar0,0,10,10,0,0,10,10at",
|
||
"m0,0r1,1,1,1,1,1,1,1,1,1x x x l",
|
||
"--..,,@@ ,,, xm1.5.5,2..3l+-+,-e",
|
||
"\u{1F600}m1,1l2,2",
|
||
"qbqxqyaeal1,2,3,4,5,6",
|
||
];
|
||
let spaces = [("0,0", "0,0"), ("-5,-5", "x,y"), ("1e39,x", "-99999999999999999999,99999999999999999999"), ("", ""), ("1,1", "1e308,1e308")];
|
||
for path in paths {
|
||
for (size, origin) in spaces {
|
||
let body = format!(
|
||
r#"<w:p><w:r><w:pict><v:shape style="width:1e30pt;height:-4pt" coordsize="{size}" coordorigin="{origin}" path="{path}" strokecolor="black"/></w:pict></w:r></w:p>"#
|
||
);
|
||
let d = read_body(&body);
|
||
// A path with formulas isn't drawn (they aren't evaluated).
|
||
if path.contains('@') {
|
||
assert!(paras(&d)[0].objects.iter().all(|o| !matches!(o, InlineObject::Shape { freeform: Some(_), .. })), "{path}");
|
||
}
|
||
for o in ¶s(&d)[0].objects {
|
||
if let InlineObject::Shape { w, h, freeform: Some(f), .. } = o {
|
||
assert!(w.is_finite() && h.is_finite() && f.alpha.is_finite(), "{path}");
|
||
assert!(f.paths.iter().flat_map(|p| &p.pts).all(|[x, y]| x.is_finite() && y.is_finite()), "{path} {size} {origin}");
|
||
for (pts, _) in f.placed(0.0, 0.0, *w, *h) {
|
||
assert!(pts.iter().all(|[x, y]| x.is_finite() && y.is_finite()), "{path}");
|
||
}
|
||
}
|
||
}
|
||
wordcraft_docx::write(&d).unwrap();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Measured in Word for Mac: a `majorBidi`/`minorBidi` theme slot names the theme's font for the
|
||
/// script of the settings' `w:themeFontLang/@w:bidi` (the Latin font for a Latin language), else
|
||
/// the theme's `a:cs`, else Times New Roman; with no bidi language there, Times New Roman whatever
|
||
/// the theme says. The run's own `w:lang` and the inherited fonts play no part. Papers set in
|
||
/// `majorBidi` with Office's usual theme (`Arab` → Times New Roman) came out in Calibri Light.
|
||
#[test]
|
||
fn bidi_theme_slots_follow_the_theme_font_language() {
|
||
let tnr = "Times New Roman";
|
||
// (themeFontLang, a:cs, Arab entries present, run lang) → (majorBidi, minorBidi)
|
||
let cases: &[(Option<&str>, &str, bool, &str, [&str; 2])] = &[
|
||
(None, "", true, "", [tnr, tnr]),
|
||
(None, "Arial", true, r#"<w:lang w:bidi="ar-SA"/>"#, [tnr, tnr]),
|
||
(Some(r#"w:val="en-US""#), "Arial", true, "", [tnr, tnr]),
|
||
(Some(r#"w:bidi="ar-SA""#), "", true, r#"<w:lang w:bidi="he-IL"/>"#, ["Georgia", "Trebuchet MS"]),
|
||
(Some(r#"w:bidi="ar-SA""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
|
||
(Some(r#"w:bidi="he-IL""#), "", true, "", ["Courier New", "Comic Sans MS"]),
|
||
(Some(r#"w:bidi="ur-PK""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
|
||
(Some(r#"w:bidi="pa-PK""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
|
||
(Some(r#"w:bidi="pa-IN""#), "Arial", true, "", ["Arial", "Arial"]),
|
||
(Some(r#"w:bidi="fr-FR""#), "Arial", true, "", ["Verdana", "Impact"]),
|
||
(Some(r#"w:bidi="th-TH""#), "Arial", true, "", ["Arial", "Arial"]),
|
||
(Some(r#"w:bidi="th-TH""#), "", true, "", [tnr, tnr]),
|
||
(Some(r#"w:bidi="ar-SA""#), "Arial", false, "", ["Arial", "Arial"]),
|
||
];
|
||
for (i, (lang, cs, arab, run_lang, want)) in cases.iter().enumerate() {
|
||
let script = |f: &str| if *arab { format!(r#"<a:font script="Arab" typeface="{f}"/>"#) } else { String::new() };
|
||
let theme = format!(
|
||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fontScheme name="F"><a:majorFont><a:latin typeface="Verdana"/><a:ea typeface=""/><a:cs typeface="{cs}"/>{}<a:font script="Hebr" typeface="Courier New"/></a:majorFont><a:minorFont><a:latin typeface="Impact"/><a:ea typeface=""/><a:cs typeface="{cs}"/>{}<a:font script="Hebr" typeface="Comic Sans MS"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#,
|
||
script("Georgia"),
|
||
script("Trebuchet MS")
|
||
);
|
||
let font_lang = lang.map(|l| format!("<w:themeFontLang {l}/>")).unwrap_or_default();
|
||
let settings = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}>{font_lang}</w:settings>"#);
|
||
let styles = format!(
|
||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles {W_NS}><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Andale Mono" w:hAnsi="Andale Mono" w:cs="Andale Mono"/><w:lang w:val="en-US" w:bidi="ar-SA"/></w:rPr></w:rPrDefault></w:docDefaults></w:styles>"#
|
||
);
|
||
let run = |slot: &str| {
|
||
format!(r#"<w:p><w:r><w:rPr><w:rFonts w:asciiTheme="{slot}" w:hAnsiTheme="{slot}"/>{run_lang}</w:rPr><w:t>x</w:t></w:r></w:p>"#)
|
||
};
|
||
let body = [run("majorBidi"), run("minorBidi"), run("majorHAnsi")].concat();
|
||
let rels = [
|
||
("rId7", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml"),
|
||
("rId8", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", "styles.xml"),
|
||
("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "theme/theme1.xml"),
|
||
];
|
||
let parts = [("word/theme/theme1.xml", theme.as_str()), ("word/settings.xml", settings.as_str()), ("word/styles.xml", styles.as_str())];
|
||
let d = wordcraft_docx::read(&docx(&body, &rels, &parts)).unwrap();
|
||
let fonts: Vec<_> = paras(&d).iter().map(|p| p.runs[0].props.font.clone().unwrap_or_default()).collect();
|
||
assert_eq!(fonts, [want[0], want[1], "Verdana"], "case {i}");
|
||
}
|
||
}
|
||
|
||
/// The font table's family classes: Word draws a missing roman font in a serif face.
|
||
#[test]
|
||
fn roman_fonts_are_read_from_the_font_table() {
|
||
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Brand Serif"><w:family w:val="roman"/></w:font><w:font w:name="Brand Sans"><w:family w:val="swiss"/></w:font></w:fonts>"#;
|
||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
|
||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
|
||
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
|
||
}
|
||
|
||
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
|
||
#[test]
|
||
fn html_auto_spacing_off_is_read_and_kept() {
|
||
let settings = |compat: &str| {
|
||
let s = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}><w:compat>{compat}</w:compat></w:settings>"#);
|
||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml")];
|
||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/settings.xml", &s)])).unwrap();
|
||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
(d.settings.sum_paragraph_spacing, again.settings.sum_paragraph_spacing)
|
||
};
|
||
assert_eq!(settings("<w:doNotUseHTMLParagraphAutoSpacing/>"), (true, true));
|
||
assert_eq!(settings(""), (false, false));
|
||
}
|
||
|
||
/// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white
|
||
/// corner cut-outs drawn as isosceles triangles pointed the wrong way.
|
||
#[test]
|
||
fn a_right_triangle_keeps_its_right_angle() {
|
||
let body = anchored_wsp("Corner", 1270000, 635000, r#"<a:prstGeom prst="rtTriangle"><a:avLst/></a:prstGeom>"#, "", "<wps:cNvSpPr/>");
|
||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||
let kind = |d: &Document| match ¶s(d)[0].objects[0] {
|
||
InlineObject::Shape { kind, .. } => *kind,
|
||
o => panic!("{o:?}"),
|
||
};
|
||
assert_eq!(kind(&d), ShapeKind::RightTriangle);
|
||
let saved = wordcraft_docx::write(&d).unwrap();
|
||
assert_eq!(kind(&wordcraft_docx::read(&saved).unwrap()), ShapeKind::RightTriangle);
|
||
}
|
||
|
||
#[test]
|
||
fn hyperlink_with_empty_target_is_no_link() {
|
||
// LibreOffice writes `Target=""` for the Hyperlink character style without an address (#1005).
|
||
let body = r#"<w:p><w:hyperlink r:id="rId2"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>plain</w:t></w:r></w:hyperlink><w:hyperlink r:id="rId3"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>mail</w:t></w:r></w:hyperlink><w:hyperlink w:anchor="top"><w:r><w:t>jump</w:t></w:r></w:hyperlink></w:p>"#;
|
||
let bytes = docx(body, &[("rId2", "hyperlink", "https://x"), ("rId3", "hyperlink", "mailto:a@example.com")], &[]);
|
||
// Blank the first target and mark the mailto one external (the helper only does that for http).
|
||
let bytes = {
|
||
let mut zr = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||
let mut entries = Vec::new();
|
||
for i in 0..zr.len() {
|
||
let mut f = zr.by_index(i).unwrap();
|
||
let mut buf = Vec::new();
|
||
std::io::Read::read_to_end(&mut f, &mut buf).unwrap();
|
||
if f.name() == "word/_rels/document.xml.rels" {
|
||
buf = String::from_utf8(buf)
|
||
.unwrap()
|
||
.replace("Target=\"https://x\"", "Target=\"\"")
|
||
.replace("a@example.com\"", "a@example.com\" TargetMode=\"External\"")
|
||
.into_bytes();
|
||
}
|
||
entries.push((f.name().to_string(), buf));
|
||
}
|
||
let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
|
||
zip(&refs)
|
||
};
|
||
let doc = wordcraft_docx::read(&bytes).unwrap();
|
||
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||
assert_eq!(p.plain_text(), "plainmailjump");
|
||
assert_eq!(p.props_of_char(0).link, None);
|
||
assert_eq!(p.props_of_char(0).style.as_deref(), Some("Hyperlink"));
|
||
assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com"));
|
||
assert_eq!(p.props_of_char(9).link.as_deref(), Some("#top"));
|
||
// Round trip keeps exactly the real links.
|
||
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||
let p = back.body.iter().find_map(|b| b.as_para()).unwrap();
|
||
assert_eq!(p.props_of_char(0).link, None);
|
||
assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com"));
|
||
}
|
||
|
||
#[test]
|
||
fn row_table_borders_round_trip() {
|
||
let d = read_body(
|
||
r#"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
|
||
<w:tr><w:tblPrEx><w:tblBorders><w:top w:val="single" w:sz="8"/><w:insideV w:val="single" w:sz="4"/></w:tblBorders></w:tblPrEx>
|
||
<w:trPr><w:trHeight w:val="400"/></w:trPr><w:tc><w:p/></w:tc><w:tc><w:p/></w:tc></w:tr>
|
||
<w:tr><w:tc><w:p/></w:tc><w:tc><w:p/></w:tc></w:tr></w:tbl><w:p/>"#,
|
||
);
|
||
let saved = wordcraft_docx::write(&d).unwrap();
|
||
let xml = text(&unzip(&saved), "word/document.xml");
|
||
assert!(xml.contains("<w:tblPrEx>"), "row table borders were dropped: {xml}");
|
||
assert!(xml.contains("<w:insideV"));
|
||
let again = wordcraft_docx::read(&saved).unwrap();
|
||
assert_eq!(d.body, again.body);
|
||
}
|
||
|
||
#[test]
|
||
fn field_result_keeps_run_props() {
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:sz w:val="8"/></w:rPr></w:style><w:style w:type="character" w:styleId="Result"><w:name w:val="Result"/><w:rPr><w:sz w:val="16"/><w:b/></w:rPr></w:style></w:styles>"#
|
||
);
|
||
for result_pr in [r#"<w:sz w:val="16"/><w:b/>"#, r#"<w:rStyle w:val="Result"/>"#, ""] {
|
||
for simple in [false, true] {
|
||
let result = format!(r#"<w:r><w:rPr>{result_pr}</w:rPr><w:t>7</w:t></w:r>"#);
|
||
let field = if simple {
|
||
format!(r#"<w:fldSimple w:instr="PAGE" w:fldLock="1" w:dirty="1">{result}</w:fldSimple>"#)
|
||
} else {
|
||
format!(
|
||
r#"<w:r><w:rPr><w:sz w:val="6"/><w:i/></w:rPr><w:fldChar w:fldCharType="begin" w:fldLock="1" w:dirty="1"/></w:r><w:r><w:instrText>PAGE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr><w:sz w:val="40"/></w:rPr><w:t/></w:r>{result}<w:r><w:rPr><w:sz w:val="50"/></w:rPr><w:fldChar w:fldCharType="end"/></w:r>"#
|
||
)
|
||
};
|
||
let body = format!(r#"<w:p><w:r><w:rPr><w:sz w:val="16"/></w:rPr><w:t>Counter </w:t></w:r>{field}</w:p>"#);
|
||
let mut d = wordcraft_docx::read(&docx(&body, &[("styles", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||
for _ in 0..2 {
|
||
let p = paras(&d)[0];
|
||
let props = p.props_of_char("Counter ".len());
|
||
assert_eq!(p.props_at(0).size, Some(8.0));
|
||
assert_eq!(props.size, result_pr.starts_with("<w:sz").then_some(8.0), "simple={simple}, result={result_pr}");
|
||
assert_eq!(props.style.as_deref(), result_pr.starts_with("<w:rStyle").then_some("Result"));
|
||
assert_eq!(props.italic, None);
|
||
assert!(
|
||
matches!(&p.objects[0], InlineObject::Field { instr, result, locked: true, dirty: true, .. } if instr == "PAGE" && result == "7")
|
||
);
|
||
d = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn empty_field_keeps_begin_props() {
|
||
let d = read_body(
|
||
r#"<w:p><w:r><w:rPr><w:sz w:val="12"/><w:i/></w:rPr><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>PAGE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr><w:sz w:val="40"/></w:rPr><w:t/></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
|
||
);
|
||
let p = paras(&d)[0];
|
||
assert_eq!(p.props_at(0).size, Some(6.0));
|
||
assert_eq!(p.props_at(0).italic, Some(true));
|
||
assert!(matches!(&p.objects[0], InlineObject::Field { result, .. } if result.is_empty()));
|
||
}
|
||
|
||
#[test]
|
||
fn shape_dashes_import_and_save() {
|
||
use wordcraft_doc::line_dash::LineDash;
|
||
for (xml, expected) in [
|
||
(r#"<a:prstDash val="dash"/>"#, LineDash::Dash),
|
||
(r#"<a:prstDash val="sysDashDot"/>"#, LineDash::SystemDashDot),
|
||
(
|
||
r#"<a:custDash><a:ds d="400000" sp="300000"/><a:ds d="100000" sp="300000"/></a:custDash>"#,
|
||
LineDash::Custom(vec![(400_000, 300_000), (100_000, 300_000)]),
|
||
),
|
||
(r#"<a:custDash><a:ds d="400%" sp="300%"/></a:custDash>"#, LineDash::Custom(vec![(400_000, 300_000)])),
|
||
(r#"<a:custDash><a:ds d="NaN%" sp="-1"/></a:custDash>"#, LineDash::Solid),
|
||
(r#"<a:prstDash val="solid"/>"#, LineDash::Solid),
|
||
("", LineDash::Solid),
|
||
] {
|
||
let sppr = format!(r#"<a:prstGeom prst="rect"/><a:noFill/><a:ln w="12700"><a:solidFill><a:srgbClr val="C00000"/></a:solidFill>{xml}</a:ln>"#);
|
||
let body = anchored_wsp("Annotation", 889000, 355600, &sppr, "", "<wps:cNvSpPr/>");
|
||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
for doc in [&d, &saved] {
|
||
let InlineObject::Shape { w, h, stroke, stroke_width, extra, .. } = ¶s(doc)[0].objects[0] else { panic!("shape") };
|
||
assert_eq!(extra.dash, expected);
|
||
assert_eq!((*w, *h, *stroke, *stroke_width), (70.0, 28.0, Some(Rgb(192, 0, 0)), 1.0));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bookmark_end_name_and_id_trim() {
|
||
let body = r#"<w:p>
|
||
<w:bookmarkStart w:id=" 42 " w:name="section_intro"/>
|
||
<w:r><w:t>Hello</w:t></w:r>
|
||
<w:bookmarkEnd w:id="42"/>
|
||
<w:bookmarkEnd w:id="999" w:name="standalone_target"/>
|
||
</w:p>"#;
|
||
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||
let p = doc.body.first().unwrap().as_para().unwrap();
|
||
let names: Vec<(&'static str, String)> = p
|
||
.objects
|
||
.iter()
|
||
.filter_map(|o| match o {
|
||
InlineObject::BookmarkStart { name } => Some(("start", name.clone())),
|
||
InlineObject::BookmarkEnd { name } => Some(("end", name.clone())),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(names, vec![("start", "section_intro".into()), ("end", "section_intro".into()), ("end", "standalone_target".into())]);
|
||
}
|
||
|
||
#[test]
|
||
fn zero_effects_stay_zero_rotated() {
|
||
const ANGLE_UNITS_PER_DEGREE: i32 = 60_000;
|
||
|
||
// ECMA-376 §20.4.2.6: zero effect extents add no space around a rotated picture.
|
||
for degrees in [0, 90, 180, 270] {
|
||
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
|
||
let body = format!(
|
||
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="914400" cy="457200"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="{}"><a:off x="0" y="0"/><a:ext cx="914400" cy="457200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#,
|
||
degrees * ANGLE_UNITS_PER_DEGREE
|
||
);
|
||
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
|
||
let (w, h, float) = paras(&d)[0].objects[0].frame().unwrap();
|
||
assert_eq!(float.rot, degrees as f32);
|
||
assert_eq!(float.effect, [0.0; 4], "rotation {degrees} must not invent effects");
|
||
let (px, py) = float.spin_pad(w, h);
|
||
let expected = if degrees % 180 == 0 { (w, h) } else { (h, w) };
|
||
assert!((w + 2.0 * px - expected.0).abs() < 0.001);
|
||
assert!((h + 2.0 * py - expected.1).abs() < 0.001);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The math namespace, declared on the math element itself (the fixture root doesn't).
|
||
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
|
||
|
||
fn equations(p: &Paragraph) -> Vec<(String, bool)> {
|
||
p.objects
|
||
.iter()
|
||
.filter_map(|o| match o {
|
||
InlineObject::Equation { linear, display, .. } => Some((linear.clone(), *display)),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// #1273: an equation straight in the body (EG_BlockLevelElts allows math content) gets a
|
||
/// paragraph of its own instead of vanishing.
|
||
#[test]
|
||
fn body_level_equation_is_kept() {
|
||
let d = read_body(&format!(
|
||
r#"<w:p><w:r><w:t>before</w:t></w:r></w:p><m:oMathPara {M_NS}><m:oMath><m:r><m:t>a=b</m:t></m:r></m:oMath></m:oMathPara><m:oMath {M_NS}><m:r><m:t>c</m:t></m:r></m:oMath><w:p><w:r><w:t>after</w:t></w:r></w:p>"#
|
||
));
|
||
let ps = paras(&d);
|
||
assert_eq!(ps.len(), 4, "{:?}", ps.iter().map(|p| p.plain_text()).collect::<Vec<_>>());
|
||
assert_eq!(equations(ps[1]), vec![("a=b".to_string(), true)]);
|
||
assert_eq!(equations(ps[2]), vec![("c".to_string(), false)]);
|
||
}
|
||
|
||
/// #1273: an equation guarded by markup compatibility (`Requires="m"`) is read as an equation,
|
||
/// not replaced by its fallback.
|
||
#[test]
|
||
fn equation_in_alternate_content_is_read() {
|
||
let d = read_body(&format!(
|
||
r#"<w:p><mc:AlternateContent {M_NS}><mc:Choice Requires="m"><m:oMath><m:r><m:t>x+1</m:t></m:r></m:oMath></mc:Choice><mc:Fallback><w:r><w:t>[picture]</w:t></w:r></mc:Fallback></mc:AlternateContent></w:p>"#
|
||
));
|
||
let p = paras(&d)[0];
|
||
assert_eq!(equations(p), vec![("x+1".to_string(), false)]);
|
||
assert!(!p.plain_text().contains("[picture]"));
|
||
}
|
||
|
||
/// #1500: gradient fills and transparency on shapes (the cases in the issue), from the shape's
|
||
/// own properties, the theme's fill styles (`a:fillRef` 3 and 1003, as Word's cover pages use),
|
||
/// a group's fill (`a:grpFill`), an outline gradient and VML's `v:fill`. Each is read as the
|
||
/// file states it and survives saving.
|
||
#[test]
|
||
fn shape_gradients_and_transparency_are_read_and_survive_saving() {
|
||
use std::sync::Arc;
|
||
use wordcraft_doc::fill::{Gradient, GradientFlip, GradientKind, PathShape};
|
||
use wordcraft_doc::wordart::GradStop;
|
||
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fmtScheme name="T"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="50000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill></a:fillStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:srgbClr val="000000"/></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>"#;
|
||
let rect = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
|
||
let wsp = |sppr: &str, style: &str| anchored_wsp("S", 914400, 914400, &format!("{rect}{sppr}"), style, "<wps:cNvSpPr/>");
|
||
let fill_ref = |idx: u32| {
|
||
format!(
|
||
r#"<wps:style><a:lnRef idx="0"><a:srgbClr val="4080C0"/></a:lnRef><a:fillRef idx="{idx}"><a:srgbClr val="4080C0"/></a:fillRef><a:effectRef idx="0"><a:srgbClr val="4080C0"/></a:effectRef><a:fontRef idx="minor"/></wps:style>"#
|
||
)
|
||
};
|
||
let gs = |pos: u32, c: &str| format!(r#"<a:gs pos="{pos}"><a:srgbClr val="{c}"/></a:gs>"#);
|
||
let shapes = [
|
||
// A: 30%-opaque white.
|
||
wsp(r#"<a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="30000"/></a:srgbClr></a:solidFill>"#, ""),
|
||
// B: two stops top to bottom, the second 40% opaque.
|
||
wsp(&format!(r#"<a:gradFill rotWithShape="1"><a:gsLst>{}<a:gs pos="100000"><a:srgbClr val="0000FF"><a:alpha val="40000"/></a:srgbClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="1"/></a:gradFill>"#, gs(0, "FF0000")), ""),
|
||
// C: three stops at 45°.
|
||
wsp(&format!(r#"<a:gradFill><a:gsLst>{}{}{}</a:gsLst><a:lin ang="2700000" scaled="0"/></a:gradFill>"#, gs(0, "FF0000"), gs(50000, "00FF00"), gs(100000, "0000FF")), ""),
|
||
// D: circular from the centre.
|
||
wsp(&format!(r#"<a:gradFill rotWithShape="1"><a:gsLst>{}{}</a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill>"#, gs(0, "FFFFFF"), gs(100000, "000000")), ""),
|
||
// E: rectangular from a corner, flipped and tiled, not turning with the shape.
|
||
wsp(&format!(r#"<a:gradFill flip="xy" rotWithShape="0"><a:gsLst>{}{}</a:gsLst><a:path path="rect"><a:fillToRect l="100000" t="100000" r="0" b="0"/></a:path><a:tileRect l="-100000" t="-100000" r="0" b="0"/></a:gradFill>"#, gs(0, "FFFFFF"), gs(100000, "000000")), ""),
|
||
// F, G: the theme's fill style 3 and background style 3 in the reference's colour.
|
||
wsp("", &fill_ref(3)),
|
||
wsp("", &fill_ref(1003)),
|
||
// H: an outline gradient.
|
||
wsp(&format!(r#"<a:noFill/><a:ln w="12700"><a:gradFill><a:gsLst>{}{}{}</a:gsLst><a:lin ang="0" scaled="0"/></a:gradFill></a:ln>"#, gs(0, "FF0000"), gs(50000, "00FF00"), gs(100000, "0000FF")), ""),
|
||
]
|
||
.concat();
|
||
let member = |id: u32, paint: &str| {
|
||
format!(
|
||
r#"<wps:wsp><wps:cNvPr id="{id}" name="M{id}"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm>{rect}{paint}</wps:spPr><wps:bodyPr/></wps:wsp>"#
|
||
)
|
||
};
|
||
// I: a group with a gradient fill; one member takes it (`a:grpFill`), one has its own, and
|
||
// one in a nested group without a fill of its own takes the outer group's.
|
||
let group = format!(
|
||
r#"<w:r><w:drawing><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="9" name="G"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="914400" cy="914400"/></a:xfrm><a:gradFill><a:gsLst>{}{}</a:gsLst><a:lin ang="0" scaled="0"/></a:gradFill></wpg:grpSpPr>{}{}<wpg:grpSp><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="914400" cy="914400"/></a:xfrm></wpg:grpSpPr>{}</wpg:grpSp></wpg:wgp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#,
|
||
gs(0, "112233"),
|
||
gs(100000, "445566"),
|
||
member(10, "<a:grpFill/>"),
|
||
member(11, r#"<a:solidFill><a:srgbClr val="00FF00"/></a:solidFill>"#),
|
||
member(12, "<a:grpFill/>")
|
||
);
|
||
// VML: a linear gradient to `color2`, half opaque.
|
||
let vml = r##"<w:r><w:pict><v:rect style="width:72pt;height:72pt" fillcolor="#ff0000" stroked="f"><v:fill type="gradient" color2="#0000ff" opacity="0.5"/></v:rect></w:pict></w:r>"##;
|
||
let bytes = docx(&format!("<w:p>{shapes}{group}{vml}</w:p>"), &[("rId2", "theme", "theme/theme1.xml")], &[("word/theme/theme1.xml", theme)]);
|
||
|
||
let stop = |pos: f32, c: Rgb, transparency: f32| GradStop { pos, color: c, transparency };
|
||
let (red, green, blue, white, black) = (Rgb(255, 0, 0), Rgb(0, 255, 0), Rgb(0, 0, 255), Rgb::WHITE, Rgb::BLACK);
|
||
let grad = |stops: Vec<GradStop>, kind: GradientKind, rot: bool| {
|
||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: rot, flip: GradientFlip::None }))
|
||
};
|
||
let lin = |angle: f32, scaled: bool| GradientKind::Linear { angle, scaled };
|
||
let centre = GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] };
|
||
let ph = Rgb(0x40, 0x80, 0xC0);
|
||
let expected = [
|
||
Some(Fill::Solid { color: white, transparency: 70.0 }),
|
||
Some(grad(vec![stop(0.0, red, 0.0), stop(100.0, blue, 60.0)], lin(90.0, true), true)),
|
||
Some(grad(vec![stop(0.0, red, 0.0), stop(50.0, green, 0.0), stop(100.0, blue, 0.0)], lin(45.0, false), false)),
|
||
Some(grad(vec![stop(0.0, white, 0.0), stop(100.0, black, 0.0)], centre, true)),
|
||
Some(Fill::Gradient(Arc::new(Gradient {
|
||
stops: vec![stop(0.0, white, 0.0), stop(100.0, black, 0.0)],
|
||
kind: GradientKind::Path { shape: PathShape::Rect, focus: [100.0, 100.0, 0.0, 0.0] },
|
||
tile: Some([-100.0, -100.0, 0.0, 0.0]),
|
||
rot_with_shape: false,
|
||
flip: GradientFlip::Xy,
|
||
}))),
|
||
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, Rgb(0x20, 0x40, 0x60), 0.0)], lin(90.0, false), true)),
|
||
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, black, 0.0)], centre, true)),
|
||
None,
|
||
];
|
||
let check = |d: &Document| {
|
||
let objs = ¶s(d)[0].objects;
|
||
assert_eq!(objs.len(), expected.len() + 2);
|
||
for (i, want) in expected.iter().enumerate() {
|
||
let InlineObject::Shape { fill, .. } = &objs[i] else { panic!("{:?}", objs[i]) };
|
||
assert_eq!(fill, want, "shape {i}");
|
||
}
|
||
let InlineObject::Shape { stroke, extra, .. } = &objs[7] else { panic!() };
|
||
let outline = grad(vec![stop(0.0, red, 0.0), stop(50.0, green, 0.0), stop(100.0, blue, 0.0)], lin(0.0, false), false);
|
||
assert_eq!((*stroke, extra.outline_fill.as_ref()), (Some(green), Some(&outline)), "outline drawn in its middle stop");
|
||
let InlineObject::Group { children, .. } = &objs[8] else { panic!("{:?}", objs[8]) };
|
||
let fills: Vec<_> = children.iter().map(|c| if let InlineObject::Shape { fill, .. } = &c.obj { fill.clone() } else { None }).collect();
|
||
let group_fill = grad(vec![stop(0.0, Rgb(0x11, 0x22, 0x33), 0.0), stop(100.0, Rgb(0x44, 0x55, 0x66), 0.0)], lin(0.0, false), false);
|
||
assert_eq!(fills, [Some(group_fill.clone()), Some(Fill::solid(green)), Some(group_fill)], "a:grpFill takes the group's fill");
|
||
let InlineObject::Shape { fill, .. } = &objs[9] else { panic!("{:?}", objs[9]) };
|
||
assert_eq!(fill.as_ref(), Some(&grad(vec![stop(0.0, red, 50.0), stop(100.0, blue, 0.0)], lin(90.0, false), true)), "VML gradient");
|
||
};
|
||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||
check(&d);
|
||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||
}
|
||
|
||
/// #3415: a table style's row and column band sizes (`w:tblStyleRowBandSize`,
|
||
/// `w:tblStyleColBandSize`, ECMA-376 §17.7.6.5 and §17.7.6.7) are read, zero included, and left
|
||
/// unset when the file has none, which is how Word reads it: no bands. A style based on one with
|
||
/// sizes inherits them.
|
||
#[test]
|
||
fn table_style_band_sizes_read_as_word_reads_them() {
|
||
let band = r#"<w:tblStylePr w:type="band1Horz"><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="C1E4F5"/></w:tcPr></w:tblStylePr>"#;
|
||
let styles = format!(
|
||
r#"<w:styles {W_NS}>
|
||
<w:style w:type="table" w:default="1" w:styleId="TableNormal"><w:name w:val="Normal Table"/></w:style>
|
||
<w:style w:type="table" w:styleId="Missing"><w:name w:val="Missing"/><w:basedOn w:val="TableNormal"/>{band}</w:style>
|
||
<w:style w:type="table" w:styleId="One"><w:name w:val="One"/><w:basedOn w:val="TableNormal"/><w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/></w:tblPr>{band}</w:style>
|
||
<w:style w:type="table" w:styleId="Zero"><w:name w:val="Zero"/><w:basedOn w:val="One"/><w:tblPr><w:tblStyleRowBandSize w:val="0"/></w:tblPr></w:style>
|
||
<w:style w:type="table" w:styleId="Child"><w:name w:val="Child"/><w:basedOn w:val="One"/>{band}</w:style>
|
||
</w:styles>"#
|
||
);
|
||
let d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||
let sizes = |id: &str| d.styles.table_style(id).map(|t| (t.parts.band_size, t.parts.col_band_size));
|
||
assert_eq!(sizes("Missing"), Some((None, None)), "no sizes: no bands, as in Word");
|
||
assert_eq!(sizes("One"), Some((Some(1), Some(1))));
|
||
assert_eq!(sizes("Zero"), Some((Some(0), Some(1))));
|
||
assert_eq!(sizes("Child"), Some((Some(1), Some(1))), "inherited");
|
||
// Written back as read: still no sizes where there were none.
|
||
let xml = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/styles.xml");
|
||
let style = |id: &str| {
|
||
let start = xml.find(&format!(r#"w:styleId="{id}""#)).unwrap();
|
||
xml[start..start + xml[start..].find("</w:style>").unwrap()].to_string()
|
||
};
|
||
assert!(!style("Missing").contains("BandSize"), "{}", style("Missing"));
|
||
assert!(style("One").contains(r#"<w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/>"#), "{}", style("One"));
|
||
assert!(style("Zero").contains(r#"<w:tblStyleRowBandSize w:val="0"/>"#), "{}", style("Zero"));
|
||
}
|
||
|
||
/// #3415: a document saved with one of our banded table styles tells Word its band sizes, so Word
|
||
/// shades the same rows we do (it reads a style without them as having no bands).
|
||
#[test]
|
||
fn our_banded_table_styles_write_their_band_sizes() {
|
||
let d = Document::new();
|
||
let xml = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/styles.xml");
|
||
let start = xml.find(r#"w:styleId="GridTable4AccentBlue""#).expect("Grid Table 4 – Blue is written");
|
||
let style = &xml[start..start + xml[start..].find("</w:style>").unwrap()];
|
||
assert!(style.contains(r#"<w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/>"#), "{style}");
|
||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||
assert_eq!(back.styles.table_style("GridTable4AccentBlue").map(|t| t.parts.band_size), Some(Some(1)));
|
||
}
|
||
|
||
#[test]
|
||
fn vml_shape_wrap_side_and_anchor_case_insensitivity() {
|
||
let body = r##"<w:p>
|
||
<w:r>
|
||
<w:pict xmlns:w10="urn:schemas-microsoft-com:office:word">
|
||
<v:rect style="position:absolute;width:100pt;height:50pt;mso-position-horizontal-relative:Page;mso-position-vertical-relative:Margin" filled="False" stroked="t">
|
||
<w10:wrap type="Square" side="left"/>
|
||
</v:rect>
|
||
</w:pict>
|
||
</w:r>
|
||
</w:p>"##;
|
||
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||
let shape = match p.objects.first() {
|
||
Some(InlineObject::Shape { fill, float, .. }) => (fill, float),
|
||
o => panic!("expected shape, got {o:?}"),
|
||
};
|
||
assert_eq!(*shape.0, None);
|
||
assert_eq!(shape.1.wrap, wordcraft_doc::para::Wrap::Square);
|
||
assert_eq!(shape.1.wrap_text, wordcraft_doc::para::WrapText::Left);
|
||
assert_eq!(shape.1.h_rel, wordcraft_doc::para::Anchor::Page);
|
||
assert_eq!(shape.1.v_rel, wordcraft_doc::para::Anchor::Margin);
|
||
}
|
||
|
||
#[test]
|
||
fn vml_textbox_style_anchor_and_fit() {
|
||
let body = r#"<w:p>
|
||
<w:r>
|
||
<w:pict>
|
||
<v:rect style="width:100pt;height:50pt">
|
||
<v:textbox style="v-text-anchor:Middle;mso-fit-shape-to-text:True">
|
||
<w:txbxContent>
|
||
<w:p><w:r><w:t>Centered text</w:t></w:r></w:p>
|
||
</w:txbxContent>
|
||
</v:textbox>
|
||
</v:rect>
|
||
</w:pict>
|
||
</w:r>
|
||
</w:p>"#;
|
||
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||
let p = doc.body.first().unwrap().as_para().unwrap();
|
||
let story_id = match p.objects.first() {
|
||
Some(InlineObject::Shape { story: Some(id), .. }) => *id,
|
||
o => panic!("expected shape with story, got {o:?}"),
|
||
};
|
||
let tb = doc.parts.get(&story_id).unwrap();
|
||
assert_eq!(tb.body.anchor, wordcraft_doc::props::VAlign::Center);
|
||
assert!(tb.body.auto_fit);
|
||
}
|