Place: Word 97-2003 .doc files place as area type with their formatting

File > Place reads a Word 97-2003 document (a compound file named .doc,
or one that holds a WordDocument stream) and sets its main text as area
type, as it does for .rtf and .docx files. The text comes from the
piece table, in 16-bit Unicode or compressed 8-bit pieces. Formatting
is resolved from the style sheet's default font, the paragraph style,
the character style and the direct character and paragraph properties,
and keeps the same properties the .docx reader maps.

Table cells are separated by tabs and each row is a paragraph, and a
field places as its result. Hidden and deleted text, pictures, headers,
footers, notes, comments and text boxes are left out, and the reply's
warnings name each kind of content left out or changed once. Word 6.0
and Word 95 documents, encrypted documents and documents last saved
with fast save are refused, and so are structures that lie outside
their streams.

The compound file is read with the cfb crate (MIT), which adds one
package to Cargo.lock.
This commit is contained in:
dotCooCoo
2026-11-01 07:16:29 +07:00
committed by bflatastic
parent 859b3732c1
commit 5fdccdf602
11 changed files with 1734 additions and 15 deletions
+3 -2
View File
@@ -124,8 +124,9 @@ pub(crate) use export_names::{default_name, merge, unique_file_names, with_black
use export_names::{distinct_names, write_encoded};
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, writable};
pub use formats::{
DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, file_stem,
format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export, writable_format,
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension,
file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export,
writable_format,
};
#[cfg(test)]
+11 -4
View File
@@ -566,8 +566,14 @@ pub const RTF_EXTS: &[&str] = &["rtf"];
/// Word documents: File → Place sets them as area type with their formatting.
pub const DOCX_EXTS: &[&str] = &["docx"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and
/// [`DOCX_EXTS`].
/// Word 97-2003 documents: File → Place sets them as area type with their formatting.
pub const DOC_EXTS: &[&str] = &["doc"];
/// The Place dialog's Word filter: Word documents and Word 97-2003 documents.
const WORD_EXTS: &[&str] = &["docx", "doc"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`],
/// [`DOCX_EXTS`] and [`DOC_EXTS`].
pub const PLACE_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
@@ -610,6 +616,7 @@ pub const PLACE_EXTS: &[&str] = &[
"txt",
"rtf",
"docx",
"doc",
];
/// One dialog filter per readable format.
@@ -645,13 +652,13 @@ pub fn library_open_filters(
}
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
/// RTF and Word.
/// RTF and Word (`.docx` and `.doc`).
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All placeable files", PLACE_EXTS))
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
.chain(std::iter::once(("Text", TEXT_EXTS)))
.chain(std::iter::once(("RTF", RTF_EXTS)))
.chain(std::iter::once(("Word", DOCX_EXTS)))
.chain(std::iter::once(("Word", WORD_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in
+12
View File
@@ -20,6 +20,8 @@
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
//! - A Word document (`.docx`) becomes area type with the character and paragraph formatting its
//! styles and direct formatting give ([`docx`]).
//! - A Word 97-2003 document (`.doc`) becomes area type the same way, its main text read from its
//! compound file, with a warning for each kind of content left out ([`doc`]).
//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document:
//! one locked object showing the artboard `page`, linked to the file and read again when it
//! changes ([`document`]); without Link, an editable copy of its art.
@@ -30,6 +32,7 @@
//! `place` tool) with several files, which emits one `file.place` per click or drag.
pub(crate) mod adopt;
mod doc;
pub(crate) mod document;
pub(crate) mod docx;
mod rtf;
@@ -169,6 +172,15 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
}
return Ok(Loaded { name, format: &docx::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
}
// A Word 97-2003 document is recognized by its name or by its compound file's WordDocument
// stream.
if doc::is_doc(src.name, &src.bytes) {
let (story, warnings) = doc::import(&src.bytes, cmd)?;
if story.plain_text().trim().is_empty() {
return Err(bad(cmd, format!("`{name}` has no text to place")));
}
return Ok(Loaded { name, format: &doc::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings, link: None, board: None });
}
let format = fileio::detect(src.name, &src.bytes)
.ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?;
let page = match p.get("page") {
File diff suppressed because it is too large Load Diff
+358 -5
View File
@@ -467,15 +467,368 @@ fn malformed_docx_fails_cleanly() {
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
}
/// The bytes of the Prls `prls`: each sprm and its operand.
fn prls(prls: &[(u16, &[u8])]) -> Vec<u8> {
prls.iter().flat_map(|(sprm, op)| sprm.to_le_bytes().into_iter().chain(op.iter().copied())).collect()
}
/// A style of a [`Word97`] document: its kind (1 paragraph, 2 character), the style it is based
/// on, and its paragraph and character properties.
type Word97Style = (u16, u16, Vec<u8>, Vec<u8>);
/// A Word 97-2003 document, built as \[MS-DOC\] lays one out: a file information block, the main
/// text's pieces stored last first, one property page per run of up to 16 character or paragraph
/// property runs, the piece table, style sheet and font table in the table stream, all in a
/// compound file.
#[derive(Default)]
struct Word97 {
/// The main text's pieces: their text and whether it is stored compressed.
pieces: Vec<(&'static str, bool)>,
/// Character properties of characters `start..end`.
chpx: Vec<(u32, u32, Vec<u8>)>,
/// The paragraph style and properties of the paragraph whose mark is character `cp`; a
/// character's paragraph is the first one whose mark is at or after it.
papx: Vec<(u32, u16, Vec<u8>)>,
/// The styles by istd; None: an empty slot.
styles: Vec<Option<Word97Style>>,
fonts: Vec<&'static str>,
/// FibBase's flags and `nFib` (0: 0x00C1).
flags: u16,
nfib: u16,
/// The table stream is `0Table` (else `1Table`).
zero_table: bool,
/// `ccpFtn`, `ccpHdd`, `ccpAtn`, `ccpEdn`, `ccpTxbx` and `ccpHdrTxbx`.
stories: [u32; 6],
/// Characters `ccpText` gives beyond the pieces.
ccp_extra: u32,
/// A Clx to write instead of the pieces'.
clx: Option<Vec<u8>>,
}
/// One 512-byte property page of `runs` (file positions and properties), written to `wd`; its
/// page number.
fn property_page(runs: &[(u32, u32, Vec<u8>)], papx: bool, wd: &mut Vec<u8>) -> u32 {
let mut page = [0u8; 512];
let n = runs.len();
for (i, r) in runs.iter().enumerate() {
page[4 * i..4 * i + 4].copy_from_slice(&r.0.to_le_bytes());
}
page[4 * n..4 * n + 4].copy_from_slice(&runs[n - 1].1.to_le_bytes());
let mut top = 511;
for (i, (_, _, props)) in runs.iter().enumerate() {
let blob = match (papx, props.len() % 2) {
(false, _) if props.is_empty() => continue,
(false, _) => [vec![props.len() as u8], props.clone()].concat(),
// PapxInFkp: cb with 2 × cb − 1 bytes, or 0 and cb′ with 2 × cb′ bytes.
(true, 1) => [vec![props.len().div_ceil(2) as u8], props.clone()].concat(),
(true, _) => [vec![0, (props.len() / 2) as u8], props.clone()].concat(),
};
top = (top - blob.len()) & !1;
page[top..top + blob.len()].copy_from_slice(&blob);
page[4 * (n + 1) + if papx { 13 * i } else { i }] = (top / 2) as u8;
}
page[511] = n as u8;
wd.resize(wd.len().div_ceil(512) * 512, 0);
let pn = (wd.len() / 512) as u32;
wd.extend(page);
pn
}
impl Word97 {
fn build(&self) -> Vec<u8> {
let mut wd = vec![0u8; 900];
let mut stored = vec![0u32; self.pieces.len()];
for (i, (text, compressed)) in self.pieces.iter().enumerate().rev() {
stored[i] = wd.len() as u32;
if *compressed {
wd.extend(text.chars().map(|c| match c {
'“' => 0x93,
'”' => 0x94,
c => u8::try_from(u32::from(c)).unwrap(),
}));
} else {
wd.extend(text.encode_utf16().flat_map(u16::to_le_bytes));
}
}
// Each character's file position and size, in character order.
let mut chars: Vec<(u32, u32)> = vec![];
for ((text, compressed), &at) in self.pieces.iter().zip(&stored) {
let w = if *compressed { 1 } else { 2 };
chars.extend((0..text.encode_utf16().count() as u32).map(|k| (at + k * w, w)));
}
let chpx_of = |cp: u32| (0u32, self.chpx.iter().rev().find(|(s, e, _)| (*s..*e).contains(&cp)).map(|r| r.2.clone()).unwrap_or_default());
let papx_of = |cp: u32| {
let p = self.papx.iter().filter(|(mark, _, _)| *mark >= cp).min_by_key(|(mark, _, _)| *mark);
p.map_or((u32::MAX, vec![0, 0]), |(mark, istd, g)| (*mark, [istd.to_le_bytes().to_vec(), g.clone()].concat()))
};
let mut bins = vec![];
for (papx, of) in [(false, &chpx_of as &dyn Fn(u32) -> (u32, Vec<u8>)), (true, &papx_of)] {
let mut runs: Vec<(u32, u32, u32, Vec<u8>)> = vec![];
for (cp, &(fc, w)) in chars.iter().enumerate() {
let (key, props) = of(cp as u32);
match runs.last_mut() {
Some((_, end, k, p)) if *end == fc && *k == key && *p == props => *end = fc + w,
_ => runs.push((fc, fc + w, key, props)),
}
}
// Pages of contiguous runs, 16 at most.
let mut groups: Vec<Vec<(u32, u32, Vec<u8>)>> = vec![];
for (start, end, _, props) in runs {
match groups.last_mut() {
Some(g) if g.len() < 16 && g.last().unwrap().1 == start => g.push((start, end, props)),
_ => groups.push(vec![(start, end, props)]),
}
}
let mut fcs: Vec<u32> = groups.iter().map(|g| g[0].0).collect();
fcs.extend(groups.last().map(|g| g.last().unwrap().1));
let pns: Vec<u32> = groups.iter().map(|g| property_page(g, papx, &mut wd)).collect();
bins.push([fcs, pns].concat().into_iter().flat_map(u32::to_le_bytes).collect::<Vec<u8>>());
}
let mut cps = vec![0u32];
for (text, _) in &self.pieces {
cps.push(cps.last().unwrap() + text.encode_utf16().count() as u32);
}
let pcds = self.pieces.iter().zip(&stored).flat_map(|((_, compressed), &at)| {
let fc = if *compressed { (at * 2) | 0x4000_0000 } else { at };
[&[0u8, 0][..], &fc.to_le_bytes(), &[0, 0]].concat()
});
let plc: Vec<u8> = cps.iter().flat_map(|c| c.to_le_bytes()).chain(pcds).collect();
let clx = self.clx.clone().unwrap_or_else(|| [vec![0x02], (plc.len() as u32).to_le_bytes().to_vec(), plc].concat());
let words = |v: &[u16]| v.iter().flat_map(|w| w.to_le_bytes()).collect::<Vec<u8>>();
let mut stsh = words(&[18, self.styles.len() as u16, 10, 1, 0, 15, 0, 0, 0, 0]);
for (istd, s) in self.styles.iter().enumerate() {
let Some((stk, base, papx, chpx)) = s else {
stsh.extend([0, 0]);
continue;
};
let upx = |bytes: Vec<u8>| [words(&[bytes.len() as u16]), bytes.clone(), vec![0; bytes.len() % 2]].concat();
let grlpupx = match stk {
1 => [upx([(istd as u16).to_le_bytes().to_vec(), papx.clone()].concat()), upx(chpx.clone())].concat(),
_ => upx(chpx.clone()),
};
let cupx = if *stk == 1 { 2 } else { 1 };
let mut std = words(&[0x0FFE, stk | (base << 4), cupx | (0x0FFF << 4), 0, 0, 1, u16::from(b'S'), 0]);
std.extend(grlpupx);
let len = std.len() as u16;
std[6..8].copy_from_slice(&len.to_le_bytes());
stsh.extend(len.to_le_bytes());
stsh.extend(std);
}
let mut ffn = words(&[self.fonts.len() as u16, 0]);
for name in &self.fonts {
let units: Vec<u16> = name.encode_utf16().chain([0]).collect();
ffn.push((39 + 2 * units.len()) as u8);
ffn.extend([0u8; 39]);
ffn.extend(words(&units));
}
let mut table = vec![];
let mut pairs = vec![(0u32, 0u32); 0x5D];
for (index, part) in [(1, stsh), (12, bins[0].clone()), (13, bins[1].clone()), (15, ffn), (33, clx)] {
pairs[index] = (table.len() as u32, part.len() as u32);
table.extend(part);
}
let mut put = |at: usize, bytes: &[u8]| wd[at..at + bytes.len()].copy_from_slice(bytes);
let flags = self.flags | if self.zero_table { 0 } else { 0x0200 };
put(0, &words(&[0xA5EC, if self.nfib == 0 { 0x00C1 } else { self.nfib }]));
put(10, &words(&[flags, 0x00BF]));
put(32, &words(&[14]));
put(62, &words(&[22]));
put(76, &(chars.len() as u32 + self.ccp_extra).to_le_bytes());
for (i, n) in [4, 5, 7, 8, 9, 10].into_iter().zip(self.stories) {
put(64 + 4 * i, &n.to_le_bytes());
}
put(152, &words(&[0x5D]));
for (i, (fc, lcb)) in pairs.iter().enumerate() {
put(154 + 8 * i, &[fc.to_le_bytes(), lcb.to_le_bytes()].concat());
}
compound_file(&[("WordDocument", &wd), (if self.zero_table { "0Table" } else { "1Table" }, &table)])
}
}
/// A compound file holding `streams`.
fn compound_file(streams: &[(&str, &[u8])]) -> Vec<u8> {
let mut cf = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
for (name, bytes) in streams {
std::io::Write::write_all(&mut cf.create_stream(name).unwrap(), bytes).unwrap();
}
cf.flush().unwrap();
cf.into_inner().into_inner()
}
#[test]
fn doc_places_as_area_type_with_its_styles_and_formatting() {
let navy: &[u8] = &[0x1F, 0x38, 0x64, 0];
// Normal: 11 pt with 8 pt after; Heading (based on Normal): centered, 12 pt before and none
// after, in Arial, bold, 16 pt and navy; Emphasis, a character style: italic.
let mut styles = vec![
Some((1, 0x0FFF, prls(&[(0xA414, &[160, 0])]), prls(&[(0x4A43, &[22, 0])]))),
Some((
1,
0,
prls(&[(0x2403, &[1]), (0xA413, &[240, 0]), (0xA414, &[0, 0])]),
prls(&[(0x4A4F, &[1, 0]), (0x0835, &[1]), (0x4A43, &[32, 0]), (0x6870, navy)]),
)),
];
styles.resize_with(10, || None);
styles.push(Some((2, 0x0FFF, vec![], prls(&[(0x0836, &[1])]))));
// The first piece holds characters 0 to 24 ("notes" at 7, the first paragraph mark at 12), the
// second "styled" at 25, "2" at 31, "hidden" at 32, "gone" at 38, " café ✓ " at 42, the field
// at 50 (its result "7" at 58) and the paragraph mark at 60.
let doc = Word97 {
pieces: vec![("Report notes\r“Plain” and ", true), ("styled2hiddengone café ✓ \u{13} PAGE \u{14}7\u{15}\r", false)],
chpx: vec![
(7, 12, prls(&[(0x4A30, &[10, 0])])),
(25, 31, prls(&[(0x4A4F, &[2, 0]), (0x0836, &[1]), (0x2A3E, &[1])])),
(31, 32, prls(&[(0x2A48, &[1])])),
(32, 38, prls(&[(0x083C, &[1])])),
(38, 42, prls(&[(0x0800, &[1])])),
(42, 50, prls(&[(0x0801, &[1])])),
],
papx: vec![(12, 1, vec![]), (60, 0, prls(&[(0x840F, &[0xD0, 0x02]), (0x8411, &(-360i16).to_le_bytes()), (0x2403, &[3])]))],
styles,
fonts: vec!["Times New Roman", "Arial", "Courier New"],
..Word97::default()
};
let mut s = session();
s.execute("document.setup", &json!({"superscript": {"size": 50, "position": 40}})).unwrap();
let file = json!({"name": "report.doc", "dataBase64": vectorcraft_format::base64_encode(&doc.build())});
let r = s.execute("file.place", &file).unwrap();
assert_eq!((r["format"].as_str(), r["name"].as_str()), (Some("doc"), Some("report.doc")));
let (text, frame) = placed(&s, &r);
assert_eq!(text, "Report notes\n“Plain” and styled2 café ✓ 7", "hidden and deleted text and the field's instructions are left out");
assert!(close(frame, Rect::new(36.0, 36.0, 364.0, 264.0)), "the artboard less a 36 pt margin: {frame:?}");
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
let runs: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str(), r.style.font_style.as_str(), r.style.size)).collect();
assert_eq!(
runs,
[
("Report ", "Arial", "Bold", 16.0),
("notes", "Arial", "Bold Italic", 16.0),
("\n", "Arial", "Bold", 16.0),
("“Plain” and ", "Times New Roman", "Regular", 11.0),
("styled", "Courier New", "Italic", 11.0),
("2", "Times New Roman", "Regular", 11.0),
(" café ✓ 7", "Times New Roman", "Regular", 11.0),
]
);
let navy = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0x1F, 0x38, 0x64));
assert_eq!((t.runs[0].style.fill == navy, t.runs[1].style.fill == navy, t.runs[3].style.fill == navy), (true, true, false));
assert_eq!((t.runs[3].style.underline, t.runs[4].style.underline), (false, true));
let superscript = vectorcraft_doc::ScriptMetrics { size: 50.0, position: 40.0 };
assert_eq!(t.runs[5].style.position, vectorcraft_doc::CharPosition::Superscript(superscript), "in Document Setup's proportions");
let paras = t.paragraph_styles();
let summary: Vec<_> = paras.iter().map(|p| (p.justify, p.left_indent, p.first_line_indent, p.space_before, p.space_after)).collect();
assert_eq!(summary, [(vectorcraft_doc::Justify::Center, 0.0, 0.0, 12.0, 0.0), (vectorcraft_doc::Justify::JustifyLeft, 36.0, -18.0, 0.0, 8.0)]);
let fields = "the Word document's fields were placed as the text of their results";
let revisions = "the Word document's tracked changes were accepted: deleted text was left out and inserted text kept";
assert_eq!(r["warnings"], json!([fields, revisions]));
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.selection.objects.len()), (2, 1), "Document Setup and Place, each one undo step");
// Info and the place cursor report it as a Word 97-2003 document, with the same warnings.
let info = s.execute("file.place.info", &file).unwrap();
assert_eq!((info["format"].as_str(), info["width"].as_f64(), &info["warnings"]), (Some("doc"), Some(468.0), &json!([fields, revisions])));
let q = s.execute("file.place.queue", &json!({"files": [file]})).unwrap();
assert_eq!((q["count"].as_u64(), q["files"][0]["format"].as_str()), (Some(1), Some("doc")));
}
#[test]
fn doc_tables_breaks_and_other_stories_place_as_paragraphs_and_tabs_with_warnings() {
let in_table = prls(&[(0x2416, &[1])]);
// A line break; a table row of two cells; a page break inside a paragraph and a section break
// that ends one; a picture, a tab and a nonbreaking hyphen; right-to-left justified text.
let text = "Line one\u{B}line two\ra\u{7}b\u{7}\u{7}x\u{C}y\rsec\u{C}next\rpic\u{1}ture\tend\u{1E}ok\rrtl\r";
let doc = Word97 {
pieces: vec![(text, true)],
// Marks: the first paragraph's at 17, the cells' at 19 and 21, the row's at 22, the page
// break at 24 inside the paragraph that ends at 26, the section break at 30, and the
// paragraph marks at 35, 51 and 55.
papx: vec![
(17, 0, vec![]),
(19, 0, in_table.clone()),
(21, 0, in_table),
(22, 0, prls(&[(0x2416, &[1]), (0x2417, &[1])])),
(26, 0, vec![]),
(30, 0, vec![]),
(35, 0, vec![]),
(51, 0, vec![]),
(55, 0, prls(&[(0x2441, &[1]), (0x2461, &[3])])),
],
zero_table: true,
stories: [5, 0, 2, 0, 0, 0],
..Word97::default()
};
let mut s = session();
// Recognized by its WordDocument stream whatever its name says.
let r = s.execute("file.place", &json!({"name": "notes.bin", "dataBase64": vectorcraft_format::base64_encode(&doc.build())})).unwrap();
assert_eq!(r["format"].as_str(), Some("doc"));
assert_eq!(placed(&s, &r).0, "Line one\nline two\na\tb\nxy\nsec\nnext\npicture\tend\u{2011}ok\nrtl");
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
let last = t.paragraph_styles().last().cloned().unwrap();
assert_eq!((last.justify, last.direction), (vectorcraft_doc::Justify::JustifyRight, Some(vectorcraft_doc::ParaDirection::RightToLeft)));
assert_eq!(
r["warnings"],
json!([
"the Word document's pictures and embedded objects were left out",
"the Word document's tables were placed as paragraphs with their cells separated by tabs",
"the Word document's footnotes and comments were left out",
])
);
}
#[test]
fn malformed_doc_fails_cleanly() {
let mut s = session();
let place = |s: &mut Session, name: &str, bytes: &[u8]| {
s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).map_err(|e| e.to_string())
};
let words = || Word97 { pieces: vec![("words\r", true)], ..Word97::default() };
let e = place(&mut s, "a.doc", b"Plain words, not a compound file").unwrap_err();
assert!(e.contains("not a format VectorCraft reads"), "{e}");
// An RTF file named `.doc` is still placed as RTF.
let r = place(&mut s, "a.doc", br"{\rtf1 Rich}").unwrap();
assert_eq!((r["format"].as_str(), placed(&s, &r).0.as_str()), (Some("rtf"), "Rich"));
let e = place(&mut s, "a.doc", &compound_file(&[("Other", b"x")])).unwrap_err();
assert!(e.contains("no WordDocument stream"), "{e}");
let e = place(&mut s, "a.bin", &compound_file(&[("Other", b"x")])).unwrap_err();
assert!(e.contains("not a format VectorCraft reads"), "a compound file of something else isn't read as a Word document: {e}");
let e = place(&mut s, "a.doc", &[&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1][..], &[0; 600]].concat()).unwrap_err();
assert!(e.contains("compound file structure is damaged"), "{e}");
let e = place(&mut s, "a.doc", &compound_file(&[("WordDocument", &[0; 900])])).unwrap_err();
assert!(e.contains("doesn't start with a file information block"), "{e}");
let e = place(&mut s, "a.doc", &Word97 { nfib: 0x0065, ..words() }.build()).unwrap_err();
assert!(e.contains("Word 6.0 or Word 95"), "{e}");
let e = place(&mut s, "a.doc", &Word97 { flags: 0x0100, ..words() }.build()).unwrap_err();
assert!(e.contains("encrypted"), "{e}");
let e = place(&mut s, "a.doc", &Word97 { flags: 0x0004, ..words() }.build()).unwrap_err();
assert!(e.contains("fast save"), "{e}");
let e = place(&mut s, "a.doc", &Word97 { ccp_extra: 3, ..words() }.build()).unwrap_err();
assert!(e.contains("doesn't cover its text"), "{e}");
// A piece stored past the end of the WordDocument stream.
let far = [&0u32.to_le_bytes()[..], &6u32.to_le_bytes(), &[0, 0], &0x00FF_0000u32.to_le_bytes(), &[0, 0]].concat();
let clx = [&[0x02][..], &(far.len() as u32).to_le_bytes(), &far].concat();
let e = place(&mut s, "a.doc", &Word97 { clx: Some(clx), ..words() }.build()).unwrap_err();
assert!(e.contains("lies outside its WordDocument stream"), "{e}");
let e = place(&mut s, "a.doc", &Word97 { clx: Some(vec![0x02, 0xFF, 0xFF, 0, 0]), ..words() }.build()).unwrap_err();
assert!(e.contains("piece table is damaged"), "{e}");
let e = place(&mut s, "a.doc", &Word97 { pieces: vec![("\r\r", true)], ..Word97::default() }.build()).unwrap_err();
assert!(e.contains("no text to place"), "{e}");
let r = place(&mut s, "a.doc", &words().build()).unwrap();
assert_eq!(placed(&s, &r).0, "words");
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
}
#[test]
fn place_reads_what_open_reads_and_text_files() {
use cmd::fileio::{DOCX_EXTS, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS, DOCX_EXTS].concat());
use cmd::fileio::{DOC_EXTS, DOCX_EXTS, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS, DOCX_EXTS, DOC_EXTS].concat());
let filters: Vec<_> = cmd::fileio::place_filters().collect();
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("Word", DOCX_EXTS))));
assert_eq!(filters.get(filters.len() - 3..), Some(&[("Text", TEXT_EXTS), ("RTF", RTF_EXTS), ("Word", DOCX_EXTS)][..]));
let word: (&str, &[&str]) = ("Word", &["docx", "doc"]);
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&word)));
assert_eq!(filters.get(filters.len() - 3..), Some(&[("Text", TEXT_EXTS), ("RTF", RTF_EXTS), word][..]));
assert!(
!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt") || exts.contains(&"rtf") || exts.contains(&"docx")),
!cmd::fileio::open_filters().any(|(_, exts)| ["txt", "rtf", "docx", "doc"].iter().any(|e| exts.contains(e))),
"Open doesn't read text, RTF or Word files"
);
}