Place: Word .docx files place as area type with their formatting

File > Place could not place a Word document: a .docx file was "not a
format VectorCraft reads". The reference app's File > Place reads
Microsoft Word files as text.

File > Place now reads .docx files, written from ECMA-376 Part 1
(WordprocessingML, Transitional and Strict), and sets their text as
area type the way a text or RTF file is placed. The package is read
through its relationships. The formatting comes from the document
defaults, the paragraph style (else the default paragraph style), the
character style (else the default character style), the styles each
is based on, and direct formatting. Bold, italic, caps, small caps,
strikethrough and hidden text are toggle properties: a style that
turns one on reverses the state the styles before it give.

Each run keeps its font (theme fonts from the theme part), size, bold
and italic, underline, strikethrough, all caps, small caps,
superscript or subscript (in Document Setup's proportions), raised or
lowered text and color. Each paragraph keeps its alignment, start,
end, first-line and hanging indents, space before and after, and
direction; the start indent of a right-to-left paragraph is its right
indent. Table cells are separated by tabs and each table row is a
paragraph. Text in insertions, hyperlinks, content controls and
fields' results is read. Hidden and deleted text, field instructions,
pictures, text boxes, headers, footers, notes and list numbers are
left out.

A ZIP archive named .docx, or one that holds word/document.xml, is
read as a Word document. fileio/zip.rs gains a reader for stored and
deflated files that checks each file's CRC. A package with more than
10,000 files, a part that unpacks to more than 16 MiB or nests
elements more than 256 deep, and a document with more than 4 MiB of
text or 100,000 paragraphs or runs are refused with an error. The
Place dialog lists Word files, and file.place, file.place.info and
file.place.queue report the format `docx`. .doc files are still not
read.

The Word document tests in tests_textimport.rs fail without the
change, and text_import_fuzz.rs places random and damaged packages.

Docs: docs/file-format-parity.md, docs/mcp.md,
docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-18 01:21:31 +07:00
committed by bflatastic
parent dd466d79e4
commit 1c4ead3703
12 changed files with 1556 additions and 17 deletions
Generated
+1
View File
@@ -4657,6 +4657,7 @@ dependencies = [
"kurbo 0.13.1",
"log",
"proptest",
"roxmltree 0.21.1",
"ruzstd",
"serde",
"serde_json",
+2
View File
@@ -35,6 +35,8 @@ vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
# The XML parts of Word documents placed as type (place/docx.rs).
roxmltree = "0.21"
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+11 -5
View File
@@ -8,7 +8,7 @@
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
//! - `screens`: Export for Screens (`document.exportForScreens`, `document.exportSettings`); `zip`
//! packs its files as one download.
//! packs its files as one download, and reads the files of Word documents placed as type.
//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
@@ -45,7 +45,7 @@ mod save;
mod screens;
mod svg;
mod text;
mod zip;
pub mod zip;
use serde_json::{Value, json};
@@ -710,7 +710,11 @@ pub const TEXT_EXTS: &[&str] = &["txt"];
/// RTF files: File → Place sets them as area type with their formatting.
pub const RTF_EXTS: &[&str] = &["rtf"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`].
/// Word documents: File → Place sets them as area type with their formatting.
pub const DOCX_EXTS: &[&str] = &["docx"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and
/// [`DOCX_EXTS`].
pub const PLACE_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
@@ -741,6 +745,7 @@ pub const PLACE_EXTS: &[&str] = &[
"afpub",
"txt",
"rtf",
"docx",
];
/// One dialog filter per readable format.
@@ -764,13 +769,14 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text
/// and RTF.
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
/// RTF and Word.
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All placeable files", PLACE_EXTS))
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
.chain(std::iter::once(("Text", TEXT_EXTS)))
.chain(std::iter::once(("RTF", RTF_EXTS)))
.chain(std::iter::once(("Word", DOCX_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
+144
View File
@@ -1,5 +1,10 @@
//! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are
//! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read.
//!
//! [`Archive`] reads the files of a ZIP archive, stored or deflated, as PKWARE's APPNOTE.TXT
//! describes the format (<https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT>).
use std::io::Read as _;
use flate2::Crc;
@@ -65,6 +70,145 @@ pub fn store<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result<Vec<u8>,
Ok(out)
}
/// Does `bytes` start with a ZIP archive's first local file header?
pub fn is_zip(bytes: &[u8]) -> bool {
bytes.starts_with(&0x0403_4b50u32.to_le_bytes())
}
/// Why [`Archive`] can't read an archive or one of its files.
#[derive(Debug, PartialEq)]
pub enum ReadError {
/// Not a ZIP archive: no end of central directory record.
NotZip,
/// More files than the reader was asked to accept.
TooManyFiles,
/// The archive needs the 64-bit (ZIP64) records, which the reader does not read.
Zip64,
/// The file is encrypted.
Encrypted(String),
/// The file is compressed with a method other than stored (0) or deflated (8).
Method(String, u16),
/// The file unpacks to more bytes than the reader was asked to accept.
TooLarge(String),
/// The archive (empty name) or the file is truncated, inconsistent or fails its CRC.
Damaged(String),
}
/// One file of an archive, as its central directory record describes it.
struct Entry {
name: String,
flags: u16,
method: u16,
crc: u32,
packed: usize,
size: usize,
local: usize,
}
/// A ZIP archive's files, listed from its central directory.
pub struct Archive<'a> {
zip: &'a [u8],
entries: Vec<Entry>,
}
impl<'a> Archive<'a> {
/// The archive `zip`, with at most `max_files` files.
pub fn open(zip: &'a [u8], max_files: usize) -> Result<Self, ReadError> {
let damaged = || ReadError::Damaged(String::new());
let u16_at = |at: usize| at.checked_add(2).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
let u32_at = |at: usize| at.checked_add(4).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
// The end record is 22 bytes followed by a comment of at most 65535 bytes: the signature
// whose comment ends the archive, else the last signature (the archive has bytes after it).
let last = zip.len().checked_sub(22).ok_or(ReadError::NotZip)?;
let candidates = || (last.saturating_sub(0xFFFF)..=last).rev().filter(|&at| u32_at(at) == Some(0x0605_4b50));
let end =
candidates().find(|&at| u16_at(at + 20).map(usize::from) == Some(last - at)).or_else(|| candidates().next()).ok_or(ReadError::NotZip)?;
let count = u16_at(end + 10).ok_or_else(damaged)?;
let start = u32_at(end + 16).ok_or_else(damaged)?;
if count == 0xFFFF || start == 0xFFFF_FFFF || u32_at(end + 12) == Some(0xFFFF_FFFF) {
return Err(ReadError::Zip64);
}
let count = usize::from(count);
if count > max_files {
return Err(ReadError::TooManyFiles);
}
let mut at = start as usize;
let mut entries = Vec::with_capacity(count);
for _ in 0..count {
if u32_at(at) != Some(0x0201_4b50) {
return Err(damaged());
}
let short = |off: usize| at.checked_add(off).and_then(u16_at).ok_or_else(damaged);
let long = |off: usize| at.checked_add(off).and_then(u32_at).ok_or_else(damaged);
let (name_len, extra, comment) = (usize::from(short(28)?), usize::from(short(30)?), usize::from(short(32)?));
let name_at = at.checked_add(46).ok_or_else(damaged)?;
let name = name_at.checked_add(name_len).and_then(|end| zip.get(name_at..end)).ok_or_else(damaged)?;
entries.push(Entry {
name: String::from_utf8_lossy(name).into_owned(),
flags: short(8)?,
method: short(10)?,
crc: long(16)?,
packed: long(20)? as usize,
size: long(24)? as usize,
local: long(42)? as usize,
});
at = name_at.checked_add(name_len + extra + comment).ok_or_else(damaged)?;
}
Ok(Self { zip, entries })
}
/// The file `name`, matched in any ASCII case.
fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.name.eq_ignore_ascii_case(name))
}
/// Does the archive hold the file `name` (in any ASCII case)?
pub fn contains(&self, name: &str) -> bool {
self.entry(name).is_some()
}
/// The bytes of the file `name` (matched in any ASCII case; None: the archive has no such
/// file), when it unpacks to at most `max` bytes and passes its CRC.
pub fn read(&self, name: &str, max: usize) -> Result<Option<Vec<u8>>, ReadError> {
let Some(e) = self.entry(name) else { return Ok(None) };
let damaged = || ReadError::Damaged(e.name.clone());
if e.flags & 1 != 0 {
return Err(ReadError::Encrypted(e.name.clone()));
}
if e.size > max {
return Err(ReadError::TooLarge(e.name.clone()));
}
// The local header: its signature, then its name and extra field lengths at 26 and 28.
let header = e.local.checked_add(30).and_then(|end| self.zip.get(e.local..end)).ok_or_else(damaged)?;
let (Some(sig), Some(name_len), Some(extra)) = (header.get(..4), header.get(26..28), header.get(28..30)) else { return Err(damaged()) };
if sig != 0x0403_4b50u32.to_le_bytes() {
return Err(damaged());
}
let len = |b: &[u8]| b.try_into().map(|b| usize::from(u16::from_le_bytes(b))).map_err(|_| damaged());
let start = e.local + 30 + len(name_len)? + len(extra)?;
let data = start.checked_add(e.packed).and_then(|end| self.zip.get(start..end)).ok_or_else(damaged)?;
let bytes = match e.method {
0 if e.packed != e.size => return Err(damaged()),
0 => data.to_vec(),
8 => {
let mut out = Vec::with_capacity(e.size);
flate2::read::DeflateDecoder::new(data).take((max as u64).saturating_add(1)).read_to_end(&mut out).map_err(|_| damaged())?;
if out.len() > max {
return Err(ReadError::TooLarge(e.name.clone()));
}
out
}
m => return Err(ReadError::Method(e.name.clone(), m)),
};
let mut crc = Crc::new();
crc.update(&bytes);
if bytes.len() != e.size || crc.sum() != e.crc {
return Err(damaged());
}
Ok(Some(bytes))
}
}
/// The entries of a store-only archive written by [`store`] (read through its central directory,
/// checking every CRC) → `(name, bytes)`.
#[cfg(test)]
+977
View File
@@ -0,0 +1,977 @@
//! Placing a Word document (`.docx`): its text set as area type with the character and paragraph
//! formatting VectorCraft has, read as ECMA-376 Part 1 describes WordprocessingML
//! (<https://ecma-international.org/publications-and-standards/standards/ecma-376/>), in its
//! Transitional and Strict namespaces.
//!
//! - The package is a ZIP archive ([`fileio::zip`]). `_rels/.rels` names the main document part
//! (else `word/document.xml`), and that part's relationships name its styles and theme parts
//! (else `styles.xml` and `theme/theme1.xml` next to it).
//! - Text: each paragraph (`w:p`) is a paragraph, and so is each table row, whose cells are
//! separated by tabs. A text-wrapping break (`w:br`) and a carriage return (`w:cr`) start a new
//! paragraph; page and column breaks are skipped. `w:tab` and `w:ptab` are tabs, and
//! `w:noBreakHyphen` and `w:softHyphen` are their hyphens. Text in insertions, moves, hyperlinks,
//! smart tags, content controls, custom XML and simple fields is read, and of a complex field,
//! its result.
//! - Character formatting: the font (`w:rFonts`: `w:asciiTheme`, `w:ascii`, `w:hAnsiTheme`, then
//! `w:hAnsi`; a theme font is the theme's major or minor Latin font), the size, bold, italic,
//! underline (every style but `none`), strikethrough and double strikethrough, all caps, small
//! caps, superscript and subscript (`w:vertAlign`), raised and lowered text (`w:position`) and
//! the color.
//! - Paragraph formatting: alignment (`w:jc`), start and end indents (`w:start` or `w:left`,
//! `w:end` or `w:right`: the right and left indents of a right-to-left paragraph), first-line and
//! hanging indents, space before and after, and direction (`w:bidi`).
//! - Styles (§17.7.2): the document defaults, then the paragraph style (else the default paragraph
//! style) and the styles it is based on, then the run's character style (else the default
//! character style) and the styles it is based on, then direct formatting. Bold, italic, caps,
//! small caps, strikethrough and hidden text are toggle properties (§17.7.3): a style that turns
//! one on reverses the state the levels before it give, and direct formatting sets it.
//!
//! Hidden text (`w:vanish`), deleted text, field instructions, drawings, pictures, objects, symbol
//! characters (`w:sym`), note and comment references, headers, footers, notes, comments and list
//! numbers are left out. A package with more than [`MAX_FILES`] files, a part that unpacks to more
//! than [`MAX_PART`] bytes or nests elements more than [`MAX_NESTING`] deep, and a document with
//! more than [`MAX_PARAGRAPHS`] paragraphs, [`MAX_RUNS`] runs of one formatting or
//! [`text::MAX_TEXT`] bytes of text are refused. Tables, content controls and other containers
//! nested more than [`MAX_DEPTH`] deep are skipped, and so are fields nested deeper.
use std::collections::HashMap;
use roxmltree::Node;
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
use super::text::{self, Story};
use super::*;
use crate::cmd::fileio::zip::{self, Archive, ReadError};
/// Word documents as `file.place` reports them (not a format `document.open` reads).
pub(super) const FORMAT: fileio::Format = fileio::Format {
id: "docx",
label: "Word",
extensions: fileio::DOCX_EXTS,
mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
read: true,
write: false,
raster: false,
options: &[],
};
/// The most files a Word document's package may hold.
const MAX_FILES: usize = 10_000;
/// The most bytes a part read from the package may unpack to.
const MAX_PART: usize = 16 << 20;
/// The most XML nodes a part read from the package may have.
const MAX_NODES: u32 = 2_000_000;
/// The deepest nesting of XML elements a part read from the package may have.
const MAX_NESTING: usize = 256;
/// The deepest nesting of tables, content controls and other containers read; deeper ones are
/// skipped, and so are fields nested deeper.
const MAX_DEPTH: usize = 64;
/// The most styles read from the styles part.
const MAX_STYLES: usize = 4096;
/// The most styles one style's `w:basedOn` chain takes in.
const MAX_BASED_ON: usize = 32;
/// The most fonts the document's formatting names.
const MAX_FONTS: usize = 4096;
/// The most bytes of a font name kept.
const MAX_NAME: usize = 256;
/// The most paragraphs placed type takes from one Word document.
const MAX_PARAGRAPHS: usize = 100_000;
/// The most runs of one character formatting placed type takes from one Word document.
const MAX_RUNS: usize = 100_000;
/// WordprocessingML's namespaces: Transitional, then Strict.
const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"];
/// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds
/// `word/document.xml`?
pub(super) fn is_docx(name: &str, bytes: &[u8]) -> bool {
zip::is_zip(bytes)
&& (fileio::DOCX_EXTS.contains(&fileio::extension(name).as_str())
|| Archive::open(bytes, MAX_FILES).is_ok_and(|z| z.contains("word/document.xml")))
}
/// The text of a Word document's `bytes` (a ZIP package) with its character and paragraph
/// formatting.
pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> {
let zip = Archive::open(bytes, MAX_FILES).map_err(|e| package_error(cmd, e))?;
let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into());
let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) };
let doc = parse(&xml, &main, cmd)?;
if local(doc.root_element()) != Some("document") {
return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document"));
}
let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default();
let mut r = Reader::default();
let theme = relationship(&zip, &main, "theme", cmd)?.unwrap_or_else(|| format!("{folder}theme/theme1.xml"));
if let Some(xml) = part(&zip, &theme, cmd)? {
r.theme_fonts(&parse(&xml, &theme, cmd)?);
}
let styles = relationship(&zip, &main, "styles", cmd)?.unwrap_or_else(|| format!("{folder}styles.xml"));
if let Some(xml) = part(&zip, &styles, cmd)? {
r.styles(&parse(&xml, &styles, cmd)?);
}
if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) {
r.block(body, 0);
}
r.finish(cmd)
}
/// The error for a package (or a part of it) the ZIP reader can't read.
fn package_error(cmd: &str, e: ReadError) -> EngineError {
bad(
cmd,
match e {
ReadError::NotZip => "not a Word document: it isn't a ZIP package".to_string(),
ReadError::TooManyFiles => format!("the Word document's package has more than {MAX_FILES} files"),
ReadError::Zip64 => "the Word document's package uses ZIP64 records, which VectorCraft doesn't read".to_string(),
ReadError::Encrypted(name) => format!("`{name}` in the Word document is encrypted"),
ReadError::Method(name, m) => format!("`{name}` in the Word document uses compression method {m}, which VectorCraft doesn't read"),
ReadError::TooLarge(name) => format!("`{name}` in the Word document is too large to place (at most {} MB unpacked)", MAX_PART >> 20),
ReadError::Damaged(name) if name.is_empty() => "the Word document's package is damaged".to_string(),
ReadError::Damaged(name) => format!("the Word document's package is damaged: `{name}` can't be read"),
},
)
}
/// The part `name` of the package as text (UTF-8, or UTF-16 with a byte-order mark); None: the
/// package has no such part.
fn part(zip: &Archive, name: &str, cmd: &str) -> Result<Option<String>> {
let Some(bytes) = zip.read(name, MAX_PART).map_err(|e| package_error(cmd, e))? else { return Ok(None) };
let utf16 = |rest: &[u8], le: bool| -> String {
let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
};
let text = match &bytes[..] {
[0xEF, 0xBB, 0xBF, rest @ ..] => std::str::from_utf8(rest).map(str::to_string),
[0xFF, 0xFE, rest @ ..] => Ok(utf16(rest, true)),
[0xFE, 0xFF, rest @ ..] => Ok(utf16(rest, false)),
_ => std::str::from_utf8(&bytes).map(str::to_string),
};
text.map(Some).map_err(|_| bad(cmd, format!("`{name}` in the Word document is not UTF-8 or UTF-16 text")))
}
/// The XML of the part `name`.
fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<'a>> {
// roxmltree parses nested elements recursively.
if nesting(xml) > MAX_NESTING {
return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")));
}
let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() };
roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")))
}
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
/// CDATA sections, processing instructions, declarations and quoted attribute values.
fn nesting(xml: &str) -> usize {
let b = xml.as_bytes();
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
// The index just past the first `end` at or after `from` (the end of the text if none).
let past = |from: usize, end: &[u8]| {
b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len())
};
while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) {
let start = i + at;
let rest = b.get(start..).unwrap_or_default();
if rest.starts_with(b"<!--") {
i = past(start + 4, b"-->");
} else if rest.starts_with(b"<![CDATA[") {
i = past(start + 9, b"]]>");
} else if rest.starts_with(b"<?") {
i = past(start + 2, b"?>");
} else if rest.starts_with(b"<!") || rest.starts_with(b"</") {
depth = depth.saturating_sub(usize::from(rest.starts_with(b"</")));
i = past(start + 2, b">");
} else {
// A start tag: up to its `>` outside quotes; `/>` closes it at once.
let mut j = start + 1;
let mut quote = None;
while let Some(&c) = b.get(j) {
match (quote, c) {
(None, b'"' | b'\'') => quote = Some(c),
(Some(q), _) if c == q => quote = None,
(None, b'>') => break,
_ => {}
}
j += 1;
}
if b.get(j.saturating_sub(1)) != Some(&b'/') {
depth += 1;
most = most.max(depth);
}
i = j + 1;
}
}
most
}
/// The part that the first relationship of part `source` (`""`: the package) whose type ends in
/// `/{kind}` points to; None when the package has no relationships part for `source` or it names
/// no such part.
fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result<Option<String>> {
let (folder, file) = source.rfind('/').map_or(("", source), |i| (source.get(..=i).unwrap_or_default(), source.get(i + 1..).unwrap_or_default()));
let rels = format!("{folder}_rels/{file}.rels");
let Some(xml) = part(zip, &rels, cmd)? else { return Ok(None) };
let doc = parse(&xml, &rels, cmd)?;
let target = doc
.root_element()
.children()
.filter(|n| n.tag_name().name() == "Relationship" && n.attribute("TargetMode") != Some("External"))
.find(|n| n.attribute("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind)))
.and_then(|n| n.attribute("Target"));
Ok(target.map(|t| resolve(folder, t)))
}
/// The part name `target` gives, relative to the folder `folder` (`""` or ending in `/`).
fn resolve(folder: &str, target: &str) -> String {
let joined = match target.strip_prefix('/') {
Some(absolute) => absolute.to_string(),
None => format!("{folder}{target}"),
};
let mut parts: Vec<&str> = vec![];
for segment in joined.split('/') {
match segment {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
/// `n`'s local name when it is a WordprocessingML element.
fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> {
(n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name())
}
/// `n`'s first WordprocessingML child element named `name`.
fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option<Node<'a, 'i>> {
n.children().find(|c| local(*c) == Some(name))
}
/// `n`'s WordprocessingML attribute `name`.
fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> {
n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value())
}
/// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`.
fn on(n: Node) -> bool {
!matches!(attr(n, "val"), Some("false" | "0" | "off"))
}
/// A measure `v` in units of `1 / per_pt` point: a number in those units, or a number with a unit
/// (`mm`, `cm`, `in`, `pt`, `pc`, `pi`).
fn measure(v: &str, per_pt: f64) -> Option<f64> {
const UNITS: [(&str, f64); 6] = [("mm", 72.0 / 25.4), ("cm", 72.0 / 2.54), ("in", 72.0), ("pt", 1.0), ("pc", 12.0), ("pi", 12.0)];
let v = v.trim();
let n = match UNITS.iter().find_map(|(unit, pt)| v.strip_suffix(unit).map(|n| (n, pt))) {
Some((n, pt)) => n.trim().parse::<f64>().ok()? * pt * per_pt,
None => v.parse::<f64>().ok()?,
};
n.is_finite().then_some(n)
}
/// Twentieths of a point.
fn twips(n: Node, name: &str) -> Option<f64> {
attr(n, name).and_then(|v| measure(v, 20.0))
}
/// Half points.
fn half_points(n: Node) -> Option<f64> {
attr(n, "val").and_then(|v| measure(v, 2.0))
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Script {
#[default]
Normal,
Super,
Sub,
}
/// The run properties one level of the style hierarchy sets (None: it doesn't set that one).
#[derive(Clone, Copy, Debug, Default)]
struct RunProps {
/// The font's number in [`Reader::fonts`].
font: Option<usize>,
half_points: Option<f64>,
bold: Option<bool>,
italic: Option<bool>,
caps: Option<bool>,
small_caps: Option<bool>,
strike: Option<bool>,
dstrike: Option<bool>,
hidden: Option<bool>,
underline: Option<bool>,
script: Option<Script>,
/// Raised (positive) or lowered text in half points.
shift: Option<f64>,
/// Some(None): automatic (the default color).
color: Option<Option<[u8; 3]>>,
}
/// Character formatting.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Chars {
font: Option<usize>,
/// None: VectorCraft's default size.
half_points: Option<f64>,
bold: bool,
italic: bool,
caps: bool,
small_caps: bool,
strike: bool,
dstrike: bool,
hidden: bool,
underline: bool,
script: Script,
shift: f64,
color: Option<[u8; 3]>,
}
impl Chars {
/// `p` applied over this formatting: as a style's (a toggle property turned on reverses its
/// state, turned off leaves it) or as direct formatting (it sets each property).
fn apply(&mut self, p: &RunProps, style: bool) {
let toggle = |state: &mut bool, v: Option<bool>| match v {
Some(true) if style => *state = !*state,
Some(v) if !style => *state = v,
_ => {}
};
toggle(&mut self.bold, p.bold);
toggle(&mut self.italic, p.italic);
toggle(&mut self.caps, p.caps);
toggle(&mut self.small_caps, p.small_caps);
toggle(&mut self.strike, p.strike);
toggle(&mut self.hidden, p.hidden);
self.font = p.font.or(self.font);
self.half_points = p.half_points.or(self.half_points);
self.dstrike = p.dstrike.unwrap_or(self.dstrike);
self.underline = p.underline.unwrap_or(self.underline);
self.script = p.script.unwrap_or(self.script);
self.shift = p.shift.unwrap_or(self.shift);
self.color = p.color.unwrap_or(self.color);
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Align {
#[default]
Left,
Center,
Right,
/// Justified, the last line at the start (the right of a right-to-left paragraph).
Both,
/// Justified, the last line too.
Distribute,
Start,
End,
}
/// The paragraph properties one level of the style hierarchy sets; lengths in twips.
#[derive(Clone, Copy, Debug, Default)]
struct ParaProps {
align: Option<Align>,
/// The indent at the paragraph's start (the left of a left-to-right paragraph).
start: Option<f64>,
/// The indent at the paragraph's end.
end: Option<f64>,
first: Option<f64>,
before: Option<f64>,
after: Option<f64>,
bidi: Option<bool>,
}
/// Paragraph formatting; lengths in twips.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Para {
align: Align,
start: f64,
end: f64,
first: f64,
before: f64,
after: f64,
rtl: Option<bool>,
}
impl Para {
fn apply(&mut self, p: &ParaProps) {
self.align = p.align.unwrap_or(self.align);
self.start = p.start.unwrap_or(self.start);
self.end = p.end.unwrap_or(self.end);
self.first = p.first.unwrap_or(self.first);
self.before = p.before.unwrap_or(self.before);
self.after = p.after.unwrap_or(self.after);
self.rtl = p.bidi.or(self.rtl);
}
}
/// A style of the styles part.
struct Style {
based_on: Option<String>,
run: RunProps,
para: ParaProps,
}
#[derive(Default)]
struct Reader {
/// The theme's major and minor Latin fonts.
theme: [Option<String>; 2],
fonts: Vec<String>,
font_numbers: HashMap<String, usize>,
styles: HashMap<String, Style>,
default_run: RunProps,
default_para: ParaProps,
/// The default paragraph style's id.
default_style: Option<String>,
/// The default character style's id.
default_char_style: Option<String>,
/// The paragraph being read: its formatting, and its run formatting before character styles
/// and direct formatting.
para: Para,
base: Chars,
/// A paragraph that has ended (its formatting and its mark's): its break goes before the
/// next text.
pending: Option<(Para, Chars)>,
/// The last cell's paragraph, which its row's break ends.
cell_end: Option<(Para, Chars)>,
/// A cell just ended: a tab goes before the next character of its row.
cell_tab: bool,
/// The complex fields open: true while their instructions are being read.
fields: Vec<bool>,
/// Fields begun past [`MAX_DEPTH`] and not yet ended.
field_overflow: usize,
/// The text, in runs of one character formatting; paragraphs end with `\n`.
spans: Vec<(String, Chars)>,
/// The bytes of text in `spans`.
len: usize,
/// The formatting of each paragraph that has ended.
paras: Vec<Para>,
/// Past [`MAX_PARAGRAPHS`], [`MAX_RUNS`] or [`text::MAX_TEXT`].
full: bool,
}
impl Reader {
/// The major and minor fonts of a theme part's font scheme.
fn theme_fonts(&mut self, doc: &roxmltree::Document) {
for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) {
*slot = doc
.descendants()
.find(|n| n.tag_name().name() == scheme)
.and_then(|n| n.children().find(|c| c.tag_name().name() == "latin"))
.and_then(|n| n.attribute("typeface"))
.map(font_name)
.filter(|f| !f.is_empty());
}
}
/// The document defaults and the styles of the styles part.
fn styles(&mut self, doc: &roxmltree::Document) {
for n in doc.root_element().children() {
match local(n) {
Some("docDefaults") => {
let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner));
if let Some(rpr) = props("rPrDefault", "rPr") {
self.default_run = self.run_props(rpr);
}
if let Some(ppr) = props("pPrDefault", "pPr") {
self.default_para = para_props(ppr);
}
}
Some("style") if self.styles.len() < MAX_STYLES => {
let Some(id) = attr(n, "styleId") else { continue };
let default = match attr(n, "type") {
Some("paragraph") => Some(&mut self.default_style),
Some("character") => Some(&mut self.default_char_style),
_ => None,
};
if let Some(default) = default.filter(|_| attr(n, "default").is_some_and(|v| matches!(v, "1" | "true" | "on"))) {
default.get_or_insert_with(|| id.to_string());
}
let run = child(n, "rPr").map(|r| self.run_props(r)).unwrap_or_default();
let para = child(n, "pPr").map(para_props).unwrap_or_default();
let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).map(str::to_string);
self.styles.entry(id.to_string()).or_insert(Style { based_on, run, para });
}
_ => {}
}
}
}
/// Style `id` and the styles it is based on, the first of the chain first.
fn chain(&self, id: Option<&str>) -> Vec<&Style> {
let mut ids: Vec<&str> = vec![];
let mut chain = vec![];
let mut next = id;
while let Some(id) = next {
if ids.contains(&id) || ids.len() >= MAX_BASED_ON {
break;
}
let Some(style) = self.styles.get(id) else { break };
ids.push(id);
chain.push(style);
next = style.based_on.as_deref();
}
chain.reverse();
chain
}
/// The number of the font a `w:rFonts` element names.
fn font(&mut self, n: Node) -> Option<usize> {
let theme = |v: &str| self.theme.get(usize::from(v.starts_with("minor"))).and_then(Option::as_deref);
let name = attr(n, "asciiTheme")
.and_then(theme)
.or_else(|| attr(n, "ascii"))
.or_else(|| attr(n, "hAnsiTheme").and_then(theme))
.or_else(|| attr(n, "hAnsi"))
.map(font_name)?;
if name.is_empty() {
return None;
}
if let Some(&i) = self.font_numbers.get(&name) {
return Some(i);
}
if self.fonts.len() >= MAX_FONTS {
return None;
}
self.fonts.push(name.clone());
self.font_numbers.insert(name, self.fonts.len() - 1);
Some(self.fonts.len() - 1)
}
/// The run properties of a `w:rPr` element.
fn run_props(&mut self, rpr: Node) -> RunProps {
let mut p = RunProps::default();
for n in rpr.children() {
match local(n) {
Some("rFonts") => p.font = self.font(n),
Some("b") => p.bold = Some(on(n)),
Some("i") => p.italic = Some(on(n)),
Some("caps") => p.caps = Some(on(n)),
Some("smallCaps") => p.small_caps = Some(on(n)),
Some("strike") => p.strike = Some(on(n)),
Some("dstrike") => p.dstrike = Some(on(n)),
Some("vanish") => p.hidden = Some(on(n)),
Some("u") => p.underline = Some(attr(n, "val") != Some("none")),
Some("vertAlign") => {
p.script = match attr(n, "val") {
Some("superscript") => Some(Script::Super),
Some("subscript") => Some(Script::Sub),
Some("baseline") => Some(Script::Normal),
_ => p.script,
}
}
Some("position") => p.shift = half_points(n).or(p.shift),
Some("sz") => p.half_points = half_points(n).filter(|v| *v > 0.0).or(p.half_points),
Some("color") => {
let hex = |v: &str| -> Option<[u8; 3]> {
let v = v.trim();
if v.len() != 6 || !v.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let byte = |i: usize| v.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
Some([byte(0)?, byte(2)?, byte(4)?])
};
p.color = match attr(n, "val") {
Some("auto") => Some(None),
Some(v) => hex(v).map(Some).or(p.color),
None => p.color,
}
}
_ => {}
}
}
p
}
/// The paragraphs, tables and containers of them that are `n`'s children.
fn block(&mut self, n: Node, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in n.children() {
if self.full {
return;
}
match local(c) {
Some("p") => self.paragraph(c, depth),
Some("tbl") => self.table(c, depth),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
self.block(content, depth + 1);
}
}
Some("customXml" | "ins" | "moveTo") => self.block(c, depth + 1),
_ => {}
}
}
}
/// A table: each row a paragraph, its cells separated by tabs.
fn table(&mut self, tbl: Node, depth: usize) {
for row in items(tbl, "tr", depth) {
for cell in items(row, "tc", depth) {
self.flush();
self.block(cell, depth + 1);
// The cell's last paragraph goes on in the next cell, after a tab.
let end = self.pending.take();
if self.cell_tab
&& let Some((_, mark)) = end
{
self.push('\t', mark);
}
self.cell_end = end.or(self.cell_end);
self.cell_tab = true;
}
self.cell_tab = false;
self.pending = self.cell_end.take();
}
}
/// A paragraph.
fn paragraph(&mut self, p: Node, depth: usize) {
self.flush();
let ppr = child(p, "pPr");
let style = ppr.and_then(|n| child(n, "pStyle")).and_then(|n| attr(n, "val")).map(str::to_string).or_else(|| self.default_style.clone());
let mut para = Para::default();
para.apply(&self.default_para);
let mut base = Chars::default();
base.apply(&self.default_run, true);
for s in self.chain(style.as_deref()) {
para.apply(&s.para);
base.apply(&s.run, true);
}
if let Some(n) = ppr {
para.apply(&para_props(n));
}
(self.para, self.base) = (para, base);
self.inline(p, depth + 1);
// The paragraph mark's run properties are direct formatting over the paragraph style's.
let mut mark = base;
if let Some(rpr) = ppr.and_then(|n| child(n, "rPr")) {
let props = self.run_props(rpr);
mark.apply(&props, false);
}
// A line break at the paragraph's end is written before the paragraph ends, and a hidden
// paragraph mark joins the paragraph to the next.
if !mark.hidden {
self.flush();
self.pending = Some((para, mark));
}
}
/// The runs of a paragraph, and the containers of runs in it.
fn inline(&mut self, n: Node, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in n.children() {
match local(c) {
Some("r") => self.run(c),
Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
self.inline(content, depth + 1);
}
}
_ => {}
}
}
}
/// A run.
fn run(&mut self, r: Node) {
let rpr = child(r, "rPr");
let mut chars = self.base;
let style = rpr.and_then(|n| child(n, "rStyle")).and_then(|n| attr(n, "val")).or(self.default_char_style.as_deref());
for s in self.chain(style) {
chars.apply(&s.run, true);
}
if let Some(n) = rpr {
let props = self.run_props(n);
chars.apply(&props, false);
}
for c in r.children() {
match local(c) {
Some("t") => {
// Comments and processing instructions may split the text.
for ch in c.children().filter(|n| n.is_text()).filter_map(|n| n.text()).flat_map(str::chars) {
self.put(ch, chars);
}
}
Some("tab" | "ptab") => self.put('\t', chars),
Some("noBreakHyphen") => self.put('\u{2011}', chars),
Some("softHyphen") => self.put('\u{AD}', chars),
Some("br") if matches!(attr(c, "type"), None | Some("textWrapping")) => self.line_break(chars),
Some("cr") => self.line_break(chars),
Some("fldChar") => match attr(c, "fldCharType") {
Some("begin") if self.fields.len() < MAX_DEPTH => self.fields.push(true),
Some("begin") => self.field_overflow += 1,
Some("separate") if self.field_overflow == 0 => {
if let Some(instructions) = self.fields.last_mut() {
*instructions = false;
}
}
Some("end") if self.field_overflow > 0 => self.field_overflow -= 1,
Some("end") => {
self.fields.pop();
}
_ => {}
},
_ => {}
}
}
}
/// Text is not read: hidden, or a field's instructions.
fn skipped(&self, chars: &Chars) -> bool {
chars.hidden || self.field_overflow > 0 || self.fields.contains(&true)
}
/// Character `c` of the document's text in formatting `chars`.
fn put(&mut self, c: char, chars: Chars) {
if self.skipped(&chars) {
return;
}
self.flush();
if std::mem::take(&mut self.cell_tab) {
self.push('\t', chars);
}
self.push(c, chars);
}
/// A line break: the rest of the paragraph is a new paragraph in the same formatting.
fn line_break(&mut self, chars: Chars) {
if self.skipped(&chars) {
return;
}
self.flush();
self.pending = Some((self.para, chars));
}
/// Writes the break of the paragraph that has ended.
fn flush(&mut self) {
let Some((para, mark)) = self.pending.take() else { return };
if self.paras.len() >= MAX_PARAGRAPHS {
self.full = true;
return;
}
self.paras.push(para);
// A cell that starts with a break is still separated from the cell before it.
if std::mem::take(&mut self.cell_tab) {
self.push('\t', mark);
}
self.push('\n', mark);
}
/// Adds `c` to the text in formatting `chars`.
fn push(&mut self, c: char, chars: Chars) {
self.len += c.len_utf8();
if self.len > text::MAX_TEXT {
self.full = true;
return;
}
let runs = self.spans.len();
match self.spans.last_mut() {
Some((s, f)) if *f == chars => s.push(c),
_ if runs >= MAX_RUNS => self.full = true,
_ => self.spans.push((c.to_string(), chars)),
}
}
/// The character attributes of formatting `c`.
fn style(&self, c: &Chars) -> CharStyle {
let mut s = CharStyle::default();
if let Some(name) = c.font.and_then(|i| self.fonts.get(i)) {
s.font_family.clone_from(name);
}
s.font_style = match (c.bold, c.italic) {
(true, true) => "Bold Italic",
(true, false) => "Bold",
(false, true) => "Italic",
(false, false) => "Regular",
}
.into();
if let Some(v) = c.half_points {
s.size = (v / 2.0).clamp(0.1, 1296.0);
}
s.baseline_shift = (c.shift / 2.0).clamp(-1296.0, 1296.0);
if let Some([r, g, b]) = c.color {
s.fill = Paint::solid(vectorcraft_color::Color::rgb8(r, g, b));
}
s.underline = c.underline;
s.strikethrough = c.strike || c.dstrike;
s.all_caps = c.caps;
// Document Setup's proportions replace these when the type is placed.
s.small_caps = c.small_caps.then_some(70.0);
s.position = match c.script {
Script::Normal => CharPosition::Normal,
Script::Super => CharPosition::Superscript(ScriptMetrics::DEFAULT),
Script::Sub => CharPosition::Subscript(ScriptMetrics::DEFAULT),
};
s
}
/// The text read, without blank paragraphs at its end, and each paragraph's attributes.
fn finish(mut self, cmd: &str) -> Result<Story> {
if self.full {
return Err(bad(
cmd,
format!(
"the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {} MB of text)",
text::MAX_TEXT >> 20
),
));
}
let last = self.pending.take().map_or(self.para, |(para, _)| para);
self.paras.push(last);
let mut all: String = self.spans.iter().map(|(s, _)| s.as_str()).collect();
while self.paras.len() > 1
&& let Some(at) = all.rfind('\n')
&& all.get(at + 1..).is_some_and(|tail| tail.trim().is_empty())
{
all.truncate(at);
self.paras.pop();
}
let mut keep = all.len();
let mut runs: Vec<TextRun> = vec![];
for (s, chars) in &self.spans {
let piece = s.get(..keep.min(s.len())).unwrap_or_default();
keep -= piece.len();
if piece.is_empty() {
continue;
}
let style = self.style(chars);
match runs.last_mut() {
Some(r) if r.style == style => r.text.push_str(piece),
_ => runs.push(TextRun::new(piece, style)),
}
}
let points = |v: f64| (v / 20.0).clamp(-1296.0, 1296.0);
let paras = self
.paras
.iter()
.map(|p| {
let rtl = p.rtl == Some(true);
let (left, right) = if rtl { (p.end, p.start) } else { (p.start, p.end) };
ParaStyle {
justify: match p.align {
Align::Left => Justify::Left,
Align::Center => Justify::Center,
Align::Right => Justify::Right,
Align::Both if rtl => Justify::JustifyRight,
Align::Both => Justify::JustifyLeft,
Align::Distribute => Justify::JustifyAll,
Align::Start => Justify::Auto,
Align::End if rtl => Justify::Left,
Align::End => Justify::Right,
},
left_indent: points(left),
right_indent: points(right),
first_line_indent: points(p.first),
space_before: points(p.before),
space_after: points(p.after),
direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }),
..ParaStyle::default()
}
})
.collect();
Ok(Story { runs, paras })
}
}
/// Font name `name` trimmed, and cut at the last character that ends within [`MAX_NAME`] bytes.
fn font_name(name: &str) -> String {
let name = name.trim();
let end = (0..=MAX_NAME.min(name.len())).rev().find(|&i| name.is_char_boundary(i)).unwrap_or_default();
name.get(..end).unwrap_or_default().to_string()
}
/// The paragraph properties of a `w:pPr` element.
fn para_props(ppr: Node) -> ParaProps {
let mut p = ParaProps::default();
for n in ppr.children() {
match local(n) {
Some("jc") => {
p.align = match attr(n, "val") {
Some("left") => Some(Align::Left),
Some("center") => Some(Align::Center),
Some("right") => Some(Align::Right),
Some("both" | "lowKashida" | "mediumKashida" | "highKashida") => Some(Align::Both),
Some("distribute" | "thaiDistribute") => Some(Align::Distribute),
Some("start") => Some(Align::Start),
Some("end") => Some(Align::End),
_ => p.align,
}
}
Some("ind") => {
// `w:left` and `w:right` (Transitional) are the start and end indents too.
p.start = twips(n, "start").or_else(|| twips(n, "left")).or(p.start);
p.end = twips(n, "end").or_else(|| twips(n, "right")).or(p.end);
// A hanging indent wins over a first-line indent.
p.first = twips(n, "hanging").map(|v| -v).or_else(|| twips(n, "firstLine")).or(p.first);
}
Some("spacing") => {
p.before = twips(n, "before").or(p.before);
p.after = twips(n, "after").or(p.after);
}
Some("bidi") => p.bidi = Some(on(n)),
_ => {}
}
}
p
}
/// `n`'s children named `name`, and those inside content controls and custom XML among them.
fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec<Node<'a, 'i>> {
let mut out = vec![];
if depth > MAX_DEPTH {
return out;
}
for c in n.children() {
match local(c) {
Some(l) if l == name => out.push(c),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
out.extend(items(content, name, depth + 1));
}
}
Some("customXml") => out.extend(items(c, name, depth + 1)),
_ => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_read_numbers_and_units() {
assert_eq!(measure("240", 20.0), Some(240.0));
assert_eq!(measure("12pt", 20.0), Some(240.0));
assert_eq!(measure("1in", 2.0), Some(144.0));
assert_eq!(measure("2.54cm", 1.0).map(|v| (v * 1e9).round() / 1e9), Some(72.0));
assert_eq!(measure("1e999", 20.0), None);
assert_eq!(measure("wide", 20.0), None);
}
#[test]
fn nesting_counts_elements_outside_markup_that_is_not_an_element() {
assert_eq!(nesting(r#"<?xml version="1.0"?><a><b x="1>2" y='</c>'><c/></b><!-- <d><e> --><![CDATA[<f><g>]]></a>"#), 2);
assert_eq!(nesting("<a><b><c>"), 3, "unclosed elements count");
assert_eq!(nesting("</a></b><a>"), 1);
assert_eq!(nesting("<a x=\"unterminated"), 1);
assert_eq!(nesting(""), 0);
}
#[test]
fn targets_resolve_against_their_folder() {
assert_eq!(resolve("", "word/document.xml"), "word/document.xml");
assert_eq!(resolve("word/", "styles.xml"), "word/styles.xml");
assert_eq!(resolve("word/", "../customXml/item1.xml"), "customXml/item1.xml");
assert_eq!(resolve("word/", "/word/theme/./theme1.xml"), "word/theme/theme1.xml");
}
}
+13 -1
View File
@@ -17,6 +17,8 @@
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
//! [`text`]).
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
//! - A Word document (`.docx`) becomes area type with the character and paragraph formatting its
//! styles and direct formatting give ([`docx`]).
//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document:
//! one locked object showing the artboard `page`, linked to the file and read again when it
//! changes ([`document`]); without Link, an editable copy of its art.
@@ -28,6 +30,7 @@
pub(crate) mod adopt;
pub(crate) mod document;
mod docx;
mod rtf;
mod text;
@@ -98,7 +101,8 @@ pub fn specs() -> Vec<CommandSpec> {
/// A file's art at its natural size, before it joins a document.
enum Art {
Image(RasterImage),
/// A text or RTF file's text, placed as area type filling the natural box.
/// A text file's, an RTF file's or a Word document's text, placed as area type filling the
/// natural box.
Text(text::Story),
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
/// bounds (not the artboard) are shown.
@@ -155,6 +159,14 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
}
return Ok(Loaded { name, format: &rtf::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
}
// A Word document is recognized by its name or by its package's `word/document.xml`.
if docx::is_docx(src.name, &src.bytes) {
let story = docx::import(&src.bytes, cmd)?;
if story.plain_text().trim().is_empty() {
return Err(bad(cmd, format!("`{name}` has no text to place")));
}
return Ok(Loaded { name, format: &docx::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
}
let format = fileio::detect(src.name, &src.bytes)
.ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?;
let page = match p.get("page") {
+3 -2
View File
@@ -17,7 +17,8 @@ pub(super) const FORMAT: fileio::Format = fileio::Format {
options: &[],
};
/// The most bytes a text or RTF file placed as type may have.
/// The most bytes a text or RTF file placed as type may have, and the most bytes of text a Word
/// document may give.
pub(super) const MAX_TEXT: usize = 4 << 20;
/// Text Import Options (`file.place`'s `text` param).
@@ -190,7 +191,7 @@ fn tabs_for_spaces(s: &str, n: usize) -> String {
out
}
/// The text placed type gets from a text or RTF file.
/// The text placed type gets from a text file, an RTF file or a Word document.
pub(super) struct Story {
/// The text in runs of one character formatting; paragraphs are separated by `\n`.
pub(super) runs: Vec<vectorcraft_doc::TextRun>,
+273 -6
View File
@@ -1,4 +1,5 @@
//! File → Place of a text file (Text Import Options) or an RTF file: the text set as area type.
//! File → Place of a text file (Text Import Options), an RTF file or a Word document: the text set
//! as area type.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeKind, TextKind};
@@ -162,12 +163,278 @@ fn malformed_rtf_fails_cleanly() {
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
}
/// A ZIP archive of `files`, each deflated, its headers declaring each file's size, or `declared`
/// for every file.
fn deflated_zip(files: &[(&str, &[u8])], declared: Option<u32>) -> Vec<u8> {
let (mut out, mut central) = (vec![], vec![]);
for (name, data) in files {
let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default());
std::io::Write::write_all(&mut e, data).unwrap();
let packed = e.finish().unwrap();
// Flags, method (8: deflated), time, date, CRC, sizes and the name's length.
let mut shared = vec![];
for v in [0u16, 8, 0, 0] {
shared.extend(v.to_le_bytes());
}
for v in [crc32fast::hash(data), packed.len() as u32, declared.unwrap_or(data.len() as u32)] {
shared.extend(v.to_le_bytes());
}
shared.extend((name.len() as u16).to_le_bytes());
let offset = out.len() as u32;
out.extend(0x0403_4b50u32.to_le_bytes());
out.extend(20u16.to_le_bytes());
out.extend(&shared);
out.extend(0u16.to_le_bytes());
out.extend(name.as_bytes());
out.extend(&packed);
central.extend(0x0201_4b50u32.to_le_bytes());
central.extend([20u16, 20].map(u16::to_le_bytes).concat());
central.extend(&shared);
central.extend([0u8; 12]);
central.extend(offset.to_le_bytes());
central.extend(name.as_bytes());
}
let start = out.len() as u32;
out.extend(&central);
out.extend(0x0605_4b50u32.to_le_bytes());
out.extend([0u8; 4]);
out.extend([files.len() as u16; 2].map(u16::to_le_bytes).concat());
out.extend((central.len() as u32).to_le_bytes());
out.extend(start.to_le_bytes());
out.extend(0u16.to_le_bytes());
out
}
const W_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
/// A Word document's package: its main part `body` (the inside of `w:body`), with a styles part
/// and a theme part named through the main part's relationships.
fn docx(body: &str, styles: &str) -> Vec<u8> {
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let rels = "http://schemas.openxmlformats.org/package/2006/relationships";
let types = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/></Types>"#;
let root =
format!(r#"<Relationships xmlns="{rels}"><Relationship Id="rId1" Type="{rel}/officeDocument" Target="word/document.xml"/></Relationships>"#);
let doc_rels = format!(
r#"<Relationships xmlns="{rels}"><Relationship Id="rId1" Type="{rel}/styles" Target="styles.xml"/><Relationship Id="rId2" Type="{rel}/theme" Target="theme/theme1.xml"/><Relationship Id="rId3" Type="{rel}/hyperlink" Target="https://example.com/" TargetMode="External"/></Relationships>"#
);
let document = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W_NS}"><w:body>{body}<w:sectPr/></w:body></w:document>"#
);
let styles = format!(r#"<w:styles xmlns:w="{W_NS}">{styles}</w:styles>"#);
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Test"><a:themeElements><a:fontScheme name="Test"><a:majorFont><a:latin typeface="Arial"/></a:majorFont><a:minorFont><a:latin typeface="Georgia"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
let files: [(&str, &[u8]); 6] = [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", root.as_bytes()),
("word/document.xml", document.as_bytes()),
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
("word/styles.xml", styles.as_bytes()),
("word/theme/theme1.xml", theme.as_bytes()),
];
deflated_zip(&files, None)
}
/// Document defaults in the theme's minor font at 11 pt with 8 pt after each paragraph, a default
/// Normal style, a centered, bold Heading 1 in the theme's major font at 16 pt and in a color, a
/// bold character style and a default character style in all caps.
const STYLES: &str = concat!(
r#"<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi"/><w:sz w:val="22"/></w:rPr></w:rPrDefault>"#,
r#"<w:pPrDefault><w:pPr><w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>"#,
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>"#,
r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:basedOn w:val="Normal"/><w:pPr><w:jc w:val="center"/><w:spacing w:before="240" w:after="0"/></w:pPr>"#,
r#"<w:rPr><w:rFonts w:asciiTheme="majorHAnsi"/><w:b/><w:sz w:val="32"/><w:color w:val="1F3864"/></w:rPr></w:style>"#,
r#"<w:style w:type="character" w:styleId="Strong"><w:rPr><w:b/></w:rPr></w:style>"#,
r#"<w:style w:type="character" w:default="1" w:styleId="Plain"><w:rPr><w:caps/></w:rPr></w:style>"#,
);
#[test]
fn docx_places_as_area_type_with_its_styles_and_formatting() {
let body = concat!(
r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t xml:space="preserve">Report </w:t></w:r>"#,
r#"<w:r><w:rPr><w:rStyle w:val="Strong"/></w:rPr><w:t>notes</w:t></w:r></w:p>"#,
r#"<w:p><w:pPr><w:ind w:left="720" w:hanging="360"/><w:jc w:val="both"/></w:pPr><w:r><w:t xml:space="preserve">Plain and </w:t></w:r>"#,
r#"<w:r><w:rPr><w:rFonts w:ascii="Courier New"/><w:i/><w:u w:val="single"/></w:rPr><w:t>styled</w:t></w:r>"#,
r#"<w:r><w:rPr><w:vertAlign w:val="superscript"/></w:rPr><w:t>2</w:t></w:r><w:r><w:rPr><w:vanish/></w:rPr><w:t>hidden</w:t></w:r>"#,
r#"<w:del w:id="1" w:author="A"><w:r><w:delText>gone</w:delText></w:r></w:del><w:ins w:id="2" w:author="A"><w:r><w:t xml:space="preserve"> page </w:t></w:r></w:ins>"#,
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>7</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
);
let mut s = session();
s.execute("document.setup", &json!({"superscript": {"size": 50, "position": 40}})).unwrap();
let file = json!({"name": "report.docx", "dataBase64": vectorcraft_format::base64_encode(&docx(body, STYLES))});
let r = s.execute("file.place", &file).unwrap();
assert_eq!((r["format"].as_str(), r["name"].as_str()), (Some("docx"), Some("report.docx")));
let (text, frame) = placed(&s, &r);
assert_eq!(text, "Report notes\nPlain and styled2 page 7", "hidden and deleted text and the field's instructions are left out");
assert!(close(frame, Rect::new(36.0, 36.0, 364.0, 264.0)), "the artboard less a 36 pt margin: {frame:?}");
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
let runs: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str(), r.style.font_style.as_str(), r.style.size)).collect();
assert_eq!(
runs,
[
("Report ", "Arial", "Bold", 16.0),
// Bold in the character style reverses the paragraph style's bold.
("notes", "Arial", "Regular", 16.0),
("\n", "Arial", "Bold", 16.0),
("Plain and ", "Georgia", "Regular", 11.0),
("styled", "Courier New", "Italic", 11.0),
("2", "Georgia", "Regular", 11.0),
(" page 7", "Georgia", "Regular", 11.0),
]
);
let navy = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0x1F, 0x38, 0x64));
assert_eq!((t.runs[0].style.fill == navy, t.runs[1].style.fill == navy, t.runs[3].style.fill == navy), (true, true, false));
assert_eq!((t.runs[3].style.underline, t.runs[4].style.underline), (false, true));
let caps: Vec<_> = t.runs.iter().map(|r| r.style.all_caps).collect();
assert_eq!(caps, [true, false, false, true, true, true, true], "runs without a character style take the default one");
let superscript = vectorcraft_doc::ScriptMetrics { size: 50.0, position: 40.0 };
assert_eq!(t.runs[5].style.position, vectorcraft_doc::CharPosition::Superscript(superscript), "in Document Setup's proportions");
let paras = t.paragraph_styles();
let summary: Vec<_> = paras.iter().map(|p| (p.justify, p.left_indent, p.first_line_indent, p.space_before, p.space_after)).collect();
assert_eq!(summary, [(vectorcraft_doc::Justify::Center, 0.0, 0.0, 12.0, 0.0), (vectorcraft_doc::Justify::JustifyLeft, 36.0, -18.0, 0.0, 8.0)]);
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.selection.objects.len()), (2, 1), "Document Setup and Place, each one undo step");
// Info and the place cursor report it as a Word document.
let info = s.execute("file.place.info", &file).unwrap();
assert_eq!((info["format"].as_str(), info["width"].as_f64()), (Some("docx"), Some(468.0)));
let q = s.execute("file.place.queue", &json!({"files": [file]})).unwrap();
assert_eq!((q["count"].as_u64(), q["files"][0]["format"].as_str()), (Some(1), Some("docx")));
}
#[test]
fn docx_double_strikethrough_is_not_a_toggle_property() {
// A paragraph style and a character style that both set double strikethrough, and a run that
// turns it off directly.
let styles = concat!(
r#"<w:style w:type="paragraph" w:styleId="Struck"><w:rPr><w:dstrike/></w:rPr></w:style>"#,
r#"<w:style w:type="character" w:styleId="AlsoStruck"><w:rPr><w:dstrike/></w:rPr></w:style>"#,
);
let body = concat!(
r#"<w:p><w:pPr><w:pStyle w:val="Struck"/></w:pPr><w:r><w:t xml:space="preserve">one </w:t></w:r>"#,
r#"<w:r><w:rPr><w:rStyle w:val="AlsoStruck"/></w:rPr><w:t xml:space="preserve">two </w:t></w:r>"#,
r#"<w:r><w:rPr><w:rStyle w:val="AlsoStruck"/><w:dstrike w:val="0"/></w:rPr><w:t>three</w:t></w:r></w:p>"#,
);
let mut s = session();
let r = s.execute("file.place", &json!({"name": "struck.docx", "dataBase64": vectorcraft_format::base64_encode(&docx(body, styles))})).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
let struck: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.strikethrough)).collect();
assert_eq!(struck, [("one two ", true), ("three", false)], "the character style keeps the paragraph style's double strikethrough");
}
#[test]
fn docx_tables_breaks_and_fields_place_as_paragraphs_and_tabs() {
// The Strict namespace, a package without relationships or styles, stored without compression
// and named without `.docx`.
let document = concat!(
r#"<w:document xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main"><w:body>"#,
r#"<w:p><w:r><w:t>Line one</w:t><w:br/><w:t>line two</w:t><w:br/><w:br w:type="page"/></w:r></w:p>"#,
r#"<w:tbl><w:tblPr/><w:tr><w:tc><w:p><w:r><w:t>a</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>b</w:t></w:r></w:p></w:tc></w:tr>"#,
r#"<w:tr><w:tc><w:p><w:r><w:t>c</w:t></w:r></w:p></w:tc><w:tc><w:p/></w:tc>"#,
r#"<w:sdt><w:sdtContent><w:tc><w:p><w:hyperlink><w:r><w:t>d</w:t></w:r></w:hyperlink></w:p></w:tc></w:sdtContent></w:sdt></w:tr>"#,
r#"<w:tr><w:tc><w:p><w:r><w:t>e</w:t></w:r></w:p></w:tc><w:tc><w:p/><w:p><w:r><w:t>f</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#,
r#"<w:p><w:fldSimple w:instr=" DATE "><w:r><w:t>today</w:t></w:r></w:fldSimple><w:r><w:tab/><w:t>end</w:t></w:r>"#,
r#"<w:r><w:drawing><w:t>not text</w:t></w:drawing></w:r></w:p>"#,
r#"<w:p><w:pPr><w:bidi/><w:jc w:val="both"/></w:pPr><w:r><w:t><!-- a comment -->justi<?pi x?>fied</w:t></w:r></w:p>"#,
r#"<w:p><w:pPr><w:bidi/><w:jc w:val="end"/><w:ind w:start="400"/></w:pPr><w:r><w:t>rtl</w:t></w:r></w:p><w:p/><w:p/>"#,
"</w:body></w:document>"
);
let mut zip = cmd::fileio::zip::store(&[("word/document.xml", document)]).unwrap();
// An archive comment holding the end record's signature.
let comment = b"note PK\x05\x06 and 22 more bytes after it, at least";
let length = zip.len() - 2;
zip[length..].copy_from_slice(&(comment.len() as u16).to_le_bytes());
zip.extend(comment);
let mut s = session();
let r = s.execute("file.place", &json!({"name": "notes.bin", "dataBase64": vectorcraft_format::base64_encode(&zip)})).unwrap();
assert_eq!(r["format"].as_str(), Some("docx"));
assert_eq!(
placed(&s, &r).0,
"Line one\nline two\n\na\tb\nc\t\td\ne\t\nf\ntoday\tend\njustified\nrtl",
"blank paragraphs at the end are left out"
);
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
let paras = t.paragraph_styles();
assert_eq!(paras[paras.len() - 2].justify, vectorcraft_doc::Justify::JustifyRight, "the last line of right-to-left justified text at the right");
let last = paras.last().cloned().unwrap();
// The start indent of a right-to-left paragraph is its right indent.
assert_eq!(
(last.justify, last.left_indent, last.right_indent, last.direction),
(vectorcraft_doc::Justify::Left, 0.0, 20.0, Some(vectorcraft_doc::ParaDirection::RightToLeft))
);
}
#[test]
fn malformed_docx_fails_cleanly() {
let mut s = session();
let place = |s: &mut Session, name: &str, bytes: &[u8]| {
s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).map_err(|e| e.to_string())
};
let store = |files: &[(&str, &[u8])]| cmd::fileio::zip::store(files).unwrap();
let document = |body: &str| format!(r#"<w:document xmlns:w="{W_NS}"><w:body>{body}</w:body></w:document>"#);
assert!(place(&mut s, "a.docx", b"Plain words, not a package").is_err(), "a .docx file that isn't a ZIP archive");
let e = place(&mut s, "a.docx", &store(&[("readme.txt", b"x")])).unwrap_err();
assert!(e.contains("no main document part"), "{e}");
let e = place(&mut s, "a.zip", &store(&[("readme.txt", b"x")])).unwrap_err();
assert!(e.contains("not a format VectorCraft reads"), "a ZIP archive of something else isn't read as a Word document: {e}");
let sheet = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"/>"#;
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", sheet.as_bytes())])).unwrap_err();
assert!(e.contains("not a WordprocessingML document"), "{e}");
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", b"<w:document><w:body>")])).unwrap_err();
assert!(e.contains("not well-formed XML"), "{e}");
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", document("<w:p/>").as_bytes())])).unwrap_err();
assert!(e.contains("no text to place"), "{e}");
// A file whose headers understate what it unpacks to, and one whose headers say it is too large.
let bomb = vec![b' '; (16 << 20) + 1];
let e = place(&mut s, "a.docx", &deflated_zip(&[("word/document.xml", &bomb)], Some(100))).unwrap_err();
assert!(e.contains("too large to place"), "{e}");
let e = place(&mut s, "a.docx", &deflated_zip(&[("word/document.xml", b"<w:document/>")], Some(17 << 20))).unwrap_err();
assert!(e.contains("too large to place"), "{e}");
let many: Vec<(String, &[u8])> = (0..10_001).map(|i| (format!("f{i}"), &b""[..])).collect();
let e = place(&mut s, "a.docx", &store(&many.iter().map(|(n, b)| (n.as_str(), *b)).collect::<Vec<_>>())).unwrap_err();
assert!(e.contains("more than 10000 files"), "{e}");
// A changed byte fails the file's CRC.
let mut changed = store(&[("word/document.xml", document("<w:p><w:r><w:t>words</w:t></w:r></w:p>").as_bytes())]);
let at = changed.windows(5).position(|w| w == b"words").unwrap();
changed[at] = b'W';
let e = place(&mut s, "a.docx", &changed).unwrap_err();
assert!(e.contains("damaged"), "{e}");
// A stored file whose directory record gives more packed bytes than unpacked ones.
let mut longer = store(&[("word/document.xml", document("<w:p><w:r><w:t>words</w:t></w:r></w:p>").as_bytes()), ("pad", &[0; 64])]);
let central = longer.windows(4).position(|w| w == 0x0201_4b50u32.to_le_bytes()).unwrap();
let packed = u32::from_le_bytes(longer[central + 20..central + 24].try_into().unwrap()) + 64;
longer[central + 20..central + 24].copy_from_slice(&packed.to_le_bytes());
let e = place(&mut s, "a.docx", &longer).unwrap_err();
assert!(e.contains("damaged"), "{e}");
// Containers nested past the depth read are skipped, and a part nested deeper still is refused.
let nested = |n: usize| {
let deep = format!("{}<w:p><w:r><w:t>deep</w:t></w:r></w:p>{}", "<w:sdt><w:sdtContent>".repeat(n), "</w:sdtContent></w:sdt>".repeat(n));
store(&[("word/document.xml", document(&format!("{deep}<w:p><w:r><w:t>shallow</w:t></w:r></w:p>")).as_bytes())])
};
let r = place(&mut s, "a.docx", &nested(100)).unwrap();
assert_eq!(placed(&s, &r).0, "shallow");
let e = place(&mut s, "a.docx", &nested(5000)).unwrap_err();
assert!(e.contains("nests elements more than 256 deep"), "{e}");
// A font name is cut to 256 bytes.
let long = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{}"/></w:rPr><w:t>font</w:t></w:r></w:p>"#, "é".repeat(1000));
let r = place(&mut s, "a.docx", &store(&[("word/document.xml", document(&long).as_bytes())])).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
assert_eq!(t.runs[0].style.font_family, "é".repeat(128));
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
}
#[test]
fn place_reads_what_open_reads_and_text_files() {
use cmd::fileio::{OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS].concat());
use cmd::fileio::{DOCX_EXTS, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS, DOCX_EXTS].concat());
let filters: Vec<_> = cmd::fileio::place_filters().collect();
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("RTF", RTF_EXTS))));
assert_eq!(filters.get(filters.len() - 2), Some(&("Text", TEXT_EXTS)));
assert!(!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt") || exts.contains(&"rtf")), "Open doesn't read text or RTF files");
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("Word", DOCX_EXTS))));
assert_eq!(filters.get(filters.len() - 3..), Some(&[("Text", TEXT_EXTS), ("RTF", RTF_EXTS), ("Word", DOCX_EXTS)][..]));
assert!(
!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt") || exts.contains(&"rtf") || exts.contains(&"docx")),
"Open doesn't read text, RTF or Word files"
);
}
+119 -1
View File
@@ -1,5 +1,6 @@
//! Untrusted text files placed with File → Place never crash: any bytes, with any Text Import
//! Options, place as area type or fail with an error, and so do hostile RTF files.
//! Options, place as area type or fail with an error, and so do hostile RTF files and Word
//! documents.
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test text_import_fuzz` runs a deeper search.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
@@ -59,6 +60,123 @@ proptest! {
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
#[test]
fn any_docx_places_without_panics(
body in prop_oneof![1 => prop::collection::vec(arb_docx_token(), 0..120), 3 => prop::collection::vec(arb_docx_paragraph(), 0..12)],
styles in prop_oneof![1 => prop::collection::vec(arb_docx_token(), 0..40), 3 => prop::collection::vec(arb_docx_style(), 0..6)],
changes in prop_oneof![
3 => Just(vec![]),
1 => prop::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..3),
],
name in prop::sample::select(vec!["fuzz.docx", "fuzz.bin"]),
) {
vectorcraft_text::FontDb::global().set_system_fallback(false);
let w = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"";
let document = format!("<w:document {w}><w:body>{}</w:body></w:document>", body.concat());
let styles = format!("<w:styles {w}>{}</w:styles>", styles.concat());
let files = [("word/document.xml", document.as_bytes()), ("word/styles.xml", styles.as_bytes())];
let mut data = vectorcraft_engine::cmd::fileio::zip::store(&files).unwrap();
// A changed byte anywhere: in a header, a size, the directory or the XML.
for (at, byte) in changes {
let i = at.index(data.len());
data[i] = byte;
}
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
}
/// A well-formed paragraph, or a table row of two such paragraphs.
fn arb_docx_paragraph() -> impl Strategy<Value = String> {
prop_oneof![
3 => arb_docx_para(),
1 => (arb_docx_para(), arb_docx_para()).prop_map(|(a, b)| format!("<w:tbl><w:tr><w:tc>{a}</w:tc><w:tc>{b}</w:tc></w:tr></w:tbl>")),
]
}
/// A well-formed paragraph: properties and runs of empty elements and text.
fn arb_docx_para() -> impl Strategy<Value = String> {
let leaves = || prop::collection::vec(arb_docx_leaf(), 0..4);
(leaves(), prop::collection::vec((leaves(), arb_docx_leaf()), 0..6)).prop_map(|(ppr, runs)| {
let runs: String = runs.iter().map(|(rpr, t)| format!("<w:r><w:rPr>{}</w:rPr><w:t>{t}</w:t></w:r>", rpr.concat())).collect();
format!("<w:p><w:pPr>{}</w:pPr>{runs}</w:p>", ppr.concat())
})
}
/// Elements the Word document reader knows that hold others, opened or closed by
/// [`arb_docx_token`].
const DOCX_TAGS: &str = "p r t pPr rPr tbl tr tc sdt sdtContent hyperlink del ins style docDefaults rPrDefault pPrDefault";
/// Empty elements the Word document reader knows.
const DOCX_EMPTY: &str = "b i caps smallCaps strike dstrike vanish tab ptab br cr noBreakHyphen softHyphen bidi p";
/// Well-formed document defaults, or a paragraph or character style `S` or `C` (based on `S` or
/// not), with properties of empty elements.
fn arb_docx_style() -> impl Strategy<Value = String> {
let leaves = || prop::collection::vec(arb_docx_leaf(), 0..4).prop_map(|l| l.concat());
let kind = prop::sample::select(vec![("paragraph", "S"), ("character", "C"), ("paragraph", "Normal\" w:default=\"1")]);
prop_oneof![
(leaves(), leaves()).prop_map(|(r, p)| format!(
"<w:docDefaults><w:rPrDefault><w:rPr>{r}</w:rPr></w:rPrDefault><w:pPrDefault><w:pPr>{p}</w:pPr></w:pPrDefault></w:docDefaults>"
)),
(kind, any::<bool>(), leaves(), leaves()).prop_map(|((t, id), based, r, p)| {
let based = if based { "<w:basedOn w:val=\"S\"/>" } else { "" };
format!("<w:style w:type=\"{t}\" w:styleId=\"{id}\">{based}<w:rPr>{r}</w:rPr><w:pPr>{p}</w:pPr></w:style>")
}),
]
}
/// Pieces of WordprocessingML the Word document reader knows (paragraphs, runs, properties,
/// tables, fields, styles), a cut-off tag, entities and text.
fn arb_docx_token() -> impl Strategy<Value = String> {
let tags = prop::sample::select(DOCX_TAGS.split_whitespace().collect::<Vec<_>>());
let open = (tags, any::<bool>()).prop_map(|(t, end)| format!("<{}w:{t}>", if end { "/" } else { "" }));
let styles = prop::sample::select(vec!["<w:style w:type=\"paragraph\" w:styleId=\"S\">", "<w:style w:type=\"character\" w:styleId=\"C\">"]);
prop_oneof![open, styles.prop_map(str::to_string), arb_docx_leaf(), Just("<w:t".to_string())]
}
/// An empty WordprocessingML element the reader knows, with or without a value in or out of
/// range, entities or text.
fn arb_docx_leaf() -> impl Strategy<Value = String> {
let empty = prop::sample::select(DOCX_EMPTY.split_whitespace().collect::<Vec<_>>());
let valued = prop::sample::select(vec![
("pStyle", "val", "S"),
("rStyle", "val", "C"),
("basedOn", "val", "S"),
("sz", "val", "-7"),
("sz", "val", "1e400"),
("sz", "val", "12pt"),
("position", "val", "99999999999"),
("color", "val", "FF00"),
("color", "val", "auto"),
("u", "val", "none"),
("vertAlign", "val", "superscript"),
("jc", "val", "distribute"),
("jc", "val", "end"),
("ind", "hanging", "-360"),
("ind", "left", "3in"),
("spacing", "after", "x"),
("fldChar", "fldCharType", "begin"),
("fldChar", "fldCharType", "separate"),
("fldChar", "fldCharType", "end"),
("rFonts", "asciiTheme", "majorHAnsi"),
("rFonts", "ascii", ""),
("br", "type", "page"),
("b", "val", "0"),
]);
prop_oneof![
empty.prop_map(|e| format!("<w:{e}/>")),
valued.prop_map(|(e, a, v)| format!("<w:{e} w:{a}=\"{v}\"/>")),
Just("&amp;&#x1F600;&lt;".to_string()),
"[a-z ]{0,6}",
]
}
/// Control words the RTF reader knows, and one it doesn't.
+3 -2
View File
@@ -17,7 +17,7 @@ from the public specifications, and users' own `.ai` files are used locally only
| | Illustrator | VectorCraft | Notes |
|---|---:|---:|---|
| Formats opened or placed | ~30 | 23 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, Word, PS |
| Formats opened or placed | ~30 | 23 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, Word `.doc`, PS |
| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (no Illustrator editing data) or as legacy version 7 (layers and groups, one artboard per file) |
| Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD |
| **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting |
@@ -112,7 +112,8 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| CorelDRAW `.cdr` | open | no | no | |
| Text `.txt` | place, export | place (Text Import Options) | yes | |
| RTF `.rtf` | place | place (area type with fonts, size, bold, italic, underline, strikethrough, caps, superscript and subscript, color, alignment, indents and paragraph spacing) | no | `tests_textimport.rs`, `place/rtf.rs`; fuzzed (`text_import_fuzz.rs`); `\'hh` bytes read as Windows-1252 or Mac Roman only |
| Word `.doc`/`.docx` | place | no | no | |
| Word `.docx` | place | place (area type with the document's styles and its fonts, size, bold, italic, underline, strikethrough, caps, superscript and subscript, color, alignment, indents and paragraph spacing) | no | `tests_textimport.rs`, `place/docx.rs`; fuzzed (`text_import_fuzz.rs`); list numbers, pictures and text boxes are left out |
| Word `.doc` | place | no | no | |
| CSS | export (CSS Properties) | — | yes | |
| OBJ / USDA / glTF | 3D export | — | no | needs 3D (gap G3) |
| Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) |
+9
View File
@@ -2683,6 +2683,15 @@ subscript, baseline shift and color, and each paragraph's alignment, indents, sp
Table cells are separated by tabs and each row is a paragraph; hidden text, pictures, headers, footers and notes are
left out. `file.place` answers `format: "rtf"`.
A Word document (a ZIP package named `.docx`, or one that holds `word/document.xml`, except a `.txt` file) is placed
the same way and keeps the same formatting, taken from the document defaults, the paragraph style and the character
style (and the styles each is based on) and direct formatting; theme fonts are read from the theme part. Table cells
are separated by tabs and each row is a paragraph. Hidden and deleted text, field instructions, pictures, text boxes,
headers, footers, notes and list numbers are left out. A package with more than 10,000 files, a part that unpacks to
more than 16 MiB or nests elements more than 256 deep, and a document with more than 4 MiB of text or 100,000
paragraphs or runs are refused.
`file.place` answers `format: "docx"`.
## PDF presets
`pdf.preset.list` lists the built-in presets (`VectorCraft Default`, which preserves editing, then High Quality Print,
+1
View File
@@ -699,6 +699,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets.
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests_textimport.rs`).
- File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests_textimport.rs`).
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.
- Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected.
- In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one.