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
+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"
);
}