mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 00:44:27 -06:00
- docx: Flat OPC (`pkg:package`) read by unpacking the parts into an in-memory package for the DOCX reader; written from the DOCX writer's parts (XML inline, binary parts in base64). Part count, part size, total size and base64 length are capped. - docx: Word 2003 XML (`w:wordDocument`) imported by rewriting it as the corresponding package (hyphenated names, w:hlink, w:listPr, w:lists, inline headers/footers, notes, aml annotations, w:binData pictures, o:DocumentProperties) and reading that with the DOCX reader. - `.xml` told apart by root element; other XML is refused with "not a Word XML document". File > Export gets "Word XML Document (*.xml)" (all 9 catalogs); .xml saves keep the document like .docx. - Tests: Flat OPC round trip equals DOCX round trip, hand-written Flat OPC package, Word 2003 import, hostile inputs, engine io. Fixes #548 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
7c5f72a31b
commit
baeb8d9c6c
@@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
|
||||
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |
|
||||
| 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) |
|
||||
| 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back |
|
||||
|
||||
@@ -8,7 +8,7 @@ use wasm_bindgen::JsCast as _;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"];
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
|
||||
const CANVAS_ID: &str = "wordcraft_canvas";
|
||||
@@ -306,6 +306,7 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("md" | "txt") => "text/plain",
|
||||
Some("tex") => "application/x-tex",
|
||||
Some("json") => "application/json",
|
||||
Some("xml") => "application/xml",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog
|
||||
} else if purpose == "recipients" {
|
||||
d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"])
|
||||
} else {
|
||||
d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"])
|
||||
d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"])
|
||||
.add_filter("Word document", &["docx"])
|
||||
.add_filter("All files", &["*"])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
//! Word XML Document: a whole WordprocessingML package as one XML file (the Flat OPC format Word
|
||||
//! saves as "Word XML Document (*.xml)"): a `pkg:package` root holding one `pkg:part` per package
|
||||
//! part, each with its name and content type, and the part's XML inline (`pkg:xmlData`) or its
|
||||
//! bytes in base64 (`pkg:binaryData`).
|
||||
//!
|
||||
//! Reading unpacks the parts into an in-memory [`Package`] and hands it to the DOCX reader;
|
||||
//! writing runs the DOCX writer and lays its parts out in one file. Bounded like the zip reader:
|
||||
//! part count, part size, total size and base64 length are capped.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use base64::Engine as _;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
use quick_xml::name::ResolveResult;
|
||||
use quick_xml::reader::NsReader;
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
use crate::package::{ContentTypes, MAX_ENTRIES, MAX_PART, MAX_TOTAL, Package};
|
||||
use crate::xml::{self, W, esc};
|
||||
use crate::{DocxError, Flavor};
|
||||
|
||||
/// The Flat OPC package namespace.
|
||||
pub const PKG_NS: &str = "http://schemas.microsoft.com/office/2006/xmlPackage";
|
||||
/// The WordprocessingML 2003 namespace (Word 2003 XML Document).
|
||||
pub const W2003_NS: &str = "http://schemas.microsoft.com/office/word/2003/wordml";
|
||||
|
||||
/// What kind of XML file a `.xml` holds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum XmlKind {
|
||||
/// A Flat OPC package (Word XML Document, Word 2007 and later).
|
||||
FlatOpc,
|
||||
/// A Word 2003 XML Document (WordprocessingML 2003).
|
||||
WordMl2003,
|
||||
/// Some other XML, or not XML at all.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Elements and declarations looked at before the root element (comments, processing
|
||||
/// instructions) before giving up.
|
||||
const MAX_PROLOG_EVENTS: usize = 10_000;
|
||||
|
||||
/// The text of `bytes` as UTF-8: UTF-16 with a byte-order mark is transcoded, a UTF-8 one dropped.
|
||||
fn utf8_text(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> {
|
||||
match xml::utf16_to_utf8(bytes) {
|
||||
Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
|
||||
None => std::borrow::Cow::Borrowed(bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell a Word XML file by its root element: `pkg:package` (Flat OPC) or `w:wordDocument`
|
||||
/// (Word 2003), each in its namespace.
|
||||
pub fn sniff_xml(bytes: &[u8]) -> XmlKind {
|
||||
let text = utf8_text(bytes);
|
||||
let mut r = NsReader::from_reader(text.as_ref());
|
||||
for _ in 0..MAX_PROLOG_EVENTS {
|
||||
match r.read_resolved_event() {
|
||||
Ok((res, Event::Start(e) | Event::Empty(e))) => {
|
||||
let ns = match res {
|
||||
ResolveResult::Bound(ns) => ns.as_ref().to_vec(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
return match (e.local_name().as_ref(), ns.as_slice()) {
|
||||
(b"package", ns) if ns == PKG_NS.as_bytes() => XmlKind::FlatOpc,
|
||||
(b"wordDocument", ns) if ns == W2003_NS.as_bytes() => XmlKind::WordMl2003,
|
||||
_ => XmlKind::Other,
|
||||
};
|
||||
}
|
||||
Ok((_, Event::Eof)) | Err(_) => return XmlKind::Other,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
XmlKind::Other
|
||||
}
|
||||
|
||||
/// Read a Flat OPC file.
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
crate::read::read_opened(&unpack(bytes)?)
|
||||
}
|
||||
|
||||
/// The data element of the part being read.
|
||||
enum Data {
|
||||
/// `pkg:xmlData`: its content starts at this byte offset.
|
||||
Xml(usize),
|
||||
/// `pkg:binaryData`: the base64 text so far.
|
||||
Binary(String),
|
||||
}
|
||||
|
||||
/// The part being read: name, content type, data.
|
||||
struct Part {
|
||||
name: String,
|
||||
content_type: String,
|
||||
data: Option<Data>,
|
||||
}
|
||||
|
||||
/// Longest base64 text of one part (the size limit, encoded; whitespace isn't kept).
|
||||
const MAX_BASE64: u64 = (MAX_PART / 3 + 1) * 4;
|
||||
|
||||
/// Unpack a Flat OPC file into its parts, with a `[Content_Types].xml` made from the parts'
|
||||
/// content types (unless the file carries one).
|
||||
pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
|
||||
let text = utf8_text(bytes);
|
||||
let src: &[u8] = text.as_ref();
|
||||
let mut r = NsReader::from_reader(src);
|
||||
r.config_mut().check_end_names = true;
|
||||
let mut depth = 0usize;
|
||||
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
let mut types: Vec<(String, String)> = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
let mut part: Option<Part> = None;
|
||||
// `xmlns` declarations on the package, part and data elements, in scope for the inline XML.
|
||||
let mut scope: Vec<Vec<(String, String)>> = Vec::new();
|
||||
let bad = |e: quick_xml::Error| DocxError::Xml(e.to_string());
|
||||
loop {
|
||||
let before = usize::try_from(r.buffer_position()).unwrap_or(usize::MAX);
|
||||
let (res, ev) = r.read_resolved_event().map_err(bad)?;
|
||||
let in_pkg = matches!(&res, ResolveResult::Bound(ns) if ns.as_ref() == PKG_NS.as_bytes());
|
||||
match ev {
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
let empty = matches!(ev, Event::Empty(_));
|
||||
depth += 1;
|
||||
let local = e.local_name().as_ref().to_vec();
|
||||
match depth {
|
||||
1 if !(in_pkg && local == b"package") => {
|
||||
return Err(DocxError::NotWord("the root element isn't a Flat OPC pkg:package".into()));
|
||||
}
|
||||
2 if in_pkg && local == b"part" => {
|
||||
if files.len() + usize::from(part.is_some()) >= MAX_ENTRIES {
|
||||
return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts")));
|
||||
}
|
||||
let name = attr(e, b"name").unwrap_or_default();
|
||||
let content_type = attr(e, b"contentType").unwrap_or_default();
|
||||
part = Some(Part { name, content_type, data: None });
|
||||
}
|
||||
3 if in_pkg => {
|
||||
if let Some(p) = part.as_mut().filter(|p| p.data.is_none()) {
|
||||
let pos = usize::try_from(r.buffer_position()).unwrap_or(usize::MAX);
|
||||
p.data = match local.as_slice() {
|
||||
// `<pkg:xmlData/>`: an empty part.
|
||||
b"xmlData" if empty => Some(Data::Binary(String::new())),
|
||||
b"xmlData" => Some(Data::Xml(pos)),
|
||||
b"binaryData" => Some(Data::Binary(String::new())),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if depth <= 3 {
|
||||
scope.push(ns_decls(e));
|
||||
}
|
||||
if empty {
|
||||
if depth == 2
|
||||
&& let Some(p) = part.take()
|
||||
{
|
||||
finish_part(p, &mut files, &mut types, &mut total)?;
|
||||
}
|
||||
if depth <= 3 {
|
||||
scope.pop();
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
Event::End(_) => {
|
||||
// The inline XML ends where its data element's end tag starts.
|
||||
if depth == 3
|
||||
&& let Some(p) = part.as_mut()
|
||||
&& let Some(Data::Xml(start)) = p.data
|
||||
{
|
||||
p.data = None;
|
||||
let xml = with_scope(src.get(start..before).unwrap_or_default(), &scope);
|
||||
store(&p.name, &p.content_type, xml, &mut files, &mut types, &mut total)?;
|
||||
}
|
||||
if depth == 2
|
||||
&& let Some(p) = part.take()
|
||||
{
|
||||
finish_part(p, &mut files, &mut types, &mut total)?;
|
||||
}
|
||||
if depth <= 3 {
|
||||
scope.pop();
|
||||
}
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Event::Text(ref t) if depth == 3 => {
|
||||
if let Some(Part { data: Some(Data::Binary(b64)), .. }) = part.as_mut() {
|
||||
let s = std::str::from_utf8(t.as_ref()).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace()));
|
||||
if b64.len() as u64 > MAX_BASE64 {
|
||||
return Err(DocxError::Limit("a binary part is larger than the size limit".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if files.is_empty() {
|
||||
return Err(DocxError::NotWord("the Flat OPC package has no parts".into()));
|
||||
}
|
||||
if !files.contains_key("[Content_Types].xml") {
|
||||
let mut w = W::new();
|
||||
w.open("Types", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/content-types")]);
|
||||
for (name, ct) in &types {
|
||||
w.empty("Override", &[("PartName", &format!("/{name}")), ("ContentType", ct)]);
|
||||
}
|
||||
w.close("Types");
|
||||
files.insert("[Content_Types].xml".into(), w.into_bytes());
|
||||
}
|
||||
Ok(Package::from_parts(files))
|
||||
}
|
||||
|
||||
/// End of a `pkg:part`: store its binary data (inline XML was stored when it ended).
|
||||
fn finish_part(p: Part, files: &mut BTreeMap<String, Vec<u8>>, types: &mut Vec<(String, String)>, total: &mut u64) -> Result<(), DocxError> {
|
||||
match p.data {
|
||||
Some(Data::Binary(b64)) => {
|
||||
let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
|
||||
.decode(b64.trim_end_matches('='))
|
||||
.map_err(|_| DocxError::Xml(format!("part {} has invalid base64 data", p.name)))?;
|
||||
store(&p.name, &p.content_type, bytes, files, types, total)
|
||||
}
|
||||
// A part without data, or whose inline XML was already stored.
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a part, within the size limits; a repeated or empty name is ignored.
|
||||
fn store(
|
||||
name: &str,
|
||||
content_type: &str,
|
||||
bytes: Vec<u8>,
|
||||
files: &mut BTreeMap<String, Vec<u8>>,
|
||||
types: &mut Vec<(String, String)>,
|
||||
total: &mut u64,
|
||||
) -> Result<(), DocxError> {
|
||||
let name = name.trim_start_matches('/');
|
||||
if name.is_empty() || files.contains_key(name) {
|
||||
return Ok(());
|
||||
}
|
||||
let len = bytes.len() as u64;
|
||||
if len > MAX_PART {
|
||||
return Err(DocxError::Limit(format!("part {name} is larger than the size limit")));
|
||||
}
|
||||
*total = total.saturating_add(len);
|
||||
if *total > MAX_TOTAL {
|
||||
return Err(DocxError::Limit("the package is larger than the size limit".into()));
|
||||
}
|
||||
if !content_type.is_empty() && !content_type.contains(['\r', '\n']) {
|
||||
types.push((name.to_string(), content_type.to_string()));
|
||||
}
|
||||
files.insert(name.to_string(), bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An attribute's unescaped value, by local name (any prefix).
|
||||
fn attr(e: &BytesStart, local: &[u8]) -> Option<String> {
|
||||
e.attributes().with_checks(false).flatten().find(|a| a.key.local_name().as_ref() == local).map(|a| {
|
||||
match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) {
|
||||
Ok(v) => v.into_owned(),
|
||||
Err(_) => String::from_utf8_lossy(&a.value).into_owned(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The `xmlns` / `xmlns:p` declarations of a start tag, as written (value still escaped).
|
||||
fn ns_decls(e: &BytesStart) -> Vec<(String, String)> {
|
||||
e.attributes()
|
||||
.with_checks(false)
|
||||
.flatten()
|
||||
.filter(|a| a.key.as_ref() == b"xmlns" || a.key.as_ref().starts_with(b"xmlns:"))
|
||||
.filter(|a| !a.value.contains(&b'"'))
|
||||
.map(|a| (String::from_utf8_lossy(a.key.as_ref()).into_owned(), String::from_utf8_lossy(&a.value).into_owned()))
|
||||
.take(256)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The inline XML of a part as a document of its own: namespaces it inherits from the package
|
||||
/// elements are declared on its root element (unless it declares them itself).
|
||||
fn with_scope(frag: &[u8], scope: &[Vec<(String, String)>]) -> Vec<u8> {
|
||||
let decls: Vec<&(String, String)> = scope.iter().flatten().filter(|(k, _)| k != "xmlns:pkg").collect();
|
||||
let Some(open) = root_tag_start(frag) else { return frag.to_vec() };
|
||||
if decls.is_empty() {
|
||||
return frag.get(open..).unwrap_or_default().to_vec();
|
||||
}
|
||||
let rest = frag.get(open..).unwrap_or_default();
|
||||
let name_end = rest.iter().position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/').unwrap_or(rest.len());
|
||||
let tag_end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len());
|
||||
let tag = rest.get(..tag_end).unwrap_or_default();
|
||||
let mut out = Vec::with_capacity(rest.len() + 256);
|
||||
out.extend_from_slice(rest.get(..name_end).unwrap_or_default());
|
||||
// Innermost declarations win: walk from the data element out, skipping prefixes seen.
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for (k, v) in decls.iter().rev() {
|
||||
let declared = tag.windows(k.len() + 1).any(|w| w.get(..k.len()) == Some(k.as_bytes()) && matches!(w.last(), Some(b'=' | b' ')));
|
||||
if declared || seen.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
seen.push(k);
|
||||
out.extend_from_slice(format!(" {k}=\"{v}\"").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(rest.get(name_end..).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
|
||||
/// Offset of the `<` of the first element in `frag` (after whitespace, comments and processing
|
||||
/// instructions).
|
||||
fn root_tag_start(frag: &[u8]) -> Option<usize> {
|
||||
let mut i = 0usize;
|
||||
loop {
|
||||
while frag.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||||
i += 1;
|
||||
}
|
||||
let rest = frag.get(i..)?;
|
||||
if rest.starts_with(b"<!--") {
|
||||
i += rest.windows(3).position(|w| w == b"-->")? + 3;
|
||||
} else if rest.starts_with(b"<?") {
|
||||
i += rest.windows(2).position(|w| w == b"?>")? + 2;
|
||||
} else if rest.first() == Some(&b'<') {
|
||||
return Some(i);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `doc` as a Word XML Document (Flat OPC).
|
||||
pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
|
||||
let parts = crate::write::write_parts(doc, Flavor::Document)?;
|
||||
let types = parts
|
||||
.iter()
|
||||
.find(|(n, _)| n == "[Content_Types].xml")
|
||||
.map(|(n, b)| ContentTypes::read(&Package::from_parts(BTreeMap::from([(n.clone(), b.clone())]))))
|
||||
.unwrap_or_default();
|
||||
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?mso-application progid=\"Word.Document\"?>\n");
|
||||
s.push_str(&format!("<pkg:package xmlns:pkg=\"{PKG_NS}\">"));
|
||||
for (name, bytes) in &parts {
|
||||
if name == "[Content_Types].xml" {
|
||||
continue;
|
||||
}
|
||||
let ct = types.of(name).unwrap_or("application/octet-stream");
|
||||
s.push_str(&format!("<pkg:part pkg:name=\"/{}\" pkg:contentType=\"{}\"", esc(name), esc(ct)));
|
||||
match xml_fragment(ct, bytes) {
|
||||
Some(x) => {
|
||||
s.push_str("><pkg:xmlData>");
|
||||
s.push_str(x);
|
||||
s.push_str("</pkg:xmlData></pkg:part>");
|
||||
}
|
||||
None => {
|
||||
s.push_str(" pkg:compression=\"store\"><pkg:binaryData>");
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
for (i, chunk) in b64.as_bytes().chunks(76).enumerate() {
|
||||
if i > 0 {
|
||||
s.push('\n');
|
||||
}
|
||||
s.push_str(std::str::from_utf8(chunk).unwrap_or_default());
|
||||
}
|
||||
s.push_str("</pkg:binaryData></pkg:part>");
|
||||
}
|
||||
}
|
||||
}
|
||||
s.push_str("</pkg:package>");
|
||||
Ok(s.into_bytes())
|
||||
}
|
||||
|
||||
/// An XML part's markup without its XML declaration, to inline as `pkg:xmlData`; `None` for
|
||||
/// a part that isn't UTF-8 XML (or has a DOCTYPE), which goes in base64.
|
||||
fn xml_fragment<'a>(ct: &str, bytes: &'a [u8]) -> Option<&'a str> {
|
||||
let ct = ct.to_ascii_lowercase();
|
||||
if !(ct.ends_with("+xml") || ct.ends_with("/xml")) {
|
||||
return None;
|
||||
}
|
||||
let s = std::str::from_utf8(bytes).ok()?;
|
||||
let s = s.strip_prefix('\u{FEFF}').unwrap_or(s).trim_start();
|
||||
let s = match s.strip_prefix("<?xml") {
|
||||
Some(rest) => rest.split_once("?>").map(|(_, r)| r)?.trim_start(),
|
||||
None => s,
|
||||
};
|
||||
(s.starts_with('<') && !s.contains("<!DOCTYPE")).then_some(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sniffs_root_elements() {
|
||||
assert_eq!(
|
||||
sniff_xml(
|
||||
format!("<?xml version=\"1.0\"?><?mso-application progid=\"Word.Document\"?><!-- x --><p:package xmlns:p=\"{PKG_NS}\"/>").as_bytes()
|
||||
),
|
||||
XmlKind::FlatOpc
|
||||
);
|
||||
assert_eq!(sniff_xml(format!("\u{FEFF}<w:wordDocument xmlns:w=\"{W2003_NS}\"/>").as_bytes()), XmlKind::WordMl2003);
|
||||
assert_eq!(sniff_xml(b"<package/>"), XmlKind::Other);
|
||||
assert_eq!(sniff_xml(b"<html><body/></html>"), XmlKind::Other);
|
||||
assert_eq!(sniff_xml(b"\x00\xff garbage"), XmlKind::Other);
|
||||
assert_eq!(sniff_xml(b""), XmlKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_xml_inherits_namespaces_from_the_package() {
|
||||
let frag = b"\n <!-- c --> <w:document><w:body/></w:document>";
|
||||
let scope = vec![vec![("xmlns:pkg".to_string(), PKG_NS.to_string()), ("xmlns:w".to_string(), "urn:w".to_string())]];
|
||||
let out = String::from_utf8(with_scope(frag, &scope)).unwrap();
|
||||
assert_eq!(out, "<w:document xmlns:w=\"urn:w\"><w:body/></w:document>");
|
||||
let own = b"<w:document xmlns:w=\"urn:own\"/>";
|
||||
assert_eq!(with_scope(own, &scope), own.to_vec());
|
||||
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,12 @@
|
||||
mod chart_spec;
|
||||
mod crypt;
|
||||
mod custom;
|
||||
mod flat;
|
||||
mod package;
|
||||
mod read;
|
||||
mod smart_art;
|
||||
mod units;
|
||||
mod wordml2003;
|
||||
mod write;
|
||||
mod xml;
|
||||
|
||||
@@ -22,10 +24,28 @@ pub use crypt::{
|
||||
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
|
||||
is_encrypted, protection_hash,
|
||||
};
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use read::read;
|
||||
pub use smart_art::smart_art_items;
|
||||
pub use write::{write, write_as};
|
||||
|
||||
/// Largest `.xml` file read as a Word XML document.
|
||||
pub const MAX_XML_FILE: usize = 1 << 30;
|
||||
|
||||
/// Read a Word XML file (`.xml`): a Word XML Document (Flat OPC, the whole package in one XML
|
||||
/// file) or a Word 2003 XML Document (WordprocessingML 2003), told apart by the root element.
|
||||
/// Any other XML gives [`DocxError::NotWordXml`].
|
||||
pub fn read_xml(bytes: &[u8]) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
if bytes.len() > MAX_XML_FILE {
|
||||
return Err(DocxError::Limit(format!("XML file of {} bytes", bytes.len())));
|
||||
}
|
||||
match sniff_xml(bytes) {
|
||||
XmlKind::FlatOpc => flat::read(bytes),
|
||||
XmlKind::WordMl2003 => wordml2003::read(bytes),
|
||||
XmlKind::Other => Err(DocxError::NotWordXml),
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`DocxError::PasswordRequired`] says.
|
||||
pub const PASSWORD_REQUIRED: &str = "this document is protected with a password";
|
||||
/// What [`DocxError::WrongPassword`] says.
|
||||
@@ -108,6 +128,9 @@ pub enum DocxError {
|
||||
/// The package is readable but isn't a WordprocessingML document.
|
||||
#[error("not a Word document: {0}")]
|
||||
NotWord(String),
|
||||
/// An `.xml` file that is neither a Word XML Document (Flat OPC) nor Word 2003 XML.
|
||||
#[error("not a Word XML document")]
|
||||
NotWordXml,
|
||||
/// The package is encrypted and no password was given.
|
||||
#[error("{}", PASSWORD_REQUIRED)]
|
||||
PasswordRequired,
|
||||
|
||||
@@ -178,6 +178,12 @@ impl Package {
|
||||
Ok(Package { files })
|
||||
}
|
||||
|
||||
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
|
||||
/// name without the leading `/`.
|
||||
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
|
||||
Package { files }
|
||||
}
|
||||
|
||||
/// Part bytes by name (leading `/` ignored; case-insensitive fallback).
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
let n = name.trim_start_matches('/');
|
||||
|
||||
@@ -99,7 +99,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
|
||||
/// [`read`] for bytes known not to be an encrypted package.
|
||||
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
let pkg = Package::open(bytes)?;
|
||||
read_opened(&Package::open(bytes)?)
|
||||
}
|
||||
|
||||
/// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted
|
||||
/// Word 2003 XML document).
|
||||
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
let root_rels = pkg.rels("");
|
||||
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
|
||||
let Some(root) = pkg.xml(&main)? else {
|
||||
@@ -112,7 +117,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
let mut doc = Document::new();
|
||||
doc.body.clear();
|
||||
let mut r = Reader {
|
||||
pkg: &pkg,
|
||||
pkg,
|
||||
doc,
|
||||
pc: PropCtx::default(),
|
||||
revs: HashMap::new(),
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
//! Word 2003 XML Document (WordprocessingML 2003, root `w:wordDocument`, from the public Office
|
||||
//! 2003 XML Reference Schemas). Import only.
|
||||
//!
|
||||
//! ECMA-376 kept most of the 2003 vocabulary (`w:p`, `w:r`, `w:pPr`, `w:rPr`, `w:tbl`,
|
||||
//! `w:sectPr`, `w:styles`…), so the file is opened by rewriting it as the package it corresponds
|
||||
//! to and reading that with the DOCX reader: one reader, one set of property rules. The rewrite
|
||||
//! renames what changed (hyphenated names, `w:hlink`, `w:listPr`, `w:nfc`, `w:vmerge`…), moves
|
||||
//! what 2003 keeps inline into parts (lists into numbering, headers and footers, footnotes,
|
||||
//! endnotes, comments, `w:binData` pictures into media) and turns `aml:annotation` bookmarks,
|
||||
//! comments and tracked insertions/deletions into their ECMA-376 elements. Bounded: the parser
|
||||
//! caps depth and element count, pictures are capped in number and size.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use base64::Engine as _;
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
use crate::DocxError;
|
||||
use crate::flat::W2003_NS;
|
||||
use crate::package::{MAX_PART, MAX_TOTAL, Package, rt};
|
||||
use crate::write::PartRels;
|
||||
use crate::xml::{self, El, MAX_DEPTH, NAMESPACES, Node, W};
|
||||
|
||||
/// Auxiliary hints (`wx:`): display hints Word writes alongside the real markup.
|
||||
const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint";
|
||||
/// Annotation markup (`aml:`): bookmarks, comments, tracked changes.
|
||||
const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core";
|
||||
|
||||
/// Most pictures (`w:binData`) taken from one file.
|
||||
const MAX_MEDIA: usize = 4096;
|
||||
/// Most header, footer, footnote, endnote and comment stories.
|
||||
const MAX_STORIES: usize = 50_000;
|
||||
|
||||
/// Read a Word 2003 XML Document.
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
crate::read::read_opened(&convert(bytes)?)
|
||||
}
|
||||
|
||||
/// Parts the converted package gets relationships for.
|
||||
const DOC: usize = 0;
|
||||
const FOOTNOTES: usize = 1;
|
||||
const ENDNOTES: usize = 2;
|
||||
const COMMENTS: usize = 3;
|
||||
|
||||
struct Conv {
|
||||
/// `w:binData` name (`wordml://…`) → media target relative to `word/`.
|
||||
media: HashMap<String, String>,
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
/// Relationships per part: document, footnotes, endnotes, comments, then one per header or
|
||||
/// footer (index in `stories` + 4).
|
||||
rels: Vec<PartRels>,
|
||||
footnotes: Vec<Node>,
|
||||
endnotes: Vec<Node>,
|
||||
comments: Vec<Node>,
|
||||
/// Header and footer parts: (part name under `word/`, root element).
|
||||
stories: Vec<(String, El)>,
|
||||
next_note: i64,
|
||||
story_count: usize,
|
||||
}
|
||||
|
||||
/// Rewrite a Word 2003 XML Document as a WordprocessingML package.
|
||||
pub(crate) fn convert(bytes: &[u8]) -> Result<Package, DocxError> {
|
||||
let root = xml::parse_ns(bytes, &[("w", W2003_NS), ("wx", AUX_NS), ("aml", AML_NS)])?;
|
||||
if root.name != "w:wordDocument" {
|
||||
return Err(DocxError::NotWord(format!("root element is {}", root.name)));
|
||||
}
|
||||
let mut c = Conv {
|
||||
media: HashMap::new(),
|
||||
files: BTreeMap::new(),
|
||||
rels: (0..4).map(|_| PartRels::default()).collect(),
|
||||
footnotes: Vec::new(),
|
||||
endnotes: Vec::new(),
|
||||
comments: Vec::new(),
|
||||
stories: Vec::new(),
|
||||
next_note: 1,
|
||||
story_count: 0,
|
||||
};
|
||||
let mut total = 0u64;
|
||||
c.collect_media(&root, 0, &mut total)?;
|
||||
|
||||
// Body: `wx:sect` / `wx:sub-section` wrappers unwrapped; the last section's properties last.
|
||||
let mut body = El { name: "w:body".into(), ..El::default() };
|
||||
if let Some(b) = root.child("w:body") {
|
||||
body.kids = c.kids(b, DOC, 1);
|
||||
}
|
||||
let last_sect = body.kids.iter().rposition(|n| matches!(n, Node::El(e) if e.name == "w:sectPr"));
|
||||
if let Some(i) = last_sect {
|
||||
let s = body.kids.remove(i);
|
||||
body.kids.retain(|n| !matches!(n, Node::El(e) if e.name == "w:sectPr"));
|
||||
body.kids.push(s);
|
||||
}
|
||||
let mut document = El { name: "w:document".into(), attrs: xml::body_ns(), kids: Vec::new() };
|
||||
document.kids.push(Node::El(body));
|
||||
|
||||
let mut overrides: Vec<(String, &str)> = Vec::new();
|
||||
let wml = |s: &str| format!("application/vnd.openxmlformats-officedocument.wordprocessingml.{s}+xml");
|
||||
let styles = c.styles(&root);
|
||||
c.put("word/styles.xml", &styles);
|
||||
c.rels[DOC].add(rt::STYLES, "styles.xml", false);
|
||||
overrides.push(("/word/styles.xml".into(), "styles"));
|
||||
if let Some(n) = root.child("w:lists").map(|l| c.numbering(l)) {
|
||||
c.put("word/numbering.xml", &n);
|
||||
c.rels[DOC].add(rt::NUMBERING, "numbering.xml", false);
|
||||
overrides.push(("/word/numbering.xml".into(), "numbering"));
|
||||
}
|
||||
for (part, root_name, kids, rel, ct) in [
|
||||
(FOOTNOTES, "w:footnotes", std::mem::take(&mut c.footnotes), rt::FOOTNOTES, "footnotes"),
|
||||
(ENDNOTES, "w:endnotes", std::mem::take(&mut c.endnotes), rt::ENDNOTES, "endnotes"),
|
||||
(COMMENTS, "w:comments", std::mem::take(&mut c.comments), rt::COMMENTS, "comments"),
|
||||
] {
|
||||
if kids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let file = format!("{ct}.xml");
|
||||
c.put(&format!("word/{file}"), &El { name: root_name.into(), attrs: xml::body_ns(), kids });
|
||||
c.put_rels(&file, part);
|
||||
c.rels[DOC].add(rel, &file, false);
|
||||
overrides.push((format!("/word/{file}"), ct));
|
||||
}
|
||||
for (i, (file, story)) in std::mem::take(&mut c.stories).into_iter().enumerate() {
|
||||
c.put(&format!("word/{file}"), &story);
|
||||
c.put_rels(&file, i + 4);
|
||||
overrides.push((format!("/word/{file}"), if story.name == "w:ftr" { "footer" } else { "header" }));
|
||||
}
|
||||
c.put("word/document.xml", &document);
|
||||
c.put_rels("document.xml", DOC);
|
||||
|
||||
let mut root_rels = PartRels::default();
|
||||
root_rels.add(rt::OFFICE_DOC, "word/document.xml", false);
|
||||
if let Some(props) = root.child("o:DocumentProperties") {
|
||||
c.files.insert("docProps/core.xml".into(), core_xml(props));
|
||||
root_rels.add(rt::CORE, "docProps/core.xml", false);
|
||||
}
|
||||
c.files.insert("_rels/.rels".into(), root_rels.xml());
|
||||
|
||||
let mut w = W::new();
|
||||
w.open("Types", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/content-types")]);
|
||||
w.empty("Default", &[("Extension", "rels"), ("ContentType", "application/vnd.openxmlformats-package.relationships+xml")]);
|
||||
w.empty("Default", &[("Extension", "xml"), ("ContentType", "application/xml")]);
|
||||
let mut exts: Vec<&str> = c.media.values().filter_map(|t| t.rsplit_once('.').map(|(_, e)| e)).collect();
|
||||
exts.sort_unstable();
|
||||
exts.dedup();
|
||||
for ext in exts {
|
||||
w.empty("Default", &[("Extension", ext), ("ContentType", media_type(ext))]);
|
||||
}
|
||||
w.empty("Override", &[("PartName", "/word/document.xml"), ("ContentType", &wml("document.main"))]);
|
||||
for (part, kind) in &overrides {
|
||||
w.empty("Override", &[("PartName", part), ("ContentType", &wml(kind))]);
|
||||
}
|
||||
if c.files.contains_key("docProps/core.xml") {
|
||||
w.empty("Override", &[("PartName", "/docProps/core.xml"), ("ContentType", "application/vnd.openxmlformats-package.core-properties+xml")]);
|
||||
}
|
||||
w.close("Types");
|
||||
c.files.insert("[Content_Types].xml".into(), w.into_bytes());
|
||||
Ok(Package::from_parts(c.files))
|
||||
}
|
||||
|
||||
impl Conv {
|
||||
/// Store an element as a part.
|
||||
fn put(&mut self, path: &str, root: &El) {
|
||||
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
|
||||
s.push_str(&root.to_xml());
|
||||
self.files.insert(path.into(), s.into_bytes());
|
||||
}
|
||||
|
||||
/// Store the relationships of `word/{file}` (if it has any).
|
||||
fn put_rels(&mut self, file: &str, part: usize) {
|
||||
if let Some(r) = self.rels.get(part).filter(|r| !r.is_empty()) {
|
||||
let x = r.xml();
|
||||
self.files.insert(format!("word/_rels/{file}.rels"), x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `w:binData` in the file, decoded into a media part.
|
||||
fn collect_media(&mut self, e: &El, depth: usize, total: &mut u64) -> Result<(), DocxError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Ok(());
|
||||
}
|
||||
for k in e.els() {
|
||||
if k.name == "w:binData" {
|
||||
let Some(name) = k.attr("w:name").filter(|n| !self.media.contains_key(*n)) else { continue };
|
||||
if self.media.len() >= MAX_MEDIA {
|
||||
continue;
|
||||
}
|
||||
let text = k.text();
|
||||
let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
|
||||
if (b64.len() / 4 * 3) as u64 > MAX_PART {
|
||||
return Err(DocxError::Limit(format!("picture {name} is larger than the size limit")));
|
||||
}
|
||||
let Ok(bytes) = base64::engine::general_purpose::STANDARD_NO_PAD.decode(b64.trim_end_matches('=')) else {
|
||||
log::warn!("wordml2003: skipping picture {name}: invalid base64");
|
||||
continue;
|
||||
};
|
||||
*total = total.saturating_add(bytes.len() as u64);
|
||||
if *total > MAX_TOTAL {
|
||||
return Err(DocxError::Limit("pictures larger than the size limit".into()));
|
||||
}
|
||||
let ext = picture_ext(&bytes, name);
|
||||
let target = format!("media/image{}.{ext}", self.media.len() + 1);
|
||||
self.files.insert(format!("word/{target}"), bytes);
|
||||
self.media.insert(name.to_string(), target);
|
||||
} else {
|
||||
self.collect_media(k, depth + 1, total)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The converted children of `e`.
|
||||
fn kids(&mut self, e: &El, part: usize, depth: usize) -> Vec<Node> {
|
||||
let mut out = Vec::with_capacity(e.kids.len());
|
||||
for n in &e.kids {
|
||||
match n {
|
||||
Node::Text(t) => out.push(Node::Text(t.clone())),
|
||||
Node::El(k) => out.extend(self.node(k, part, depth + 1)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `e` converted: nothing (dropped), its children (unwrapped) or one element.
|
||||
fn node(&mut self, e: &El, part: usize, depth: usize) -> Vec<Node> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Vec::new();
|
||||
}
|
||||
match e.name.as_str() {
|
||||
"wx:sect" | "wx:sub-section" | "wx:pBdrGroup" => return self.kids(e, part, depth),
|
||||
"w:binData" | "w:fonts" | "w:docPr" | "w:lists" | "w:styles" | "w:latentStyles" | "w:versionOfBuiltInStyleNames" => return Vec::new(),
|
||||
"w:hlink" => return vec![Node::El(self.hyperlink(e, part, depth))],
|
||||
"w:listPr" => return vec![Node::El(list_pr(e))],
|
||||
"w:footnote" | "w:endnote" => return self.note(e, depth).into_iter().map(Node::El).collect(),
|
||||
"aml:annotation" => return self.annotation(e, part, depth),
|
||||
"w:sectPr" => return vec![Node::El(self.section(e, part, depth))],
|
||||
"w:nfc" => return vec![Node::El(num_fmt(e))],
|
||||
_ => {}
|
||||
}
|
||||
if !keep(&e.name) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut el = El { name: rename(&e.name), attrs: attrs(e), kids: self.kids(e, part, depth) };
|
||||
if el.name == "v:imagedata"
|
||||
&& let Some(target) = e.attr("src").and_then(|s| self.media.get(s)).cloned()
|
||||
&& let Some(r) = self.rels.get_mut(part)
|
||||
{
|
||||
let id = r.add(rt::IMAGE, &target, false);
|
||||
el.attrs.retain(|(k, _)| k != "r:id");
|
||||
el.attrs.push(("r:id".into(), id));
|
||||
}
|
||||
if el.name == "w:style" {
|
||||
for (k, v) in &mut el.attrs {
|
||||
if k == "w:default" && v == "on" {
|
||||
*v = "1".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![Node::El(el)]
|
||||
}
|
||||
|
||||
/// `w:hlink` → `w:hyperlink` (an external relationship for `w:dest`).
|
||||
fn hyperlink(&mut self, e: &El, part: usize, depth: usize) -> El {
|
||||
let mut attrs = Vec::new();
|
||||
if let Some(dest) = e.attr("w:dest").filter(|d| !d.is_empty())
|
||||
&& let Some(r) = self.rels.get_mut(part)
|
||||
{
|
||||
attrs.push(("r:id".into(), r.add(rt::HYPERLINK, dest, true)));
|
||||
}
|
||||
for (from, to) in [("w:bookmark", "w:anchor"), ("w:screenTip", "w:tooltip"), ("w:target", "w:tgtFrame")] {
|
||||
if let Some(v) = e.attr(from) {
|
||||
attrs.push((to.into(), v.to_string()));
|
||||
}
|
||||
}
|
||||
El { name: "w:hyperlink".into(), attrs, kids: self.kids(e, part, depth) }
|
||||
}
|
||||
|
||||
/// A footnote or endnote written inline in its run → a reference, the note in its part.
|
||||
fn note(&mut self, e: &El, depth: usize) -> Option<El> {
|
||||
// Separator definitions (`w:type`) live in document settings, not in the text.
|
||||
if e.attr("w:type").is_some() || self.story_count >= MAX_STORIES {
|
||||
return None;
|
||||
}
|
||||
self.story_count += 1;
|
||||
let foot = e.name == "w:footnote";
|
||||
let id = self.next_note.to_string();
|
||||
self.next_note = self.next_note.saturating_add(1);
|
||||
let part = if foot { FOOTNOTES } else { ENDNOTES };
|
||||
let kids = self.kids(e, part, depth);
|
||||
let note = El { name: e.name.clone(), attrs: vec![("w:id".into(), id.clone())], kids };
|
||||
if foot {
|
||||
self.footnotes.push(Node::El(note));
|
||||
} else {
|
||||
self.endnotes.push(Node::El(note));
|
||||
}
|
||||
let name = if foot { "w:footnoteReference" } else { "w:endnoteReference" };
|
||||
Some(El { name: name.into(), attrs: vec![("w:id".into(), id)], kids: Vec::new() })
|
||||
}
|
||||
|
||||
/// `aml:annotation`: bookmarks, comments and tracked insertions/deletions.
|
||||
fn annotation(&mut self, e: &El, part: usize, depth: usize) -> Vec<Node> {
|
||||
let id = e.attr("aml:id").filter(|v| v.parse::<i64>().is_ok()).unwrap_or("0").to_string();
|
||||
let el = |name: &str, attrs: Vec<(String, String)>| vec![Node::El(El { name: name.into(), attrs, kids: Vec::new() })];
|
||||
let author_date = |e: &El| {
|
||||
let mut a = vec![("w:id".to_string(), id.clone())];
|
||||
if let Some(v) = e.attr("aml:author") {
|
||||
a.push(("w:author".into(), v.to_string()));
|
||||
}
|
||||
if let Some(v) = e.attr("aml:createdate") {
|
||||
a.push(("w:date".into(), v.to_string()));
|
||||
}
|
||||
a
|
||||
};
|
||||
let content = |c: &mut Conv, part: usize| e.child("aml:content").map(|k| c.kids(k, part, depth + 1)).unwrap_or_default();
|
||||
match e.attr("w:type").unwrap_or("") {
|
||||
"Word.Bookmark.Start" => {
|
||||
el("w:bookmarkStart", vec![("w:id".into(), id.clone()), ("w:name".into(), e.attr("w:name").unwrap_or("").to_string())])
|
||||
}
|
||||
"Word.Bookmark.End" => el("w:bookmarkEnd", vec![("w:id".into(), id)]),
|
||||
"Word.Comment.Start" => el("w:commentRangeStart", vec![("w:id".into(), id)]),
|
||||
"Word.Comment.End" => el("w:commentRangeEnd", vec![("w:id".into(), id)]),
|
||||
"Word.Comment" => {
|
||||
if self.story_count >= MAX_STORIES {
|
||||
return Vec::new();
|
||||
}
|
||||
self.story_count += 1;
|
||||
let mut attrs = author_date(e);
|
||||
if let Some(v) = e.attr("w:initials") {
|
||||
attrs.push(("w:initials".into(), v.to_string()));
|
||||
}
|
||||
let kids = content(self, COMMENTS);
|
||||
self.comments.push(Node::El(El { name: "w:comment".into(), attrs, kids }));
|
||||
el("w:commentReference", vec![("w:id".into(), id)])
|
||||
}
|
||||
t @ ("Word.Insertion" | "Word.Deletion") => {
|
||||
let name = if t == "Word.Insertion" { "w:ins" } else { "w:del" };
|
||||
vec![Node::El(El { name: name.into(), attrs: author_date(e), kids: content(self, part) })]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:sectPr`: inline `w:hdr` / `w:ftr` become header and footer parts.
|
||||
fn section(&mut self, e: &El, part: usize, depth: usize) -> El {
|
||||
let mut refs = Vec::new();
|
||||
let mut rest = Vec::new();
|
||||
for n in &e.kids {
|
||||
let Node::El(k) = n else { continue };
|
||||
if (k.name == "w:hdr" || k.name == "w:ftr") && self.story_count < MAX_STORIES {
|
||||
self.story_count += 1;
|
||||
let footer = k.name == "w:ftr";
|
||||
let kind = match k.attr("w:type") {
|
||||
Some("even") => "even",
|
||||
Some("first") => "first",
|
||||
_ => "default",
|
||||
};
|
||||
let idx = self.stories.len() + 4;
|
||||
self.rels.push(PartRels::default());
|
||||
let kids = self.kids(k, idx, depth + 1);
|
||||
let file = format!("{}{}.xml", if footer { "footer" } else { "header" }, self.stories.len() + 1);
|
||||
self.stories.push((file.clone(), El { name: k.name.clone(), attrs: xml::body_ns(), kids }));
|
||||
let id = self.rels.get_mut(part).map(|r| r.add(if footer { rt::FOOTER } else { rt::HEADER }, &file, false)).unwrap_or_default();
|
||||
let name = if footer { "w:footerReference" } else { "w:headerReference" };
|
||||
refs.push(Node::El(El { name: name.into(), attrs: vec![("w:type".into(), kind.into()), ("r:id".into(), id)], kids: Vec::new() }));
|
||||
} else if k.name != "w:hdr" && k.name != "w:ftr" {
|
||||
rest.extend(self.node(k, part, depth + 1));
|
||||
}
|
||||
}
|
||||
refs.extend(rest);
|
||||
El { name: "w:sectPr".into(), attrs: attrs(e), kids: refs }
|
||||
}
|
||||
|
||||
/// `w:styles` → `styles.xml`, with the document's default fonts (`w:fonts/w:defaultFonts`).
|
||||
fn styles(&mut self, root: &El) -> El {
|
||||
let mut kids = Vec::new();
|
||||
if let Some(f) = root.child("w:fonts").and_then(|f| f.child("w:defaultFonts")) {
|
||||
let fonts = El { name: "w:rFonts".into(), attrs: attrs(f), kids: Vec::new() };
|
||||
let rpr = El { name: "w:rPr".into(), attrs: Vec::new(), kids: vec![Node::El(fonts)] };
|
||||
let rpr_default = El { name: "w:rPrDefault".into(), attrs: Vec::new(), kids: vec![Node::El(rpr)] };
|
||||
kids.push(Node::El(El { name: "w:docDefaults".into(), attrs: Vec::new(), kids: vec![Node::El(rpr_default)] }));
|
||||
}
|
||||
if let Some(s) = root.child("w:styles") {
|
||||
for st in s.children("w:style") {
|
||||
kids.extend(self.node(st, DOC, 2));
|
||||
}
|
||||
}
|
||||
El { name: "w:styles".into(), attrs: xml::body_ns(), kids }
|
||||
}
|
||||
|
||||
/// `w:lists` → `numbering.xml`: `w:listDef` → `w:abstractNum`, `w:list` → `w:num`.
|
||||
fn numbering(&mut self, lists: &El) -> El {
|
||||
let mut kids = Vec::new();
|
||||
for def in lists.children("w:listDef") {
|
||||
let Some(id) = def.attr("w:listDefId") else { continue };
|
||||
let mut a = El { name: "w:abstractNum".into(), attrs: vec![("w:abstractNumId".into(), id.to_string())], kids: Vec::new() };
|
||||
for k in def.els() {
|
||||
let converted = match k.name.as_str() {
|
||||
"w:plt" => {
|
||||
let v = match k.attr("w:val").unwrap_or("") {
|
||||
"SingleLevel" => "singleLevel",
|
||||
"HybridMultilevel" => "hybridMultilevel",
|
||||
_ => "multilevel",
|
||||
};
|
||||
vec![Node::El(val_el("w:multiLevelType", v))]
|
||||
}
|
||||
"w:lsid" => vec![Node::El(val_el("w:nsid", k.attr("w:val").unwrap_or("")))],
|
||||
"w:listStyleLink" => vec![Node::El(val_el("w:numStyleLink", k.attr("w:val").unwrap_or("")))],
|
||||
_ => self.node(k, DOC, 2),
|
||||
};
|
||||
a.kids.extend(converted);
|
||||
}
|
||||
kids.push(Node::El(a));
|
||||
}
|
||||
for list in lists.children("w:list") {
|
||||
let Some(id) = list.attr("w:ilfo") else { continue };
|
||||
let Some(abs) = list.child_val("w:ilst") else { continue };
|
||||
let mut n =
|
||||
El { name: "w:num".into(), attrs: vec![("w:numId".into(), id.to_string())], kids: vec![Node::El(val_el("w:abstractNumId", abs))] };
|
||||
for o in list.children("w:lvlOverride") {
|
||||
n.kids.extend(self.node(o, DOC, 2));
|
||||
}
|
||||
kids.push(Node::El(n));
|
||||
}
|
||||
El { name: "w:numbering".into(), attrs: xml::body_ns(), kids }
|
||||
}
|
||||
}
|
||||
|
||||
/// `<name w:val="v"/>`.
|
||||
fn val_el(name: &str, v: &str) -> El {
|
||||
El { name: name.into(), attrs: vec![("w:val".into(), v.into())], kids: Vec::new() }
|
||||
}
|
||||
|
||||
/// `w:listPr` (`w:ilvl`, `w:ilfo`) → `w:numPr` (`w:ilvl`, `w:numId`).
|
||||
fn list_pr(e: &El) -> El {
|
||||
let mut kids = Vec::new();
|
||||
if let Some(v) = e.child_val("w:ilvl") {
|
||||
kids.push(Node::El(val_el("w:ilvl", v)));
|
||||
}
|
||||
if let Some(v) = e.child_val("w:ilfo") {
|
||||
kids.push(Node::El(val_el("w:numId", v)));
|
||||
}
|
||||
El { name: "w:numPr".into(), attrs: Vec::new(), kids }
|
||||
}
|
||||
|
||||
/// `w:nfc` (a number format code) → `w:numFmt`.
|
||||
fn num_fmt(e: &El) -> El {
|
||||
let f = match e.attr("w:val").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0) {
|
||||
1 => "upperRoman",
|
||||
2 => "lowerRoman",
|
||||
3 => "upperLetter",
|
||||
4 => "lowerLetter",
|
||||
5 => "ordinal",
|
||||
6 => "cardinalText",
|
||||
7 => "ordinalText",
|
||||
9 => "chicago",
|
||||
14 | 19 => "decimalFullWidth",
|
||||
15 => "decimalHalfWidth",
|
||||
18 => "decimalEnclosedCircle",
|
||||
22 => "decimalZero",
|
||||
23 => "bullet",
|
||||
24 => "ganada",
|
||||
25 => "chosung",
|
||||
26 => "decimalEnclosedFullstop",
|
||||
27 => "decimalEnclosedParen",
|
||||
255 => "none",
|
||||
_ => "decimal",
|
||||
};
|
||||
val_el("w:numFmt", f)
|
||||
}
|
||||
|
||||
/// Is an element or attribute with this name kept (a namespace the package knows, or none)?
|
||||
fn keep(name: &str) -> bool {
|
||||
match name.split_once(':') {
|
||||
None => true,
|
||||
Some(("xml", _)) => true,
|
||||
Some((p, _)) => NAMESPACES.iter().any(|(q, _)| *q == p),
|
||||
}
|
||||
}
|
||||
|
||||
/// A WordprocessingML 2003 name in its ECMA-376 spelling: `w:sz-cs` → `w:szCs`, `w:first-line`
|
||||
/// → `w:firstLine`, `w:fareast` → `w:eastAsia`, `w:vmerge` → `w:vMerge`.
|
||||
fn rename(name: &str) -> String {
|
||||
let Some(local) = name.strip_prefix("w:") else { return name.to_string() };
|
||||
match local {
|
||||
"fareast" => return "w:eastAsia".into(),
|
||||
"vmerge" => return "w:vMerge".into(),
|
||||
"hmerge" => return "w:hMerge".into(),
|
||||
_ => {}
|
||||
}
|
||||
let mut out = String::from("w:");
|
||||
let mut up = false;
|
||||
for c in local.chars() {
|
||||
if c == '-' {
|
||||
up = true;
|
||||
} else if up {
|
||||
out.extend(c.to_uppercase());
|
||||
up = false;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The attributes of `e` that the package keeps, renamed.
|
||||
fn attrs(e: &El) -> Vec<(String, String)> {
|
||||
e.attrs.iter().filter(|(k, _)| keep(k)).map(|(k, v)| (rename(k), v.clone())).collect()
|
||||
}
|
||||
|
||||
/// A picture's file extension: from its bytes, else from its `wordml://` name.
|
||||
fn picture_ext(b: &[u8], name: &str) -> String {
|
||||
let sniffed = if b.starts_with(b"\x89PNG") {
|
||||
"png"
|
||||
} else if b.starts_with(&[0xFF, 0xD8]) {
|
||||
"jpeg"
|
||||
} else if b.starts_with(b"GIF8") {
|
||||
"gif"
|
||||
} else if b.starts_with(b"BM") {
|
||||
"bmp"
|
||||
} else if b.get(40..44) == Some(b" EMF") {
|
||||
"emf"
|
||||
} else if b.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) || b.starts_with(&[0x01, 0x00, 0x09, 0x00]) {
|
||||
"wmf"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if !sniffed.is_empty() {
|
||||
return sniffed.into();
|
||||
}
|
||||
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
|
||||
if !ext.is_empty() && ext.len() <= 5 && ext.chars().all(|c| c.is_ascii_alphanumeric()) && ext != "xml" && ext != "rels" {
|
||||
ext
|
||||
} else {
|
||||
"bin".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn media_type(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
"png" => "image/png",
|
||||
"jpeg" | "jpg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"bmp" => "image/bmp",
|
||||
"emf" => "image/x-emf",
|
||||
"wmf" => "image/x-wmf",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// `o:DocumentProperties` → `docProps/core.xml`.
|
||||
fn core_xml(props: &El) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open(
|
||||
"cp:coreProperties",
|
||||
&[
|
||||
("xmlns:cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"),
|
||||
("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
|
||||
("xmlns:dcterms", "http://purl.org/dc/terms/"),
|
||||
],
|
||||
);
|
||||
for (from, to) in [
|
||||
("o:Title", "dc:title"),
|
||||
("o:Subject", "dc:subject"),
|
||||
("o:Author", "dc:creator"),
|
||||
("o:Keywords", "cp:keywords"),
|
||||
("o:Description", "dc:description"),
|
||||
("o:LastAuthor", "cp:lastModifiedBy"),
|
||||
("o:Revision", "cp:revision"),
|
||||
("o:Category", "cp:category"),
|
||||
("o:Created", "dcterms:created"),
|
||||
("o:LastSaved", "dcterms:modified"),
|
||||
] {
|
||||
if let Some(t) = props.child(from).map(El::text).filter(|t| !t.trim().is_empty()) {
|
||||
w.leaf(to, &[], t.trim());
|
||||
}
|
||||
}
|
||||
w.close("cp:coreProperties");
|
||||
w.into_bytes()
|
||||
}
|
||||
@@ -44,10 +44,10 @@ impl PartRels {
|
||||
}
|
||||
self.list.push((id.to_string(), kind.to_string(), target.to_string(), external));
|
||||
}
|
||||
fn is_empty(&self) -> bool {
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.list.is_empty()
|
||||
}
|
||||
fn xml(&self) -> Vec<u8> {
|
||||
pub(crate) fn xml(&self) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open("Relationships", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/relationships")]);
|
||||
for (id, kind, target, external) in &self.list {
|
||||
@@ -117,6 +117,12 @@ pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
|
||||
|
||||
/// Write a package of the given flavour (`.docx`, `.docm`, `.dotx`, `.dotm`).
|
||||
pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
|
||||
zip_entries(&write_parts(doc, flavor)?)
|
||||
}
|
||||
|
||||
/// The parts of the package [`write_as`] zips, in order, `[Content_Types].xml` first: (part name
|
||||
/// without the leading `/`, bytes).
|
||||
pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String, Vec<u8>)>, DocxError> {
|
||||
// A range field must be whole in the file: drop markers that lost their partner in editing.
|
||||
let balanced;
|
||||
let doc = if doc.has_unbalanced_field_ranges() {
|
||||
@@ -451,8 +457,7 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
|
||||
}
|
||||
w.close("Types");
|
||||
entries.insert(0, ("[Content_Types].xml".into(), w.into_bytes()));
|
||||
|
||||
zip_entries(&entries)
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn push_part(entries: &mut Vec<(String, Vec<u8>)>, overrides: &mut Vec<(String, String)>, path: &str, bytes: Vec<u8>, ct: &str, rels: PartRels) {
|
||||
|
||||
+14
-5
@@ -68,7 +68,10 @@ const STRICT: &[(&str, &str)] = &[
|
||||
("m", "http://purl.oclc.org/ooxml/officeDocument/math"),
|
||||
];
|
||||
|
||||
fn prefix_for(uri: &[u8]) -> &'static str {
|
||||
fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
|
||||
if let Some((p, _)) = extra.iter().find(|(_, u)| u.as_bytes() == uri) {
|
||||
return p;
|
||||
}
|
||||
for (p, u) in NAMESPACES.iter().chain(OTHER_NAMESPACES).chain(STRICT) {
|
||||
if u.as_bytes() == uri {
|
||||
return p;
|
||||
@@ -210,10 +213,9 @@ impl El {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse XML bytes into the root element.
|
||||
/// UTF-16 LE/BE text with a byte-order mark as UTF-8 (`None` without one). Lossy: an unpaired
|
||||
/// surrogate becomes U+FFFD and a trailing odd byte is dropped. The result is at most 1.5× the input.
|
||||
fn utf16_to_utf8(bytes: &[u8]) -> Option<String> {
|
||||
pub(crate) fn utf16_to_utf8(bytes: &[u8]) -> Option<String> {
|
||||
let (rest, le) = match bytes.get(..2) {
|
||||
Some([0xFF, 0xFE]) => (bytes.get(2..)?, true),
|
||||
Some([0xFE, 0xFF]) => (bytes.get(2..)?, false),
|
||||
@@ -223,7 +225,14 @@ fn utf16_to_utf8(bytes: &[u8]) -> Option<String> {
|
||||
Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect())
|
||||
}
|
||||
|
||||
/// Parse XML bytes into the root element.
|
||||
pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
|
||||
parse_ns(bytes, &[])
|
||||
}
|
||||
|
||||
/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a
|
||||
/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows.
|
||||
pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, DocxError> {
|
||||
// UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped.
|
||||
let transcoded = utf16_to_utf8(bytes);
|
||||
let bytes = match &transcoded {
|
||||
@@ -246,7 +255,7 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
|
||||
buf.clear();
|
||||
let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let el_prefix = match &res {
|
||||
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref())),
|
||||
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
|
||||
_ => None,
|
||||
};
|
||||
match ev {
|
||||
@@ -271,7 +280,7 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
|
||||
let (ares, alocal) = r.resolver_mut().resolve_attribute(key);
|
||||
let alocal = String::from_utf8_lossy(alocal.as_ref()).into_owned();
|
||||
let aname = match ares {
|
||||
ResolveResult::Bound(ns) => format!("{}:{alocal}", prefix_for(ns.as_ref())),
|
||||
ResolveResult::Bound(ns) => format!("{}:{alocal}", prefix_for(ns.as_ref(), extra)),
|
||||
_ => alocal,
|
||||
};
|
||||
let val = match a.normalized_value(XmlVersion::Explicit1_0) {
|
||||
|
||||
@@ -1278,6 +1278,21 @@ fn second_generation_is_stable() {
|
||||
assert_eq!(d2, d1);
|
||||
}
|
||||
|
||||
/// A Word XML Document (Flat OPC) carries the same package as a .docx: reading it back gives
|
||||
/// what the .docx round trip gives, pictures included (#548).
|
||||
#[test]
|
||||
fn flat_opc_round_trip_equals_docx() {
|
||||
let d = kitchen_sink();
|
||||
let xml = wordcraft_docx::write_flat(&d).expect("write flat");
|
||||
let text = std::str::from_utf8(&xml).expect("utf-8");
|
||||
assert!(text.starts_with("<?xml") && text.contains("<?mso-application progid=\"Word.Document\"?>"));
|
||||
assert!(text.contains("pkg:name=\"/word/document.xml\"") && text.contains("<pkg:binaryData>"));
|
||||
assert_eq!(wordcraft_docx::sniff_xml(&xml), wordcraft_docx::XmlKind::FlatOpc);
|
||||
let back = wordcraft_docx::read_xml(&xml).expect("read flat");
|
||||
assert_eq!(back, rt(&d));
|
||||
assert_eq!(back.media.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_stylesheet_survives() {
|
||||
let d = Document::new();
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Word XML files (#548): Flat OPC (Word XML Document) and Word 2003 XML, from hand-written
|
||||
//! samples (never files produced by Word).
|
||||
|
||||
use base64::Engine as _;
|
||||
use wordcraft_doc::props::Align;
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
use wordcraft_doc::{Block, Document, InlineObject, Paragraph};
|
||||
use wordcraft_docx::{DocxError, XmlKind, read_xml, sniff_xml};
|
||||
|
||||
fn paras(doc: &Document) -> Vec<&Paragraph> {
|
||||
doc.body.iter().filter_map(|b| b.as_para()).collect()
|
||||
}
|
||||
|
||||
fn part_text(doc: &Document, id: Option<u32>) -> String {
|
||||
id.and_then(|i| doc.parts.get(&i))
|
||||
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn tiny_png_b64() -> String {
|
||||
let img = image::RgbaImage::from_fn(2, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 100, 50, 255]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(out.into_inner());
|
||||
// Line breaks inside base64, as the formats write it.
|
||||
b64.as_bytes().chunks(20).map(|c| std::str::from_utf8(c).unwrap()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// A minimal Flat OPC package written by hand: namespaces declared on the package element only,
|
||||
/// a hyperlink relationship, a picture in base64 and an empty part.
|
||||
#[test]
|
||||
fn reads_a_hand_written_flat_opc_package() {
|
||||
let png = tiny_png_b64();
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<?mso-application progid="Word.Document"?>
|
||||
<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<pkg:part pkg:name="/_rels/.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml">
|
||||
<pkg:xmlData>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>
|
||||
</pkg:xmlData>
|
||||
</pkg:part>
|
||||
<pkg:part pkg:name="/word/_rels/document.xml.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml">
|
||||
<pkg:xmlData><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.org/flat" TargetMode="External"/><Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/dot.png"/></Relationships></pkg:xmlData>
|
||||
</pkg:part>
|
||||
<pkg:part pkg:name="/word/document.xml" pkg:contentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml">
|
||||
<pkg:xmlData>
|
||||
<w:document xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml"><w:body>
|
||||
<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Seed & soil</w:t></w:r></w:p>
|
||||
<w:p><w:hyperlink r:id="rId5"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>
|
||||
<w:p><w:r><w:pict><v:shape style="width:24pt;height:12pt"><v:imagedata r:id="rId6"/></v:shape></w:pict></w:r></w:p>
|
||||
</w:body></w:document>
|
||||
</pkg:xmlData>
|
||||
</pkg:part>
|
||||
<pkg:part pkg:name="/word/media/dot.png" pkg:contentType="image/png" pkg:compression="store">
|
||||
<pkg:binaryData>{png}</pkg:binaryData>
|
||||
</pkg:part>
|
||||
<pkg:part pkg:name="/word/empty.xml" pkg:contentType="application/xml"><pkg:xmlData/></pkg:part>
|
||||
</pkg:package>"#
|
||||
);
|
||||
assert_eq!(sniff_xml(xml.as_bytes()), XmlKind::FlatOpc);
|
||||
let doc = read_xml(xml.as_bytes()).unwrap();
|
||||
let ps = paras(&doc);
|
||||
assert_eq!(ps[0].plain_text(), "Seed & soil");
|
||||
assert_eq!(ps[0].props_at(0).bold, Some(true));
|
||||
assert_eq!(ps[1].props_at(0).link.as_deref(), Some("https://example.org/flat"));
|
||||
assert!(matches!(ps[2].objects.first(), Some(InlineObject::Image { w, h, .. }) if *w == 24.0 && *h == 12.0));
|
||||
assert_eq!(doc.media.len(), 1);
|
||||
}
|
||||
|
||||
/// A Word 2003 XML Document, hand-written from the Office 2003 XML Reference Schemas.
|
||||
fn wordml_2003_sample() -> String {
|
||||
let png = tiny_png_b64();
|
||||
format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<?mso-application progid="Word.Document"?>
|
||||
<w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml" xmlns:wx="http://schemas.microsoft.com/office/word/2003/auxHint" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:aml="http://schemas.microsoft.com/aml/2001/core" w:macrosPresent="no">
|
||||
<o:DocumentProperties><o:Title>Garden notes</o:Title><o:Author>Ada Gardener</o:Author><o:Keywords>beans</o:Keywords></o:DocumentProperties>
|
||||
<w:fonts><w:defaultFonts w:ascii="Georgia" w:fareast="Georgia" w:h-ansi="Georgia" w:cs="Georgia"/></w:fonts>
|
||||
<w:lists>
|
||||
<w:listDef w:listDefId="3"><w:plt w:val="HybridMultilevel"/>
|
||||
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:nfc w:val="4"/><w:lvlText w:val="%1)"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
|
||||
</w:listDef>
|
||||
<w:list w:ilfo="2"><w:ilst w:val="3"/></w:list>
|
||||
</w:lists>
|
||||
<w:styles>
|
||||
<w:versionOfBuiltInStyleNames w:val="4"/>
|
||||
<w:style w:type="paragraph" w:default="on" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><wx:font wx:val="Georgia"/><w:sz w:val="24"/><w:sz-cs w:val="24"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><wx:uiName wx:val="Heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:before="240" w:after="60"/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
|
||||
</w:styles>
|
||||
<w:docPr><w:view w:val="print"/><w:zoom w:percent="100"/><w:footnotePr><w:footnote w:type="separator"><w:p><w:r><w:separator/></w:r></w:p></w:footnote></w:footnotePr></w:docPr>
|
||||
<w:body>
|
||||
<wx:sect>
|
||||
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><aml:annotation aml:id="0" w:type="Word.Bookmark.Start" w:name="plan"/><aml:annotation aml:id="1" w:type="Word.Comment.Start"/><w:r><w:t>Plan</w:t></w:r><aml:annotation aml:id="1" w:type="Word.Comment.End"/><w:r><aml:annotation aml:id="1" aml:author="Bo" w:initials="B" w:type="Word.Comment"><aml:content><w:p><w:r><w:t>Too short?</w:t></w:r></w:p></aml:content></aml:annotation></w:r><aml:annotation aml:id="0" w:type="Word.Bookmark.End"/></w:p>
|
||||
<w:p><w:pPr><w:jc w:val="center"/><w:ind w:first-line="360"/><w:spacing w:line="360" w:line-rule="auto"/></w:pPr>
|
||||
<w:r><w:t>Plain </w:t></w:r>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="Courier New" w:h-ansi="Courier New"/><w:b/><w:i/><w:u w:val="single"/><w:sz w:val="28"/><w:color w:val="FF0000"/></w:rPr><w:t>loud</w:t></w:r>
|
||||
<w:r><w:tab/><w:t>then</w:t><w:br/><w:t>more</w:t></w:r>
|
||||
<w:r><w:footnote><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t>Sown in May.</w:t></w:r></w:p></w:footnote></w:r>
|
||||
</w:p>
|
||||
<w:p><w:hlink w:dest="https://example.org/beans" w:screenTip="Beans"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>bean guide</w:t></w:r></w:hlink></w:p>
|
||||
<w:p><w:pPr><w:listPr><w:ilvl w:val="0"/><w:ilfo w:val="2"/><wx:t wx:val="a)"/></w:listPr></w:pPr><w:r><w:t>Water the beans</w:t></w:r><aml:annotation aml:id="7" aml:author="Ada" aml:createdate="2004-05-01T10:00:00Z" w:type="Word.Insertion"><aml:content><w:r><w:t> daily</w:t></w:r></aml:content></aml:annotation></w:p>
|
||||
<w:tbl>
|
||||
<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
|
||||
<w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
|
||||
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
|
||||
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc></w:tr>
|
||||
</w:tbl>
|
||||
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot"/></v:shape></w:pict></w:r></w:p>
|
||||
<w:sectPr>
|
||||
<w:hdr w:type="odd"><w:p><w:r><w:t>Garden header</w:t></w:r></w:p></w:hdr>
|
||||
<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>
|
||||
</w:sectPr>
|
||||
</wx:sect>
|
||||
</w:body>
|
||||
</w:wordDocument>"##
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_word_2003_xml() {
|
||||
let xml = wordml_2003_sample();
|
||||
assert_eq!(sniff_xml(xml.as_bytes()), XmlKind::WordMl2003);
|
||||
let doc = read_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(doc.core.title, "Garden notes");
|
||||
assert_eq!(doc.core.creator, "Ada Gardener");
|
||||
|
||||
let h1 = doc.styles.get("Heading1").expect("Heading1 style");
|
||||
assert_eq!(h1.chr.bold, Some(true));
|
||||
assert_eq!(h1.chr.size, Some(16.0));
|
||||
|
||||
let ps = paras(&doc);
|
||||
assert_eq!(ps[0].plain_text(), "Plan");
|
||||
assert_eq!(ps[0].props.style.as_deref(), Some("Heading1"));
|
||||
assert!(ps[0].objects.iter().any(|o| matches!(o, InlineObject::BookmarkStart { name, .. } if name == "plan")));
|
||||
|
||||
let p = ps[1];
|
||||
assert_eq!(p.props.align, Some(Align::Center));
|
||||
assert_eq!(p.props.indent_first, Some(18.0));
|
||||
let loud = p.text.find("loud").unwrap();
|
||||
let c = p.props_of_char(loud);
|
||||
assert_eq!((c.bold, c.italic, c.size, c.font.as_deref()), (Some(true), Some(true), Some(14.0), Some("Courier New")));
|
||||
assert!(c.underline.is_some() && c.color.is_some());
|
||||
assert!(p.plain_text().contains("Plain loud\tthen"));
|
||||
let note = p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::NoteRef { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(part_text(&doc, note), "Sown in May.");
|
||||
|
||||
assert_eq!(ps[2].props_at(0).link.as_deref(), Some("https://example.org/beans"));
|
||||
let listed = ps[3];
|
||||
let num = listed.props.numbering.expect("list paragraph");
|
||||
let lvl = doc.numbering.level(num.num, num.level).expect("list level");
|
||||
assert_eq!((lvl.format, lvl.text.as_str(), lvl.indent), (NumFormat::LowerLetter, "%1)", 36.0));
|
||||
let ins = listed.props_of_char(listed.text.find("daily").unwrap()).ins.and_then(|i| doc.revisions.get(i as usize));
|
||||
assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada"));
|
||||
let comment = doc.comments.values().next().expect("comment");
|
||||
assert_eq!((comment.author.as_str(), part_text(&doc, Some(comment.part)).as_str()), ("Bo", "Too short?"));
|
||||
|
||||
let table = doc.body.iter().find_map(|b| match b.as_ref() {
|
||||
Block::Table(t) => Some(t),
|
||||
_ => None,
|
||||
});
|
||||
let table = table.expect("table");
|
||||
assert_eq!(table.rows.len(), 2);
|
||||
assert_eq!(table.rows[0].cells[1].blocks.first().and_then(|b| b.as_para()).map(|p| p.plain_text()).as_deref(), Some("B1"));
|
||||
|
||||
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
|
||||
assert!(matches!(pic, Some(InlineObject::Image { w, h, .. }) if *w == 36.0 && *h == 18.0));
|
||||
assert_eq!(doc.media.len(), 1);
|
||||
|
||||
assert_eq!(doc.last_section.page_w, 612.0);
|
||||
assert_eq!(doc.last_section.margin_left, 90.0);
|
||||
assert_eq!(part_text(&doc, doc.last_section.headers.default), "Garden header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_word_xml_is_an_error_not_a_panic() {
|
||||
let flat = r#"<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage">"#;
|
||||
let w2003 = r#"<w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml">"#;
|
||||
// Other XML (and non-XML) is refused with the Word XML error.
|
||||
for other in ["<html><body>hi</body></html>", "", "not xml at all", "<package/>"] {
|
||||
assert_eq!(read_xml(other.as_bytes()), Err(DocxError::NotWordXml), "{other:?}");
|
||||
}
|
||||
// Broken packages: errors.
|
||||
for bad in [
|
||||
flat.to_string(),
|
||||
format!("{flat}</pkg:package>"),
|
||||
format!("{flat}<pkg:part pkg:name=\"/word/document.xml\"><pkg:binaryData>!!!not base64</pkg:binaryData></pkg:part></pkg:package>"),
|
||||
format!("{flat}<pkg:part pkg:name=\"/word/document.xml\"><pkg:xmlData><w:document></pkg:xmlData></pkg:part></pkg:package>"),
|
||||
] {
|
||||
assert!(read_xml(bad.as_bytes()).is_err(), "{bad}");
|
||||
}
|
||||
// Lenient leftovers never panic.
|
||||
for odd in [
|
||||
format!("{flat}<pkg:part/><pkg:part pkg:name=\"\"><pkg:xmlData/></pkg:part><x/></pkg:package>"),
|
||||
format!(
|
||||
"{w2003}<w:body><w:p><w:r><w:footnote w:type=\"separator\"/><w:pict><w:binData w:name=\"a\">%%%</w:binData><v:imagedata src=\"a\"/></w:pict></w:r></w:p></w:body></w:wordDocument>"
|
||||
),
|
||||
format!(
|
||||
"{w2003}<w:lists><w:list/><w:listDef/></w:lists><w:body><aml:annotation xmlns:aml=\"http://schemas.microsoft.com/aml/2001/core\" aml:id=\"x\" w:type=\"Word.Comment\"/><w:sectPr><w:hdr/><w:ftr w:type=\"first\"/></w:sectPr></w:body></w:wordDocument>"
|
||||
),
|
||||
format!("{w2003}{}", "<w:p>".repeat(5000)),
|
||||
] {
|
||||
let _ = read_xml(odd.as_bytes());
|
||||
}
|
||||
// Too many parts.
|
||||
let mut many = String::from(flat);
|
||||
for i in 0..20_001 {
|
||||
many.push_str(&format!("<pkg:part pkg:name=\"/p{i}.bin\"><pkg:binaryData>AA==</pkg:binaryData></pkg:part>"));
|
||||
}
|
||||
many.push_str("</pkg:package>");
|
||||
assert!(matches!(read_xml(many.as_bytes()), Err(DocxError::Limit(_))));
|
||||
}
|
||||
@@ -176,7 +176,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
crate::io::save_path_with(&path, &s.doc, password).map_err(CmdError::Failed)?;
|
||||
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) {
|
||||
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = false;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"tex",
|
||||
"latex",
|
||||
"ltx",
|
||||
"xml",
|
||||
"wcraft.json",
|
||||
"json",
|
||||
];
|
||||
/// Formats WordCraft saves (Save As).
|
||||
pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json"];
|
||||
pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json", "xml"];
|
||||
|
||||
fn ext_of(name: &str) -> String {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
|
||||
@@ -7,6 +7,8 @@ pub fn open(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
|
||||
match ext {
|
||||
"docx" | "docm" | "dotx" | "dotm" => Some(wordcraft_docx::read(bytes).map_err(|e| e.to_string())),
|
||||
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
|
||||
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
|
||||
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
|
||||
other => wordcraft_formats::import(other, bytes),
|
||||
}
|
||||
}
|
||||
@@ -18,6 +20,7 @@ pub fn save(ext: &str, doc: &Document) -> Option<Result<Vec<u8>, String>> {
|
||||
return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string()));
|
||||
}
|
||||
match ext {
|
||||
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
|
||||
"png" => Some(render_png(doc, 0, 2.0)),
|
||||
"pdf" => Some(wordcraft_pdf::export(doc, &Default::default()).map_err(|e| e.to_string())),
|
||||
other => wordcraft_formats::export(other, doc),
|
||||
|
||||
@@ -2954,6 +2954,19 @@ fn style_visibility_round_trips_through_docx() {
|
||||
assert!(s.doc.styles.get("Heading4").is_some_and(|h| !h.quick), "undoable");
|
||||
}
|
||||
|
||||
/// #548: `.xml` saves a Word XML Document (Flat OPC) and opens it again; other XML is refused.
|
||||
#[test]
|
||||
fn word_xml_saves_and_opens_through_io() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Kept in one XML file"}));
|
||||
let bytes = crate::io::save_bytes("notes.xml", &s.doc).unwrap();
|
||||
assert_eq!(wordcraft_docx::sniff_xml(&bytes), wordcraft_docx::XmlKind::FlatOpc);
|
||||
let doc = crate::io::open_bytes("notes.XML", &bytes).unwrap();
|
||||
assert_eq!(doc.body, crate::io::open_bytes("n.docx", &crate::io::save_bytes("n.docx", &s.doc).unwrap()).unwrap().body);
|
||||
let err = crate::io::open_bytes("feed.xml", b"<rss><channel/></rss>").unwrap_err();
|
||||
assert!(err.contains("not a Word XML document"), "{err}");
|
||||
}
|
||||
|
||||
/// #146: the first/last column, total row and banded column regions apply in layout (later
|
||||
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
|
||||
#[test]
|
||||
|
||||
@@ -350,6 +350,7 @@ fn export_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
for (label, ext) in [
|
||||
("PDF document (*.pdf)", "pdf"),
|
||||
("Word document (*.docx)", "docx"),
|
||||
("Word XML Document (*.xml)", "xml"),
|
||||
("OpenDocument Text (*.odt)", "odt"),
|
||||
("Rich Text Format (*.rtf)", "rtf"),
|
||||
("Web page (*.html)", "html"),
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
Word Left Palabra a la izquierda
|
||||
Word Right Palabra a la derecha
|
||||
Word document (*.docx) Documento de Word (*.docx)
|
||||
Word XML Document (*.xml) Documento XML de Word (*.xml)
|
||||
WordArt WordArt
|
||||
WordCraft WordCraft
|
||||
Words Palabras
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -840,6 +840,7 @@
|
||||
Word Left Sõna vasakule
|
||||
Word Right Sõna paremale
|
||||
Word document (*.docx) Wordi dokument (*.docx)
|
||||
Word XML Document (*.xml) Wordi XML-dokument (*.xml)
|
||||
WordArt WordArt
|
||||
WordCraft WordCraft
|
||||
Words Sõnad
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 444 and column 13.
|
@@ -930,6 +930,7 @@
|
||||
Window ウィンドウ
|
||||
Word Count 文字カウント
|
||||
Word document (*.docx) Word 文書 (*.docx)
|
||||
Word XML Document (*.xml) Word XML ドキュメント (*.xml)
|
||||
Word Left 前の単語へ
|
||||
Word Right 次の単語へ
|
||||
WordArt ワードアート
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 552 and column 13.
|
@@ -930,6 +930,7 @@
|
||||
Window Janela
|
||||
Word Count Contagem de palavras
|
||||
Word document (*.docx) Documento Word (*.docx)
|
||||
Word XML Document (*.xml) Documento XML do Word (*.xml)
|
||||
Word Left Palavra à esquerda
|
||||
Word Right Palavra à direita
|
||||
WordArt Texto artístico
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2112,6 +2112,8 @@ preview Title Пример
|
||||
Apply Применить
|
||||
Convert Notes Преобразовать сноски
|
||||
|
||||
# Word XML Document (#548).
|
||||
Word XML Document (*.xml) Документ Word XML (*.xml)
|
||||
# Find and Replace special characters and wildcards (#543).
|
||||
Use wildcards Подстановочные знаки
|
||||
Special Специальный
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -990,6 +990,7 @@
|
||||
Window Prozor
|
||||
Word Count Broj reči
|
||||
Word document (*.docx) Word dokument (*.docx)
|
||||
Word XML Document (*.xml) Word XML dokument (*.xml)
|
||||
Word Left Reč levo
|
||||
Word Right Reč desno
|
||||
WordArt WordArt
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -994,6 +994,7 @@
|
||||
Window Прозор
|
||||
Word Count Број речи
|
||||
Word document (*.docx) Word документ (*.docx)
|
||||
Word XML Document (*.xml) Word XML документ (*.xml)
|
||||
Word Left Реч лево
|
||||
Word Right Реч десно
|
||||
WordArt WordArt
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -1734,6 +1734,8 @@
|
||||
Apply Uygula
|
||||
Convert Notes Notları Dönüştür
|
||||
|
||||
# Word XML Document (#548).
|
||||
Word XML Document (*.xml) Word XML Belgesi (*.xml)
|
||||
# Find and Replace special characters and wildcards (#543).
|
||||
Use wildcards Joker karakter kullan
|
||||
Special Özel
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 430 and column 13.
|
@@ -956,6 +956,7 @@
|
||||
Window Вікно
|
||||
Word Count Кількість слів
|
||||
Word document (*.docx) Документ Word (*.docx)
|
||||
Word XML Document (*.xml) Документ Word XML (*.xml)
|
||||
Word Left На слово ліворуч
|
||||
Word Right На слово праворуч
|
||||
WordArt WordArt
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -942,6 +942,7 @@
|
||||
Window 窗口
|
||||
Word Count 字数统计
|
||||
Word document (*.docx) Word 文档 (*.docx)
|
||||
Word XML Document (*.xml) Word XML 文档 (*.xml)
|
||||
Word Left 左移一个单词
|
||||
Word Right 右移一个单词
|
||||
WordArt 艺术字
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -931,6 +931,7 @@
|
||||
Window 視窗
|
||||
Word Count 字數統計
|
||||
Word document (*.docx) Word 文件 (*.docx)
|
||||
Word XML Document (*.xml) Word XML 文件 (*.xml)
|
||||
Word Left 左移一個單字
|
||||
Word Right 右移一個單字
|
||||
WordArt 文字藝術師
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 553 and column 13.
|
@@ -1266,7 +1266,7 @@ impl AutoSaveBlock {
|
||||
/// copy that leaves it unsaved. AutoSave writes only these formats.
|
||||
fn keeps_everything(name: &str) -> bool {
|
||||
let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json")
|
||||
matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json" | "xml")
|
||||
}
|
||||
|
||||
/// The dialog a user-run command opens when it lacks the input it needs (scripts and agents get
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (every `w:numFmt` number format kept, East Asian ones drawn, #534; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens, #548; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
write: Word opens it showing the same). Summary in [`target-app-parity.md`](target-app-parity.md);
|
||||
the work items are in [`gaps.md`](gaps.md).
|
||||
|
||||
**Dimension: ~60% (estimated), 185–300 h to full** (the sum of the rows below). The weighted table
|
||||
**Dimension: ~60% (estimated), 179–290 h to full** (the sum of the rows below). The weighted table
|
||||
gives ~64%; we discount it to ~60% because DOCX fidelity has never been checked on real-world
|
||||
files. DOCX dominates the weight: a Word user sends and receives .docx; everything else is
|
||||
occasional.
|
||||
@@ -30,8 +30,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection; Word's encoding choice dialog and line-ending options missing |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
|
||||
| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | The DOCX parts in one XML file; cheap once the DOCX code is reused |
|
||||
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | ❌ | ❌ | 0% | 5–8 | — | Legacy |
|
||||
| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element |
|
||||
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | ❌ | ~55% | 2–3 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Saving as 2003 XML is not planned (legacy) |
|
||||
| `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value |
|
||||
| Encrypted/password-protected `.docx`/`.doc` | 1% | R/W | ❌ | ❌ | 0% | 6–10 | — | ECMA-376 Agile Encryption; `.doc` RC4/XOR detected and refused (`docbin` fib). Issue #55 |
|
||||
| `.xlsx`/`.xlsm`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell). Not shown: number formats other than dates (currency, percent, thousands separators) in `.xlsx`; no `.xls`, Access or Outlook sources |
|
||||
@@ -91,6 +91,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) |
|
||||
| 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly |
|
||||
| 2026-10-11 | trivial | List and page number formats: all ST_NumberFormat values round-trip; Chinese, Japanese and Korean counting, legal and digit formats, stems/branches, kana, hangul, enclosed and full-width numbers drawn; unknown values are decimal; .doc number format codes mapped (#534) |
|
||||
| 2026-10-11 | trivial | SmartArt made in WordCraft written as diagram parts (data, layout, quick style, colours, drawing) and read back editable (#497) |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Insert › SmartArt landed, #497; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word XML Document and Word 2003 XML landed, #548; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
|
||||
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
|
||||
@@ -36,7 +36,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
||||
| 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 24 | **Default shortcuts**: ~135 of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and the Quick Access Toolbar #379 landed) | | [ui-parity.md](ui-parity.md) | Power users | 4–7 | [ui-parity.md](ui-parity.md) |
|
||||
| 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) |
|
||||
| 26 | **Minor formats**: Flat OPC XML, Word 2003 XML, MHT, Works/WordPerfect | | | Rare | 20–35 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 26 | **Minor formats**: MHT, Works/WordPerfect, saving Word 2003 XML | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) | Rare | 15–28 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) |
|
||||
| 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) |
|
||||
| 29 | **Equations**: ink equations | | #191 notes; long display equations wrap at operators (#326) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) |
|
||||
@@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h |
|
||||
| 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts |
|
||||
| 2026-10-11 | trivial | File › Options panes landed (#485); removed from the dialog-depth list |
|
||||
| 2026-10-11 | trivial | #18: the document canvas describes its text, structure and caret to screen readers through AccessKit (#488); real screen-reader testing remains |
|
||||
|
||||
Reference in New Issue
Block a user