M1: preserve native Word ink through DOCX saves (#592)
Co-authored-by: Brandon Thomas <brandon@storyteller.ai> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
b30cda1535
commit
417896301b
|
|
@ -199,6 +199,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | Hungarian (`hu`) interface: all 2,419 catalog entries, built-in style names translated, `hu-HU` system locales and a saved language choice; locale, catalog and bundled-font tests; no Hungarian proofing resources |
|
||||
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
|
||||
| 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources |
|
||||
| 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing |
|
||||
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
|
||||
| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents |
|
||||
| 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions |
|
||||
|
|
@ -262,6 +263,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
|
||||
| 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages |
|
||||
| 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages |
|
||||
| 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing |
|
||||
| 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) |
|
||||
| 2026-10-10 | trivial | Progress log: Draw tab ink (#307) |
|
||||
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Charts and SmartArt diagrams: drawings kept as neutral items ready to draw. Charts and SmartArt
|
||||
//! Charts, SmartArt diagrams and native ink: drawings kept as neutral items ready to draw. Charts and SmartArt
|
||||
//! WordCraft can edit also keep their model ([`crate::chart::ChartSpec`],
|
||||
//! [`crate::smart_art::SmartArtSpec`]); the rest are shown, not edited.
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||
use crate::para::ShapeKind;
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// A chart or a SmartArt diagram, as items ready to draw (and, for an editable chart or SmartArt
|
||||
/// A chart, SmartArt diagram or native ink preview, as items ready to draw (and, for an editable chart or SmartArt
|
||||
/// graphic, its model).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -22,7 +22,7 @@ pub struct Graphic {
|
|||
pub w: f32,
|
||||
#[serde(default)]
|
||||
pub h: f32,
|
||||
/// Where the graphic was read from, so saving writes the chart or diagram itself back (not
|
||||
/// Where the graphic was read from, so saving writes the original drawing itself back (not
|
||||
/// only these items). `None` for graphics made some other way.
|
||||
#[serde(skip)]
|
||||
pub source: Option<std::sync::Arc<Embedded>>,
|
||||
|
|
@ -44,7 +44,8 @@ pub struct Graphic {
|
|||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Embedded {
|
||||
/// The object's markup as a standalone XML element with its namespace declarations (DOCX:
|
||||
/// the `a:graphic` of a chart or SmartArt diagram, the `w:object` of an OLE object).
|
||||
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
|
||||
/// or the `w:object` of an OLE object).
|
||||
pub xml: String,
|
||||
/// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing).
|
||||
pub refs: Vec<EmbeddedRef>,
|
||||
|
|
@ -68,6 +69,8 @@ pub enum GraphicKind {
|
|||
#[default]
|
||||
Chart,
|
||||
Diagram,
|
||||
/// Word's native InkML, kept opaque; displayed as its fallback picture when available.
|
||||
Ink,
|
||||
}
|
||||
|
||||
impl GraphicKind {
|
||||
|
|
@ -76,6 +79,7 @@ impl GraphicKind {
|
|||
match self {
|
||||
GraphicKind::Chart => "chart",
|
||||
GraphicKind::Diagram => "diagram",
|
||||
GraphicKind::Ink => "ink",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,14 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi
|
|||
use super::Reader;
|
||||
use super::props::{sectpr, tcpr, trpr};
|
||||
use super::shape_style::{preset_kind, shape_paint, vml_color};
|
||||
use crate::package::{Rels, rt};
|
||||
use crate::package::{ContentTypes, Rels, rt};
|
||||
use crate::units::{int, measure, u32_of};
|
||||
use crate::xml::El;
|
||||
use crate::xml::{El, Node};
|
||||
use wordcraft_doc::wordart::TextWarp;
|
||||
|
||||
const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
|
||||
const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
|
||||
|
||||
/// Deepest table nesting we build (deeper tables become plain paragraphs).
|
||||
const MAX_TABLE_DEPTH: usize = 24;
|
||||
/// Deepest field nesting we track.
|
||||
|
|
@ -163,7 +166,7 @@ fn clean_text(s: &str) -> String {
|
|||
|
||||
fn children_of_choice(ac: &El) -> Option<&El> {
|
||||
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
|
||||
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
|
||||
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
|
||||
let ok = |c: &El| c.attr("Requires").unwrap_or("").split_whitespace().all(|r| KNOWN.contains(&r));
|
||||
ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice"))
|
||||
}
|
||||
|
|
@ -500,7 +503,10 @@ impl Reader<'_> {
|
|||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(c) = children_of_choice(k) {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels) {
|
||||
let props = self.run_props_none(ctx);
|
||||
self.emit_obj(sc, pb, o, &props);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -661,7 +667,9 @@ impl Reader<'_> {
|
|||
}
|
||||
"w:ruby" => self.read_ruby(sc, pb, k, props),
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(c) = children_of_choice(k) {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
for x in c.els() {
|
||||
self.read_run_child(sc, pb, x, props, rels, note, depth + 1);
|
||||
}
|
||||
|
|
@ -881,6 +889,25 @@ impl Reader<'_> {
|
|||
let gd = c.child("a:graphic").and_then(|g| g.child("a:graphicData"))?;
|
||||
// Checked by URI before the blip search: a diagram's data can hold a blip further down.
|
||||
let uri = gd.attr("uri").unwrap_or("");
|
||||
if uri == INK_URI {
|
||||
let content = gd.child("w14:contentPart")?;
|
||||
let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?;
|
||||
let pkg = self.pkg;
|
||||
if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") {
|
||||
return None;
|
||||
}
|
||||
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
|
||||
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
|
||||
*e = (*e - p).clamp(0.0, 1584.0);
|
||||
}
|
||||
// The renderer's existing missing-picture box marks ink without a usable preview.
|
||||
// InkML stays opaque: no approximation of its strokes or conversion to freeforms.
|
||||
let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media: String::new() }];
|
||||
let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, items, w, h, source: Some(source), ..Default::default() });
|
||||
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
|
||||
}
|
||||
let graphic_kind = if uri.ends_with("/chart") {
|
||||
Some(GraphicKind::Chart)
|
||||
} else if uri.ends_with("/diagram") {
|
||||
|
|
@ -892,7 +919,7 @@ impl Reader<'_> {
|
|||
// Kept so saving writes the chart or diagram back: the frame around it is written
|
||||
// from the object's (possibly moved or resized) size and position.
|
||||
let extra: Vec<String> = match kind {
|
||||
GraphicKind::Chart => Vec::new(),
|
||||
GraphicKind::Chart | GraphicKind::Ink => Vec::new(),
|
||||
GraphicKind::Diagram => self.diagram_drawing_rel(gd, rels).into_iter().collect(),
|
||||
};
|
||||
let source = c.child("a:graphic").and_then(|g| self.keep_embedded(g, rels, &extra));
|
||||
|
|
@ -938,6 +965,34 @@ impl Reader<'_> {
|
|||
Some(obj)
|
||||
}
|
||||
|
||||
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
|
||||
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
|
||||
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
|
||||
let drawing = choice.child("w:drawing")?;
|
||||
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
|
||||
return None;
|
||||
}
|
||||
let fallback = ac.child("mc:Fallback");
|
||||
let preview = fallback.and_then(|f| f.child("w:pict")).and_then(|p| self.read_vml(sc, p, rels));
|
||||
let Some(mut object) = self.read_drawing(sc, drawing, rels) else { return preview };
|
||||
if let InlineObject::Graphic { w, h, graphic, .. } = &mut object {
|
||||
let graphic = Arc::make_mut(graphic);
|
||||
if let Some(InlineObject::Image { media, .. }) = preview {
|
||||
graphic.items = vec![GraphicItem::Image { rect: [0.0, 0.0, *w, *h], media }];
|
||||
}
|
||||
if let Some(fallback) = fallback {
|
||||
let kept =
|
||||
El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] };
|
||||
if let Some(source) = self.keep_embedded(&kept, rels, &[]) {
|
||||
graphic.source = Some(source);
|
||||
}
|
||||
// A malformed or over-budget fallback must not discard a valid native part.
|
||||
}
|
||||
}
|
||||
Some(object)
|
||||
}
|
||||
|
||||
/// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left
|
||||
/// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word
|
||||
/// stores beside them. Built once per part and size; once the file's graphic budget is spent,
|
||||
|
|
@ -946,6 +1001,7 @@ impl Reader<'_> {
|
|||
let part = match kind {
|
||||
GraphicKind::Chart => gd.child("c:chart").and_then(|c| c.attr("r:id")).and_then(|id| super::part_of(rels, id, rt::CHART)),
|
||||
GraphicKind::Diagram => self.diagram_part(gd, rels),
|
||||
GraphicKind::Ink => None,
|
||||
};
|
||||
let Some(path) = part else { return Arc::new(Graphic { kind, items: Vec::new(), w, h, source, chart: None, smart_art: None }) };
|
||||
let key = (kind, path, w.to_bits(), h.to_bits());
|
||||
|
|
@ -978,6 +1034,7 @@ impl Reader<'_> {
|
|||
None => Vec::new(),
|
||||
},
|
||||
GraphicKind::Diagram => self.diagram_drawing(path, w, h),
|
||||
GraphicKind::Ink => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1649,7 +1706,9 @@ struct Xfrm {
|
|||
/// The rotation and flips of the `a:xfrm` in shape properties `sppr` (ECMA-376 §20.1.7.6:
|
||||
/// `rot` in 60000ths of a degree, clockwise; `flipH`, `flipV`). None when missing or junk.
|
||||
fn xfrm_spin(sppr: Option<&El>) -> wordcraft_geom::Spin {
|
||||
let Some(x) = sppr.and_then(|s| s.child("a:xfrm")) else { return wordcraft_geom::Spin::default() };
|
||||
let Some(x) = sppr.and_then(|s| if matches!(s.name.as_str(), "a:xfrm" | "w14:xfrm") { Some(s) } else { s.child("a:xfrm") }) else {
|
||||
return wordcraft_geom::Spin::default();
|
||||
};
|
||||
let rot = x.attr("rot").and_then(int).map(|v| v.rem_euclid(21_600_000) as f32 / 60_000.0).unwrap_or(0.0);
|
||||
wordcraft_geom::Spin::new(rot, on_off_attr(x, "flipH"), on_off_attr(x, "flipV"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Writing back charts, SmartArt diagrams and OLE objects read from a file (see `read::embed`).
|
||||
//! Writing back charts, SmartArt diagrams, native ink and OLE objects read from a file (see `read::embed`).
|
||||
//!
|
||||
//! Each written object gets its kept markup with fresh relationship ids, pointing at copies of the
|
||||
//! parts it needs. The first object to use a part writes it under its original name (unless that
|
||||
|
|
@ -16,7 +16,7 @@ use super::PartRels;
|
|||
use crate::package::{
|
||||
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt,
|
||||
};
|
||||
use crate::units::emu;
|
||||
use crate::units::{MAX_LEN_PT, emu, int};
|
||||
use crate::xml::{self, El, MAX_DEPTH};
|
||||
|
||||
/// Most bytes written for copies of parts (objects pasted more than once).
|
||||
|
|
@ -251,6 +251,12 @@ impl super::Writer<'_> {
|
|||
/// the object's current size and position into its own markup (a chart or diagram's frame is
|
||||
/// written by the caller). `None` when it can't be written back (its parts are gone).
|
||||
pub(super) fn embedded_xml(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float, &str)>) -> Option<String> {
|
||||
self.embedded_tree(src, rels, resize).map(|root| root.to_xml())
|
||||
}
|
||||
|
||||
/// The same bounded preservation operation, retaining the parsed tree for a caller that
|
||||
/// must rebuild an ink AlternateContent drawing frame without reparsing unbound prefixes.
|
||||
pub(super) fn embedded_tree(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float, &str)>) -> Option<El> {
|
||||
let Some(left) = self.embeds.markup_bytes.checked_sub(src.xml.len() as u64) else {
|
||||
log::warn!("docx: too much embedded-object markup; the rest is left out");
|
||||
return None;
|
||||
|
|
@ -305,7 +311,7 @@ impl super::Writer<'_> {
|
|||
if let Some((w, h, float, alt)) = resize {
|
||||
self.resize_object(&mut root, w, h, float, alt, 0);
|
||||
}
|
||||
Some(root.to_xml())
|
||||
Some(root)
|
||||
}
|
||||
|
||||
/// The `a:graphic` of a SmartArt graphic written from its model, laid out `w` × `h` points:
|
||||
|
|
@ -437,6 +443,51 @@ impl super::Writer<'_> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ink's own transform need not fill its outer drawing frame. Scale its original offsets
|
||||
/// and extents proportionally; an unrelated edit must not stretch the preserved strokes.
|
||||
pub(super) fn ink_transform(e: &mut El, from: (f32, f32), to: (f32, f32), float: &Float, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
if e.name == "w14:xfrm" {
|
||||
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "rot" | "flipH" | "flipV"));
|
||||
for (k, v) in super::story::spin_attrs(float.spin()) {
|
||||
e.attrs.push((k.to_string(), v));
|
||||
}
|
||||
let scale = |old: f32, new: f32| if old.is_finite() && old > 0.0 && new.is_finite() { (new.max(0.0) / old) as f64 } else { 1.0 };
|
||||
let (sx, sy) = (scale(from.0, to.0), scale(from.1, to.1));
|
||||
let max = MAX_LEN_PT as f64 * 12_700.0;
|
||||
for c in e.kids.iter_mut().filter_map(|k| match k {
|
||||
xml::Node::El(c) => Some(c),
|
||||
_ => None,
|
||||
}) {
|
||||
if !matches!(c.name.as_str(), "a:off" | "a:ext") {
|
||||
continue;
|
||||
}
|
||||
for (key, value) in &mut c.attrs {
|
||||
let factor = match key.as_str() {
|
||||
"x" | "cx" => sx,
|
||||
"y" | "cy" => sy,
|
||||
_ => continue,
|
||||
};
|
||||
if factor != 1.0
|
||||
&& let Some(n) = int(value)
|
||||
{
|
||||
let min = if c.name == "a:ext" { 0.0 } else { -max };
|
||||
*value = ((n as f64 * factor).clamp(min, max).round() as i64).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in e.kids.iter_mut().filter_map(|k| match k {
|
||||
xml::Node::El(c) => Some(c),
|
||||
_ => None,
|
||||
}) {
|
||||
Self::ink_transform(c, from, to, float, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set an extent's `cx` and `cy` (those it has) to `w` × `h` points.
|
||||
|
|
|
|||
|
|
@ -524,38 +524,50 @@ impl Writer<'_> {
|
|||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
// A chart or diagram read from a file: its frame from the object's size and position,
|
||||
// the graphic inside as read. Ones made some other way have nothing to write, except
|
||||
// charts and SmartArt with a model (made or edited in WordCraft): their parts are
|
||||
// written from it.
|
||||
// Editable charts and SmartArt keep main's model writer; opaque graphics and native
|
||||
// ink keep their original source and related parts.
|
||||
InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => {
|
||||
let inner = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) {
|
||||
// A chart WordCraft can edit: a chart part written from its model.
|
||||
(Some(spec), _, _) => self.chart_graphic(spec, rels),
|
||||
(None, Some(spec), _) => self.smart_art_graphic(spec, *gw, *gh, rels),
|
||||
(None, None, Some(src)) => {
|
||||
let Some(inner) = self.embedded_xml(src, rels, None) else { return };
|
||||
inner
|
||||
}
|
||||
(None, None, None) => return,
|
||||
let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink;
|
||||
let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) {
|
||||
(Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(),
|
||||
(None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(),
|
||||
(None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))),
|
||||
(None, None, None) => None,
|
||||
};
|
||||
if inner.is_empty() {
|
||||
let Some(mut root) = root else { return };
|
||||
if ink {
|
||||
Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0);
|
||||
}
|
||||
let alternate = (ink && root.name == "mc:AlternateContent").then_some(&root);
|
||||
let fallback = alternate.and_then(|e| e.child("mc:Fallback"));
|
||||
let native = alternate.and_then(|e| e.child("mc:Choice")).and_then(|e| e.find("a:graphic"));
|
||||
if alternate.is_some() && native.is_none() {
|
||||
return;
|
||||
}
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
if fallback.is_some() {
|
||||
w.open("mc:AlternateContent", &[]);
|
||||
w.open("mc:Choice", &[("Requires", "wpi")]);
|
||||
}
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = match graphic.kind {
|
||||
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
|
||||
};
|
||||
self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt);
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.raw(&inner);
|
||||
w.raw(&native.unwrap_or(&root).to_xml());
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
if let Some(fallback) = fallback {
|
||||
w.close("mc:Choice");
|
||||
w.raw(&fallback.to_xml());
|
||||
w.close("mc:AlternateContent");
|
||||
}
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::name::ResolveResult;
|
||||
use quick_xml::name::{QName, ResolveResult};
|
||||
use quick_xml::reader::NsReader;
|
||||
|
||||
use crate::DocxError;
|
||||
|
|
@ -26,6 +26,7 @@ pub const NAMESPACES: &[(&str, &str)] = &[
|
|||
("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"),
|
||||
("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
|
||||
("w14", "http://schemas.microsoft.com/office/word/2010/wordml"),
|
||||
("wpi", "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"),
|
||||
("w15", "http://schemas.microsoft.com/office/word/2012/wordml"),
|
||||
("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"),
|
||||
("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"),
|
||||
|
|
@ -81,6 +82,24 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
|
|||
"?"
|
||||
}
|
||||
|
||||
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
|
||||
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
|
||||
fn requirement_prefix(uri: &[u8]) -> &'static str {
|
||||
let prefix = prefix_for(uri, &[]);
|
||||
if prefix != "?" {
|
||||
return prefix;
|
||||
}
|
||||
match uri {
|
||||
b"http://schemas.microsoft.com/office/drawing/2010/main" => "a14",
|
||||
b"http://schemas.microsoft.com/office/word/2015/wordml/symex" => "w16se",
|
||||
b"http://schemas.microsoft.com/office/word/2016/wordml/cid" => "w16cid",
|
||||
b"http://schemas.microsoft.com/office/word/2018/wordml" => "w16",
|
||||
b"http://schemas.microsoft.com/office/word/2018/wordml/cex" => "w16cex",
|
||||
b"http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" => "w16sdtdh",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Node {
|
||||
El(El),
|
||||
|
|
@ -288,6 +307,28 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
|||
Ok(v) => v.into_owned(),
|
||||
Err(_) => String::from_utf8_lossy(&a.value).into_owned(),
|
||||
};
|
||||
// Requires contains namespace prefixes, not feature names. Resolve them while
|
||||
// the namespace scope still exists; declarations are omitted from this tree.
|
||||
let val = if el.name == "mc:Choice" && aname == "Requires" {
|
||||
let requirements: Vec<_> = val.split_whitespace().take(65).collect();
|
||||
if requirements.len() > 64 || requirements.iter().any(|prefix| prefix.len() > 128) {
|
||||
"?".to_string()
|
||||
} else {
|
||||
requirements
|
||||
.into_iter()
|
||||
.map(|prefix| {
|
||||
let qualified = format!("{prefix}:requirement");
|
||||
match r.resolver_mut().resolve_attribute(QName(qualified.as_bytes())).0 {
|
||||
ResolveResult::Bound(ns) => requirement_prefix(ns.as_ref()),
|
||||
_ => "?",
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
} else {
|
||||
val
|
||||
};
|
||||
el.attrs.push((aname, val));
|
||||
}
|
||||
if matches!(ev, Event::Start(_)) {
|
||||
|
|
@ -489,6 +530,14 @@ mod tests {
|
|||
assert_eq!(t.attr("xml:space"), Some("preserve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
|
||||
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
|
||||
let e = parse(x).unwrap();
|
||||
let values: Vec<_> = e.children("mc:Choice").map(|c| c.attr("Requires")).collect();
|
||||
assert_eq!(values, [Some("wpi"), Some("?"), Some("?")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_error() {
|
||||
let mut s = String::new();
|
||||
|
|
|
|||
|
|
@ -1513,3 +1513,208 @@ fn vml_lines_and_rectangles() {
|
|||
}
|
||||
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
|
||||
}
|
||||
|
||||
// ---- native Word ink: opaque preservation, never re-encoded as WordCraft freeforms ----
|
||||
|
||||
const INK_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
|
||||
const INK_XML: &str = r##"<ink xmlns="http://www.w3.org/2003/InkML"><definitions><context xml:id="ctx"><inkSource xml:id="src"><traceFormat><channel name="X" type="integer" units="cm"/><channel name="Y" type="integer" units="cm"/></traceFormat></inkSource></context><brush xml:id="blue"><brushProperty name="color" value="#2563EB"/><brushProperty name="width" value="0.08" units="cm"/></brush></definitions><trace contextRef="#ctx" brushRef="#blue">0 0, 1 1, 2 0</trace></ink>"##;
|
||||
const INK_TYPES: &str = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/ink/ink1.xml" ContentType="application/inkml+xml"/><Default Extension="png" ContentType="image/png"/></Types>"#;
|
||||
|
||||
fn ink_docx(fallback: bool, alias: bool) -> Vec<u8> {
|
||||
let content = r#"<q:contentPart xmlns:q="http://schemas.microsoft.com/office/word/2010/wordml" r:id="rInk"><q:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></q:xfrm></q:contentPart>"#;
|
||||
let drawing = format!(
|
||||
r#"<w:drawing><wp:inline><wp:extent cx="1828800" cy="914400"/><wp:docPr id="1" name="Study ink"/><a:graphic><a:graphicData uri="{INK_NS}">{content}</a:graphicData></a:graphic></wp:inline></w:drawing>"#
|
||||
);
|
||||
let prefix = if alias { "pen" } else { "wpi" };
|
||||
let markup = if fallback {
|
||||
format!(
|
||||
r##"<mc:AlternateContent><mc:Choice xmlns:{prefix}="{INK_NS}" Requires="{prefix}">{drawing}</mc:Choice><mc:Fallback><w:pict><v:shape id="preview" style="width:144pt;height:72pt"><v:imagedata r:id="rPreview"/></v:shape></w:pict></mc:Fallback></mc:AlternateContent>"##
|
||||
)
|
||||
} else {
|
||||
drawing
|
||||
};
|
||||
let body = format!("<w:p><w:r><w:t>Original</w:t></w:r></w:p><w:p><w:r>{markup}</w:r></w:p>");
|
||||
let mut relationships = vec![("rInk", "customXml", "ink/ink1.xml")];
|
||||
let mut extra = vec![("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)];
|
||||
if fallback {
|
||||
relationships.push(("rPreview", "image", "media/preview.png"));
|
||||
extra.push(("word/media/preview.png", "original preview bytes"));
|
||||
}
|
||||
docx(&body, &relationships, &extra)
|
||||
}
|
||||
|
||||
fn check_ink(files: &std::collections::BTreeMap<String, Vec<u8>>) -> String {
|
||||
let body = text(files, "word/document.xml");
|
||||
let [id] = attr_values(&body, "w14:contentPart", "r:id").try_into().unwrap_or_else(|_| panic!("ink reference lost: {body}"));
|
||||
let (kind, target) = rel(&text(files, "word/_rels/document.xml.rels"), &id);
|
||||
assert!(kind.ends_with("/customXml"));
|
||||
let part = part_at("word", &target);
|
||||
assert_eq!(text(files, &part), INK_XML);
|
||||
assert!(text(files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#)));
|
||||
part
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_survives_unrelated_edit_and_two_saves() {
|
||||
let mut d = wordcraft_docx::read(&ink_docx(false, false)).unwrap();
|
||||
let g = graphics(&d);
|
||||
assert_eq!(g.len(), 1, "native ink must remain a model object");
|
||||
let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() };
|
||||
assert_eq!(graphic.kind.noun(), "ink");
|
||||
assert!(graphic.source.is_some());
|
||||
assert!(!graphic.items.is_empty(), "a missing preview must not silently leave a blank frame");
|
||||
let p = std::sync::Arc::make_mut(&mut d.body[0]);
|
||||
let Block::Para(p) = p else { panic!() };
|
||||
p.delete(0, p.len()).unwrap();
|
||||
p.insert_text(0, "Edited", &Default::default()).unwrap();
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
check_ink(&files);
|
||||
assert!(text(&files, "word/document.xml").contains("Edited"));
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(graphics(&d).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_keeps_alternate_content_and_preview_with_renamed_prefixes() {
|
||||
for alias in [false, true] {
|
||||
let mut d = wordcraft_docx::read(&ink_docx(true, alias)).unwrap();
|
||||
let g = graphics(&d);
|
||||
assert_eq!(g.len(), 1, "fallback must not replace native ink");
|
||||
let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() };
|
||||
assert!(
|
||||
graphic.items.iter().any(|item| matches!(item, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media)))
|
||||
);
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
check_ink(&files);
|
||||
let body = text(&files, "word/document.xml");
|
||||
assert!(body.contains("<mc:AlternateContent>") && body.contains("<mc:Fallback>"));
|
||||
let [id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
|
||||
let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);
|
||||
assert!(kind.ends_with("/image"));
|
||||
assert_eq!(text(&files, &part_at("word", &target)), "original preview bytes");
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(graphics(&d).len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_missing_part_uses_available_fallback() {
|
||||
let bytes = ink_docx(true, true);
|
||||
let mut files = unzip(&bytes);
|
||||
files.remove("word/ink/ink1.xml");
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
assert!(graphics(&d).is_empty());
|
||||
assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. })));
|
||||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(!text(&files, "word/document.xml").contains("w14:contentPart"));
|
||||
assert!(files.values().any(|bytes| bytes == b"original preview bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_missing_optional_preview_keeps_native_part() {
|
||||
let mut files = unzip(&ink_docx(true, false));
|
||||
files.remove("word/media/preview.png");
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
assert_eq!(graphics(&d).len(), 1);
|
||||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||||
check_ink(&files);
|
||||
assert!(!text(&files, "word/document.xml").contains("<mc:Fallback>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_requires_the_bound_namespace_and_ink_content_type() {
|
||||
for wrong_namespace in [true, false] {
|
||||
let mut files = unzip(&ink_docx(true, false));
|
||||
let name = if wrong_namespace { "word/document.xml" } else { "[Content_Types].xml" };
|
||||
let old = text(&files, name);
|
||||
let changed = if wrong_namespace {
|
||||
old.replace(&format!(r#"xmlns:wpi="{INK_NS}""#), r#"xmlns:wpi="urn:future:unsupported""#)
|
||||
} else {
|
||||
old.replace("application/inkml+xml", "application/xml")
|
||||
};
|
||||
files.insert(name.into(), changed.into_bytes());
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
assert!(graphics(&d).is_empty());
|
||||
assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_copies_have_independent_parts_and_current_frames() {
|
||||
let mut d = wordcraft_docx::read(&ink_docx(true, false)).unwrap();
|
||||
let [mut object] = graphics(&d).try_into().unwrap();
|
||||
let InlineObject::Graphic { w, h, float, .. } = &mut object else { panic!() };
|
||||
(*w, *h) = (288.0, 144.0);
|
||||
*float = wordcraft_doc::para::Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 36.0, ..Default::default() };
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, object.clone(), &Default::default()).unwrap();
|
||||
p.insert_object(p.len(), object, &Default::default()).unwrap();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
let body = text(&files, "word/document.xml");
|
||||
let ids = attr_values(&body, "w14:contentPart", "r:id");
|
||||
assert_eq!(ids.len(), 2);
|
||||
let parts: Vec<_> = ids.iter().map(|id| part_at("word", &rel(&text(&files, "word/_rels/document.xml.rels"), id).1)).collect();
|
||||
assert_ne!(parts[0], parts[1]);
|
||||
for part in parts {
|
||||
assert_eq!(text(&files, &part), INK_XML);
|
||||
}
|
||||
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"]);
|
||||
assert_eq!(attr_values(&body, "a:ext", "cx"), ["3657600", "3657600"]);
|
||||
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:288pt;height:144pt", "width:288pt;height:144pt"]);
|
||||
assert_eq!(body.matches("<wp:posOffset>914400</wp:posOffset>").count(), 2);
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
let g = graphics(&back);
|
||||
assert_eq!(g.len(), 2);
|
||||
for object in g {
|
||||
let InlineObject::Graphic { w, h, float, .. } = object else { panic!() };
|
||||
assert_eq!((w, h, float.x, float.y), (288.0, 144.0, 72.0, 36.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_markup_limit_does_not_write_a_dangling_reference() {
|
||||
let mut files = unzip(&ink_docx(false, false));
|
||||
let body = text(&files, "word/document.xml").replace("<a:graphicData ", &format!(r#"<a:graphicData padding="{}" "#, "x".repeat(1024 * 1024 + 1)));
|
||||
files.insert("word/document.xml".into(), body.into_bytes());
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(!text(&files, "word/document.xml").contains("w14:contentPart"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_preserves_inner_transform_and_rotation_padding_on_unrelated_saves() {
|
||||
let mut files = unzip(&ink_docx(false, false));
|
||||
let body = text(&files, "word/document.xml")
|
||||
.replace("<wp:docPr", r#"<wp:effectExtent l="55468" t="512668" r="55468" b="512668"/><wp:docPr"#)
|
||||
.replace("<q:xfrm>", r#"<q:xfrm rot="2700000">"#)
|
||||
.replace(r#"<a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/>"#, r#"<a:off x="127000" y="254000"/><a:ext cx="914400" cy="457200"/>"#);
|
||||
files.insert("word/document.xml".into(), body.into_bytes());
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
let mut d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
for _ in 0..2 {
|
||||
let [object] = graphics(&d).try_into().unwrap();
|
||||
let InlineObject::Graphic { float, .. } = object else { panic!() };
|
||||
assert_eq!(float.spin().deg, 45.0);
|
||||
assert!(float.effect.iter().all(|v| v.abs() < 0.001), "rotation padding is not counted twice: {:?}", float.effect);
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
check_ink(&files);
|
||||
let body = text(&files, "word/document.xml");
|
||||
assert_eq!(attr_values(&body, "a:off", "x"), ["127000"]);
|
||||
assert_eq!(attr_values(&body, "a:ext", "cx"), ["914400"]);
|
||||
assert_eq!(attr_values(&body, "w14:xfrm", "rot"), ["2700000"]);
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
|||
| Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) |
|
||||
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes |
|
||||
| OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited |
|
||||
| Word ink (`w14:contentPart`, InkML), 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
|
||||
| Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; run-level `mc:AlternateContent` keeps the native branch and VML picture fallback. Shows the preview or a missing-picture box. Repeated saves, copies, resize, rotation and namespace aliases tested with original fixtures. No native stroke rendering, eraser, conversion or replay; direct run-level content parts and ink nested in groups/canvases remain unsupported |
|
||||
| 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
|
||||
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
|
||||
| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read; attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles |
|
||||
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties round-trip; bibliography model I/O remains separate (#532 open) |
|
||||
|
|
@ -131,8 +132,8 @@ Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full condi
|
|||
|
||||
1. A **real-world DOCX corpus** opened, rendered and round-tripped, compared page by page with
|
||||
Word (local only: Word output can't be committed; keep a manifest and our own renders).
|
||||
2. **Preserve what we can't render**: ink and drawing canvases survive a round trip
|
||||
(keep the parts and the run). Charts, SmartArt and OLE objects do (#292, #319).
|
||||
2. **Preserve what we can't render**: remaining ink forms and unsupported graphics survive a round trip
|
||||
(keep the parts and the run). Charts, SmartArt and OLE objects do (#292, #319); drawing-level native ink now keeps its InkML and optional picture fallback.
|
||||
3. Move tracking (`w:moveFrom`/`w:moveTo`) read and written (formatting revisions: #41; content controls: #381).
|
||||
4. Encrypted `.docx` opens with a password.
|
||||
|
||||
|
|
@ -191,6 +192,7 @@ raise readiness estimates. No Word-derived files are fixtures.
|
|||
| 2026-10-11 | minor | Preserve imported TOC ranges independently of paragraph styles, with explicit evaluation limits |
|
||||
| 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` |
|
||||
| 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) |
|
||||
| 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing |
|
||||
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
|
||||
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
|
||||
| 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed |
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
|||
| # | Gap | B | Evidence | Impact | Hours | Doc |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink is preserved with its optional picture fallback; direct run-level content parts and ink nested in groups/canvases remain unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
|
|
@ -24,7 +24,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
|||
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 11 | **Interface languages**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) |
|
||||
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); missing: Ink to Math, arrows in Ink to Shape, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); missing: Ink to Math, arrows in Ink to Shape, pressure, rendering/editing Word's own ink (drawing-level `w14:contentPart`/InkML now preserved on DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts, axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) |
|
||||
|
|
@ -102,6 +102,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
|||
| 2026-10-11 | trivial | #24: Word's field keys (Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11) (#339) |
|
||||
| 2026-10-10 | trivial | #24: 125 of ~250 keyboard shortcuts after #330 |
|
||||
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
|
||||
| 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing |
|
||||
| 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth |
|
||||
| 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h |
|
||||
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ Opus 5.5 agent wall-clock.
|
|||
2. **DOCX real-world corpus** (gap #1, 30–50 h): collect openly licensed real-world .docx files,
|
||||
open/render/round-trip them, compare with Word locally, fix what breaks. Omitted/empty
|
||||
paragraph defaults now have synthetic save/reopen coverage; the corpus gate remains open.
|
||||
3. **Preserve what we can't render** (gap #2, 10–15 h): charts, SmartArt, OLE, ink, group shapes
|
||||
survive a round trip and show their fallback pictures.
|
||||
3. **Preserve what we can't render** (gap #2, 10–15 h): remaining native ink forms and unsupported
|
||||
graphics survive a round trip. Drawing-level native ink now keeps its InkML and optional picture
|
||||
fallback alongside chart, SmartArt, OLE and drawing-canvas support.
|
||||
4. **Stability sweep** (gap #3, 20–30 h): the open crash, hang and write-failure issues, plus an
|
||||
autosave/recovery soak test.
|
||||
5. **Pagination fidelity** (gap #4, 30–50 h): column balancing, footnote continuation, compat
|
||||
|
|
@ -75,6 +76,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
|
|||
| 2026-10-11 | trivial | M15: Polish interface and Polish proofing landed |
|
||||
| 2026-10-11 | trivial | M3: icons landed (#309) |
|
||||
| 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) |
|
||||
| 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing |
|
||||
| 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) |
|
||||
| 2026-10-11 | trivial | M7: formatting revisions landed (#41) |
|
||||
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
|
||||
|
|
|
|||
Loading…
Reference in New Issue