mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 04:47:10 -06:00
Write WordprocessingML 2003 by rewriting the DOCX writer's package into the 2003 vocabulary; file.save/saveAs take format wordml2003, File > Export lists the format, CLI convert --format wordml2003, a plain save over a 2003 file keeps it. Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
Brandon Thomas
parent
f322c60bdf
commit
09b8b436cc
@@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet |
|
||||
| 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing |
|
||||
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
|
||||
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
|
||||
|
||||
@@ -14,8 +14,10 @@ const USAGE: &str = "\
|
||||
wordcraft-cli — WordCraft from the command line
|
||||
|
||||
USAGE:
|
||||
wordcraft-cli convert <in> <out> [--markup]
|
||||
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png);
|
||||
wordcraft-cli convert <in> <out> [--markup] [--format wordml2003|flatopc]
|
||||
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
|
||||
xml); an .xml file is a Word XML Document (Flat OPC), or with
|
||||
--format wordml2003 a Word 2003 XML Document;
|
||||
--markup (Print Markup): comments beside each page and
|
||||
tracked changes in pdf/png, a comment list in html/md/txt
|
||||
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
|
||||
@@ -111,7 +113,7 @@ fn automation_roots(
|
||||
}
|
||||
|
||||
const NO_OPTIONS: &[OptionSpec] = &[];
|
||||
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }];
|
||||
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }, OptionSpec { name: "--format", takes_value: true }];
|
||||
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
|
||||
const RUN_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--file", takes_value: true },
|
||||
@@ -186,13 +188,26 @@ fn run(args: &[String]) -> Result<(), Failure> {
|
||||
match cmd {
|
||||
"convert" => {
|
||||
validate_options("convert", &rest, CONVERT_OPTIONS)?;
|
||||
let (i, o) = (pos(0)?, pos(1)?);
|
||||
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
|
||||
let format = arg_value(&rest, "--format");
|
||||
let paths: Vec<&String> = rest
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(n, a)| !a.starts_with("--") && !(*n > 0 && rest.get(n - 1).is_some_and(|p| p == "--format")))
|
||||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
let missing = || format!("missing argument\n\n{USAGE}");
|
||||
let (i, o) = (paths.first().ok_or_else(missing)?.to_string(), paths.get(1).ok_or_else(missing)?.to_string());
|
||||
let markup = rest.iter().any(|a| a == "--markup");
|
||||
let mut s = open(&i)?;
|
||||
let r = if o.to_ascii_lowercase().ends_with(".png") {
|
||||
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
|
||||
} else {
|
||||
s.run("file.save", &json!({"path": o, "markup": markup}))
|
||||
let mut params = json!({"path": o, "markup": markup});
|
||||
if let Some(f) = format {
|
||||
params["format"] = json!(f);
|
||||
}
|
||||
s.run("file.save", ¶ms)
|
||||
};
|
||||
r.map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {o}");
|
||||
|
||||
@@ -16,6 +16,7 @@ mod read;
|
||||
mod smart_art;
|
||||
mod units;
|
||||
mod wordml2003;
|
||||
mod wordml2003_write;
|
||||
mod write;
|
||||
mod xml;
|
||||
|
||||
@@ -27,6 +28,7 @@ pub use crypt::{
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use read::read;
|
||||
pub use smart_art::smart_art_items;
|
||||
pub use wordml2003_write::write as write_wordml2003;
|
||||
pub use write::{write, write_as};
|
||||
|
||||
/// Largest `.xml` file read as a Word XML document.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Word 2003 XML Document (WordprocessingML 2003, root `w:wordDocument`, from the public Office
|
||||
//! 2003 XML Reference Schemas). Import only.
|
||||
//! 2003 XML Reference Schemas). Import; the writer is `wordml2003_write`.
|
||||
//!
|
||||
//! 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
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
//! Word 2003 XML Document writer (WordprocessingML 2003, root `w:wordDocument`, from the public
|
||||
//! Office 2003 XML Reference Schemas).
|
||||
//!
|
||||
//! The mirror image of the reader in `wordml2003`: the DOCX writer produces the package, and the
|
||||
//! package is rewritten into the 2003 vocabulary. ECMA-376 kept most of the 2003 element names
|
||||
//! (`w:p`, `w:r`, `w:rPr`, `w:tbl`, `w:sectPr`…), so the body is mostly renamed (`w:szCs` →
|
||||
//! `w:sz-cs`, `w:eastAsia` → `w:fareast`, `w:vMerge` → `w:vmerge`), and what 2003 keeps inline is
|
||||
//! moved back inline: hyperlink targets (`w:hlink`), footnotes and endnotes (inside their
|
||||
//! reference run), headers and footers (inside `w:sectPr`), comments, bookmarks and tracked
|
||||
//! changes (`aml:annotation`), lists (`w:listPr` and a `w:lists` block), pictures (`w:pict` with
|
||||
//! `w:binData` in base64) and the core properties (`o:DocumentProperties`).
|
||||
//!
|
||||
//! What the 2003 format cannot carry is dropped or degraded, never an error: drawings other than
|
||||
//! pictures (shapes, text boxes, groups, charts, SmartArt, ink), picture crop, rotation and
|
||||
//! floating position (pictures become inline), content controls (their content stays), math,
|
||||
//! revisions of formatting, theme colours and fonts, custom XML, macros, embedded objects and
|
||||
//! `docDefaults` other than fonts (folded into the Normal style). Elements outside the 2003
|
||||
//! vocabulary are not written, so Word has nothing to reject. Bounded: nesting is capped by the
|
||||
//! parser's depth limit, pictures and stories are capped in number.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use base64::Engine as _;
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
use crate::flat::W2003_NS;
|
||||
use crate::package::{Package, Rels, rt};
|
||||
use crate::xml::{El, MAX_DEPTH, Node};
|
||||
use crate::{DocxError, Flavor};
|
||||
|
||||
const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint";
|
||||
const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core";
|
||||
const VML_NS: &str = "urn:schemas-microsoft-com:vml";
|
||||
const OFFICE_NS: &str = "urn:schemas-microsoft-com:office:office";
|
||||
const W10_NS: &str = "urn:schemas-microsoft-com:office:word";
|
||||
|
||||
/// Most pictures written (the reader takes as many).
|
||||
const MAX_MEDIA: usize = 4096;
|
||||
/// Most header, footer, footnote, endnote and comment stories written.
|
||||
const MAX_STORIES: usize = 50_000;
|
||||
/// A picture's longest side, points (a corrupt extent must not give a huge frame).
|
||||
const MAX_PICT_PT: f64 = 31_680.0;
|
||||
|
||||
/// Write `doc` as a Word 2003 XML Document.
|
||||
pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
|
||||
let parts: BTreeMap<String, Vec<u8>> = crate::write::write_parts(doc, Flavor::Document)?.into_iter().collect();
|
||||
let pkg = Package::from_parts(parts);
|
||||
let doc_part = pkg
|
||||
.rels("_rels/.rels")
|
||||
.by_type(rt::OFFICE_DOC)
|
||||
.map(|r| r.target.clone())
|
||||
.filter(|t| pkg.get(t).is_some())
|
||||
.unwrap_or_else(|| "word/document.xml".to_string());
|
||||
let mut c = Conv::new(&pkg, &doc_part);
|
||||
|
||||
let body = match pkg.xml(&doc_part)? {
|
||||
Some(root) => {
|
||||
let rels = c.doc_rels.clone();
|
||||
match root.child("w:body") {
|
||||
Some(b) => c.kids(b, &rels, "w:body", 1),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let mut kids: Vec<Node> = vec![Node::El(core_props(doc))];
|
||||
if let Some(f) = c.default_fonts() {
|
||||
kids.push(Node::El(f));
|
||||
}
|
||||
if let Some(l) = c.lists() {
|
||||
kids.push(Node::El(l));
|
||||
}
|
||||
kids.push(Node::El(c.styles()));
|
||||
kids.push(Node::El(plain(
|
||||
"w:docPr",
|
||||
vec![Node::El(attr_el("w:view", &[("w:val", "print")])), Node::El(attr_el("w:zoom", &[("w:percent", "100")]))],
|
||||
)));
|
||||
kids.push(Node::El(plain("w:body", vec![Node::El(plain("wx:sect", body))])));
|
||||
|
||||
let attrs = [
|
||||
("xmlns:w", W2003_NS),
|
||||
("xmlns:wx", AUX_NS),
|
||||
("xmlns:o", OFFICE_NS),
|
||||
("xmlns:v", VML_NS),
|
||||
("xmlns:w10", W10_NS),
|
||||
("xmlns:aml", AML_NS),
|
||||
("w:macrosPresent", "no"),
|
||||
("w:embeddedObjPresent", "no"),
|
||||
("w:ocxPresent", "no"),
|
||||
("xml:space", "preserve"),
|
||||
];
|
||||
let root = El { name: "w:wordDocument".into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids };
|
||||
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?mso-application progid=\"Word.Document\"?>\n");
|
||||
s.push_str(&root.to_xml());
|
||||
Ok(s.into_bytes())
|
||||
}
|
||||
|
||||
fn plain(name: &str, kids: Vec<Node>) -> El {
|
||||
El { name: name.into(), attrs: Vec::new(), kids }
|
||||
}
|
||||
|
||||
fn attr_el(name: &str, attrs: &[(&str, &str)]) -> El {
|
||||
El { name: name.into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids: Vec::new() }
|
||||
}
|
||||
|
||||
fn text_el(name: &str, text: &str) -> El {
|
||||
El { name: name.into(), attrs: Vec::new(), kids: vec![Node::Text(text.to_string())] }
|
||||
}
|
||||
|
||||
/// `o:DocumentProperties` from the core properties.
|
||||
fn core_props(doc: &Document) -> El {
|
||||
let c = &doc.core;
|
||||
let mut kids = Vec::new();
|
||||
let revision = c.revision.to_string();
|
||||
for (name, v) in [
|
||||
("o:Title", c.title.as_str()),
|
||||
("o:Subject", c.subject.as_str()),
|
||||
("o:Author", c.creator.as_str()),
|
||||
("o:Keywords", c.keywords.as_str()),
|
||||
("o:Description", c.description.as_str()),
|
||||
("o:LastAuthor", c.last_modified_by.as_str()),
|
||||
("o:Revision", if c.revision > 0 { revision.as_str() } else { "" }),
|
||||
("o:Created", c.created.as_str()),
|
||||
("o:LastSaved", c.modified.as_str()),
|
||||
("o:Category", c.category.as_str()),
|
||||
] {
|
||||
if !v.trim().is_empty() {
|
||||
kids.push(Node::El(text_el(name, v.trim())));
|
||||
}
|
||||
}
|
||||
plain("o:DocumentProperties", kids)
|
||||
}
|
||||
|
||||
struct Conv<'a> {
|
||||
pkg: &'a Package,
|
||||
doc_rels: Rels,
|
||||
footnotes: HashMap<String, El>,
|
||||
endnotes: HashMap<String, El>,
|
||||
comments: HashMap<String, El>,
|
||||
foot_rels: Rels,
|
||||
end_rels: Rels,
|
||||
comment_rels: Rels,
|
||||
styles_part: Option<El>,
|
||||
numbering_part: Option<El>,
|
||||
/// Picture part path → `wordml://` name, for the pictures already given a `w:binData`.
|
||||
media: HashMap<String, String>,
|
||||
/// `w:binData` elements waiting for the `w:pict` being written.
|
||||
bins: Vec<El>,
|
||||
/// Annotation ids: (kind, original id) → the id written (one number space, as in 2003).
|
||||
ann_ids: HashMap<(char, String), i64>,
|
||||
next_ann: i64,
|
||||
next_shape: i64,
|
||||
stories: usize,
|
||||
in_note: bool,
|
||||
}
|
||||
|
||||
impl<'a> Conv<'a> {
|
||||
fn new(pkg: &'a Package, doc_part: &str) -> Conv<'a> {
|
||||
let doc_rels = pkg.rels(doc_part);
|
||||
let part_of = |kind: &str| doc_rels.by_type(kind).map(|r| r.target.clone());
|
||||
let notes = |kind: &str, local: &str| -> (HashMap<String, El>, Rels) {
|
||||
let Some(path) = part_of(kind) else { return (HashMap::new(), Rels::default()) };
|
||||
let map = pkg
|
||||
.xml(&path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|root| {
|
||||
root.els()
|
||||
.filter(|e| e.name == local)
|
||||
.filter(|e| e.attr("w:type").is_none())
|
||||
.filter_map(|e| e.attr("w:id").map(|id| (id.to_string(), e.clone())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(map, pkg.rels(&path))
|
||||
};
|
||||
let (footnotes, foot_rels) = notes(rt::FOOTNOTES, "w:footnote");
|
||||
let (endnotes, end_rels) = notes(rt::ENDNOTES, "w:endnote");
|
||||
let (comments, comment_rels) = notes(rt::COMMENTS, "w:comment");
|
||||
let load = |kind: &str| part_of(kind).and_then(|p| pkg.xml(&p).ok().flatten());
|
||||
Conv {
|
||||
pkg,
|
||||
styles_part: load(rt::STYLES),
|
||||
numbering_part: load(rt::NUMBERING),
|
||||
doc_rels,
|
||||
footnotes,
|
||||
endnotes,
|
||||
comments,
|
||||
foot_rels,
|
||||
end_rels,
|
||||
comment_rels,
|
||||
media: HashMap::new(),
|
||||
bins: Vec::new(),
|
||||
ann_ids: HashMap::new(),
|
||||
next_ann: 0,
|
||||
next_shape: 1025,
|
||||
stories: 0,
|
||||
in_note: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The annotation id for `(kind, id)`: the same each time it is asked.
|
||||
fn ann_id(&mut self, kind: char, id: &str) -> i64 {
|
||||
if let Some(n) = self.ann_ids.get(&(kind, id.to_string())) {
|
||||
return *n;
|
||||
}
|
||||
let n = self.next_ann;
|
||||
self.next_ann = self.next_ann.saturating_add(1);
|
||||
self.ann_ids.insert((kind, id.to_string()), n);
|
||||
n
|
||||
}
|
||||
|
||||
fn kids(&mut self, e: &El, rels: &Rels, parent: &str, 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, rels, parent, depth + 1)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `e` rewritten for 2003: nothing (dropped), its content (unwrapped) or one element.
|
||||
fn node(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Vec::new();
|
||||
}
|
||||
let name = e.name.as_str();
|
||||
match name {
|
||||
// Content controls: the content stays, the control goes.
|
||||
"w:sdt" => return e.child("w:sdtContent").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
|
||||
"w:drawing" => return self.picture(e, rels),
|
||||
// A VML fallback is kept for what the modern drawing could not be.
|
||||
"mc:AlternateContent" => return e.child("mc:Fallback").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
|
||||
"w:object" | "w:txbxContent" => return Vec::new(),
|
||||
"w:footnoteReference" | "w:endnoteReference" => return self.note(e, depth),
|
||||
"w:commentReference" => return self.comment(e, depth),
|
||||
"w:commentRangeStart" | "w:commentRangeEnd" | "w:bookmarkStart" | "w:bookmarkEnd" => return self.range_marker(e),
|
||||
"w:ins" | "w:del" | "w:moveFrom" | "w:moveTo" => {
|
||||
// Paragraph-mark and row revisions sit in property blocks: no 2003 form.
|
||||
if matches!(parent, "w:rPr" | "w:trPr") {
|
||||
return Vec::new();
|
||||
}
|
||||
return self.revision(e, rels, parent, depth);
|
||||
}
|
||||
"w:hyperlink" => return vec![Node::El(self.hyperlink(e, rels, depth))],
|
||||
"w:numPr" if parent == "w:pPr" => return vec![Node::El(list_pr(e))],
|
||||
"w:numFmt" if parent == "w:lvl" => return vec![Node::El(nfc(e))],
|
||||
"w:sectPr" => return vec![Node::El(self.section(e, rels, depth))],
|
||||
"w:pict" => return vec![Node::El(self.vml_pict(e, rels, depth))],
|
||||
"w:tblLook" => {
|
||||
return match e.attr("w:val") {
|
||||
Some(v) => vec![Node::El(attr_el("w:tblLook", &[("w:val", v)]))],
|
||||
None => Vec::new(),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !allowed(name) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut el = El { name: rename(name), attrs: attrs(e), kids: Vec::new() };
|
||||
if name == "v:imagedata" {
|
||||
let id = e.attr("r:id").or_else(|| e.attr("r:pict")).map(str::to_string);
|
||||
if let Some(src) = id.and_then(|id| rels.by_id(&id).map(|r| r.target.clone())).and_then(|t| self.bin_for(&t)) {
|
||||
el.attrs.retain(|(k, _)| k != "src");
|
||||
el.attrs.push(("src".into(), src));
|
||||
} else {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
el.kids = self.kids(e, rels, name, depth);
|
||||
vec![Node::El(el)]
|
||||
}
|
||||
|
||||
/// A `wordml://` name for the picture at package path `target`, queueing its `w:binData` the
|
||||
/// first time. `None` for a format 2003 XML pictures don't hold, or past the cap.
|
||||
fn bin_for(&mut self, target: &str) -> Option<String> {
|
||||
if let Some(n) = self.media.get(target) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let ext = target.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?;
|
||||
if !matches!(ext.as_str(), "png" | "jpeg" | "jpg" | "gif" | "bmp" | "emf" | "wmf" | "tif" | "tiff") || self.media.len() >= MAX_MEDIA {
|
||||
return None;
|
||||
}
|
||||
let bytes = self.pkg.get(target)?;
|
||||
let name = format!("wordml://0300{:04}.{ext}", self.media.len() + 1);
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let bin = El {
|
||||
name: "w:binData".into(),
|
||||
attrs: vec![("w:name".into(), name.clone()), ("xml:space".into(), "preserve".into())],
|
||||
kids: vec![Node::Text(b64)],
|
||||
};
|
||||
self.bins.push(bin);
|
||||
self.media.insert(target.to_string(), name.clone());
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
|
||||
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
|
||||
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
|
||||
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
|
||||
let Some(src) = self.bin_for(&target) else { return Vec::new() };
|
||||
let pt = |k: &str| {
|
||||
let emu = drawing.find("wp:extent").and_then(|x| x.attr(k)).and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(0.0);
|
||||
(if emu.is_finite() { emu / 12700.0 } else { 0.0 }).clamp(0.0, MAX_PICT_PT)
|
||||
};
|
||||
let style = format!("width:{}pt;height:{}pt", trim_num(pt("cx")), trim_num(pt("cy")));
|
||||
let alt = drawing.find("wp:docPr").and_then(|d| d.attr("descr")).unwrap_or("").to_string();
|
||||
let shape_id = format!("_x0000_i{}", self.next_shape);
|
||||
self.next_shape = self.next_shape.saturating_add(1);
|
||||
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
|
||||
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
|
||||
vec![Node::El(plain("w:pict", kids))]
|
||||
}
|
||||
|
||||
/// The `v:shape` holding a picture's `v:imagedata`.
|
||||
fn shape_frame(&self, id: &str, style: &str, src: &str, alt: &str) -> El {
|
||||
let mut shape = attr_el(
|
||||
"v:shape",
|
||||
&[
|
||||
("id", id),
|
||||
("style", style),
|
||||
("coordsize", "21600,21600"),
|
||||
("o:spt", "75"),
|
||||
("path", "m0,0l0,21600,21600,21600,21600,0xe"),
|
||||
("filled", "f"),
|
||||
("stroked", "f"),
|
||||
],
|
||||
);
|
||||
if !alt.is_empty() {
|
||||
shape.attrs.push(("alt".into(), alt.to_string()));
|
||||
}
|
||||
let mut data = attr_el("v:imagedata", &[("src", src)]);
|
||||
data.attrs.push(("o:title".into(), alt.to_string()));
|
||||
shape.kids.push(Node::El(data));
|
||||
shape
|
||||
}
|
||||
|
||||
/// A legacy `w:pict` (VML) kept as it is, its pictures given `w:binData`.
|
||||
fn vml_pict(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
|
||||
let kids = self.kids(e, rels, "w:pict", depth);
|
||||
let mut all: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
|
||||
all.extend(kids);
|
||||
plain("w:pict", all)
|
||||
}
|
||||
|
||||
fn hyperlink(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
|
||||
let mut a = Vec::new();
|
||||
if let Some(r) = e.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| r.external) {
|
||||
a.push(("w:dest".to_string(), r.target.clone()));
|
||||
}
|
||||
for (from, to) in [("w:anchor", "w:bookmark"), ("w:tooltip", "w:screenTip"), ("w:tgtFrame", "w:target")] {
|
||||
if let Some(v) = e.attr(from) {
|
||||
a.push((to.to_string(), v.to_string()));
|
||||
}
|
||||
}
|
||||
El { name: "w:hlink".into(), attrs: a, kids: self.kids(e, rels, "w:hlink", depth) }
|
||||
}
|
||||
|
||||
/// A footnote or endnote reference as the note itself, inline in its run.
|
||||
fn note(&mut self, e: &El, depth: usize) -> Vec<Node> {
|
||||
let foot = e.name == "w:footnoteReference";
|
||||
let Some(id) = e.attr("w:id") else { return Vec::new() };
|
||||
if self.in_note || self.stories >= MAX_STORIES {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return Vec::new() };
|
||||
self.stories += 1;
|
||||
let rels = if foot { self.foot_rels.clone() } else { self.end_rels.clone() };
|
||||
self.in_note = true;
|
||||
let name = if foot { "w:footnote" } else { "w:endnote" };
|
||||
let kids = self.kids(¬e, &rels, name, depth);
|
||||
self.in_note = false;
|
||||
vec![Node::El(plain(name, kids))]
|
||||
}
|
||||
|
||||
fn annotation(&self, kind: &str, id: i64, mut extra: Vec<(String, String)>, content: Option<Vec<Node>>) -> El {
|
||||
let mut a = vec![("aml:id".to_string(), id.to_string())];
|
||||
a.append(&mut extra);
|
||||
a.push(("w:type".into(), kind.into()));
|
||||
El { name: "aml:annotation".into(), attrs: a, kids: content.map(|c| vec![Node::El(plain("aml:content", c))]).unwrap_or_default() }
|
||||
}
|
||||
|
||||
fn comment(&mut self, e: &El, depth: usize) -> Vec<Node> {
|
||||
let Some(id) = e.attr("w:id") else { return Vec::new() };
|
||||
if self.in_note || self.stories >= MAX_STORIES {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(c) = self.comments.get(id).cloned() else { return Vec::new() };
|
||||
self.stories += 1;
|
||||
let rels = self.comment_rels.clone();
|
||||
let n = self.ann_id('c', id);
|
||||
self.in_note = true;
|
||||
let content = self.kids(&c, &rels, "w:comment", depth);
|
||||
self.in_note = false;
|
||||
let mut extra = Vec::new();
|
||||
if let Some(v) = c.attr("w:author") {
|
||||
extra.push(("aml:author".to_string(), v.to_string()));
|
||||
}
|
||||
if let Some(v) = c.attr("w:date") {
|
||||
extra.push(("aml:createdate".to_string(), v.to_string()));
|
||||
}
|
||||
if let Some(v) = c.attr("w:initials") {
|
||||
extra.push(("w:initials".to_string(), v.to_string()));
|
||||
}
|
||||
vec![Node::El(self.annotation("Word.Comment", n, extra, Some(content)))]
|
||||
}
|
||||
|
||||
fn range_marker(&mut self, e: &El) -> Vec<Node> {
|
||||
let Some(id) = e.attr("w:id") else { return Vec::new() };
|
||||
let (kind, ty) = match e.name.as_str() {
|
||||
"w:commentRangeStart" => ('c', "Word.Comment.Start"),
|
||||
"w:commentRangeEnd" => ('c', "Word.Comment.End"),
|
||||
"w:bookmarkStart" => ('b', "Word.Bookmark.Start"),
|
||||
_ => ('b', "Word.Bookmark.End"),
|
||||
};
|
||||
let n = self.ann_id(kind, id);
|
||||
let extra = if e.name == "w:bookmarkStart" { vec![("w:name".to_string(), e.attr("w:name").unwrap_or("").to_string())] } else { Vec::new() };
|
||||
vec![Node::El(self.annotation(ty, n, extra, None))]
|
||||
}
|
||||
|
||||
/// Tracked insertions and deletions (moves become a deletion and an insertion).
|
||||
fn revision(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
|
||||
let ty = if matches!(e.name.as_str(), "w:ins" | "w:moveTo") { "Word.Insertion" } else { "Word.Deletion" };
|
||||
let n = self.ann_id('r', e.attr("w:id").unwrap_or(""));
|
||||
let mut extra = Vec::new();
|
||||
if let Some(v) = e.attr("w:author") {
|
||||
extra.push(("aml:author".to_string(), v.to_string()));
|
||||
}
|
||||
if let Some(v) = e.attr("w:date") {
|
||||
extra.push(("aml:createdate".to_string(), v.to_string()));
|
||||
}
|
||||
let content = self.kids(e, rels, parent, depth);
|
||||
vec![Node::El(self.annotation(ty, n, extra, Some(content)))]
|
||||
}
|
||||
|
||||
/// `w:sectPr` with its headers and footers inline (`w:hdr`, `w:ftr`).
|
||||
fn section(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
|
||||
let mut stories = Vec::new();
|
||||
let mut rest = Vec::new();
|
||||
for k in e.els() {
|
||||
if k.name == "w:headerReference" || k.name == "w:footerReference" {
|
||||
let footer = k.name == "w:footerReference";
|
||||
if self.stories >= MAX_STORIES {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = k.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| !r.external).map(|r| r.target.clone()) else { continue };
|
||||
let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue };
|
||||
self.stories += 1;
|
||||
let story_rels = self.pkg.rels(&path);
|
||||
let ty = match k.attr("w:type") {
|
||||
Some("even") => "even",
|
||||
Some("first") => "first",
|
||||
_ => "odd",
|
||||
};
|
||||
let name = if footer { "w:ftr" } else { "w:hdr" };
|
||||
let was = std::mem::replace(&mut self.in_note, true);
|
||||
let kids = self.kids(&root, &story_rels, name, depth + 1);
|
||||
self.in_note = was;
|
||||
let mut story = plain(name, kids);
|
||||
story.attrs.push(("w:type".into(), ty.into()));
|
||||
stories.push(Node::El(story));
|
||||
} else {
|
||||
rest.extend(self.node(k, rels, "w:sectPr", depth + 1));
|
||||
}
|
||||
}
|
||||
stories.extend(rest);
|
||||
El { name: "w:sectPr".into(), attrs: Vec::new(), kids: stories }
|
||||
}
|
||||
|
||||
/// `w:fonts/w:defaultFonts` from the document defaults.
|
||||
fn default_fonts(&self) -> Option<El> {
|
||||
let f = self.styles_part.as_ref()?.child("w:docDefaults")?.child("w:rPrDefault")?.child("w:rPr")?.child("w:rFonts")?;
|
||||
let a = attrs(f);
|
||||
(!a.is_empty()).then(|| plain("w:fonts", vec![Node::El(El { name: "w:defaultFonts".into(), attrs: a, kids: Vec::new() })]))
|
||||
}
|
||||
|
||||
/// `w:styles`: the styles, with the document defaults (other than fonts) folded into the
|
||||
/// default paragraph style, as 2003 has no `docDefaults`.
|
||||
fn styles(&mut self) -> El {
|
||||
let Some(root) = self.styles_part.clone() else { return plain("w:styles", Vec::new()) };
|
||||
let defaults = root.child("w:docDefaults");
|
||||
let default_of = |part: &str, block: &str| -> Vec<El> {
|
||||
defaults
|
||||
.and_then(|d| d.child(part))
|
||||
.and_then(|p| p.child(block))
|
||||
.map(|b| b.els().filter(|e| e.name != "w:rFonts").cloned().collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (def_r, def_p) = (default_of("w:rPrDefault", "w:rPr"), default_of("w:pPrDefault", "w:pPr"));
|
||||
let rels = self.doc_rels.clone();
|
||||
let mut kids = Vec::new();
|
||||
for st in root.children("w:style") {
|
||||
let mut st = st.clone();
|
||||
if st.attr("w:type") == Some("paragraph") && matches!(st.attr("w:default"), Some("1" | "true" | "on")) {
|
||||
fold(&mut st, "w:pPr", &def_p);
|
||||
fold(&mut st, "w:rPr", &def_r);
|
||||
}
|
||||
kids.extend(self.node(&st, &rels, "w:styles", 2));
|
||||
}
|
||||
plain("w:styles", kids)
|
||||
}
|
||||
|
||||
/// `w:lists`: `w:abstractNum` → `w:listDef`, `w:num` → `w:list`.
|
||||
fn lists(&mut self) -> Option<El> {
|
||||
let root = self.numbering_part.clone()?;
|
||||
let rels = self.doc_rels.clone();
|
||||
let mut kids = Vec::new();
|
||||
for abs in root.children("w:abstractNum") {
|
||||
let Some(id) = abs.attr("w:abstractNumId") else { continue };
|
||||
let mut def = El { name: "w:listDef".into(), attrs: vec![("w:listDefId".into(), id.to_string())], kids: Vec::new() };
|
||||
for k in abs.els() {
|
||||
match k.name.as_str() {
|
||||
"w:nsid" => def.kids.push(Node::El(attr_el("w:lsid", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
|
||||
"w:multiLevelType" => {
|
||||
let v = match k.attr("w:val").unwrap_or("") {
|
||||
"singleLevel" => "SingleLevel",
|
||||
"hybridMultilevel" => "HybridMultilevel",
|
||||
_ => "Multilevel",
|
||||
};
|
||||
def.kids.push(Node::El(attr_el("w:plt", &[("w:val", v)])));
|
||||
}
|
||||
"w:numStyleLink" => def.kids.push(Node::El(attr_el("w:listStyleLink", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
|
||||
"w:lvl" => def.kids.extend(self.node(k, &rels, "w:abstractNum", 3)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
kids.push(Node::El(def));
|
||||
}
|
||||
for num in root.children("w:num") {
|
||||
let (Some(id), Some(abs)) = (num.attr("w:numId"), num.child_val("w:abstractNumId")) else { continue };
|
||||
let mut list = El { name: "w:list".into(), attrs: vec![("w:ilfo".into(), id.to_string())], kids: Vec::new() };
|
||||
list.kids.push(Node::El(attr_el("w:ilst", &[("w:val", abs)])));
|
||||
for o in num.children("w:lvlOverride") {
|
||||
list.kids.extend(self.node(o, &rels, "w:num", 3));
|
||||
}
|
||||
kids.push(Node::El(list));
|
||||
}
|
||||
(!kids.is_empty()).then(|| plain("w:lists", kids))
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the properties of `defaults` that block `name` of `style` doesn't set (at its start).
|
||||
fn fold(style: &mut El, name: &str, defaults: &[El]) {
|
||||
if defaults.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pos = style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name == name));
|
||||
let idx = match pos {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
// Schema order within a style: name, basedOn, next, link, … then pPr, rPr.
|
||||
let at = if name == "w:pPr" {
|
||||
style
|
||||
.kids
|
||||
.iter()
|
||||
.position(|n| matches!(n, Node::El(e) if e.name == "w:rPr" || e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
|
||||
} else {
|
||||
style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
|
||||
}
|
||||
.unwrap_or(style.kids.len());
|
||||
style.kids.insert(at, Node::El(plain(name, Vec::new())));
|
||||
at
|
||||
}
|
||||
};
|
||||
if let Some(Node::El(block)) = style.kids.get_mut(idx) {
|
||||
let have: Vec<String> = block.els().map(|e| e.name.clone()).collect();
|
||||
let mut add: Vec<Node> = defaults.iter().filter(|d| !have.contains(&d.name)).cloned().map(Node::El).collect();
|
||||
add.append(&mut block.kids);
|
||||
block.kids = add;
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:numPr` (`w:ilvl`, `w:numId`) → `w:listPr` (`w:ilvl`, `w:ilfo`).
|
||||
fn list_pr(e: &El) -> El {
|
||||
let mut kids = Vec::new();
|
||||
if let Some(v) = e.child_val("w:ilvl") {
|
||||
kids.push(Node::El(attr_el("w:ilvl", &[("w:val", v)])));
|
||||
}
|
||||
if let Some(v) = e.child_val("w:numId") {
|
||||
kids.push(Node::El(attr_el("w:ilfo", &[("w:val", v)])));
|
||||
}
|
||||
plain("w:listPr", kids)
|
||||
}
|
||||
|
||||
/// `w:numFmt` → `w:nfc` (a number format code).
|
||||
fn nfc(e: &El) -> El {
|
||||
let code = match e.attr("w:val").unwrap_or("decimal") {
|
||||
"upperRoman" => "1",
|
||||
"lowerRoman" => "2",
|
||||
"upperLetter" => "3",
|
||||
"lowerLetter" => "4",
|
||||
"ordinal" => "5",
|
||||
"cardinalText" => "6",
|
||||
"ordinalText" => "7",
|
||||
"chicago" => "9",
|
||||
"decimalFullWidth" => "14",
|
||||
"decimalHalfWidth" => "15",
|
||||
"decimalEnclosedCircle" => "18",
|
||||
"decimalZero" => "22",
|
||||
"bullet" => "23",
|
||||
"ganada" => "24",
|
||||
"chosung" => "25",
|
||||
"decimalEnclosedFullstop" => "26",
|
||||
"decimalEnclosedParen" => "27",
|
||||
"none" => "255",
|
||||
_ => "0",
|
||||
};
|
||||
attr_el("w:nfc", &[("w:val", code)])
|
||||
}
|
||||
|
||||
/// A number without a pointless fraction (`36`, `10.5`).
|
||||
fn trim_num(v: f64) -> String {
|
||||
let s = format!("{v:.2}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// Is this element written? WordprocessingML elements of the 2003 vocabulary, and the VML a
|
||||
/// picture needs; everything else (DrawingML, `w14`, math, markup compatibility…) is not.
|
||||
fn allowed(name: &str) -> bool {
|
||||
match name.split_once(':') {
|
||||
Some(("w", local)) => allowed_w(local),
|
||||
Some(("v" | "o" | "w10", _)) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_w(local: &str) -> bool {
|
||||
matches!(
|
||||
local,
|
||||
"p" | "pPr"
|
||||
| "r"
|
||||
| "rPr"
|
||||
| "t"
|
||||
| "tab"
|
||||
| "br"
|
||||
| "cr"
|
||||
| "noBreakHyphen"
|
||||
| "softHyphen"
|
||||
| "sym"
|
||||
| "pgNum"
|
||||
| "delText"
|
||||
| "instrText"
|
||||
| "fldChar"
|
||||
| "fldSimple"
|
||||
| "footnoteRef"
|
||||
| "endnoteRef"
|
||||
| "annotationRef"
|
||||
| "separator"
|
||||
| "continuationSeparator"
|
||||
| "tbl"
|
||||
| "tblPr"
|
||||
| "tblGrid"
|
||||
| "gridCol"
|
||||
| "tr"
|
||||
| "trPr"
|
||||
| "tc"
|
||||
| "tcPr"
|
||||
| "sectPr"
|
||||
| "hdr"
|
||||
| "ftr"
|
||||
| "pStyle"
|
||||
| "keepNext"
|
||||
| "keepLines"
|
||||
| "pageBreakBefore"
|
||||
| "framePr"
|
||||
| "widowControl"
|
||||
| "suppressLineNumbers"
|
||||
| "pBdr"
|
||||
| "shd"
|
||||
| "tabs"
|
||||
| "suppressAutoHyphens"
|
||||
| "kinsoku"
|
||||
| "wordWrap"
|
||||
| "overflowPunct"
|
||||
| "topLinePunct"
|
||||
| "autoSpaceDE"
|
||||
| "autoSpaceDN"
|
||||
| "bidi"
|
||||
| "adjustRightInd"
|
||||
| "snapToGrid"
|
||||
| "spacing"
|
||||
| "ind"
|
||||
| "contextualSpacing"
|
||||
| "mirrorIndents"
|
||||
| "textAlignment"
|
||||
| "textDirection"
|
||||
| "textboxTightWrap"
|
||||
| "outlineLvl"
|
||||
| "jc"
|
||||
| "divId"
|
||||
| "top"
|
||||
| "left"
|
||||
| "bottom"
|
||||
| "right"
|
||||
| "between"
|
||||
| "bar"
|
||||
| "insideH"
|
||||
| "insideV"
|
||||
| "tl2br"
|
||||
| "tr2bl"
|
||||
| "rStyle"
|
||||
| "rFonts"
|
||||
| "b"
|
||||
| "bCs"
|
||||
| "i"
|
||||
| "iCs"
|
||||
| "caps"
|
||||
| "smallCaps"
|
||||
| "strike"
|
||||
| "dstrike"
|
||||
| "outline"
|
||||
| "shadow"
|
||||
| "emboss"
|
||||
| "imprint"
|
||||
| "noProof"
|
||||
| "vanish"
|
||||
| "webHidden"
|
||||
| "color"
|
||||
| "w"
|
||||
| "kern"
|
||||
| "position"
|
||||
| "sz"
|
||||
| "szCs"
|
||||
| "highlight"
|
||||
| "u"
|
||||
| "effect"
|
||||
| "bdr"
|
||||
| "fitText"
|
||||
| "vertAlign"
|
||||
| "rtl"
|
||||
| "cs"
|
||||
| "em"
|
||||
| "lang"
|
||||
| "eastAsianLayout"
|
||||
| "specVanish"
|
||||
| "tblStyle"
|
||||
| "tblpPr"
|
||||
| "tblOverlap"
|
||||
| "bidiVisual"
|
||||
| "tblStyleRowBandSize"
|
||||
| "tblStyleColBandSize"
|
||||
| "tblW"
|
||||
| "tblCellSpacing"
|
||||
| "tblInd"
|
||||
| "tblBorders"
|
||||
| "tblLayout"
|
||||
| "tblCellMar"
|
||||
| "cantSplit"
|
||||
| "trHeight"
|
||||
| "tblHeader"
|
||||
| "gridBefore"
|
||||
| "gridAfter"
|
||||
| "wBefore"
|
||||
| "wAfter"
|
||||
| "hidden"
|
||||
| "tcW"
|
||||
| "gridSpan"
|
||||
| "hMerge"
|
||||
| "vMerge"
|
||||
| "tcBorders"
|
||||
| "noWrap"
|
||||
| "tcMar"
|
||||
| "tcFitText"
|
||||
| "vAlign"
|
||||
| "pgSz"
|
||||
| "pgMar"
|
||||
| "paperSrc"
|
||||
| "pgBorders"
|
||||
| "lnNumType"
|
||||
| "pgNumType"
|
||||
| "cols"
|
||||
| "col"
|
||||
| "formProt"
|
||||
| "noEndnote"
|
||||
| "titlePg"
|
||||
| "rtlGutter"
|
||||
| "docGrid"
|
||||
| "type"
|
||||
| "style"
|
||||
| "name"
|
||||
| "aliases"
|
||||
| "basedOn"
|
||||
| "next"
|
||||
| "link"
|
||||
| "autoRedefine"
|
||||
| "semiHidden"
|
||||
| "qFormat"
|
||||
| "locked"
|
||||
| "tblStylePr"
|
||||
| "lvl"
|
||||
| "start"
|
||||
| "lvlRestart"
|
||||
| "isLgl"
|
||||
| "suff"
|
||||
| "lvlText"
|
||||
| "legacy"
|
||||
| "lvlJc"
|
||||
| "lvlOverride"
|
||||
| "startOverride"
|
||||
| "ilvl"
|
||||
| "binData"
|
||||
)
|
||||
}
|
||||
|
||||
/// The 2003 spelling of an ECMA-376 name: `w:szCs` → `w:sz-cs`, `w:eastAsia` → `w:fareast`,
|
||||
/// `w:vMerge` → `w:vmerge`. Names outside `w:` are kept.
|
||||
fn rename(name: &str) -> String {
|
||||
let Some(local) = name.strip_prefix("w:") else { return name.to_string() };
|
||||
let new = match local {
|
||||
"eastAsia" => "fareast",
|
||||
"vMerge" => "vmerge",
|
||||
"hMerge" => "hmerge",
|
||||
"bCs" => "b-cs",
|
||||
"iCs" => "i-cs",
|
||||
"szCs" => "sz-cs",
|
||||
"hAnsi" => "h-ansi",
|
||||
"firstLine" => "first-line",
|
||||
"lineRule" => "line-rule",
|
||||
"hRule" => "h-rule",
|
||||
"linePitch" => "line-pitch",
|
||||
"firstLineChars" => "first-line-chars",
|
||||
"hangingChars" => "hanging-chars",
|
||||
"leftChars" => "left-chars",
|
||||
"rightChars" => "right-chars",
|
||||
other => other,
|
||||
};
|
||||
format!("w:{new}")
|
||||
}
|
||||
|
||||
/// Elements whose `w:val` is an on/off switch (2003 writes `on` / `off`).
|
||||
fn is_toggle(local: &str) -> bool {
|
||||
matches!(
|
||||
local,
|
||||
"b" | "bCs"
|
||||
| "i"
|
||||
| "iCs"
|
||||
| "caps"
|
||||
| "smallCaps"
|
||||
| "strike"
|
||||
| "dstrike"
|
||||
| "outline"
|
||||
| "shadow"
|
||||
| "emboss"
|
||||
| "imprint"
|
||||
| "noProof"
|
||||
| "snapToGrid"
|
||||
| "vanish"
|
||||
| "webHidden"
|
||||
| "keepNext"
|
||||
| "keepLines"
|
||||
| "pageBreakBefore"
|
||||
| "widowControl"
|
||||
| "suppressLineNumbers"
|
||||
| "suppressAutoHyphens"
|
||||
| "wordWrap"
|
||||
| "contextualSpacing"
|
||||
| "mirrorIndents"
|
||||
| "rtl"
|
||||
| "cs"
|
||||
| "specVanish"
|
||||
| "tblHeader"
|
||||
| "cantSplit"
|
||||
| "noWrap"
|
||||
| "hidden"
|
||||
| "semiHidden"
|
||||
| "qFormat"
|
||||
| "locked"
|
||||
| "tcFitText"
|
||||
| "isLgl"
|
||||
| "bidi"
|
||||
| "overflowPunct"
|
||||
| "kinsoku"
|
||||
| "autoSpaceDE"
|
||||
| "autoSpaceDN"
|
||||
| "adjustRightInd"
|
||||
| "topLinePunct"
|
||||
| "titlePg"
|
||||
)
|
||||
}
|
||||
|
||||
/// The attributes of `e` that 2003 has, renamed: theme references, revision ids and attributes of
|
||||
/// other vocabularies (`w14:`, `r:`…) are dropped.
|
||||
fn attrs(e: &El) -> Vec<(String, String)> {
|
||||
let local_el = e.name.strip_prefix("w:").unwrap_or("");
|
||||
let vml = e.name.starts_with("v:") || e.name.starts_with("o:") || e.name.starts_with("w10:");
|
||||
let mut out = Vec::new();
|
||||
for (k, v) in &e.attrs {
|
||||
let key = match k.split_once(':') {
|
||||
Some(("w", l)) => {
|
||||
let lower = l.to_ascii_lowercase();
|
||||
if lower.starts_with("rsid") || lower.contains("theme") {
|
||||
continue;
|
||||
}
|
||||
rename(k)
|
||||
}
|
||||
Some(("xml", "space")) => k.clone(),
|
||||
Some(("v" | "o" | "w10", _)) if vml => k.clone(),
|
||||
None if vml => k.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
let mut val = v.clone();
|
||||
if (key == "w:val" && is_toggle(local_el)) || ((key == "w:default" || key == "w:customStyle") && e.name == "w:style") {
|
||||
val = switch(&val);
|
||||
}
|
||||
out.push((key, val));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An on/off value in 2003's spelling.
|
||||
fn switch(v: &str) -> String {
|
||||
match v {
|
||||
"1" | "true" | "on" => "on".into(),
|
||||
"0" | "false" | "off" => "off".into(),
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Saving as Word 2003 XML (#1081): documents built in code, written as WordprocessingML 2003
|
||||
//! and read back with the 2003 reader.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::props::{Align, CharProps, NumRef, Rgb, TextColor};
|
||||
use wordcraft_doc::table::{Cell, Table};
|
||||
use wordcraft_doc::{Block, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
|
||||
use wordcraft_docx::{XmlKind, read_xml, sniff_xml, write_wordml2003};
|
||||
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 80, y as u8 * 120, 200, 255]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
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 sample() -> Document {
|
||||
let mut d = Document::new();
|
||||
d.core.title = "Garden <notes> & more".into();
|
||||
d.core.creator = "Ada".into();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]);
|
||||
let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]);
|
||||
d.comments.insert(3, Comment { author: "Bo".into(), part: com, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None });
|
||||
d.last_section.headers.default = Some(head);
|
||||
|
||||
let mut p = Paragraph::new();
|
||||
for (t, c) in [
|
||||
("Plain ", CharProps::default()),
|
||||
(
|
||||
"loud",
|
||||
CharProps { bold: Some(true), italic: Some(true), size: Some(14.0), color: Some(TextColor::Rgb(Rgb(255, 0, 0))), ..Default::default() },
|
||||
),
|
||||
(" link", CharProps { link: Some("https://example.org/beans?a=1&b=2".into()), ..Default::default() }),
|
||||
] {
|
||||
let off = p.len();
|
||||
p.insert_text(off, t, &c).unwrap();
|
||||
}
|
||||
p.props.align = Some(Align::Center);
|
||||
p.props.indent_left = Some(36.0);
|
||||
p.props.space_after = Some(12.0);
|
||||
p.insert_object(0, InlineObject::CommentStart { id: 3 }, &CharProps::default()).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::CommentEnd { id: 3 }, &CharProps::default()).unwrap();
|
||||
let at = p.text.find("loud").unwrap();
|
||||
p.insert_object(at, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap();
|
||||
let mut blocks = vec![para_block(p)];
|
||||
|
||||
let mut li = Paragraph::with_text("listed", CharProps { ins: Some(0), ..Default::default() });
|
||||
li.props.numbering = Some(NumRef { num, level: 0 });
|
||||
blocks.push(para_block(li));
|
||||
|
||||
let mut img = Paragraph::new();
|
||||
img.insert_object(
|
||||
0,
|
||||
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
blocks.push(para_block(img));
|
||||
|
||||
let mut t = Table::new(2, 2, 300.0);
|
||||
t.rows[0].cells[0] = Cell::with_text("A1");
|
||||
t.rows[0].cells[1] = Cell::with_text("B1");
|
||||
t.rows[1].cells[0].props.shading = Some(Rgb(0, 128, 0));
|
||||
blocks.push(Arc::new(Block::Table(t)));
|
||||
blocks.push(para_block(Paragraph::new()));
|
||||
d.body = blocks;
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_the_2003_reader() {
|
||||
let d = sample();
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
let text = std::str::from_utf8(&xml).expect("utf-8");
|
||||
assert!(text.contains("<w:wordDocument") && text.contains("http://schemas.microsoft.com/office/word/2003/wordml"));
|
||||
assert!(text.contains("<w:binData") && text.contains("<w:hlink w:dest=\"https://example.org/beans?a=1&b=2\""));
|
||||
assert!(text.contains("<w:footnote>") && text.contains("<wx:sect>") && text.contains("<aml:annotation"));
|
||||
assert!(!text.contains("w:szCs") && !text.contains("<w:drawing"));
|
||||
if std::env::var_os("WML2003_DUMP").is_some() {
|
||||
println!("{text}");
|
||||
}
|
||||
assert_eq!(sniff_xml(&xml), XmlKind::WordMl2003);
|
||||
|
||||
let back = read_xml(&xml).expect("read");
|
||||
assert_eq!(back.core.title, "Garden <notes> & more");
|
||||
assert_eq!(back.core.creator, "Ada");
|
||||
let ps = paras(&back);
|
||||
assert_eq!(ps[0].plain_text(), "Plain loud link");
|
||||
assert_eq!(ps[0].props.align, Some(Align::Center));
|
||||
assert_eq!(ps[0].props.indent_left, Some(36.0));
|
||||
assert_eq!(ps[0].props.space_after, Some(12.0));
|
||||
let c = ps[0].props_of_char(ps[0].text.find("loud").unwrap());
|
||||
assert_eq!((c.bold, c.italic, c.size), (Some(true), Some(true), Some(14.0)));
|
||||
assert!(c.color.is_some());
|
||||
assert_eq!(ps[0].props_at(ps[0].text.find("link").unwrap()).link.as_deref(), Some("https://example.org/beans?a=1&b=2"));
|
||||
let note = ps[0].objects.iter().find_map(|o| match o {
|
||||
InlineObject::NoteRef { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(part_text(&back, note), "Sown in May.");
|
||||
let comment = back.comments.values().next().expect("comment");
|
||||
assert_eq!((comment.author.as_str(), part_text(&back, Some(comment.part)).as_str()), ("Bo", "Too short?"));
|
||||
|
||||
let listed = ps[1];
|
||||
assert!(listed.props.numbering.is_some(), "list paragraph keeps its list");
|
||||
let ins = listed.props_of_char(0).ins.and_then(|i| back.revisions.get(i as usize));
|
||||
assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada"));
|
||||
|
||||
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
|
||||
assert!(matches!(pic, Some(InlineObject::Image { w, h, alt, .. }) if *w == 30.0 && *h == 20.0 && alt == "dot"));
|
||||
assert_eq!(back.media.len(), 1);
|
||||
|
||||
let table = back.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"));
|
||||
assert_eq!(table.rows[1].cells[0].props.shading, Some(Rgb(0, 128, 0)));
|
||||
assert_eq!(part_text(&back, back.last_section.headers.default), "Head");
|
||||
}
|
||||
|
||||
/// Features 2003 XML can't carry drop out without an error, and the text stays.
|
||||
#[test]
|
||||
fn control_characters_and_unsupported_content_do_not_break_the_file() {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::with_text("a\u{1}b\u{FFFE}c <&> \"q\"", CharProps { size: Some(f32::NAN), ..Default::default() });
|
||||
p.insert_object(
|
||||
0,
|
||||
InlineObject::Opaque { format: "docx".into(), xml: "<w:r><w:t>raw</w:t></w:r>".into(), text: "raw".into() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
assert!(read_xml(&xml).is_ok());
|
||||
let text = String::from_utf8(xml).expect("utf-8");
|
||||
assert!(!text.contains('\u{1}') && !text.contains('\u{FFFE}'));
|
||||
assert!(!text.contains("<w:drawing") && !text.contains("<mc:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_and_deep_documents_write() {
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(Paragraph::with_text(&"word ".repeat(400_000), CharProps::default()))];
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
assert!(read_xml(&xml).is_ok());
|
||||
|
||||
// Tables nested deeper than the writers go: no panic, whatever comes out.
|
||||
let mut inner = Cell::with_text("deep");
|
||||
for _ in 0..60 {
|
||||
let mut t = Table::new(1, 1, 100.0);
|
||||
t.rows[0].cells[0] = inner;
|
||||
inner = Cell::empty();
|
||||
inner.blocks = vec![Arc::new(Block::Table(t)), para_block(Paragraph::new())];
|
||||
}
|
||||
let mut d = Document::new();
|
||||
d.body = inner.blocks;
|
||||
if let Ok(xml) = write_wordml2003(&d) {
|
||||
let _ = read_xml(&xml);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ use super::{pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// Longest user name kept, in characters; longer names are cut, not refused.
|
||||
/// `format` of the save commands for an `.xml` file: Word 2003 XML Document (WordprocessingML 2003).
|
||||
pub const FORMAT_WORD_2003: &str = "wordml2003";
|
||||
/// `format` for an `.xml` file as a Word XML Document (Flat OPC), the default.
|
||||
pub const FORMAT_FLAT_OPC: &str = "flatopc";
|
||||
|
||||
pub const MAX_AUTHOR_CHARS: usize = 255;
|
||||
|
||||
/// Sets the user name that new tracked changes and comments are recorded under. The name is
|
||||
@@ -35,9 +40,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("file.save", "Save", "File", save)
|
||||
.key("Mod+S / Shift+F12")
|
||||
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup)}"#)
|
||||
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool (as file.save)}"#).pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
|
||||
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
|
||||
// to undo; it marks the document as changed itself.
|
||||
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
|
||||
@@ -199,11 +204,22 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
s.password = Some(crate::io::Password::new(pw));
|
||||
}
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
let mut opts = export_options(s, v);
|
||||
match v.get("format").filter(|f| !f.is_null()) {
|
||||
None => {
|
||||
// Saving over a Word 2003 XML file (one opened as such, say) keeps its format.
|
||||
opts.word2003 = ext == "xml" && is_word_2003_file(&path);
|
||||
}
|
||||
Some(f) if matches!(f.as_str(), Some(FORMAT_WORD_2003 | FORMAT_FLAT_OPC)) && ext == "xml" => {}
|
||||
Some(_) => {
|
||||
return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file")));
|
||||
}
|
||||
}
|
||||
s.stamp_save();
|
||||
let password = s.password.as_ref().map(crate::io::Password::as_str);
|
||||
crate::io::save_path_with(&path, &s.doc, password, &export_options(s, v)).map_err(CmdError::Failed)?;
|
||||
crate::io::save_path_with(&path, &s.doc, password, &opts).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", "xml"].contains(&ext.as_str()) {
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = false;
|
||||
@@ -211,6 +227,27 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
|
||||
}
|
||||
|
||||
/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)?
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_word_2003_file(path: &std::path::Path) -> bool {
|
||||
use std::io::Read;
|
||||
if crate::file_access::check_read(&path.to_string_lossy()).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Ok(f) = std::fs::File::open(path) else { return false };
|
||||
let mut head = Vec::new();
|
||||
// The root element and its namespaces come first.
|
||||
if f.take(64 * 1024).read_to_end(&mut head).is_err() {
|
||||
return false;
|
||||
}
|
||||
wordcraft_docx::sniff_xml(&head) == wordcraft_docx::XmlKind::WordMl2003
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn is_word_2003_file(_path: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::str(v, "path").is_none() {
|
||||
s.ui_requests.push(json!({"open": "saveAs"}));
|
||||
@@ -223,7 +260,7 @@ fn save_as(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
|
||||
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
|
||||
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
|
||||
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments }
|
||||
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments, word2003: p::str(v, "format") == Some(FORMAT_WORD_2003) }
|
||||
}
|
||||
|
||||
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
@@ -51,6 +51,9 @@ pub struct ExportOptions {
|
||||
pub markup: bool,
|
||||
/// With `markup`, show the comments (Track Changes Options › Comments).
|
||||
pub comments: bool,
|
||||
/// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML
|
||||
/// Document (Flat OPC): `format: "wordml2003"` on the save commands.
|
||||
pub word2003: bool,
|
||||
}
|
||||
|
||||
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
|
||||
|
||||
@@ -26,6 +26,7 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string()));
|
||||
}
|
||||
match ext {
|
||||
"xml" if opts.word2003 => Some(wordcraft_docx::write_wordml2003(doc).map_err(|e| e.to_string())),
|
||||
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
|
||||
"png" => Some(render_png(doc, 0, 2.0, opts)),
|
||||
"pdf" => {
|
||||
|
||||
@@ -3558,6 +3558,33 @@ fn word_xml_saves_and_opens_through_io() {
|
||||
assert!(err.contains("not a Word XML document"), "{err}");
|
||||
}
|
||||
|
||||
/// #1081: `format: "wordml2003"` saves an `.xml` file as a Word 2003 XML Document, a plain save
|
||||
/// over such a file keeps that format, and `format: "flatopc"` converts it back.
|
||||
#[test]
|
||||
fn word_2003_xml_saves_through_the_save_commands() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Kept for old readers"}));
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-2003-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("old.xml").to_string_lossy().to_string();
|
||||
let kind = |p: &str| wordcraft_docx::sniff_xml(&std::fs::read(p).unwrap());
|
||||
run(&mut s, "file.saveAs", json!({"path": path, "format": "wordml2003"}));
|
||||
assert_eq!(kind(&path), wordcraft_docx::XmlKind::WordMl2003);
|
||||
assert!(crate::io::open_path(std::path::Path::new(&path)).unwrap().plain_text(wordcraft_doc::StoryRef::Body).contains("Kept for old readers"));
|
||||
run(&mut s, "file.save", json!({}));
|
||||
assert_eq!(kind(&path), wordcraft_docx::XmlKind::WordMl2003);
|
||||
run(&mut s, "file.save", json!({"path": path, "format": "flatopc"}));
|
||||
assert_eq!(kind(&path), wordcraft_docx::XmlKind::FlatOpc);
|
||||
for bad in [
|
||||
json!({"path": dir.join("a.docx").to_string_lossy(), "format": "wordml2003"}),
|
||||
json!({"path": path, "format": "bogus"}),
|
||||
json!({"path": path, "format": 7}),
|
||||
] {
|
||||
assert!(s.run("file.save", &bad).is_err(), "{bad}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #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]
|
||||
|
||||
@@ -215,7 +215,13 @@ impl WordApp {
|
||||
}
|
||||
AfterPick::Export { ext } => {
|
||||
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
|
||||
let r = if ext == "png" { self.run("file.exportPng", json!({"path": path})) } else { self.run("file.saveAs", json!({"path": path})) };
|
||||
let r = if ext == "png" {
|
||||
self.run("file.exportPng", json!({"path": path}))
|
||||
} else if ext == crate::print_page::EXPORT_WORD_2003 {
|
||||
self.run("file.saveAs", json!({"path": path, "format": ext}))
|
||||
} else {
|
||||
self.run("file.saveAs", json!({"path": path}))
|
||||
};
|
||||
if r.is_ok() {
|
||||
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
|
||||
}
|
||||
@@ -272,7 +278,8 @@ impl WordApp {
|
||||
|
||||
/// File › Export: ask where to save a copy in this format, then write it.
|
||||
pub(crate) fn export_dialog(&mut self, ext: &str) {
|
||||
let name = format!("{}.{ext}", self.title_stem());
|
||||
let file_ext = if ext == crate::print_page::EXPORT_WORD_2003 { "xml" } else { ext };
|
||||
let name = format!("{}.{file_ext}", self.title_stem());
|
||||
let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::Export { ext: ext.to_string() });
|
||||
}
|
||||
|
||||
|
||||
@@ -2158,6 +2158,7 @@
|
||||
Use the Insert key to switch overtype on and off Използване на клавиша Insert за включване и изключване на заместването
|
||||
Extend Selection by Units Разширяване на селекцията по единици
|
||||
Word XML Document (*.xml) XML документ на Word (*.xml)
|
||||
Word 2003 XML Document (*.xml) XML документ на Word 2003 (*.xml)
|
||||
Deleted Изтрито
|
||||
Print Markup Печат на маркировките
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Коментари до всяка страница и проследени промени в PDF файлове, разпечатки и изображения; списък с коментари в края при уеб страници, Markdown и текст.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -1876,6 +1876,7 @@
|
||||
Animate the caret (it glides and fades) Animovat kurzor (plynule se přesouvá a prolíná)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Zapnuto: kurzor se při psaní nebo použití kláves se šipkami plynule přesune na nové místo a jeho blikání se prolíná. Vypnuto: kurzor přeskakuje a blikání se ostře zapíná a vypíná.
|
||||
Word XML Document (*.xml) Dokument Word XML (*.xml)
|
||||
Word 2003 XML Document (*.xml) Dokument Word 2003 XML (*.xml)
|
||||
Deleted Odstraněno
|
||||
Resize Table by Dragging Změnit velikost tabulky přetažením
|
||||
Select Column Vybrat sloupec
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1459,6 +1459,7 @@
|
||||
Animate the caret (it glides and fades) Einfügemarke animieren (sie gleitet und blendet aus)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Ein: Die Einfügemarke gleitet beim Tippen oder mit den Pfeiltasten an ihre neue Stelle, und ihr Blinken blendet weich. Aus: Sie springt und blinkt hart an und aus.
|
||||
Word XML Document (*.xml) Word-XML-Dokument (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word-2003-XML-Dokument (*.xml)
|
||||
Choose the sheet that holds the recipients. Wählen Sie das Tabellenblatt mit den Empfängern.
|
||||
First row of data contains column headers Erste Datenzeile enthält Spaltenüberschriften
|
||||
Deleted Gelöscht
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -999,6 +999,7 @@
|
||||
Word Right Palabra a la derecha
|
||||
Word document (*.docx) Documento de Word (*.docx)
|
||||
Word XML Document (*.xml) Documento XML de Word (*.xml)
|
||||
Word 2003 XML Document (*.xml) Documento XML de Word 2003 (*.xml)
|
||||
WordArt WordArt
|
||||
WordCraft WordCraft
|
||||
WordCraft is now the default app for Word documents. WordCraft es ahora la aplicación predeterminada para los documentos de Word.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -877,6 +877,7 @@
|
||||
Word Right Sõna paremale
|
||||
Word document (*.docx) Wordi dokument (*.docx)
|
||||
Word XML Document (*.xml) Wordi XML-dokument (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 XML-dokument (*.xml)
|
||||
WordArt WordArt
|
||||
WordCraft WordCraft
|
||||
Words Sõnad
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 459 and column 13.
|
@@ -1908,6 +1908,7 @@
|
||||
Animate the caret (it glides and fades) Animer le curseur (il glisse et s’estompe)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Activé : le curseur glisse jusqu’à sa nouvelle position lorsque vous tapez ou utilisez les touches fléchées, et son clignotement s’estompe en douceur. Désactivé : il saute et clignote de façon nette.
|
||||
Word XML Document (*.xml) Document XML Word (*.xml)
|
||||
Word 2003 XML Document (*.xml) Document XML Word 2003 (*.xml)
|
||||
Deleted Supprimé
|
||||
Resize Table by Dragging Redimensionner le tableau en le faisant glisser
|
||||
Print Markup Imprimer les marques de révision
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2317,6 +2317,7 @@
|
||||
Previous Page Előző oldal
|
||||
Print to PDF Nyomtatás PDF-be
|
||||
Word XML Document (*.xml) Word XML-dokumentum (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 XML-dokumentum (*.xml)
|
||||
Use wildcards Helyettesítő karakterek használata
|
||||
Special Különleges
|
||||
Match prefix Egyezés a szó elején
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1534,6 +1534,7 @@
|
||||
Use the Insert key to switch overtype on and off Usa il tasto Ins per attivare e disattivare la sovrascrittura
|
||||
Extend Selection by Units Estendi selezione per unità
|
||||
Word XML Document (*.xml) Documento Word XML (*.xml)
|
||||
Word 2003 XML Document (*.xml) Documento Word 2003 XML (*.xml)
|
||||
Deleted Eliminato
|
||||
Print Markup Stampa revisioni
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Commenti accanto a ogni pagina e revisioni in PDF, stampe e immagini; un elenco dei commenti alla fine di pagine web, Markdown e testo.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -950,6 +950,7 @@
|
||||
Word Count 文字カウント
|
||||
Word document (*.docx) Word 文書 (*.docx)
|
||||
Word XML Document (*.xml) Word XML ドキュメント (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 XML ドキュメント (*.xml)
|
||||
Word Left 前の単語へ
|
||||
Word Right 次の単語へ
|
||||
WordArt ワードアート
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 563 and column 13.
|
@@ -2052,6 +2052,7 @@
|
||||
White space Odstępy
|
||||
With: Na:
|
||||
Word XML Document (*.xml) Dokument XML programu Word (*.xml)
|
||||
Word 2003 XML Document (*.xml) Dokument XML programu Word 2003 (*.xml)
|
||||
Word document Dokument programu Word
|
||||
Word level Poziom wyrazów
|
||||
WordArt Styles Style WordArt
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -950,6 +950,7 @@
|
||||
Word Count Contagem de palavras
|
||||
Word document (*.docx) Documento Word (*.docx)
|
||||
Word XML Document (*.xml) Documento XML do Word (*.xml)
|
||||
Word 2003 XML Document (*.xml) Documento XML do Word 2003 (*.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.
|
@@ -2202,6 +2202,7 @@ preview Title Пример
|
||||
Print to PDF Печать в PDF
|
||||
# Word XML Document (#548).
|
||||
Word XML Document (*.xml) Документ Word XML (*.xml)
|
||||
Word 2003 XML Document (*.xml) Документ Word 2003 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.
|
@@ -1010,6 +1010,7 @@
|
||||
Word Count Broj reči
|
||||
Word document (*.docx) Word dokument (*.docx)
|
||||
Word XML Document (*.xml) Word XML dokument (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 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.
|
@@ -1014,6 +1014,7 @@
|
||||
Word Count Број речи
|
||||
Word document (*.docx) Word документ (*.docx)
|
||||
Word XML Document (*.xml) Word XML документ (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 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.
|
@@ -2201,6 +2201,7 @@ preview Title Örnek
|
||||
Print to PDF PDF'ye yazdır
|
||||
# Word XML Document (#548).
|
||||
Word XML Document (*.xml) Word XML Belgesi (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 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 439 and column 13.
|
@@ -978,6 +978,7 @@
|
||||
Word Count Кількість слів
|
||||
Word document (*.docx) Документ Word (*.docx)
|
||||
Word XML Document (*.xml) Документ Word XML (*.xml)
|
||||
Word 2003 XML Document (*.xml) Документ Word 2003 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.
|
@@ -962,6 +962,7 @@
|
||||
Word Count 字数统计
|
||||
Word document (*.docx) Word 文档 (*.docx)
|
||||
Word XML Document (*.xml) Word XML 文档 (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 XML 文档 (*.xml)
|
||||
Word Left 左移一个单词
|
||||
Word Right 右移一个单词
|
||||
WordArt 艺术字
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -951,6 +951,7 @@
|
||||
Word Count 字數統計
|
||||
Word document (*.docx) Word 文件 (*.docx)
|
||||
Word XML Document (*.xml) Word XML 文件 (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 XML 文件 (*.xml)
|
||||
Word Left 左移一個單字
|
||||
Word Right 右移一個單字
|
||||
WordArt 文字藝術師
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 564 and column 13.
|
@@ -233,12 +233,16 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The export "format" of a Word 2003 XML Document: an `.xml` file saved with `format: "wordml2003"`.
|
||||
pub(crate) const EXPORT_WORD_2003: &str = "wordml2003";
|
||||
|
||||
/// The "save a copy" formats (File › Export, and below the settings on the Print page).
|
||||
pub fn export_formats(app: &mut WordApp, ui: &mut Ui) {
|
||||
for (label, ext) in [
|
||||
("PDF document (*.pdf)", "pdf"),
|
||||
("Word document (*.docx)", "docx"),
|
||||
("Word XML Document (*.xml)", "xml"),
|
||||
("Word 2003 XML Document (*.xml)", EXPORT_WORD_2003),
|
||||
("OpenDocument Text (*.odt)", "odt"),
|
||||
("Rich Text Format (*.rtf)", "rtf"),
|
||||
("Web page (*.html)", "html"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (direct run-level InkML content parts now survive DOCX saves; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; 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-11 · **Last updated:** 2026-10-12 · **Change:** trivial (saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; 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;
|
||||
@@ -31,7 +31,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| `.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. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687) |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
|
||||
| `.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) |
|
||||
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `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. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) |
|
||||
| `.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 |
|
||||
@@ -177,6 +177,7 @@ raise readiness estimates. No Word-derived files are fixtures.
|
||||
| 2026-10-12 | trivial | DOCX: `w:hideMark` on table cells read and written |
|
||||
| 2026-10-12 | trivial | DOCX text frames (`w:framePr` other than drop caps) are read and written back; they were dropped on save |
|
||||
| 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% |
|
||||
| 2026-10-12 | trivial | Word 2003 XML Document (WordprocessingML 2003) written (#1081): Export, `format: "wordml2003"`, CLI `--format`; not yet checked in Word |
|
||||
| 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) |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (direct run-level native InkML content parts now survive DOCX saves; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-12 · **Change:** trivial (saving Word 2003 XML, #1081; previously trivial: direct run-level native InkML content parts now survive DOCX saves; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -40,7 +40,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
||||
| 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**: 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) |
|
||||
| 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [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) |
|
||||
@@ -88,6 +88,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
| 2026-10-11 | trivial | Table column and row borders and the resize handle drag on the page (#217); UI/UX gap narrowed to the ruler's column markers |
|
||||
| 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains |
|
||||
| 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet |
|
||||
| 2026-10-12 | trivial | Saving Word 2003 XML Document landed (#1081); gap #26 narrowed to 14–26 h |
|
||||
| 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-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts |
|
||||
|
||||
Reference in New Issue
Block a user