mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
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:
1 parent
7367e7a4fa
commit
9d359c09e2
19 files changed
+246
-163
No files matched your search
Generated
+29
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user