From 9d359c09e2a488321d9404cb8bada6768a8a07f1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 02:56:57 +0000 Subject: [PATCH] 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. --- Cargo.lock | 29 +++++ Cargo.toml | 1 + README.md | 2 + crates/doc-binary/Cargo.toml | 30 +++++ crates/doc-binary/README.md | 26 ++++ crates/doc-binary/src/drawing.rs | 10 +- crates/doc-binary/src/fmt.rs | 6 +- crates/doc-binary/src/lib.rs | 87 +++++++------ crates/doc-binary/src/list.rs | 6 +- crates/doc-binary/src/metadata.rs | 21 +--- crates/doc-binary/src/notes.rs | 2 +- crates/doc-binary/src/officeart.rs | 2 +- crates/doc-binary/src/piece.rs | 2 +- crates/doc-binary/src/sections.rs | 4 +- crates/doc-binary/src/stsh.rs | 2 +- crates/doc-binary/src/table.rs | 10 +- crates/doc-binary/src/tests.rs | 157 +++++++++++------------- crates/doc-binary/src/tests/drawing.rs | 10 +- crates/doc-binary/src/tests/metadata.rs | 2 +- 19 files changed, 246 insertions(+), 163 deletions(-) create mode 100644 crates/doc-binary/Cargo.toml create mode 100644 crates/doc-binary/README.md diff --git a/Cargo.lock b/Cargo.lock index 0d365b9ce..ddb6b2e7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index c9c524fe5..5b2d47ec5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index 9477b8c0e..00f78711e 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ")`, `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. diff --git a/crates/doc-binary/src/drawing.rs b/crates/doc-binary/src/drawing.rs index 33f45de34..9a5483b53 100644 --- a/crates/doc-binary/src/drawing.rs +++ b/crates/doc-binary/src/drawing.rs @@ -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(); diff --git a/crates/doc-binary/src/fmt.rs b/crates/doc-binary/src/fmt.rs index a5d85012c..1b8be2333 100644 --- a/crates/doc-binary/src/fmt.rs +++ b/crates/doc-binary/src/fmt.rs @@ -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}; diff --git a/crates/doc-binary/src/lib.rs b/crates/doc-binary/src/lib.rs index 6b3014933..386e753f5 100644 --- a/crates/doc-binary/src/lib.rs +++ b/crates/doc-binary/src/lib.rs @@ -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>, 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, 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, max_paragraphs: usize) -> Result { - 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 { +fn story_blocks( + ctx: &WalkCtx<'_>, + a: u32, + b: u32, + pics: &mut media::Pictures, + max_paragraphs: usize, +) -> Result { 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 { +) -> Result { 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, - 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(comp: &mut cfb::CompoundFile, name: &str) -> Option> { - 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>, 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)] diff --git a/crates/doc-binary/src/list.rs b/crates/doc-binary/src/list.rs index 34931f8e4..873792812 100644 --- a/crates/doc-binary/src/list.rs +++ b/crates/doc-binary/src/list.rs @@ -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; diff --git a/crates/doc-binary/src/metadata.rs b/crates/doc-binary/src/metadata.rs index 4b9fcf766..e45520394 100644 --- a/crates/doc-binary/src/metadata.rs +++ b/crates/doc-binary/src/metadata.rs @@ -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(comp: &mut cfb::CompoundFile) -> Option { - 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 { + parse(&comp.read_path_capped("\u{5}SummaryInformation", MAX_STREAM).ok()?) } fn u32_at(bytes: &[u8], offset: usize) -> Option { @@ -152,8 +141,8 @@ fn text(value: &[u8], codepage: u16) -> Option { 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 } diff --git a/crates/doc-binary/src/notes.rs b/crates/doc-binary/src/notes.rs index dac259099..51e3c4b84 100644 --- a/crates/doc-binary/src/notes.rs +++ b/crates/doc-binary/src/notes.rs @@ -127,7 +127,7 @@ fn sttb_strings(table: &[u8], fc: u32, lcb: u32) -> Option> { 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); } diff --git a/crates/doc-binary/src/officeart.rs b/crates/doc-binary/src/officeart.rs index fd665874d..b5cf18c1c 100644 --- a/crates/doc-binary/src/officeart.rs +++ b/crates/doc-binary/src/officeart.rs @@ -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; diff --git a/crates/doc-binary/src/piece.rs b/crates/doc-binary/src/piece.rs index fda4e5470..5f36a8198 100644 --- a/crates/doc-binary/src/piece.rs +++ b/crates/doc-binary/src/piece.rs @@ -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; diff --git a/crates/doc-binary/src/sections.rs b/crates/doc-binary/src/sections.rs index 209fa5841..31ebfd251 100644 --- a/crates/doc-binary/src/sections.rs +++ b/crates/doc-binary/src/sections.rs @@ -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}; diff --git a/crates/doc-binary/src/stsh.rs b/crates/doc-binary/src/stsh.rs index 6186ab1f5..3bd0328f3 100644 --- a/crates/doc-binary/src/stsh.rs +++ b/crates/doc-binary/src/stsh.rs @@ -165,7 +165,7 @@ pub(crate) fn fonts(table: &[u8], fc: u32, lcb: u32) -> Vec { 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 diff --git a/crates/doc-binary/src/table.rs b/crates/doc-binary/src/table.rs index 400c8715c..8cb2be78d 100644 --- a/crates/doc-binary/src/table.rs +++ b/crates/doc-binary/src/table.rs @@ -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}; diff --git a/crates/doc-binary/src/tests.rs b/crates/doc-binary/src/tests.rs index 863801463..80cef3257 100644 --- a/crates/doc-binary/src/tests.rs +++ b/crates/doc-binary/src/tests.rs @@ -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 { +fn body_texts(doc: &craft_wordmodel::Document) -> Vec { 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 { 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::(); 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 = doc.plain_text(wordcraft_doc::StoryRef::Body).split('\n').map(str::to_string).collect(); + let texts: Vec = 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 { +fn note_texts(doc: &craft_wordmodel::Document, p: &craft_wordmodel::Paragraph) -> Vec { 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::>().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 { /// 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 { } /// Word-shaped 5-column table: header row, a shaded body row, a one-cell legend row. -fn word_table_doc(row1: Vec, row2: Vec, row3: Vec) -> wordcraft_doc::Document { +fn word_table_doc(row1: Vec, row2: Vec, row3: Vec) -> 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| [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 = 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::>(); - 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::(); 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 = [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 { @@ -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 { diff --git a/crates/doc-binary/src/tests/drawing.rs b/crates/doc-binary/src/tests/drawing.rs index dc18c3d37..b91d2df4c 100644 --- a/crates/doc-binary/src/tests/drawing.rs +++ b/crates/doc-binary/src/tests/drawing.rs @@ -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, diff --git a/crates/doc-binary/src/tests/metadata.rs b/crates/doc-binary/src/tests/metadata.rs index b36a963c4..14cd3b2dd 100644 --- a/crates/doc-binary/src/tests/metadata.rs +++ b/crates/doc-binary/src/tests/metadata.rs @@ -31,7 +31,7 @@ fn summary(cp: u16, values: &[(u32, u32, Vec)]) -> Vec { 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();