diff --git a/ROADMAP.md b/ROADMAP.md index 232233225..4a9add834 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save | | 2026-10-12 | Sample-text formulas on Enter (#1067): `=rand()` and `=lorem()`, optional paragraph/sentence counts, 5/3 defaults, original English and one-step undo | | 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 | @@ -276,6 +277,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings | | 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | | 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3f8a9e43a..d6f372de1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -283,10 +283,11 @@ pub struct TextBody { /// story; its own blocks are unused. See [`Document::text_box_chains`]. pub next: Option, /// Internal margins (left, top, right, bottom), EMU, when the file gives its own - /// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1); `None` is Word's defaults. + /// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1, or VML `v:textbox` `inset`); + /// `None` is Word's defaults. pub inset: Option<[u32; 4]>, - /// Resize shape to fit text (`a:spAutoFit`): Word grows the box to its text, so none of - /// the text is cut. + /// Resize shape to fit text (`a:spAutoFit`, VML `mso-fit-shape-to-text`): Word grows the + /// box to its text, so none of the text is cut. pub auto_fit: bool, } diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 5f936b9d2..601eaa55d 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -212,6 +212,10 @@ pub struct Float { /// decorative mark). See [`InlineObject::alt_text`]. #[serde(skip_serializing_if = "AltText::is_empty")] pub alt: AltText, + /// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor + /// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes). + #[serde(skip_serializing_if = "is_false")] + pub hidden: bool, } /// Longest alternative text kept (characters): hostile files and commands are capped. diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 0ece8d837..7c50ebb07 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -893,6 +893,7 @@ impl Reader<'_> { let (alt, meta) = nv_alt(c.child("wp:docPr")); let mut float = if anchored { anchor_float(c) } else { Float::default() }; float.alt = meta; + float.hidden = c.child("wp:docPr").is_some_and(|p| on_off_attr(p, "hidden")); if let Some(e) = c.child("wp:effectExtent") { for (slot, n) in float.effect.iter_mut().zip(["l", "t", "r", "b"]) { // Negative for a rotated object narrower than its frame; settled below. @@ -1165,8 +1166,9 @@ impl Reader<'_> { let max = crate::units::MAX_LEN_PT; let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max); let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0)); - let (alt, meta) = nv_alt(if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") }); - let float = Float { alt: meta, ..Float::default() }; + let nv = if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") }; + let (alt, meta) = nv_alt(nv); + let float = Float { alt: meta, hidden: nv.is_some_and(|p| on_off_attr(p, "hidden")), ..Float::default() }; let obj = if e.name == "pic:pic" { self.read_pic(e, rels, w, h, alt.clone(), float) } else { @@ -1310,10 +1312,35 @@ impl Reader<'_> { let stroke = on("stroked").then(|| shape.attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten(); let stroke_width = shape.attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0); let fill = on("filled").then(|| shape.attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten(); - if let Some(t) = shape.find("w:txbxContent") + if let Some((tb, t)) = vml_textbox(shape, 0) && sc.story_depth < MAX_STORY_DEPTH { - let story = Some(self.read_textbox(sc, t, rels)); + let id = self.read_textbox(sc, t, rels); + if let Some(part) = self.doc.parts.get_mut(&id) { + // Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left + // out keeping Word's default; a bare number is in EMUs, VML's base unit. + // `o:insetmode="auto"` leaves them to the application. + let mut inset = wordcraft_doc::TEXT_BOX_INSET; + let custom = tb.attr("o:insetmode").is_none_or(|m| !m.eq_ignore_ascii_case("auto")); + for (slot, v) in inset.iter_mut().zip(tb.attr("inset").filter(|_| custom).unwrap_or("").split(',')) { + if let Some(pt) = measure(v, 12_700.0) { + *slot = (pt.max(0.0) * 12_700.0).round().min(wordcraft_doc::MAX_TEXT_BOX_INSET as f32) as u32; + } + } + part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset); + // Resize shape to fit text (DrawingML's `a:spAutoFit`). + let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text"); + part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true")); + // Where the text sits between the box's top and bottom (DrawingML's `anchor`). + if let Some(a) = vml_style(style, "v-text-anchor") { + part.body.anchor = match a { + a if a.starts_with("middle") => wordcraft_doc::props::VAlign::Center, + a if a.starts_with("bottom") => wordcraft_doc::props::VAlign::Bottom, + _ => wordcraft_doc::props::VAlign::Top, + }; + } + } + let story = Some(id); return Some(InlineObject::Shape { kind: ShapeKind::TextBox, w, @@ -1590,6 +1617,18 @@ fn wrap_polygon(e: &El) -> Option { } /// The value of property `key` in a VML `style` attribute (CSS declarations; the last one wins). +/// The first `v:textbox` under `e` holding text, with its `w:txbxContent` (depth-first, bounded +/// like `El::find`): in a group, a member's margins go with that member's text. +fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> { + if depth > crate::xml::MAX_DEPTH { + return None; + } + e.els().find_map(|c| match c.child("w:txbxContent") { + Some(t) if c.name == "v:textbox" => Some((c, t)), + _ => vml_textbox(c, depth + 1), + }) +} + fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> { style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim()) } @@ -1602,9 +1641,10 @@ fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> { /// `z-index`. Anything else stays inline. fn vml_float(shape: &El, style: &str) -> Float { let get = |k: &str| vml_style(style, k); + let hidden = get("visibility").is_some_and(|v| v.eq_ignore_ascii_case("hidden")); if !get("position").is_some_and(|p| p.eq_ignore_ascii_case("absolute")) { // Inline: only the turn (an OLE object turned in WordCraft is saved so, #319). - let mut f = Float::default(); + let mut f = Float { hidden, ..Float::default() }; f.set_spin(vml_spin(style)); return f; } @@ -1651,6 +1691,7 @@ fn vml_float(shape: &El, style: &str) -> Float { dist: dist("mso-wrap-distance-left").max(dist("mso-wrap-distance-right")), dist_top: dist("mso-wrap-distance-top"), dist_bottom: dist("mso-wrap-distance-bottom"), + hidden, ..Float::default() }; f.set_spin(vml_spin(style)); diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index f53d10a0e..d894f6715 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -891,7 +891,7 @@ impl Writer<'_> { self.used_media.insert(media.clone()); w.open("pic:pic", &[]); w.open("pic:nvPicPr", &[]); - nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt); + nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden); w.open("pic:cNvPicPr", &[]); w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]); w.close("pic:cNvPicPr"); @@ -923,7 +923,7 @@ impl Writer<'_> { w.open("wps:wsp", &[]); if let Some(id) = id { let name = shape_name(*kind, freeform.as_deref(), id); - nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt); + nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden); } if kind.is_connector() { // A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`). @@ -1139,7 +1139,7 @@ impl Writer<'_> { Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]), Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]), } - nv_props(w, "wp:docPr", docpr, name, alt, &float.alt); + nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden); } fn table(&mut self, w: &mut W, t: &Table, rels: &mut PartRels, depth: usize) { @@ -1542,7 +1542,7 @@ fn toc_span(bl: &Blocks) -> Option<(usize, usize)> { /// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as /// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read /// from a file with no description) is not written twice. -pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText) { +pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) { let mut attrs = vec![("id", id), ("name", name)]; if descr != alt.title || descr.is_empty() { attrs.push(("descr", descr)); @@ -1550,6 +1550,9 @@ pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, if !alt.title.is_empty() { attrs.push(("title", alt.title.as_str())); } + if hidden { + attrs.push(("hidden", "1")); + } if !alt.decorative { w.empty(tag, &attrs); return; diff --git a/crates/docx/tests/text_boxes.rs b/crates/docx/tests/text_boxes.rs index 2831c9ca4..7efe7b040 100644 --- a/crates/docx/tests/text_boxes.rs +++ b/crates/docx/tests/text_boxes.rs @@ -108,3 +108,90 @@ fn hostile_links_read_without_chains_running_wild() { let b = r.parts.get(&got[0]).unwrap().body; assert_eq!((b.vert, b.anchor), (TextVert::Vert, VAlign::Center)); } + +#[test] +fn vml_text_boxes_keep_their_inset_fit_and_anchor() { + // ECMA-376 Part 4 §19.1.2.22: `inset="l,t,r,b"`, each one left out keeping the default + // (0.1in, 0.05in); `mso-fit-shape-to-text` resizes the shape to its text; `v-text-anchor` + // places the text between the box's top and bottom. + let content = r#"Boxed"#; + let shape = |style: &str, textbox: &str| { + format!( + r#"{content}"# + ) + }; + let body = [ + shape("", r#"inset="0,0,0,0""#), + shape("v-text-anchor:middle", r#"inset="1mm,,2pt" style="mso-fit-shape-to-text:T""#), + shape("", ""), + // A bare number is in EMUs; one that doesn't parse keeps the default; negative is 0; + // too large is the most a margin may be. + shape("v-text-anchor:bottom-baseline", r#"inset="12700,bogus,-5pt,99in""#), + // `o:insetmode="auto"` leaves the margins to the application. + shape("", r#"o:insetmode="auto" inset="0,0,0,0""#), + // In a group, a member's margins go with that member's text, not an empty box's before it. + format!(r#"{content}"#), + ] + .concat(); + let doc = format!( + r#"{body}"# + ); + let rels = br#""#; + let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap(); + let bodies: Vec = boxes(&d).iter().map(|id| d.parts[id].body).collect(); + assert_eq!(bodies.len(), 6); + assert_eq!(bodies[0].inset, Some([0, 0, 0, 0])); + assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center)); + assert_eq!(bodies[2], TextBody::default()); + assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom)); + assert_eq!(bodies[4].inset, None); + assert_eq!(bodies[5].inset, Some([0, 0, 0, 0])); + // Saved and read again, they stay. + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + let again: Vec = boxes(&back).iter().map(|id| back.parts[id].body).collect(); + assert_eq!(again, bodies); +} + +#[test] +fn hidden_drawings_stay_hidden() { + // VML `visibility:hidden` (floating or inline) and DrawingML `wp:docPr/@hidden` (also a group + // member's `wps:cNvPr/@hidden`) mark a drawing hidden; it is kept, and saved hidden. + let vml = r#""#; + let shown = r#""#; + let inline_vml = r#""#; + let dml = r#""#; + let member = |id: u32, hidden: &str| { + format!( + r#""# + ) + }; + let group = format!( + r#"{}{}"#, + member(9, r#" hidden="1""#), + member(10, "") + ); + let doc = format!( + r#"{vml}{shown}{inline_vml}{dml}{group}"# + ); + let rels = br#""#; + // Each drawing's flag, a group's members' after it. + let hidden = |d: &Document| { + let mut out = Vec::new(); + d.objects_in_reading_order(&mut |o| { + out.extend(o.frame().map(|(_, _, f)| f.hidden)); + if let InlineObject::Group { children, .. } = o { + out.extend(children.iter().filter_map(|c| c.obj.frame()).map(|(_, _, f)| f.hidden)); + } + }); + out + }; + let want = [true, false, true, true, false, true, false]; + let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap(); + assert_eq!(hidden(&d), want); + let saved = wordcraft_docx::write(&d).unwrap(); + let xml = document_xml(&saved); + let marked = + |tag: &str| xml.split(&format!("<{tag} ")).skip(1).filter(|a| a.split('>').next().is_some_and(|a| a.contains(r#"hidden="1""#))).count(); + assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}"); + assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want); +} diff --git a/crates/engine/src/cmd/accessibility.rs b/crates/engine/src/cmd/accessibility.rs index c6044e48a..ab579011e 100644 --- a/crates/engine/src/cmd/accessibility.rs +++ b/crates/engine/src/cmd/accessibility.rs @@ -114,8 +114,9 @@ impl Checker<'_> { let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) else { continue }; let pos = at(path, off); let select = json!({"anchor": pos, "focus": at(path, off + OBJ.len_utf8())}); - // Ink: every stroke is its own object, so asking about each would bury the report. - if o.is_decorative() || o.ink().is_some() { + // Ink: every stroke is its own object, so asking about each would bury the report. A + // hidden drawing isn't shown, so nobody reads it. + if o.is_decorative() || o.ink().is_some() || o.frame().is_some_and(|(_, _, f)| f.hidden) { continue; } let titled = o.frame().is_some_and(|(_, _, f)| !f.alt.title.trim().is_empty()); diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 11493302c..1bf3e8a6f 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -1342,7 +1342,8 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult { InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)), _ => ("object", format!("Object {}", i + 1)), }; - json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)}) + let hidden = o.frame().is_some_and(|(_, _, f)| f.hidden); + json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o), "hidden": hidden}) }) .collect(), )) diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index c80cbf5de..6e8b0128a 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -778,7 +778,8 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { } let obj = p.objects.get(k); k += 1; - if hidden { + // Hidden text, or a hidden drawing: not in the copy. + if hidden || obj.and_then(InlineObject::frame).is_some_and(|(_, _, f)| f.hidden) { continue; } match obj { diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 8dea5bf98..d22e5f695 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -677,3 +677,16 @@ fn exported_links_keep_only_safe_targets() { assert!(!md.contains(&format!("[{text}](")), "md: {md}"); } } + +#[test] +fn hidden_drawings_are_left_out_of_exports() { + let mut d = wordcraft_doc::Document::from_text("x"); + let key = d.add_media(vec![0x89, b'P', b'N', b'G'], "png"); + for hidden in [false, true] { + let float = wordcraft_doc::para::Float { hidden, ..Default::default() }; + let image = wordcraft_doc::InlineObject::Image { media: key.clone(), w: 10.0, h: 10.0, alt: "Logo".into(), float, crop: [0.0; 4], ole: None }; + d.insert_object(&wordcraft_doc::Pos::body(0, 0), image, &Default::default()).unwrap(); + } + // Two pictures, one hidden: one comes out. + assert_eq!(crate::html::export(&d).matches(" { let mut members = Vec::new(); for ([x, y, w, h], c) in o.group_rects(rect.x, rect.y, rect.w, rect.h) { + if c.frame().is_some_and(|(_, _, f)| f.hidden) { + continue; + } object_draws(c, Rect::new(x, y, w, h), own.within(outer), alpha, opts, &mut members); } match o.canvas_style() { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 0261333a3..727cff1fe 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -734,12 +734,13 @@ impl Ctx<'_> { } /// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden -/// text unless `show_hidden`, or in a tracked deletion with `hide_deleted`. `table_chr` is the +/// text unless `show_hidden`, in a tracked deletion with `hide_deleted`, or a hidden drawing +/// (`Float::hidden`, whatever is shown). `table_chr` is the /// cell's table-style character formatting ([`para::CellText::chr`]), resolved as the paragraph is /// laid out with (`StyleSheet::resolve_char_in`). fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps>, show_hidden: bool, hide_deleted: bool) -> impl Fn(usize) -> bool { let mut left = Vec::new(); - // Nothing can be left out with hidden text shown and markup on. + // No text left out with hidden text shown and markup on (hidden drawings still are, below). if (hide_deleted || !show_hidden) && !p.objects.is_empty() { let style = p.props.style.as_deref(); let is_left = |c: &CharProps| { @@ -766,7 +767,8 @@ fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps> left.push(l); } } - move |k| left.get(k).copied().unwrap_or(false) + // A hidden drawing is left out whatever is shown (Word shows it in no view). + move |k| left.get(k).copied().unwrap_or(false) || p.objects.get(k).and_then(InlineObject::frame).is_some_and(|(_, _, f)| f.hidden) } /// Footnote and endnote numbers (each kind counted separately), in document order, as each @@ -785,15 +787,16 @@ fn note_info(doc: &Document, hide_deleted: bool, pages: &HashMap) -> let mut order = [0u32; 2]; let mut si = 0usize; // Reading order, text boxes included where they are. A hidden reference mark still takes - // its number, as in Word; a deleted one (or one in a deleted text box) does not. + // its number, as in Word; a deleted one (or one in a deleted or hidden text box) does not, + // and `para_notes` doesn't place its note either. let mut cur: Option<(usize, Vec)> = None; doc.objects_in_reading_order_by_block(&mut |block, p, k| { let key = p as *const Paragraph as usize; if cur.as_ref().is_none_or(|(at, _)| *at != key) { - let deleted = left_out_objects(doc, p, None, true, hide_deleted); - cur = Some((key, (0..p.objects.len()).map(deleted).collect())); + let gone = left_out_objects(doc, p, None, true, hide_deleted); + cur = Some((key, (0..p.objects.len()).map(gone).collect())); } - if cur.as_ref().is_some_and(|(_, deleted)| deleted.get(k).copied().unwrap_or(false)) { + if cur.as_ref().is_some_and(|(_, gone)| gone.get(k).copied().unwrap_or(false)) { return false; } if let Some(InlineObject::NoteRef { kind, id, .. }) = p.objects.get(k) { @@ -2504,7 +2507,12 @@ fn carried(rect: Rect, spin: Spin, centre: Point) -> Rect { } fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> { - o.group_rects(rect.x, rect.y, rect.w, rect.h).into_iter().map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)).collect() + // Hidden members aren't placed (nor their text boxes laid out). + o.group_rects(rect.x, rect.y, rect.w, rect.h) + .into_iter() + .filter(|(_, c)| !c.frame().is_some_and(|(_, _, f)| f.hidden)) + .map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)) + .collect() } /// Position the floating objects anchored in `p` for a paragraph whose text starts at `y0` in @@ -2651,7 +2659,7 @@ fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32 if p.objects.iter().any(|o| !o.text_boxes().is_empty()) { for (ci, c) in pl.clusters.iter().enumerate() { if let para::ClKind::Object(oi) = c.kind - && let Some(o) = p.objects.get(oi) + && let Some(o) = p.objects.get(oi).filter(|o| !o.frame().is_some_and(|(_, _, f)| f.hidden)) { for id in o.text_boxes() { notes.extend(doc.notes_in_text_box(id).into_iter().map(|n| (ci, n))); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 14c064a2c..41f9fadf0 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1286,8 +1286,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { obj_index += 1; match p.objects.get(k) { Some(o) if o.is_drawing() => { + // A hidden one takes no room, and with no height its line doesn't draw it. if let Some((w, h, float)) = o.frame() && float.wrap == wordcraft_doc::para::Wrap::Inline + && !float.hidden { // A picture wider than the line keeps its size, as in Word: it runs past // the margin and the page edge clips it (see `ParaEnv::fit_objects`). diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index a46b5ceda..de4b5d19f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1341,6 +1341,56 @@ fn text_wraps_around_square_float() { assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. }))); } +#[test] +fn hidden_drawings_are_neither_drawn_nor_take_room() { + // A hidden floating shape (VML `visibility:hidden`, `wp:docPr/@hidden`): no wrap area, nothing + // drawn, as in Word for Windows. A hidden inline one takes no room in its line. + let mut d = Document::from_text(&"Words flow around the picture here. ".repeat(30)); + let float = wordcraft_doc::para::Float { + wrap: wordcraft_doc::para::Wrap::Square, + h_rel: wordcraft_doc::para::Anchor::Column, + v_rel: wordcraft_doc::para::Anchor::Paragraph, + hidden: true, + ..Default::default() + }; + let inline = wordcraft_doc::para::Float { hidden: true, ..Default::default() }; + d.insert_object(&Pos::body(0, 0), picture(200.0, 200.0, inline), &Default::default()).unwrap(); + d.insert_object(&Pos::body(0, 0), picture(144.0, 100.0, float), &Default::default()).unwrap(); + let l = lay(&d); + let Placed::Lines { para, .. } = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })).unwrap() else { panic!() }; + let first = ¶.lines[0]; + assert!(first.left < 1.0, "the first line uses the full width: {}", first.left); + assert!(first.baseline - first.top < 20.0, "the hidden inline shape doesn't raise the line: {first:?}"); + assert!(!l.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. } | Placed::Object { .. }))); + let draws = flat(display::page_display(&d, &l.pages[0], &Default::default())); + assert!(!draws.iter().any(|x| matches!(x, display::Draw::Shape { .. })), "nothing drawn"); + // Showing hidden text doesn't show hidden drawings. + let shown = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden: true, ..Default::default() }); + let Placed::Lines { para, .. } = shown.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })).unwrap() else { panic!() }; + assert!(para.lines[0].left < 1.0 && !shown.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. } | Placed::Object { .. }))); + + // A group draws its members but a hidden one. + let mut d = Document::from_text(""); + let member = |hidden: bool, x: f32| wordcraft_doc::para::GroupChild { + x, + y: 0.0, + obj: picture(50.0, 50.0, wordcraft_doc::para::Float { hidden, ..Default::default() }), + }; + let group = InlineObject::Group { + w: 100.0, + h: 50.0, + float: Default::default(), + ch_w: 100.0, + ch_h: 50.0, + children: vec![member(true, 0.0), member(false, 50.0)], + canvas: None, + }; + d.insert_object(&Pos::body(0, 0), group, &Default::default()).unwrap(); + let l = lay(&d); + let draws = flat(display::page_display(&d, &l.pages[0], &Default::default())); + assert_eq!(draws.iter().filter(|x| matches!(x, display::Draw::Shape { .. })).count(), 1, "{draws:?}"); +} + #[test] fn deleted_float_leaves_no_wrap_area_without_markup() { // A square-wrapped shape anchored at the start of the paragraph, its anchor track-deleted. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index caa73c915..0777606f6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (undrawn chart types show a placeholder, #998; previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: undrawn chart types show a placeholder, #998; previously 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; @@ -61,10 +61,10 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped | -| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416) | +| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | -| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611) | +| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611). Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Not read: vertical text (`layout-flow`) | | 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`); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). 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 | @@ -163,6 +163,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | VML text boxes keep their internal margins (`inset`), fit to their text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`); hidden drawings (`visibility:hidden`, `wp:docPr/@hidden`) are kept but not shown | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) |