craft-doc-binary: on craft-wordmodel, craft-cfb and craft-codepage

- The compound file is read with craft-cfb (bounded directory, streams refused
  above 1 GiB before anything is allocated), in place of the cfb crate and
  WordCraft's cfb_guard precheck; an oversized stream is DocbinError::Limit.
- 8-bit text uses craft-codepage in place of wordcraft_doc::encoding.
- wordcraft-doc is craft-wordmodel; wordcraft-geom's helpers are its geom.

The tests keep building their files with the cfb crate (a dev-dependency), so
craft-cfb's reader is checked against another implementation's writer. The
repeating-DIFAT regression is craft-cfb's own; the guard asserts in the
valid-files test go with the guard.
This commit is contained in:
Dylan M. Taylor committed 2026-11-12 02:56:57 +00:00
1 parent 7367e7a4fa
commit 9d359c09e2
19 files changed
+246 -163

No files matched your search

Generated
+29
View File
@@ -618,6 +618,17 @@ dependencies = [
"shlex",
]
[[package]]
name = "cfb"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b"
dependencies = [
"fnv",
"uuid",
"web-time",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -1057,6 +1068,24 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-doc-binary"
version = "0.1.0"
dependencies = [
"cfb",
"chrono",
"craft-cfb",
"craft-codepage",
"craft-wordmodel",
"image",
"log",
"miniz_oxide 0.9.1",
"proptest",
"serde_json",
"sha1 0.11.0",
"thiserror 2.0.21",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
+1
View File
@@ -37,6 +37,7 @@ craft-crw = { path = "crates/crw" }
craft-csv = { path = "crates/csv" }
craft-dav = { path = "crates/dav" }
craft-dialogs = { path = "crates/dialogs" }
craft-doc-binary = { path = "crates/doc-binary" }
craft-docx = { path = "crates/docx" }
craft-dxf = { path = "crates/dxf" }
craft-drawingml = { path = "crates/drawingml" }
+2
View File
@@ -66,6 +66,8 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-doc`](crates/doc/README.md): reads PhotoCraft `.pcraft`, VectorCraft `.vectorcraft`
and EffectCraft `.ecproj` documents without the apps: the kind and version, the previews and
PDF they carry, and their pages and layers. Rendering and editing stay in each app.
- [`craft-doc-binary`](crates/doc-binary/README.md): the Word 97-2003 binary format (`.doc`, [MS-DOC]) read into
`craft-wordmodel`, with XOR, RC4 and RC4 CryptoAPI password protection. WordCraft's reader, on `craft-cfb`.
- [`craft-docpath`](crates/docpath/src/lib.rs): decides whether a path or `file:` URL stored in a
document may be opened. Paths stored in documents are followed only when they are local or on the
document's own network share; other network paths, device paths and non-file URLs are refused.
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "craft-doc-binary"
description = "Reader for the Word 97-2003 binary file format (.doc, [MS-DOC]) into the craft-wordmodel document model, with XOR, RC4 and RC4 CryptoAPI password protection."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-cfb = { workspace = true }
craft-codepage = { workspace = true }
craft-wordmodel = { workspace = true }
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
image = { version = "0.25", default-features = false, features = ["png", "bmp"] }
log = "0.4"
miniz_oxide = "0.9"
sha1 = "0.11"
thiserror = { workspace = true }
[dev-dependencies]
# Test files are written with an independent compound-file writer, so craft-cfb's reader is
# checked against another implementation's output.
cfb = "0.15"
craft-wordmodel = { workspace = true, features = ["test-support"] }
proptest = { workspace = true }
serde_json = "1"
+26
View File
@@ -0,0 +1,26 @@
# craft-doc-binary
Reader for the Word 97-2003 binary file format (`.doc`, [MS-DOC]) into the `craft-wordmodel`
document model. It is WordCraft's reader, on `craft-cfb` for the compound file and
`craft-codepage` for 8-bit text.
```rust
// Not a compound file: an error, never a panic.
assert!(craft_doc_binary::read(b"not a .doc").is_err());
assert!(!craft_doc_binary::is_encrypted(b"not a .doc"));
```
- `read(bytes)`, `read_with_password(bytes, Option<&str>)`, `is_encrypted(bytes)`.
- Text in pieces (8-bit and UTF-16), paragraph and character formatting, styles, lists,
tables, sections, headers and footers, notes, comments, fields, bookmarks, pictures and
OfficeArt shapes, document properties.
- XOR obfuscation, RC4 and RC4 CryptoAPI password protection.
There is no writer.
## Limits
Streams (1 GiB each, refused by `craft-cfb` before anything is allocated), paragraphs, pieces,
property counts, table nesting and drawing records are capped; the constants are in the source
next to what they bound, and each has a test. Hostile input gives an error or a best-effort
document, never a panic.
+5 -5
View File
@@ -4,9 +4,9 @@
use std::collections::HashMap;
use wordcraft_doc::fill::Fill;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::Rgb;
use craft_wordmodel::fill::Fill;
use craft_wordmodel::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap};
use craft_wordmodel::props::Rgb;
use crate::media::Pictures;
use crate::officeart::{Node, Sp};
@@ -97,7 +97,7 @@ pub(crate) fn anchors(
let (mut x, mut y) = (tw(xl as i64), tw(yt as i64));
let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT));
let sp = node.sp();
let rot = wordcraft_geom::normalize_degrees(if sp.props.rot.is_finite() { sp.props.rot } else { 0.0 });
let rot = craft_wordmodel::geom::normalize_degrees(if sp.props.rot.is_finite() { sp.props.rot } else { 0.0 });
// A shape turned by 45°–135° (or 225°–315°) is anchored by its turned bounds: the
// unrotated frame has the other aspect, about the same centre.
if (45.0..135.0).contains(&rot) || (225.0..315.0).contains(&rot) {
@@ -253,7 +253,7 @@ fn members_into(nodes: &[Node], t: (f32, f32, f32, f32), depth: usize, b: &Build
let (x0, y0, sx, sy) = t;
let fit = |v: f32| if v.is_finite() { v.clamp(-1e7, 1e7) } else { 0.0 };
for n in nodes {
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
if out.len() >= craft_wordmodel::para::MAX_GROUP_CHILDREN {
return;
}
let sp = n.sp();
+3 -3
View File
@@ -1,10 +1,10 @@
//! Sprms → model properties, and style-chain resolution.
use wordcraft_doc::Rgb;
use wordcraft_doc::props::{
use craft_wordmodel::Rgb;
use craft_wordmodel::props::{
Align, Border, BorderStyle, Borders, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign,
};
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet};
use craft_wordmodel::styles::{Style, StyleKind, StyleSheet};
use crate::sprm::{self, Prl, Toggle};
+51 -36
View File
@@ -1,12 +1,18 @@
//! WordCraft reader for the Word 97-2003 binary file format (`.doc`, [MS-DOC]).
//! Reader for the Word 97-2003 binary file format (`.doc`, [MS-DOC]), WordCraft's.
//!
//! [`read`] turns the bytes of a `.doc` file into a [`wordcraft_doc::Document`]. There is no
//! [`read`] turns the bytes of a `.doc` file into a [`craft_wordmodel::Document`]. There is no
//! writer: saving always goes through the other formats. The reader is written from the public
//! specification ([MS-DOC], Word (.doc) Binary File Format, Microsoft Open Specifications) and,
//! like every WordCraft parser, is lenient by design (unknown structures are skipped, bad numbers
//! like the other Craft parsers, is lenient by design (unknown structures are skipped, bad numbers
//! fall back to defaults) and bounded (stream sizes, piece counts and property counts are
//! capped), so hostile input yields an error or a best-effort document, never a panic.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// The code came from an app whose lints allow this.
#![allow(clippy::type_complexity)]
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeExamples;
mod crypt;
mod drawing;
@@ -24,19 +30,18 @@ mod sprm;
mod stsh;
mod table;
use std::io::{Read, Seek};
use std::sync::Arc;
use wordcraft_doc::link::hyperlink_target;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::para::Paragraph;
use wordcraft_doc::para::Run;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::props::NumRef;
use wordcraft_doc::section::HeaderSet;
use wordcraft_doc::styles::StyleSheet;
use wordcraft_doc::{Document, PartKind};
use craft_wordmodel::link::hyperlink_target;
use craft_wordmodel::para::InlineObject;
use craft_wordmodel::para::NoteKind;
use craft_wordmodel::para::Paragraph;
use craft_wordmodel::para::Run;
use craft_wordmodel::props::CharProps;
use craft_wordmodel::props::NumRef;
use craft_wordmodel::section::HeaderSet;
use craft_wordmodel::styles::StyleSheet;
use craft_wordmodel::{Document, PartKind};
use fkp::Bins;
use piece::{PieceTable, PrmRef};
@@ -183,14 +188,15 @@ pub enum DocbinError {
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
/// anything that is not a readable Word 97-2003 file.
pub fn is_encrypted(bytes: &[u8]) -> bool {
let Ok(mut comp) = open_container(bytes) else { return false };
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
let Ok(comp) = open_container(bytes) else { return false };
stream(&comp, "WordDocument").ok().flatten().is_some_and(|w| fib::peek_encryption(&w).is_some())
}
/// Open the compound file around a `.doc`, after [`wordcraft_doc::cfb_guard::check`].
fn open_container(bytes: &[u8]) -> Result<cfb::CompoundFile<std::io::Cursor<&[u8]>>, DocbinError> {
wordcraft_doc::cfb_guard::check(bytes).map_err(DocbinError::Container)?;
cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))
/// Open the compound file around a `.doc` with `craft-cfb`, which bounds the directory and the
/// streams it reads.
fn open_container(bytes: &[u8]) -> Result<craft_cfb::CompoundFile<'_>, DocbinError> {
craft_cfb::CompoundFile::open_with(bytes, craft_cfb::Limits { max_stream_size: MAX_STREAM, ..craft_cfb::Limits::default() })
.map_err(|e| DocbinError::Container(e.to_string()))
}
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
@@ -209,8 +215,8 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a
/// file of [`MAX_PARAGRAPHS`] marks.
fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result<Document, DocbinError> {
let mut comp = open_container(bytes)?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
let comp = open_container(bytes)?;
let mut word = stream(&comp, "WordDocument")?.ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
}
@@ -220,11 +226,14 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
let which = if let Some(e) = enc { e.which_tbl_stm } else { fib::Fib::parse(&word)?.which_tbl_stm };
let named = if which { "1Table" } else { "0Table" };
let other = if which { "0Table" } else { "1Table" };
let mut table = stream(&mut comp, named).or_else(|| stream(&mut comp, other)).unwrap_or_default();
let mut table = match stream(&comp, named)? {
Some(t) => t,
None => stream(&comp, other)?.unwrap_or_default(),
};
if table.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Table stream is {} bytes", table.len())));
}
let mut data = stream(&mut comp, "Data").unwrap_or_default();
let mut data = stream(&comp, "Data")?.unwrap_or_default();
if data.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len())));
}
@@ -263,7 +272,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
// Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default
// 15 would apply Word 2013+ justification and line-breaking rules to them.
doc.settings.compat_mode = 11;
if let Some(core) = metadata::read(&mut comp) {
if let Some(core) = metadata::read(&comp) {
doc.core = core;
}
doc.body.clear();
@@ -428,7 +437,13 @@ fn paragraph_limit(max_paragraphs: usize) -> DocbinError {
/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before
/// its partial list is assembled, so a flood does not build blocks it will throw away.
fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, max_paragraphs: usize) -> Result<wordcraft_doc::Blocks, DocbinError> {
fn story_blocks(
ctx: &WalkCtx<'_>,
a: u32,
b: u32,
pics: &mut media::Pictures,
max_paragraphs: usize,
) -> Result<craft_wordmodel::Blocks, DocbinError> {
let paras = walk(ctx, a, b, pics);
if ctx.paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
@@ -451,7 +466,7 @@ fn header_story_blocks(
b: u32,
pics: &mut media::Pictures,
max_paragraphs: usize,
) -> Result<wordcraft_doc::Blocks, DocbinError> {
) -> Result<craft_wordmodel::Blocks, DocbinError> {
let mut paras = walk(ctx, a, b, pics);
if ctx.paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
@@ -533,7 +548,7 @@ impl ParaBudget {
struct ParaBuild {
text: String,
runs: Vec<Run>,
props: wordcraft_doc::props::ParaProps,
props: craft_wordmodel::props::ParaProps,
mark: CharProps,
/// Which resolved formatting ([`FmtState::generation`]) the last character came from, while
/// the last run is still the one it extended: the same generation means equal properties.
@@ -1022,14 +1037,14 @@ fn char_props(grpprl: &[u8], fonts: &[String], base: &CharProps) -> CharProps {
p
}
/// Read a root-level stream fully, or `None` if it is absent.
fn stream<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>, name: &str) -> Option<Vec<u8>> {
let mut s = comp.open_stream(format!("/{name}")).ok()?;
let mut v = Vec::new();
// Read with a cap one byte above the limit so oversized streams are still caught by the
// caller instead of exhausting memory here.
s.by_ref().take(MAX_STREAM + 1).read_to_end(&mut v).ok()?;
Some(v)
/// Read a root-level stream fully; `None` if it is absent or cannot be read. A stream larger than
/// [`MAX_STREAM`] is [`DocbinError::Limit`], and nothing is allocated for it.
fn stream(comp: &craft_cfb::CompoundFile<'_>, name: &str) -> Result<Option<Vec<u8>>, DocbinError> {
match comp.read_path_capped(name, MAX_STREAM) {
Ok(v) => Ok(Some(v)),
Err(craft_cfb::Error::LimitExceeded(e)) => Err(DocbinError::Limit(format!("{name} stream: {e}"))),
Err(_) => Ok(None),
}
}
#[cfg(test)]
+3 -3
View File
@@ -2,9 +2,9 @@
//! instances with LFOLVL overrides) resolved into the numbering model
//! ([MS-DOC] §2.8.1 "Determining List Formatting of a Paragraph", §2.9).
use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num, Numbering};
use wordcraft_doc::props::{Align, CharProps, ParaProps};
use wordcraft_doc::section::NumFormat;
use craft_wordmodel::numbering::{AbstractNum, Level, LevelSuffix, Num, Numbering};
use craft_wordmodel::props::{Align, CharProps, ParaProps};
use craft_wordmodel::section::NumFormat;
use crate::fib::{Fib, pair};
use crate::fmt;
+5 -16
View File
@@ -1,25 +1,14 @@
//! Optional document text properties from [MS-OLEPS] §2.21 and §2.25.1.
//! Broken metadata must not prevent opening the document or allocate from unchecked sizes.
use std::io::{Read, Seek};
use wordcraft_doc::CoreProps;
use craft_wordmodel::CoreProps;
const MAX_STREAM: u64 = 2 << 20;
const MAX_STRING: usize = 64 << 10;
const SUMMARY_FMTID: &[u8] = &[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0x00, 0x2b, 0x27, 0xb3, 0xd9];
pub(crate) fn read<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>) -> Option<CoreProps> {
let stream = comp.open_stream("/\u{5}SummaryInformation").ok()?;
if stream.len() > MAX_STREAM {
return None;
}
let mut bytes = Vec::new();
stream.take(MAX_STREAM + 1).read_to_end(&mut bytes).ok()?;
if bytes.len() as u64 > MAX_STREAM {
return None;
}
parse(&bytes)
pub(crate) fn read(comp: &craft_cfb::CompoundFile<'_>) -> Option<CoreProps> {
parse(&comp.read_path_capped("\u{5}SummaryInformation", MAX_STREAM).ok()?)
}
fn u32_at(bytes: &[u8], offset: usize) -> Option<u32> {
@@ -152,8 +141,8 @@ fn text(value: &[u8], codepage: u16) -> Option<String> {
let bytes = bytes.split(|b| *b == 0).next().unwrap_or_default();
if codepage == 65001 {
Some(String::from_utf8_lossy(bytes).into_owned())
} else if wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&codepage) {
Some(bytes.iter().map(|b| wordcraft_doc::encoding::windows_byte(codepage, *b)).collect())
} else if craft_codepage::SINGLE_BYTE_CODE_PAGES.contains(&codepage) {
Some(bytes.iter().map(|b| craft_codepage::windows_byte(codepage, *b)).collect())
} else {
None
}
+1 -1
View File
@@ -127,7 +127,7 @@ fn sttb_strings(table: &[u8], fc: u32, lcb: u32) -> Option<Vec<String>> {
let s: String = if extended {
chars.as_chunks::<2>().0.iter().take(40).map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect()
} else {
chars.iter().take(40).map(|&c| wordcraft_doc::encoding::cp1252(c)).collect()
chars.iter().take(40).map(|&c| craft_codepage::cp1252(c)).collect()
};
out.push(s);
}
+1 -1
View File
@@ -10,7 +10,7 @@ use std::io::Write;
use image::ImageEncoder;
use wordcraft_doc::props::Rgb;
use craft_wordmodel::props::Rgb;
/// Deepest container nesting walked (groups inside groups…).
const MAX_DEPTH: usize = 12;
+1 -1
View File
@@ -1,7 +1,7 @@
//! The piece table ([MS-DOC] `Clx`/`Pcdt`/`PlcPcd`): where every character position lives in
//! the WordDocument stream, and how it is encoded.
use wordcraft_doc::encoding::cp1252;
use craft_codepage::cp1252;
use crate::DocbinError;
+2 -2
View File
@@ -1,8 +1,8 @@
//! Sections and headers/footers: `PlcfSed` → `Sed` → `Sepx` (grpprls of section sprms) and
//! the `Plcfhdd` stories of the header subdocument ([MS-DOC] §2.8.25, §2.9.244).
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::section::{Columns, SectionProps, SectionStart};
use craft_wordmodel::section::NumFormat;
use craft_wordmodel::section::{Columns, SectionProps, SectionStart};
use crate::fib::{Fib, pair};
use crate::sprm::{self, Prl};
+1 -1
View File
@@ -165,7 +165,7 @@ pub(crate) fn fonts(table: &[u8], fc: u32, lcb: u32) -> Vec<String> {
s
} else {
// An 8-bit name reads as garbage through UTF-16; fall back to Windows-1252.
tail.iter().take_while(|&&b| b != 0).take(64).map(|&b| wordcraft_doc::encoding::cp1252(b)).collect()
tail.iter().take_while(|&&b| b != 0).take(64).map(|&b| craft_codepage::cp1252(b)).collect()
}
};
// STTB header: [fExtend 0xFFFF] cData(2) cbExtra(2); each entry is followed by cbExtra
+5 -5
View File
@@ -3,11 +3,11 @@
use std::sync::Arc;
use wordcraft_doc::Rgb;
use wordcraft_doc::para::Paragraph;
use wordcraft_doc::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
use wordcraft_doc::table::{Cell, Row, Table};
use wordcraft_doc::{Block, Blocks, para_block};
use craft_wordmodel::Rgb;
use craft_wordmodel::para::Paragraph;
use craft_wordmodel::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
use craft_wordmodel::table::{Cell, Row, Table};
use craft_wordmodel::{Block, Blocks, para_block};
use crate::fmt;
use crate::sprm::{self, Prl};
+74 -83
View File
@@ -145,7 +145,7 @@ fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
}
fn body_texts(doc: &wordcraft_doc::Document) -> Vec<String> {
fn body_texts(doc: &craft_wordmodel::Document) -> Vec<String> {
doc.body.iter().filter_map(|b| b.as_para().map(|p| p.text.clone())).collect()
}
@@ -246,7 +246,7 @@ fn special_characters_mapped() {
assert_eq!(body_texts(&doc), ["a\tb\nc\u{C}d\u{E}e\u{2011}f\u{AD}g", "\u{FFFC}"]);
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("field para");
assert!(
matches!(p1.objects.first(), Some(wordcraft_doc::para::InlineObject::Field { instr, result, .. }) if instr == "field" && result == "result"),
matches!(p1.objects.first(), Some(craft_wordmodel::para::InlineObject::Field { instr, result, .. }) if instr == "field" && result == "result"),
"objects: {:?}",
p1.objects
);
@@ -713,7 +713,7 @@ fn rich_doc_with(spec: &RichSpec, extra: &RichExtra) -> Vec<u8> {
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
}
fn para0(doc: &wordcraft_doc::Document) -> wordcraft_doc::Paragraph {
fn para0(doc: &craft_wordmodel::Document) -> craft_wordmodel::Paragraph {
doc.body.first().and_then(|b| b.as_para().cloned()).unwrap_or_default()
}
@@ -736,7 +736,7 @@ fn character_formatting_applied() {
// runs: plain(0..6) bold(6..11) bold+red(11..16)
assert!(p.runs.get(1).is_some_and(|r| r.props.bold == Some(true)), "run 1 bold: {:?}", p.runs);
assert!(p.runs.get(2).is_some_and(|r| r.props.bold == Some(true)), "run 2 bold+red: {:?}", p.runs);
assert!(p.runs.get(2).is_some_and(|r| matches!(r.props.color, Some(wordcraft_doc::props::TextColor::Rgb(_)))), "run 2 red");
assert!(p.runs.get(2).is_some_and(|r| matches!(r.props.color, Some(craft_wordmodel::props::TextColor::Rgb(_)))), "run 2 red");
assert!(p.runs.first().is_some_and(|r| r.props.bold.is_none()));
}
@@ -833,7 +833,7 @@ fn paragraph_props_and_heading_style() {
};
let doc = read(&rich_doc(&spec)).expect("opens");
let heading = doc.styles.styles.iter().find(|s| s.name == "Heading 1").expect("style");
assert_eq!(heading.para.align, Some(wordcraft_doc::props::Align::Center));
assert_eq!(heading.para.align, Some(craft_wordmodel::props::Align::Center));
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.props.style.as_deref(), Some("Heading 1"));
assert_eq!(heading.para.outline_level, Some(0));
@@ -865,8 +865,8 @@ fn first_line_indent_chars_and_exact_line_spacing() {
let para = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).expect("para").props.clone();
let p0 = para(0);
assert_eq!((p0.indent_first, p0.indent_first_chars), (Some(24.0), Some(2.0)));
assert_eq!(p0.line_spacing, Some(wordcraft_doc::props::LineSpacing::Multiple(1.5)));
assert_eq!(para(1).line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(20.0)));
assert_eq!(p0.line_spacing, Some(craft_wordmodel::props::LineSpacing::Multiple(1.5)));
assert_eq!(para(1).line_spacing, Some(craft_wordmodel::props::LineSpacing::Exactly(20.0)));
assert_eq!(para(2).indent_first, Some(-18.0)); // sprmPDxaLeft1_80: a hanging indent
}
@@ -887,7 +887,7 @@ fn corpus_files_open() {
Err(e) => panic!("{}: {e}", p.display()),
};
assert!(!doc.styles.styles.is_empty(), "{}: no styles", p.display());
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
let text = doc.plain_text(craft_wordmodel::StoryRef::Body);
assert!(text.chars().count() > 3, "{}: no text", p.display());
checked += 1;
}
@@ -1343,20 +1343,20 @@ fn table_merges_borders_shading() {
let r0 = tbl.rows[0].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<String>();
assert_eq!(r0, "ab");
// Row 2: shading, vertical-merge restart, exact height 20pt.
assert_eq!(tbl.rows[1].cells[0].props.shading, Some(wordcraft_doc::Rgb(255, 255, 0)));
assert_eq!(tbl.rows[1].cells[0].props.vmerge, wordcraft_doc::props::VMerge::Restart);
assert_eq!(tbl.rows[1].cells[0].props.shading, Some(craft_wordmodel::Rgb(255, 255, 0)));
assert_eq!(tbl.rows[1].cells[0].props.vmerge, craft_wordmodel::props::VMerge::Restart);
assert_eq!(tbl.rows[1].props.height, Some(20.0));
// Table borders from the first row that carries them: single 0.5pt black.
let b = tbl.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!(top.style, wordcraft_doc::props::BorderStyle::Single);
assert_eq!(top.style, craft_wordmodel::props::BorderStyle::Single);
assert_eq!(top.width, 0.5);
}
/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border.
#[test]
fn table_borders_read_border_types_as_paragraphs_do() {
use wordcraft_doc::props::BorderStyle as S;
use craft_wordmodel::props::BorderStyle as S;
let text = "a\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let row_end = {
@@ -1476,18 +1476,18 @@ fn list_numbering_parsed() {
assert_eq!(doc.numbering.abstracts.len(), 1);
assert_eq!(doc.numbering.nums.len(), 1);
let l0 = doc.numbering.level(1, 0).expect("level 0");
assert_eq!(l0.format, wordcraft_doc::section::NumFormat::Decimal);
assert_eq!(l0.format, craft_wordmodel::section::NumFormat::Decimal);
assert_eq!(l0.text, "%1.");
assert_eq!(l0.indent, 36.0);
assert_eq!(l0.hanging, 18.0);
assert_eq!(l0.suffix, wordcraft_doc::numbering::LevelSuffix::Space);
assert_eq!(l0.suffix, craft_wordmodel::numbering::LevelSuffix::Space);
// Paragraphs 1-2 are in the list, paragraph 3 is not.
let n = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| p.props.numbering);
assert_eq!(n(0), Some(wordcraft_doc::props::NumRef { num: 1, level: 0 }));
assert_eq!(n(1), Some(wordcraft_doc::props::NumRef { num: 1, level: 0 }));
assert_eq!(n(0), Some(craft_wordmodel::props::NumRef { num: 1, level: 0 }));
assert_eq!(n(1), Some(craft_wordmodel::props::NumRef { num: 1, level: 0 }));
assert_eq!(n(2), None);
// The numbering produces labels through the shared counter.
let mut c = wordcraft_doc::numbering::Counters::default();
let mut c = craft_wordmodel::numbering::Counters::default();
assert_eq!(c.next_label(&doc.numbering, 1, 0).unwrap().0, "1.");
assert_eq!(c.next_label(&doc.numbering, 1, 0).unwrap().0, "2.");
}
@@ -1535,7 +1535,7 @@ fn combined_structure_document() {
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
assert_eq!(tbl.rows.len(), 1);
assert_eq!(tbl.rows[0].cells.len(), 2);
let texts: Vec<String> = doc.plain_text(wordcraft_doc::StoryRef::Body).split('\n').map(str::to_string).collect();
let texts: Vec<String> = doc.plain_text(craft_wordmodel::StoryRef::Body).split('\n').map(str::to_string).collect();
assert!(texts.contains(&"first".to_string()) && texts.contains(&"second".to_string()), "list text present: {texts:?}");
// Section 1 is attached to the intro paragraph and owns the header story; the final
// section is landscape.
@@ -1594,11 +1594,11 @@ fn footnote_reference_links_story_part() {
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let note = p0.objects.first().expect("note ref object");
let wordcraft_doc::para::InlineObject::NoteRef { kind, id, .. } = note else { panic!("not a note: {note:?}") };
assert_eq!(*kind, wordcraft_doc::para::NoteKind::Footnote);
let craft_wordmodel::para::InlineObject::NoteRef { kind, id, .. } = note else { panic!("not a note: {note:?}") };
assert_eq!(*kind, craft_wordmodel::para::NoteKind::Footnote);
let part = doc.parts.get(id).expect("note part");
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Note body"))), "note text: {:?}", part.blocks);
assert_eq!(part.kind, wordcraft_doc::PartKind::Footnote);
assert_eq!(part.kind, craft_wordmodel::PartKind::Footnote);
}
#[test]
@@ -1618,8 +1618,8 @@ fn endnote_reference_links_story_part() {
};
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let wordcraft_doc::para::InlineObject::NoteRef { kind, id, .. } = p0.objects.first().expect("note ref object") else { panic!("no note ref") };
assert_eq!(*kind, wordcraft_doc::para::NoteKind::Endnote);
let craft_wordmodel::para::InlineObject::NoteRef { kind, id, .. } = p0.objects.first().expect("note ref object") else { panic!("no note ref") };
assert_eq!(*kind, craft_wordmodel::para::NoteKind::Endnote);
let part = doc.parts.get(id).expect("endnote part");
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Endnote body"))));
}
@@ -1637,7 +1637,7 @@ fn fields_and_hyperlinks() {
let linked = p0.runs.iter().find(|r| r.props.link.is_some()).expect("linked run");
assert_eq!(linked.props.link.as_deref(), Some("http://x"));
// The PAGE field became one object with its cached result.
let wordcraft_doc::para::InlineObject::Field { instr, result, .. } = p0.objects.first().expect("field object") else {
let craft_wordmodel::para::InlineObject::Field { instr, result, .. } = p0.objects.first().expect("field object") else {
panic!("no field: {:?}", p0.objects)
};
assert_eq!(instr, "PAGE");
@@ -1683,11 +1683,15 @@ fn bookmarks_become_objects() {
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
// The start anchor sits before the text, the end after "Hello".
assert!(
matches!(p0.objects.first(), Some(wordcraft_doc::para::InlineObject::BookmarkStart { name }) if name == "bm1"),
matches!(p0.objects.first(), Some(craft_wordmodel::para::InlineObject::BookmarkStart { name }) if name == "bm1"),
"objects: {:?}",
p0.objects
);
assert!(
matches!(p0.objects.get(1), Some(craft_wordmodel::para::InlineObject::BookmarkEnd { name }) if name == "bm1"),
"objects: {:?}",
p0.objects
);
assert!(matches!(p0.objects.get(1), Some(wordcraft_doc::para::InlineObject::BookmarkEnd { name }) if name == "bm1"), "objects: {:?}", p0.objects);
assert_eq!(p0.text, "\u{FFFC}Hello\u{FFFC} world");
}
@@ -1710,7 +1714,7 @@ fn inline_png_picture() {
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.text, "Pic\u{FFFC}!");
let wordcraft_doc::para::InlineObject::Image { media, w, h, .. } = p0.objects.first().expect("image object") else {
let craft_wordmodel::para::InlineObject::Image { media, w, h, .. } = p0.objects.first().expect("image object") else {
panic!("no image: {:?}", p0.objects)
};
assert_eq!((*w, *h), (72.0, 36.0)); // 1440 × 720 twips
@@ -1770,7 +1774,7 @@ fn structured_spec(text: &'static str, ccp: u32) -> StructSpec {
// Hardening regressions: hostile CP ranges, piece lookups, picture fan-out, overflows.
/// All the paragraph text of a document: body plus every part.
fn all_text(doc: &wordcraft_doc::Document) -> String {
fn all_text(doc: &craft_wordmodel::Document) -> String {
let mut s: String = body_texts(doc).join("\n");
for part in doc.parts.values() {
for b in &part.blocks {
@@ -1812,18 +1816,18 @@ fn huge_story_ranges_are_clamped_to_their_subdocument() {
assert!(all.len() < 200, "text grew to {} bytes", all.len());
let header = doc.last_section.headers.default.and_then(|id| doc.parts.get(&id)).expect("header part");
assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks);
let footnote = doc.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).expect("footnote part");
let footnote = doc.parts.values().find(|p| p.kind == craft_wordmodel::PartKind::Footnote).expect("footnote part");
assert!(footnote.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Note body")));
// The endnote story starts past the endnote subdocument: nothing to read.
assert!(!doc.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::Endnote));
assert!(!doc.parts.values().any(|p| p.kind == craft_wordmodel::PartKind::Endnote));
}
/// The text of each note a paragraph references, in order.
fn note_texts(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Vec<String> {
fn note_texts(doc: &craft_wordmodel::Document, p: &craft_wordmodel::Paragraph) -> Vec<String> {
p.objects
.iter()
.filter_map(|o| match o {
wordcraft_doc::para::InlineObject::NoteRef { id, .. } => doc.parts.get(id),
craft_wordmodel::para::InlineObject::NoteRef { id, .. } => doc.parts.get(id),
_ => None,
})
.map(|part| part.blocks.iter().filter_map(|b| b.as_para()).map(|para| para.text.as_str()).collect::<Vec<_>>().join("\n"))
@@ -1847,7 +1851,7 @@ fn note_stories_pointing_at_the_same_text_are_read_once() {
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Footnote).count(), 1);
assert_eq!(doc.parts.values().filter(|p| p.kind == craft_wordmodel::PartKind::Footnote).count(), 1);
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(note_texts(&doc, p0), vec!["Note body".to_string()]);
}
@@ -1897,7 +1901,7 @@ fn header_stories_pointing_at_the_same_text_are_read_once() {
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Header).count(), 1);
assert_eq!(doc.parts.values().filter(|p| p.kind == craft_wordmodel::PartKind::Header).count(), 1);
let first = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.as_ref()).expect("first section");
let header = first.headers.default.and_then(|id| doc.parts.get(&id)).expect("first section's header");
assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks);
@@ -2022,7 +2026,7 @@ fn anchors_sharing_one_picture_share_one_media_entry() {
assert_eq!(doc.media.len(), 1);
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.objects.len(), 5000);
assert!(p0.objects.iter().all(|o| matches!(o, wordcraft_doc::para::InlineObject::Image { media, .. } if media == "image1.png")));
assert!(p0.objects.iter().all(|o| matches!(o, craft_wordmodel::para::InlineObject::Image { media, .. } if media == "image1.png")));
}
#[test]
@@ -2133,7 +2137,7 @@ fn piece_prm_character_formatting_applied() {
#[test]
fn section_break_kinds_imported() {
// #459. SBkcOperand: 0 continuous, 1 new column, 2 new page, 3 even page, 4 odd page.
use wordcraft_doc::section::SectionStart;
use craft_wordmodel::section::SectionStart;
let kinds = [SectionStart::Continuous, SectionStart::NextColumn, SectionStart::NextPage, SectionStart::EvenPage, SectionStart::OddPage];
for (bkc, want) in kinds.into_iter().enumerate() {
let spec = one_section("Text\r", grpprl(&[(0x3009, &[bkc as u8])]), vec![(0, 0, Vec::new())]);
@@ -2175,7 +2179,7 @@ fn negative_list_reference_keeps_list_membership() {
spec.plf_lfo = plf_lfo(0x1000);
let doc = read(&struct_doc(&spec)).expect("opens");
let para = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).map(|p| (p.props.numbering, p.props.indent_left)).unwrap_or_default();
let one = Some(wordcraft_doc::props::NumRef { num: 1, level: 0 });
let one = Some(craft_wordmodel::props::NumRef { num: 1, level: 0 });
assert_eq!(para(0), (one, Some(36.0)), "0x0001");
assert_eq!(para(1), (one, Some(36.0)), "0xFFFF = -1");
assert_eq!(para(2).0, None, "0");
@@ -2253,7 +2257,7 @@ fn tabs_papx(del: &[i16], add: &[(i16, u8)]) -> Vec<u8> {
/// a style and by the paragraph, stops it removes, and the TBD's alignment and leader are read.
#[test]
fn tab_stops_read_from_styles_and_paragraphs() {
use wordcraft_doc::props::{TabAlign, TabLeader};
use craft_wordmodel::props::{TabAlign, TabLeader};
// Normal: centre at 3.25" (4680 tw), right at 6.5" (9360 tw) with a dot leader (tlc 1).
let normal = grpprl(&[(0xC60D, &tabs_papx(&[], &[(4680, 1), (9360, 2 | 1 << 3)]))]);
// The paragraph drops the centre stop and adds a left stop at 0.5" and a bar at 1".
@@ -2312,7 +2316,7 @@ fn hostile_tab_operands_are_safe() {
/// Before, every section of such a file was lost and each section mark became a page break.
#[test]
fn section_marks_end_paragraphs() {
use wordcraft_doc::section::SectionStart;
use craft_wordmodel::section::SectionStart;
let spec = StructSpec {
text: "first\u{C}second\r",
header_stories: vec![],
@@ -2370,7 +2374,7 @@ fn shd_clear(back: [u8; 3], n: usize) -> Vec<u8> {
}
/// Word-shaped 5-column table: header row, a shaded body row, a one-cell legend row.
fn word_table_doc(row1: Vec<u8>, row2: Vec<u8>, row3: Vec<u8>) -> wordcraft_doc::Document {
fn word_table_doc(row1: Vec<u8>, row2: Vec<u8>, row3: Vec<u8>) -> craft_wordmodel::Document {
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}f\u{7}g\u{7}h\u{7}i\u{7}j\u{7}\u{7}legend\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
@@ -2426,10 +2430,10 @@ fn word_table_row_end_mark_and_cell_shading() {
let counts: Vec<usize> = tbl.rows.iter().map(|r| r.cells.len()).collect();
assert_eq!(counts, [5, 5, 1], "cells per row (the row-end mark is not a cell)");
let shd = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.shading).collect::<Vec<_>>();
assert_eq!(shd(0), vec![Some(wordcraft_doc::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour");
let light = Some(wordcraft_doc::Rgb(0xE8, 0xF0, 0xFA));
assert_eq!(shd(0), vec![Some(craft_wordmodel::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour");
let light = Some(craft_wordmodel::Rgb(0xE8, 0xF0, 0xFA));
assert_eq!(shd(1), vec![light, light, light, light, None], "automatic Shd clears the Shd80 fill");
assert_eq!(shd(2), vec![Some(wordcraft_doc::Rgb(0xFF, 0xF2, 0xCC))], "legend fill");
assert_eq!(shd(2), vec![Some(craft_wordmodel::Rgb(0xFF, 0xF2, 0xCC))], "legend fill");
let legend = tbl.rows[2].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<String>();
assert_eq!(legend, "legend");
let tail = doc.body.last().and_then(|b| b.as_para()).expect("tail para");
@@ -2444,10 +2448,10 @@ fn word97_shd80_clear_fill_shows_its_background() {
let shd80: Vec<u8> = [0xE0u16, 16 << 5, 0, (1 << 10) | 2].iter().flat_map(|v| v.to_le_bytes()).collect();
let papx = sprm_cb(0xD609, &shd80);
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(255, 255, 0)));
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(192, 192, 192)));
assert_eq!(row.shading_of(0), Some(craft_wordmodel::Rgb(255, 255, 0)));
assert_eq!(row.shading_of(1), Some(craft_wordmodel::Rgb(192, 192, 192)));
assert_eq!(row.shading_of(2), None);
assert_eq!(row.shading_of(3), Some(wordcraft_doc::Rgb(0, 0, 255)));
assert_eq!(row.shading_of(3), Some(craft_wordmodel::Rgb(0, 0, 255)));
}
#[test]
@@ -2461,11 +2465,11 @@ fn shd_arrays_keep_to_their_cell_ranges() {
for (first, second, third) in [(0xD612u16, 0xD616u16, 0xD60Cu16), (0xD670, 0xD671, 0xD672)] {
let papx = [sprm_cb(second, &green), sprm_cb(third, &blue), sprm_cb(first, &over_long)].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(21), Some(wordcraft_doc::Rgb(0x11, 0x22, 0x33)), "{first:04X}");
assert_eq!(row.shading_of(22), Some(wordcraft_doc::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22");
assert_eq!(row.shading_of(44), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{third:04X} starts at cell 44");
assert_eq!(row.shading_of(21), Some(craft_wordmodel::Rgb(0x11, 0x22, 0x33)), "{first:04X}");
assert_eq!(row.shading_of(22), Some(craft_wordmodel::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22");
assert_eq!(row.shading_of(44), Some(craft_wordmodel::Rgb(0, 0, 0xFF)), "{third:04X} starts at cell 44");
let papx = sprm_cb(first, &red);
assert_eq!(crate::table::decode(&papx).shading_of(0), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(crate::table::decode(&papx).shading_of(0), Some(craft_wordmodel::Rgb(0xFF, 0, 0)));
}
}
@@ -2493,7 +2497,7 @@ fn hostile_table_shading_and_spans_are_safe() {
];
for (r1, r2, r3) in rows {
let doc = word_table_doc(r1, r2, r3);
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
let text = doc.plain_text(craft_wordmodel::StoryRef::Body);
assert!(text.contains("legend") && text.contains("tail"), "{text:?}");
if let Some(t) = doc.body.iter().find_map(|b| b.as_table()) {
assert!(t.grid.len() <= 63);
@@ -2526,7 +2530,7 @@ fn hostile_table_shading_and_spans_are_safe() {
],
);
let doc = read(&struct_doc(&spec)).expect("opens");
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
let text = doc.plain_text(craft_wordmodel::StoryRef::Body);
assert!(text.contains('a') && text.contains("tail"), "{text:?}");
for t in doc.body.iter().filter_map(|b| b.as_table()) {
assert!(t.rows.iter().all(|r| !r.cells.is_empty()), "no zero-cell rows");
@@ -2544,13 +2548,13 @@ fn nil_raw_shading_keeps_the_earlier_colour() {
let auto = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0];
let papx = [sprm_cb(0xD612, &[blue.clone(), blue.clone(), blue.clone()].concat()), sprm_cb(0xD670, &[nil.clone(), red, auto].concat())].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "nil keeps blue");
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(row.shading_of(0), Some(craft_wordmodel::Rgb(0, 0, 0xFF)), "nil keeps blue");
assert_eq!(row.shading_of(1), Some(craft_wordmodel::Rgb(0xFF, 0, 0)));
assert_eq!(row.shading_of(2), None, "automatic clears");
for (first, raw) in [(0xD616u16, 0xD671u16), (0xD60C, 0xD672)] {
let papx = [sprm_cb(first, &blue), sprm_cb(raw, &nil)].concat();
let cell = if first == 0xD616 { 22 } else { 44 };
assert_eq!(crate::table::decode(&papx).shading_of(cell), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{raw:04X}");
assert_eq!(crate::table::decode(&papx).shading_of(cell), Some(craft_wordmodel::Rgb(0, 0, 0xFF)), "{raw:04X}");
}
}
@@ -2687,7 +2691,7 @@ fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() {
/// border (type 0x02) stays thick in both, as tables read it too.
#[test]
fn paragraph_borders_read_from_both_forms() {
use wordcraft_doc::props::BorderStyle as S;
use craft_wordmodel::props::BorderStyle as S;
let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text
let thick80 = [8u8, 0x02, 0x06, 0x01];
let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2
@@ -2706,29 +2710,17 @@ fn paragraph_borders_read_from_both_forms() {
let b = p0.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(top.color, Some(craft_wordmodel::Rgb(0xFF, 0, 0)));
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick");
assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some());
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph");
let b = p1.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(top.color, Some(craft_wordmodel::Rgb(0xFF, 0, 0)));
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick");
}
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
/// build a FAT of hundreds of megabytes before checking it. The precheck refuses it first.
#[test]
fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() {
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
// The precheck's own error is the proof that it ran before cfb (no clock to skew).
assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of")));
assert!(!super::is_encrypted(&f));
// A file cfb wrote itself passes.
assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok());
}
/// A compound file `cfb` writes in `version`: `streams` at its root, then `extra` streams under
/// an `ObjectPool` storage (where Word keeps embedded objects).
fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str, &[u8])]) -> Vec<u8> {
@@ -2743,15 +2735,15 @@ fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str,
comp.into_inner().into_inner()
}
/// #3642 review: valid compound files pass the guard and open, so it refuses only the hostile
/// #3642 review: valid compound files open, so craft-cfb's checks refuse only the hostile
/// ones. The repository keeps no files Word made, so these are written by the `cfb` crate in
/// the shape of Word 97–2003 files: the WordDocument and table streams of a .doc these tests
/// build, with the summary, Data and embedded-object streams Word adds, in 512-byte sectors
/// (version 3, Word's own) and 4096-byte ones (version 4); and one with a Data stream of 8 MB,
/// past what the header's 109 FAT sector slots map in 512-byte sectors (109 × 128 sectors,
/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too, which the guard walks.
/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too.
#[test]
fn valid_doc_shaped_compound_files_pass_the_guard_and_open() {
fn valid_doc_shaped_compound_files_open() {
let doc = struct_doc(&structured_spec("Hello world\r", 12));
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(doc.as_slice())).expect("cfb open");
// The table stream is 1Table or 0Table, as the FIB's fWhichTblStm says: kept under its name.
@@ -2783,7 +2775,6 @@ fn valid_doc_shaped_compound_files_pass_the_guard_and_open() {
let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's");
for (what, f) in &files {
assert_eq!(wordcraft_doc::cfb_guard::check(f), Ok(()), "{what}");
let doc = read(f).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(body_texts(&doc).first().map(String::as_str), Some("Hello world"), "{what}");
}
@@ -2805,7 +2796,7 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
// what the copies charge to the work budget, which a busy machine can't skew.
let mut result = super::FieldResult::default();
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
(0..n).for_each(|_| result.push('y', craft_wordmodel::props::CharProps::default()));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
let work = super::Work::new(u64::MAX);
super::finish_field(&mut super::ParaBuild::default(), f, &work);
@@ -2816,7 +2807,7 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
/// out of the work budget; past it the text stays, unlinked.
#[test]
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
use wordcraft_doc::props::CharProps;
use craft_wordmodel::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut result = super::FieldResult::default();
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
@@ -2880,7 +2871,7 @@ fn a_large_prc_is_interpreted_once_per_formatting_change() {
/// is skipped (the text keeps its CHPX formatting).
#[test]
fn prm_grpprls_past_the_work_budget_are_skipped() {
use wordcraft_doc::props::CharProps;
use craft_wordmodel::props::CharProps;
let g = grpprl(&[(0x0835, &[0x01])]);
let work = super::Work::new(g.len() as u64 + 1);
let base = CharProps::default();
@@ -2898,7 +2889,7 @@ fn prm_grpprls_past_the_work_budget_are_skipped() {
/// (365 bytes each: 4 million result characters took 1.4 GB).
#[test]
fn a_field_result_stores_one_props_per_run() {
use wordcraft_doc::props::CharProps;
use craft_wordmodel::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut r = super::FieldResult::default();
for _ in 0..200_000 {
@@ -2917,7 +2908,7 @@ fn a_field_result_stores_one_props_per_run() {
/// links all of it; a plain field keeps its result text whole.
#[test]
fn field_results_keep_their_text_and_runs() {
use wordcraft_doc::props::CharProps;
use craft_wordmodel::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut result = super::FieldResult::default();
for (c, p) in
@@ -2936,7 +2927,7 @@ fn field_results_keep_their_text_and_runs() {
assert!(
p.objects
.iter()
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
.any(|o| matches!(o, craft_wordmodel::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
"{:?}",
p.objects
);
@@ -2999,8 +2990,8 @@ fn a_piece_span_matches_the_per_cp_lookups() {
&[],
0,
);
let mut comp = crate::open_container(&doc).expect("container");
let table = crate::stream(&mut comp, "0Table").expect("table");
let comp = crate::open_container(&doc).expect("container");
let table = crate::stream(&comp, "0Table").expect("readable").expect("table");
let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces");
for cp in 0..14 {
let span = pieces.span(cp);
@@ -3018,7 +3009,7 @@ fn a_piece_span_matches_the_per_cp_lookups() {
/// even compared while the formatting stays the same.
#[test]
fn shared_props_start_one_run_per_formatting() {
use wordcraft_doc::props::CharProps;
use craft_wordmodel::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut pb = super::ParaBuild::default();
for _ in 0..100_000 {
+5 -5
View File
@@ -1,10 +1,10 @@
//! Pictures and drawings: OfficeArt records ([MS-ODRAW]) hand-built for these tests from the
//! specification, with tiny pictures generated in code. No file produced by Word is used.
use craft_wordmodel::fill::Fill;
use craft_wordmodel::para::{Anchor, InlineObject, ShapeKind, Wrap};
use craft_wordmodel::props::Rgb;
use proptest::prelude::*;
use wordcraft_doc::fill::Fill;
use wordcraft_doc::para::{Anchor, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::Rgb;
use super::{StructSpec, grpprl, plc, read, struct_doc, structured_spec};
use crate::officeart;
@@ -179,7 +179,7 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
let Some(InlineObject::Shape { kind, fill, float, story, .. }) = p0.objects.get(1) else { panic!("{:?}", p0.objects) };
assert_eq!((*kind, fill.clone(), float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom));
let part = story.and_then(|id| doc.parts.get(&id)).expect("text box story part");
assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox);
assert_eq!(part.kind, craft_wordmodel::PartKind::TextBox);
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
}
@@ -431,7 +431,7 @@ fn a_floating_copy_is_charged_its_alt_text() {
};
let bare = crate::object_cost(&image(String::new()));
assert_eq!(crate::object_cost(&image("€".repeat(4096))), bare + 3 * 4096);
let member = |obj| wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj };
let member = |obj| craft_wordmodel::para::GroupChild { x: 0.0, y: 0.0, obj };
let group = InlineObject::Group {
w: 1.0,
h: 1.0,
+1 -1
View File
@@ -31,7 +31,7 @@ fn summary(cp: u16, values: &[(u32, u32, Vec<u8>)]) -> Vec<u8> {
header
}
fn open_with_metadata(bytes: &[u8], template: bool) -> wordcraft_doc::Document {
fn open_with_metadata(bytes: &[u8], template: bool) -> craft_wordmodel::Document {
let file = text_doc(&[Piece { compressed: false, text: "Body stays readable\r".into() }], &[], 0);
let mut cfb = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
cfb.create_stream("/\u{5}SummaryInformation").unwrap().write_all(bytes).unwrap();