mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
Place: Word 97-2003 .doc files place as area type with their formatting
File > Place reads a Word 97-2003 document (a compound file named .doc, or one that holds a WordDocument stream) and sets its main text as area type, as it does for .rtf and .docx files. The text comes from the piece table, in 16-bit Unicode or compressed 8-bit pieces. Formatting is resolved from the style sheet's default font, the paragraph style, the character style and the direct character and paragraph properties, and keeps the same properties the .docx reader maps. Table cells are separated by tabs and each row is a paragraph, and a field places as its result. Hidden and deleted text, pictures, headers, footers, notes, comments and text boxes are left out, and the reply's warnings name each kind of content left out or changed once. Word 6.0 and Word 95 documents, encrypted documents and documents last saved with fast save are refused, and so are structures that lie outside their streams. The compound file is read with the cfb crate (MIT), which adds one package to Cargo.lock.
This commit is contained in:
Generated
+12
@@ -705,6 +705,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"
|
||||
@@ -6205,6 +6216,7 @@ dependencies = [
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
@@ -40,6 +40,8 @@ hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["st
|
||||
flate2 = { workspace = true }
|
||||
# The XML parts of Word documents placed as type (place/docx.rs).
|
||||
roxmltree = "0.21"
|
||||
# The compound file that holds a Word 97-2003 document placed as type (place/doc.rs).
|
||||
cfb = "0.15"
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -124,8 +124,9 @@ pub(crate) use export_names::{default_name, merge, unique_file_names, with_black
|
||||
use export_names::{distinct_names, write_encoded};
|
||||
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, writable};
|
||||
pub use formats::{
|
||||
DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, file_stem,
|
||||
format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export, writable_format,
|
||||
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension,
|
||||
file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export,
|
||||
writable_format,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -566,8 +566,14 @@ pub const RTF_EXTS: &[&str] = &["rtf"];
|
||||
/// 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`].
|
||||
/// Word 97-2003 documents: File → Place sets them as area type with their formatting.
|
||||
pub const DOC_EXTS: &[&str] = &["doc"];
|
||||
|
||||
/// The Place dialog's Word filter: Word documents and Word 97-2003 documents.
|
||||
const WORD_EXTS: &[&str] = &["docx", "doc"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`],
|
||||
/// [`DOCX_EXTS`] and [`DOC_EXTS`].
|
||||
pub const PLACE_EXTS: &[&str] = &[
|
||||
"vectorcraft",
|
||||
"drawcraft",
|
||||
@@ -610,6 +616,7 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"txt",
|
||||
"rtf",
|
||||
"docx",
|
||||
"doc",
|
||||
];
|
||||
|
||||
/// One dialog filter per readable format.
|
||||
@@ -645,13 +652,13 @@ pub fn library_open_filters(
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
|
||||
/// RTF and Word.
|
||||
/// RTF and Word (`.docx` and `.doc`).
|
||||
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)))
|
||||
.chain(std::iter::once(("Word", WORD_EXTS)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
//! - 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 Word 97-2003 document (`.doc`) becomes area type the same way, its main text read from its
|
||||
//! compound file, with a warning for each kind of content left out ([`doc`]).
|
||||
//! - 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.
|
||||
@@ -30,6 +32,7 @@
|
||||
//! `place` tool) with several files, which emits one `file.place` per click or drag.
|
||||
|
||||
pub(crate) mod adopt;
|
||||
mod doc;
|
||||
pub(crate) mod document;
|
||||
pub(crate) mod docx;
|
||||
mod rtf;
|
||||
@@ -169,6 +172,15 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
|
||||
}
|
||||
return Ok(Loaded { name, format: &docx::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
}
|
||||
// A Word 97-2003 document is recognized by its name or by its compound file's WordDocument
|
||||
// stream.
|
||||
if doc::is_doc(src.name, &src.bytes) {
|
||||
let (story, warnings) = doc::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: &doc::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings, 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") {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -467,15 +467,368 @@ fn malformed_docx_fails_cleanly() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
|
||||
}
|
||||
|
||||
/// The bytes of the Prls `prls`: each sprm and its operand.
|
||||
fn prls(prls: &[(u16, &[u8])]) -> Vec<u8> {
|
||||
prls.iter().flat_map(|(sprm, op)| sprm.to_le_bytes().into_iter().chain(op.iter().copied())).collect()
|
||||
}
|
||||
|
||||
/// A style of a [`Word97`] document: its kind (1 paragraph, 2 character), the style it is based
|
||||
/// on, and its paragraph and character properties.
|
||||
type Word97Style = (u16, u16, Vec<u8>, Vec<u8>);
|
||||
|
||||
/// A Word 97-2003 document, built as \[MS-DOC\] lays one out: a file information block, the main
|
||||
/// text's pieces stored last first, one property page per run of up to 16 character or paragraph
|
||||
/// property runs, the piece table, style sheet and font table in the table stream, all in a
|
||||
/// compound file.
|
||||
#[derive(Default)]
|
||||
struct Word97 {
|
||||
/// The main text's pieces: their text and whether it is stored compressed.
|
||||
pieces: Vec<(&'static str, bool)>,
|
||||
/// Character properties of characters `start..end`.
|
||||
chpx: Vec<(u32, u32, Vec<u8>)>,
|
||||
/// The paragraph style and properties of the paragraph whose mark is character `cp`; a
|
||||
/// character's paragraph is the first one whose mark is at or after it.
|
||||
papx: Vec<(u32, u16, Vec<u8>)>,
|
||||
/// The styles by istd; None: an empty slot.
|
||||
styles: Vec<Option<Word97Style>>,
|
||||
fonts: Vec<&'static str>,
|
||||
/// FibBase's flags and `nFib` (0: 0x00C1).
|
||||
flags: u16,
|
||||
nfib: u16,
|
||||
/// The table stream is `0Table` (else `1Table`).
|
||||
zero_table: bool,
|
||||
/// `ccpFtn`, `ccpHdd`, `ccpAtn`, `ccpEdn`, `ccpTxbx` and `ccpHdrTxbx`.
|
||||
stories: [u32; 6],
|
||||
/// Characters `ccpText` gives beyond the pieces.
|
||||
ccp_extra: u32,
|
||||
/// A Clx to write instead of the pieces'.
|
||||
clx: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// One 512-byte property page of `runs` (file positions and properties), written to `wd`; its
|
||||
/// page number.
|
||||
fn property_page(runs: &[(u32, u32, Vec<u8>)], papx: bool, wd: &mut Vec<u8>) -> u32 {
|
||||
let mut page = [0u8; 512];
|
||||
let n = runs.len();
|
||||
for (i, r) in runs.iter().enumerate() {
|
||||
page[4 * i..4 * i + 4].copy_from_slice(&r.0.to_le_bytes());
|
||||
}
|
||||
page[4 * n..4 * n + 4].copy_from_slice(&runs[n - 1].1.to_le_bytes());
|
||||
let mut top = 511;
|
||||
for (i, (_, _, props)) in runs.iter().enumerate() {
|
||||
let blob = match (papx, props.len() % 2) {
|
||||
(false, _) if props.is_empty() => continue,
|
||||
(false, _) => [vec![props.len() as u8], props.clone()].concat(),
|
||||
// PapxInFkp: cb with 2 × cb − 1 bytes, or 0 and cb′ with 2 × cb′ bytes.
|
||||
(true, 1) => [vec![props.len().div_ceil(2) as u8], props.clone()].concat(),
|
||||
(true, _) => [vec![0, (props.len() / 2) as u8], props.clone()].concat(),
|
||||
};
|
||||
top = (top - blob.len()) & !1;
|
||||
page[top..top + blob.len()].copy_from_slice(&blob);
|
||||
page[4 * (n + 1) + if papx { 13 * i } else { i }] = (top / 2) as u8;
|
||||
}
|
||||
page[511] = n as u8;
|
||||
wd.resize(wd.len().div_ceil(512) * 512, 0);
|
||||
let pn = (wd.len() / 512) as u32;
|
||||
wd.extend(page);
|
||||
pn
|
||||
}
|
||||
|
||||
impl Word97 {
|
||||
fn build(&self) -> Vec<u8> {
|
||||
let mut wd = vec![0u8; 900];
|
||||
let mut stored = vec![0u32; self.pieces.len()];
|
||||
for (i, (text, compressed)) in self.pieces.iter().enumerate().rev() {
|
||||
stored[i] = wd.len() as u32;
|
||||
if *compressed {
|
||||
wd.extend(text.chars().map(|c| match c {
|
||||
'“' => 0x93,
|
||||
'”' => 0x94,
|
||||
c => u8::try_from(u32::from(c)).unwrap(),
|
||||
}));
|
||||
} else {
|
||||
wd.extend(text.encode_utf16().flat_map(u16::to_le_bytes));
|
||||
}
|
||||
}
|
||||
// Each character's file position and size, in character order.
|
||||
let mut chars: Vec<(u32, u32)> = vec![];
|
||||
for ((text, compressed), &at) in self.pieces.iter().zip(&stored) {
|
||||
let w = if *compressed { 1 } else { 2 };
|
||||
chars.extend((0..text.encode_utf16().count() as u32).map(|k| (at + k * w, w)));
|
||||
}
|
||||
let chpx_of = |cp: u32| (0u32, self.chpx.iter().rev().find(|(s, e, _)| (*s..*e).contains(&cp)).map(|r| r.2.clone()).unwrap_or_default());
|
||||
let papx_of = |cp: u32| {
|
||||
let p = self.papx.iter().filter(|(mark, _, _)| *mark >= cp).min_by_key(|(mark, _, _)| *mark);
|
||||
p.map_or((u32::MAX, vec![0, 0]), |(mark, istd, g)| (*mark, [istd.to_le_bytes().to_vec(), g.clone()].concat()))
|
||||
};
|
||||
let mut bins = vec![];
|
||||
for (papx, of) in [(false, &chpx_of as &dyn Fn(u32) -> (u32, Vec<u8>)), (true, &papx_of)] {
|
||||
let mut runs: Vec<(u32, u32, u32, Vec<u8>)> = vec![];
|
||||
for (cp, &(fc, w)) in chars.iter().enumerate() {
|
||||
let (key, props) = of(cp as u32);
|
||||
match runs.last_mut() {
|
||||
Some((_, end, k, p)) if *end == fc && *k == key && *p == props => *end = fc + w,
|
||||
_ => runs.push((fc, fc + w, key, props)),
|
||||
}
|
||||
}
|
||||
// Pages of contiguous runs, 16 at most.
|
||||
let mut groups: Vec<Vec<(u32, u32, Vec<u8>)>> = vec![];
|
||||
for (start, end, _, props) in runs {
|
||||
match groups.last_mut() {
|
||||
Some(g) if g.len() < 16 && g.last().unwrap().1 == start => g.push((start, end, props)),
|
||||
_ => groups.push(vec![(start, end, props)]),
|
||||
}
|
||||
}
|
||||
let mut fcs: Vec<u32> = groups.iter().map(|g| g[0].0).collect();
|
||||
fcs.extend(groups.last().map(|g| g.last().unwrap().1));
|
||||
let pns: Vec<u32> = groups.iter().map(|g| property_page(g, papx, &mut wd)).collect();
|
||||
bins.push([fcs, pns].concat().into_iter().flat_map(u32::to_le_bytes).collect::<Vec<u8>>());
|
||||
}
|
||||
let mut cps = vec![0u32];
|
||||
for (text, _) in &self.pieces {
|
||||
cps.push(cps.last().unwrap() + text.encode_utf16().count() as u32);
|
||||
}
|
||||
let pcds = self.pieces.iter().zip(&stored).flat_map(|((_, compressed), &at)| {
|
||||
let fc = if *compressed { (at * 2) | 0x4000_0000 } else { at };
|
||||
[&[0u8, 0][..], &fc.to_le_bytes(), &[0, 0]].concat()
|
||||
});
|
||||
let plc: Vec<u8> = cps.iter().flat_map(|c| c.to_le_bytes()).chain(pcds).collect();
|
||||
let clx = self.clx.clone().unwrap_or_else(|| [vec![0x02], (plc.len() as u32).to_le_bytes().to_vec(), plc].concat());
|
||||
let words = |v: &[u16]| v.iter().flat_map(|w| w.to_le_bytes()).collect::<Vec<u8>>();
|
||||
let mut stsh = words(&[18, self.styles.len() as u16, 10, 1, 0, 15, 0, 0, 0, 0]);
|
||||
for (istd, s) in self.styles.iter().enumerate() {
|
||||
let Some((stk, base, papx, chpx)) = s else {
|
||||
stsh.extend([0, 0]);
|
||||
continue;
|
||||
};
|
||||
let upx = |bytes: Vec<u8>| [words(&[bytes.len() as u16]), bytes.clone(), vec![0; bytes.len() % 2]].concat();
|
||||
let grlpupx = match stk {
|
||||
1 => [upx([(istd as u16).to_le_bytes().to_vec(), papx.clone()].concat()), upx(chpx.clone())].concat(),
|
||||
_ => upx(chpx.clone()),
|
||||
};
|
||||
let cupx = if *stk == 1 { 2 } else { 1 };
|
||||
let mut std = words(&[0x0FFE, stk | (base << 4), cupx | (0x0FFF << 4), 0, 0, 1, u16::from(b'S'), 0]);
|
||||
std.extend(grlpupx);
|
||||
let len = std.len() as u16;
|
||||
std[6..8].copy_from_slice(&len.to_le_bytes());
|
||||
stsh.extend(len.to_le_bytes());
|
||||
stsh.extend(std);
|
||||
}
|
||||
let mut ffn = words(&[self.fonts.len() as u16, 0]);
|
||||
for name in &self.fonts {
|
||||
let units: Vec<u16> = name.encode_utf16().chain([0]).collect();
|
||||
ffn.push((39 + 2 * units.len()) as u8);
|
||||
ffn.extend([0u8; 39]);
|
||||
ffn.extend(words(&units));
|
||||
}
|
||||
let mut table = vec![];
|
||||
let mut pairs = vec![(0u32, 0u32); 0x5D];
|
||||
for (index, part) in [(1, stsh), (12, bins[0].clone()), (13, bins[1].clone()), (15, ffn), (33, clx)] {
|
||||
pairs[index] = (table.len() as u32, part.len() as u32);
|
||||
table.extend(part);
|
||||
}
|
||||
let mut put = |at: usize, bytes: &[u8]| wd[at..at + bytes.len()].copy_from_slice(bytes);
|
||||
let flags = self.flags | if self.zero_table { 0 } else { 0x0200 };
|
||||
put(0, &words(&[0xA5EC, if self.nfib == 0 { 0x00C1 } else { self.nfib }]));
|
||||
put(10, &words(&[flags, 0x00BF]));
|
||||
put(32, &words(&[14]));
|
||||
put(62, &words(&[22]));
|
||||
put(76, &(chars.len() as u32 + self.ccp_extra).to_le_bytes());
|
||||
for (i, n) in [4, 5, 7, 8, 9, 10].into_iter().zip(self.stories) {
|
||||
put(64 + 4 * i, &n.to_le_bytes());
|
||||
}
|
||||
put(152, &words(&[0x5D]));
|
||||
for (i, (fc, lcb)) in pairs.iter().enumerate() {
|
||||
put(154 + 8 * i, &[fc.to_le_bytes(), lcb.to_le_bytes()].concat());
|
||||
}
|
||||
compound_file(&[("WordDocument", &wd), (if self.zero_table { "0Table" } else { "1Table" }, &table)])
|
||||
}
|
||||
}
|
||||
|
||||
/// A compound file holding `streams`.
|
||||
fn compound_file(streams: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut cf = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
|
||||
for (name, bytes) in streams {
|
||||
std::io::Write::write_all(&mut cf.create_stream(name).unwrap(), bytes).unwrap();
|
||||
}
|
||||
cf.flush().unwrap();
|
||||
cf.into_inner().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_places_as_area_type_with_its_styles_and_formatting() {
|
||||
let navy: &[u8] = &[0x1F, 0x38, 0x64, 0];
|
||||
// Normal: 11 pt with 8 pt after; Heading (based on Normal): centered, 12 pt before and none
|
||||
// after, in Arial, bold, 16 pt and navy; Emphasis, a character style: italic.
|
||||
let mut styles = vec![
|
||||
Some((1, 0x0FFF, prls(&[(0xA414, &[160, 0])]), prls(&[(0x4A43, &[22, 0])]))),
|
||||
Some((
|
||||
1,
|
||||
0,
|
||||
prls(&[(0x2403, &[1]), (0xA413, &[240, 0]), (0xA414, &[0, 0])]),
|
||||
prls(&[(0x4A4F, &[1, 0]), (0x0835, &[1]), (0x4A43, &[32, 0]), (0x6870, navy)]),
|
||||
)),
|
||||
];
|
||||
styles.resize_with(10, || None);
|
||||
styles.push(Some((2, 0x0FFF, vec![], prls(&[(0x0836, &[1])]))));
|
||||
// The first piece holds characters 0 to 24 ("notes" at 7, the first paragraph mark at 12), the
|
||||
// second "styled" at 25, "2" at 31, "hidden" at 32, "gone" at 38, " café ✓ " at 42, the field
|
||||
// at 50 (its result "7" at 58) and the paragraph mark at 60.
|
||||
let doc = Word97 {
|
||||
pieces: vec![("Report notes\r“Plain” and ", true), ("styled2hiddengone café ✓ \u{13} PAGE \u{14}7\u{15}\r", false)],
|
||||
chpx: vec![
|
||||
(7, 12, prls(&[(0x4A30, &[10, 0])])),
|
||||
(25, 31, prls(&[(0x4A4F, &[2, 0]), (0x0836, &[1]), (0x2A3E, &[1])])),
|
||||
(31, 32, prls(&[(0x2A48, &[1])])),
|
||||
(32, 38, prls(&[(0x083C, &[1])])),
|
||||
(38, 42, prls(&[(0x0800, &[1])])),
|
||||
(42, 50, prls(&[(0x0801, &[1])])),
|
||||
],
|
||||
papx: vec![(12, 1, vec![]), (60, 0, prls(&[(0x840F, &[0xD0, 0x02]), (0x8411, &(-360i16).to_le_bytes()), (0x2403, &[3])]))],
|
||||
styles,
|
||||
fonts: vec!["Times New Roman", "Arial", "Courier New"],
|
||||
..Word97::default()
|
||||
};
|
||||
let mut s = session();
|
||||
s.execute("document.setup", &json!({"superscript": {"size": 50, "position": 40}})).unwrap();
|
||||
let file = json!({"name": "report.doc", "dataBase64": vectorcraft_format::base64_encode(&doc.build())});
|
||||
let r = s.execute("file.place", &file).unwrap();
|
||||
assert_eq!((r["format"].as_str(), r["name"].as_str()), (Some("doc"), Some("report.doc")));
|
||||
let (text, frame) = placed(&s, &r);
|
||||
assert_eq!(text, "Report notes\n“Plain” and styled2 café ✓ 7", "hidden and deleted text and the field's instructions are left out");
|
||||
assert!(close(frame, Rect::new(36.0, 36.0, 364.0, 264.0)), "the artboard less a 36 pt margin: {frame:?}");
|
||||
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
|
||||
let runs: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str(), r.style.font_style.as_str(), r.style.size)).collect();
|
||||
assert_eq!(
|
||||
runs,
|
||||
[
|
||||
("Report ", "Arial", "Bold", 16.0),
|
||||
("notes", "Arial", "Bold Italic", 16.0),
|
||||
("\n", "Arial", "Bold", 16.0),
|
||||
("“Plain” and ", "Times New Roman", "Regular", 11.0),
|
||||
("styled", "Courier New", "Italic", 11.0),
|
||||
("2", "Times New Roman", "Regular", 11.0),
|
||||
(" café ✓ 7", "Times New Roman", "Regular", 11.0),
|
||||
]
|
||||
);
|
||||
let navy = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0x1F, 0x38, 0x64));
|
||||
assert_eq!((t.runs[0].style.fill == navy, t.runs[1].style.fill == navy, t.runs[3].style.fill == navy), (true, true, false));
|
||||
assert_eq!((t.runs[3].style.underline, t.runs[4].style.underline), (false, true));
|
||||
let superscript = vectorcraft_doc::ScriptMetrics { size: 50.0, position: 40.0 };
|
||||
assert_eq!(t.runs[5].style.position, vectorcraft_doc::CharPosition::Superscript(superscript), "in Document Setup's proportions");
|
||||
let paras = t.paragraph_styles();
|
||||
let summary: Vec<_> = paras.iter().map(|p| (p.justify, p.left_indent, p.first_line_indent, p.space_before, p.space_after)).collect();
|
||||
assert_eq!(summary, [(vectorcraft_doc::Justify::Center, 0.0, 0.0, 12.0, 0.0), (vectorcraft_doc::Justify::JustifyLeft, 36.0, -18.0, 0.0, 8.0)]);
|
||||
let fields = "the Word document's fields were placed as the text of their results";
|
||||
let revisions = "the Word document's tracked changes were accepted: deleted text was left out and inserted text kept";
|
||||
assert_eq!(r["warnings"], json!([fields, revisions]));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.history.undo.len(), st.selection.objects.len()), (2, 1), "Document Setup and Place, each one undo step");
|
||||
// Info and the place cursor report it as a Word 97-2003 document, with the same warnings.
|
||||
let info = s.execute("file.place.info", &file).unwrap();
|
||||
assert_eq!((info["format"].as_str(), info["width"].as_f64(), &info["warnings"]), (Some("doc"), Some(468.0), &json!([fields, revisions])));
|
||||
let q = s.execute("file.place.queue", &json!({"files": [file]})).unwrap();
|
||||
assert_eq!((q["count"].as_u64(), q["files"][0]["format"].as_str()), (Some(1), Some("doc")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_tables_breaks_and_other_stories_place_as_paragraphs_and_tabs_with_warnings() {
|
||||
let in_table = prls(&[(0x2416, &[1])]);
|
||||
// A line break; a table row of two cells; a page break inside a paragraph and a section break
|
||||
// that ends one; a picture, a tab and a nonbreaking hyphen; right-to-left justified text.
|
||||
let text = "Line one\u{B}line two\ra\u{7}b\u{7}\u{7}x\u{C}y\rsec\u{C}next\rpic\u{1}ture\tend\u{1E}ok\rrtl\r";
|
||||
let doc = Word97 {
|
||||
pieces: vec![(text, true)],
|
||||
// Marks: the first paragraph's at 17, the cells' at 19 and 21, the row's at 22, the page
|
||||
// break at 24 inside the paragraph that ends at 26, the section break at 30, and the
|
||||
// paragraph marks at 35, 51 and 55.
|
||||
papx: vec![
|
||||
(17, 0, vec![]),
|
||||
(19, 0, in_table.clone()),
|
||||
(21, 0, in_table),
|
||||
(22, 0, prls(&[(0x2416, &[1]), (0x2417, &[1])])),
|
||||
(26, 0, vec![]),
|
||||
(30, 0, vec![]),
|
||||
(35, 0, vec![]),
|
||||
(51, 0, vec![]),
|
||||
(55, 0, prls(&[(0x2441, &[1]), (0x2461, &[3])])),
|
||||
],
|
||||
zero_table: true,
|
||||
stories: [5, 0, 2, 0, 0, 0],
|
||||
..Word97::default()
|
||||
};
|
||||
let mut s = session();
|
||||
// Recognized by its WordDocument stream whatever its name says.
|
||||
let r = s.execute("file.place", &json!({"name": "notes.bin", "dataBase64": vectorcraft_format::base64_encode(&doc.build())})).unwrap();
|
||||
assert_eq!(r["format"].as_str(), Some("doc"));
|
||||
assert_eq!(placed(&s, &r).0, "Line one\nline two\na\tb\nxy\nsec\nnext\npicture\tend\u{2011}ok\nrtl");
|
||||
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
|
||||
let last = t.paragraph_styles().last().cloned().unwrap();
|
||||
assert_eq!((last.justify, last.direction), (vectorcraft_doc::Justify::JustifyRight, Some(vectorcraft_doc::ParaDirection::RightToLeft)));
|
||||
assert_eq!(
|
||||
r["warnings"],
|
||||
json!([
|
||||
"the Word document's pictures and embedded objects were left out",
|
||||
"the Word document's tables were placed as paragraphs with their cells separated by tabs",
|
||||
"the Word document's footnotes and comments were left out",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_doc_fails_cleanly() {
|
||||
let mut s = session();
|
||||
let place = |s: &mut Session, name: &str, bytes: &[u8]| {
|
||||
s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).map_err(|e| e.to_string())
|
||||
};
|
||||
let words = || Word97 { pieces: vec![("words\r", true)], ..Word97::default() };
|
||||
let e = place(&mut s, "a.doc", b"Plain words, not a compound file").unwrap_err();
|
||||
assert!(e.contains("not a format VectorCraft reads"), "{e}");
|
||||
// An RTF file named `.doc` is still placed as RTF.
|
||||
let r = place(&mut s, "a.doc", br"{\rtf1 Rich}").unwrap();
|
||||
assert_eq!((r["format"].as_str(), placed(&s, &r).0.as_str()), (Some("rtf"), "Rich"));
|
||||
let e = place(&mut s, "a.doc", &compound_file(&[("Other", b"x")])).unwrap_err();
|
||||
assert!(e.contains("no WordDocument stream"), "{e}");
|
||||
let e = place(&mut s, "a.bin", &compound_file(&[("Other", b"x")])).unwrap_err();
|
||||
assert!(e.contains("not a format VectorCraft reads"), "a compound file of something else isn't read as a Word document: {e}");
|
||||
let e = place(&mut s, "a.doc", &[&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1][..], &[0; 600]].concat()).unwrap_err();
|
||||
assert!(e.contains("compound file structure is damaged"), "{e}");
|
||||
let e = place(&mut s, "a.doc", &compound_file(&[("WordDocument", &[0; 900])])).unwrap_err();
|
||||
assert!(e.contains("doesn't start with a file information block"), "{e}");
|
||||
let e = place(&mut s, "a.doc", &Word97 { nfib: 0x0065, ..words() }.build()).unwrap_err();
|
||||
assert!(e.contains("Word 6.0 or Word 95"), "{e}");
|
||||
let e = place(&mut s, "a.doc", &Word97 { flags: 0x0100, ..words() }.build()).unwrap_err();
|
||||
assert!(e.contains("encrypted"), "{e}");
|
||||
let e = place(&mut s, "a.doc", &Word97 { flags: 0x0004, ..words() }.build()).unwrap_err();
|
||||
assert!(e.contains("fast save"), "{e}");
|
||||
let e = place(&mut s, "a.doc", &Word97 { ccp_extra: 3, ..words() }.build()).unwrap_err();
|
||||
assert!(e.contains("doesn't cover its text"), "{e}");
|
||||
// A piece stored past the end of the WordDocument stream.
|
||||
let far = [&0u32.to_le_bytes()[..], &6u32.to_le_bytes(), &[0, 0], &0x00FF_0000u32.to_le_bytes(), &[0, 0]].concat();
|
||||
let clx = [&[0x02][..], &(far.len() as u32).to_le_bytes(), &far].concat();
|
||||
let e = place(&mut s, "a.doc", &Word97 { clx: Some(clx), ..words() }.build()).unwrap_err();
|
||||
assert!(e.contains("lies outside its WordDocument stream"), "{e}");
|
||||
let e = place(&mut s, "a.doc", &Word97 { clx: Some(vec![0x02, 0xFF, 0xFF, 0, 0]), ..words() }.build()).unwrap_err();
|
||||
assert!(e.contains("piece table is damaged"), "{e}");
|
||||
let e = place(&mut s, "a.doc", &Word97 { pieces: vec![("\r\r", true)], ..Word97::default() }.build()).unwrap_err();
|
||||
assert!(e.contains("no text to place"), "{e}");
|
||||
let r = place(&mut s, "a.doc", &words().build()).unwrap();
|
||||
assert_eq!(placed(&s, &r).0, "words");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_reads_what_open_reads_and_text_files() {
|
||||
use cmd::fileio::{DOCX_EXTS, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
|
||||
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS, DOCX_EXTS].concat());
|
||||
use cmd::fileio::{DOC_EXTS, DOCX_EXTS, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
|
||||
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS, DOCX_EXTS, DOC_EXTS].concat());
|
||||
let filters: Vec<_> = cmd::fileio::place_filters().collect();
|
||||
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("Word", DOCX_EXTS))));
|
||||
assert_eq!(filters.get(filters.len() - 3..), Some(&[("Text", TEXT_EXTS), ("RTF", RTF_EXTS), ("Word", DOCX_EXTS)][..]));
|
||||
let word: (&str, &[&str]) = ("Word", &["docx", "doc"]);
|
||||
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&word)));
|
||||
assert_eq!(filters.get(filters.len() - 3..), Some(&[("Text", TEXT_EXTS), ("RTF", RTF_EXTS), word][..]));
|
||||
assert!(
|
||||
!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt") || exts.contains(&"rtf") || exts.contains(&"docx")),
|
||||
!cmd::fileio::open_filters().any(|(_, exts)| ["txt", "rtf", "docx", "doc"].iter().any(|e| exts.contains(e))),
|
||||
"Open doesn't read text, RTF or Word files"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +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 and Word
|
||||
//! documents.
|
||||
//! Options, place as area type or fail with an error, and so do hostile RTF files, Word documents
|
||||
//! and Word 97-2003 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).
|
||||
@@ -91,6 +91,164 @@ proptest! {
|
||||
});
|
||||
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_doc_places_without_panics(
|
||||
text in prop::collection::vec(prop::sample::select(DOC_CHARS.chars().collect::<Vec<_>>()), 1..60),
|
||||
compressed in any::<bool>(),
|
||||
chpx in arb_grpprl(),
|
||||
papx in arb_grpprl(),
|
||||
istd in 0u16..4,
|
||||
stsh in prop_oneof![1 => prop::collection::vec(any::<u8>(), 0..120), 3 => arb_stsh()],
|
||||
ffn in prop::collection::vec(any::<u8>(), 0..80),
|
||||
changes in prop_oneof![
|
||||
3 => Just(vec![]),
|
||||
1 => prop::collection::vec((any::<bool>(), any::<prop::sample::Index>(), any::<u8>()), 1..4),
|
||||
],
|
||||
name in prop::sample::select(vec!["fuzz.doc", "fuzz.bin"]),
|
||||
) {
|
||||
vectorcraft_text::FontDb::global().set_system_fallback(false);
|
||||
let text: String = text.into_iter().collect();
|
||||
let (mut wd, mut table) = word97(&text, compressed, &chpx, istd, &papx, &stsh, &ffn);
|
||||
// A changed byte anywhere in either stream: in the FIB, the text, a property page or a
|
||||
// table.
|
||||
for (in_table, at, byte) in changes {
|
||||
let stream = if in_table { &mut table } else { &mut wd };
|
||||
let i = at.index(stream.len());
|
||||
stream[i] = byte;
|
||||
}
|
||||
let mut cf = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
|
||||
std::io::Write::write_all(&mut cf.create_stream("WordDocument").unwrap(), &wd).unwrap();
|
||||
std::io::Write::write_all(&mut cf.create_stream("1Table").unwrap(), &table).unwrap();
|
||||
cf.flush().unwrap();
|
||||
let data = cf.into_inner().into_inner();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters of a Word document's text: letters, marks of paragraphs, cells and breaks, fields,
|
||||
/// pictures, hyphens and surrogate-free Unicode.
|
||||
const DOC_CHARS: &str = "ab \r\u{7}\u{B}\u{C}\u{13}\u{14}\u{15}\u{1}\u{8}\t\u{1E}\u{1F}(é“✓";
|
||||
|
||||
/// Character and paragraph properties: Prls the Word 97-2003 reader knows, with operands in and out
|
||||
/// of range, and raw bytes.
|
||||
fn arb_grpprl() -> impl Strategy<Value = Vec<u8>> {
|
||||
let prl = prop_oneof![
|
||||
prop::sample::select(vec![
|
||||
&[0x35, 0x08, 0x81][..],
|
||||
&[0x35, 0x08, 0x80],
|
||||
&[0x3C, 0x08, 0x01],
|
||||
&[0x00, 0x08, 0x01],
|
||||
&[0x01, 0x08, 0x01],
|
||||
&[0x55, 0x08, 0x01],
|
||||
&[0x30, 0x4A, 0x0A, 0x00],
|
||||
&[0x30, 0x4A, 0xFF, 0xFF],
|
||||
&[0x43, 0x4A, 0xFF, 0xFF],
|
||||
&[0x4F, 0x4A, 0x07, 0x00],
|
||||
&[0x42, 0x2A, 0x63],
|
||||
&[0x70, 0x68, 1, 2, 3, 0xFF],
|
||||
&[0x45, 0x48, 0x00, 0x80],
|
||||
&[0x03, 0x24, 0x09],
|
||||
&[0x61, 0x24, 0x08],
|
||||
&[0x17, 0x24, 0x01],
|
||||
&[0x4B, 0x24, 0x01],
|
||||
&[0x41, 0x24, 0x01],
|
||||
&[0x0F, 0x84, 0x00, 0x80],
|
||||
&[0x13, 0xA4, 0xFF, 0xFF],
|
||||
&[0x08, 0xD6, 0xFF, 0xFF],
|
||||
&[0x15, 0xC6, 0xFF, 0x7F],
|
||||
&[0x2A, 0xC6, 0xFF],
|
||||
])
|
||||
.prop_map(<[u8]>::to_vec),
|
||||
prop::collection::vec(any::<u8>(), 0..4),
|
||||
];
|
||||
prop::collection::vec(prl, 0..6).prop_map(|p| p.concat())
|
||||
}
|
||||
|
||||
/// A style sheet of up to four styles (paragraph or character, based on another or none) with
|
||||
/// properties of [`arb_grpprl`], or empty slots.
|
||||
fn arb_stsh() -> impl Strategy<Value = Vec<u8>> {
|
||||
let style = prop::option::of((1u16..3, prop_oneof![Just(0x0FFFu16), 0u16..5], arb_grpprl(), arb_grpprl()));
|
||||
prop::collection::vec(style, 0..5).prop_map(|styles| {
|
||||
let words = |v: &[u16]| v.iter().flat_map(|w| w.to_le_bytes()).collect::<Vec<u8>>();
|
||||
let mut stsh = words(&[18, styles.len() as u16, 10, 1, 0, 15, 0, 0, 0, 0]);
|
||||
for s in styles {
|
||||
let Some((stk, base, papx, chpx)) = s else {
|
||||
stsh.extend([0, 0]);
|
||||
continue;
|
||||
};
|
||||
let upx = |b: Vec<u8>| [words(&[b.len() as u16]), b.clone(), vec![0; b.len() % 2]].concat();
|
||||
let (cupx, grlpupx) = if stk == 1 { (2, [upx([vec![0, 0], papx].concat()), upx(chpx)].concat()) } else { (1, upx(chpx)) };
|
||||
let mut std = words(&[0x0FFE, stk | (base << 4), cupx | (0x0FFF << 4), 0, 0, 1, u16::from(b'S'), 0]);
|
||||
std.extend(grlpupx);
|
||||
stsh.extend((std.len() as u16).to_le_bytes());
|
||||
stsh.extend(std);
|
||||
}
|
||||
stsh
|
||||
})
|
||||
}
|
||||
|
||||
/// The WordDocument and table streams of a Word 97-2003 document whose main text is `text` in one
|
||||
/// piece, with one page of character properties `chpx` and one of paragraph properties `papx` of
|
||||
/// style `istd` covering it, and the style sheet `stsh` and font table `ffn`.
|
||||
fn word97(text: &str, compressed: bool, chpx: &[u8], istd: u16, papx: &[u8], stsh: &[u8], ffn: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut wd = vec![0u8; 1024];
|
||||
let at = wd.len() as u32;
|
||||
if compressed {
|
||||
wd.extend(text.chars().map(|c| if c == '“' { 0x93 } else { u32::from(c).min(0xFF) as u8 }));
|
||||
} else {
|
||||
wd.extend(text.encode_utf16().flat_map(u16::to_le_bytes));
|
||||
}
|
||||
let ccp = text.encode_utf16().count() as u32;
|
||||
let end = wd.len() as u32;
|
||||
// One page each: a run of every character, its properties near the page's end.
|
||||
let page = |props: Vec<u8>, papx: bool| {
|
||||
let mut p = vec![0u8; 512];
|
||||
p[0..4].copy_from_slice(&at.to_le_bytes());
|
||||
p[4..8].copy_from_slice(&end.to_le_bytes());
|
||||
let blob = if papx { [vec![0, (props.len() / 2) as u8], props].concat() } else { [vec![props.len() as u8], props].concat() };
|
||||
let top = (511 - blob.len().min(400)) & !1;
|
||||
let n = blob.len().min(511 - top);
|
||||
p[top..top + n].copy_from_slice(&blob[..n]);
|
||||
p[8] = (top / 2) as u8;
|
||||
p[511] = 1;
|
||||
p
|
||||
};
|
||||
let papx_props = [istd.to_le_bytes().to_vec(), papx.to_vec(), vec![0; papx.len() % 2]].concat();
|
||||
wd.resize(wd.len().div_ceil(512) * 512, 0);
|
||||
let chpx_page = (wd.len() / 512) as u32;
|
||||
wd.extend(page(chpx.iter().copied().take(200).collect(), false));
|
||||
wd.extend(page(papx_props.into_iter().take(400).collect(), true));
|
||||
let bte = |pn: u32| [at, end, pn].into_iter().flat_map(u32::to_le_bytes).collect::<Vec<u8>>();
|
||||
let fc = if compressed { (at * 2) | 0x4000_0000 } else { at };
|
||||
let plc = [&0u32.to_le_bytes()[..], &ccp.to_le_bytes(), &[0, 0], &fc.to_le_bytes(), &[0, 0]].concat();
|
||||
let clx = [&[0x02][..], &(plc.len() as u32).to_le_bytes(), &plc].concat();
|
||||
let mut table = vec![];
|
||||
let mut pairs = vec![(0u32, 0u32); 0x5D];
|
||||
for (index, part) in [(1, stsh.to_vec()), (12, bte(chpx_page)), (13, bte(chpx_page + 1)), (15, ffn.to_vec()), (33, clx)] {
|
||||
pairs[index] = (table.len() as u32, part.len() as u32);
|
||||
table.extend(part);
|
||||
}
|
||||
let words = |v: &[u16]| v.iter().flat_map(|w| w.to_le_bytes()).collect::<Vec<u8>>();
|
||||
let mut put = |at: usize, bytes: &[u8]| wd[at..at + bytes.len()].copy_from_slice(bytes);
|
||||
put(0, &words(&[0xA5EC, 0x00C1]));
|
||||
put(10, &words(&[0x0200, 0x00BF]));
|
||||
put(32, &words(&[14]));
|
||||
put(62, &words(&[22]));
|
||||
put(76, &ccp.to_le_bytes());
|
||||
put(80, &1u32.to_le_bytes());
|
||||
put(152, &words(&[0x5D]));
|
||||
for (i, (fc, lcb)) in pairs.iter().enumerate() {
|
||||
put(154 + 8 * i, &[fc.to_le_bytes(), lcb.to_le_bytes()].concat());
|
||||
}
|
||||
(wd, table)
|
||||
}
|
||||
|
||||
/// A well-formed paragraph, or a table row of two such paragraphs.
|
||||
|
||||
@@ -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 | 27 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, Pixar, Word `.doc`, PS |
|
||||
| Formats opened or placed | ~30 | 28 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, Pixar, 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 |
|
||||
@@ -113,7 +113,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
| 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 `.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 | |
|
||||
| Word `.doc` | place | place (area type with the same formatting as `.docx`, from the style sheet's styles and direct formatting; Word 97 and later) | no | `tests/textimport.rs`, `place/doc.rs`; fuzzed (`text_import_fuzz.rs`); pictures, headers, footers, notes, comments and text boxes are left out with a warning; encrypted and fast-saved documents are refused |
|
||||
| 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) |
|
||||
|
||||
+12
@@ -3832,6 +3832,18 @@ more than 16 MiB or nests elements more than 256 deep, and a document with more
|
||||
paragraphs or runs are refused.
|
||||
`file.place` answers `format: "docx"`.
|
||||
|
||||
A Word 97-2003 document (a compound file named `.doc`, or one that holds a `WordDocument` stream; an RTF file named
|
||||
`.doc` is placed as RTF) is placed the same way and keeps the same formatting, taken from the style sheet's default
|
||||
font, the paragraph style and the character style (and the styles each is based on) and direct formatting. Its main
|
||||
text is read; table cells are separated by tabs and each row is a paragraph, and a field places as its result. Hidden
|
||||
and deleted text, field instructions, pictures and embedded objects, headers, footers, notes, comments and text boxes
|
||||
are left out, and `warnings` names each kind of content left out or changed once (pictures and objects, fields,
|
||||
tracked changes, tables, and the other stories the document has). Documents saved by Word 6.0 or Word 95, encrypted or
|
||||
password-protected documents and documents last saved with fast save are refused, and so are a stream larger than
|
||||
64 MiB, a document with more than 4 MiB of text, 100,000 pieces, 16,384 pages of properties or 100,000 paragraphs or
|
||||
runs, and one whose structures lie outside their streams.
|
||||
`file.place` answers `format: "doc"`.
|
||||
|
||||
## PDF presets
|
||||
|
||||
`pdf.preset.list` lists the built-in presets (`VectorCraft Default`, which preserves editing, then High Quality Print,
|
||||
|
||||
@@ -1093,6 +1093,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- 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`).
|
||||
- A Word paragraph justified with a low, medium or high kashida length places justified with Insert Kashidas set to Short, Medium or Long (`file.place`, `crates/engine/src/tests/textimport.rs`).
|
||||
- File › Place reads Word 97-2003 `.doc` documents as area type with the same formatting, read from the main text's pieces, the style sheet's paragraph and character styles and direct formatting. Table rows are paragraphs with their cells separated by tabs and fields place as their results; hidden and deleted text, pictures, headers, footers, notes, comments and text boxes are left out, and `warnings` names each kind left out or changed. Encrypted documents, documents last saved with fast save and Word 6.0 and Word 95 documents are refused (`file.place`, `crates/engine/src/tests/textimport.rs`).
|
||||
- On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before.
|
||||
- The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`).
|
||||
- A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284).
|
||||
|
||||
Reference in New Issue
Block a user