Files
wordcraft/crates/docx/src/write/story/object.rs
T
41d46625e0 DOCX: VML horizontal rules take their line (#3956)
* DOCX: VML horizontal rules take their line

A web page's <hr> pasted into Word is a VML rule: `v:rect` with
`o:hr="t"`, often `width:0`. WordCraft took the width as written and
drew a 1 pt dot where Word draws a line across the page.

Measured on Word with a synthetic document: a rule with no width and
no `o:hrpct` spans the line inside the paragraph's indents; `o:hrpct`
is a share of it in tenths of a percent (500 = half); `o:hrpct="0"`
keeps the shape's own width; `o:hralign` places a shorter one (left,
centre, right); `fillcolor` and `stroked` apply as to any VML shape.
WordCraft now matches each case within 0.1 pt.

- wordcraft-doc: ShapeExtra::hr (HorizontalRule: share, alignment,
  Word's standard look).
- wordcraft-docx: `o:hr`, `o:hrpct`, `o:hralign`, `o:hrstd` read; a rule
  is written back as the same VML rule (DrawingML has none), and Word
  opens the re-saved file with the same lines.
- wordcraft-layout: a rule takes its whole line and is drawn at its
  share; measured for a table column it asks for no width, so it
  follows its column. (Word draws a rule in a cell at the page's text
  width, centred on the cell and past its edges; it stays in the cell
  here.)

docs/file-format-parity.md is updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Horizontal rules: only an inline, shown rule is one

Review: the rule arm of the writer ignored the shape's placement, so a
rule imported with position:absolute or visibility:hidden came back
inline and visible. Only a rule in the text and shown is read as a rule
now (a pasted <hr> is one); a positioned or hidden one stays a plain
shape, which keeps both when saved. The writer checks the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Horizontal rules: a see-through fill is kept; column measuring asks for the right width

Review: `write_hr` wrote only the fill's colour, so a rule with `v:fill
opacity` came back opaque after a save; the opacity now goes back as `v:fill`.

Review: measured for a table column, every rule asked for no width, so one of
its own width (`o:hrpct="0"`) could end up clipped by a narrow column; it now
asks for its width. AutoFit to contents measures on a line of its own width
that the rule took for a real line, so a rule sharing its line made the
column as wide as it could go; any measuring width now counts.

`Placed::Shape` boxes its extras: with the rule, `Placed` went past clippy's
large_enum_variant limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Horizontal rules: placed in what the line has left; rotation and flips kept on save

Review: a whole-line rule ignores a first-line indent or a line narrowed by a
float. Measured in Word, it does too: the width stays the share of the
paragraph's width and the rule runs past the margin, or under a float on its
right. What did differ was the placement: Word centres and right-aligns a rule
in what the line has left, with a first-line indent moving the end of that room
along, and a right-aligned rule beside a float was drawn off the page here. The
display now places it that way (`ParaLayout::rule_room`).

Review: a save dropped a rule's rotation and flips; they now go back in its
style, as Word writes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: VML horizontal-rule revision row at the top of the history

---------

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 10:55:20 -05:00

517 lines
25 KiB
Rust

use super::drawing::{DrawingInfo, shape_name, spin_attrs, write_fill, xfrm_spin_only};
use super::field::{FieldData, field_run};
use super::section::comment_reference_style;
use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr};
impl Writer<'_> {
pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
match o {
// Written by `para_content` (inline) or `blocks` (around paragraphs).
InlineObject::ControlStart { .. } | InlineObject::ControlEnd => {}
InlineObject::BookmarkStart { name } => {
let id = self.bookmark_id(name);
w.empty("w:bookmarkStart", &[("w:id", &id), ("w:name", name)]);
}
InlineObject::BookmarkEnd { name } => {
let id = self.bookmark_id(name);
w.empty("w:bookmarkEnd", &[("w:id", &id)]);
}
InlineObject::PermStart { id, group, editor } => {
let id = id.to_string();
let mut a = vec![("w:id", id.as_str())];
if !group.is_empty() {
a.push(("w:edGrp", group.as_str()));
}
if !editor.is_empty() {
a.push(("w:ed", editor.as_str()));
}
w.empty("w:permStart", &a);
}
InlineObject::PermEnd { id } => w.empty("w:permEnd", &[("w:id", &id.to_string())]),
InlineObject::CommentStart { id } => {
if self.doc.comments.contains_key(id) {
w.empty("w:commentRangeStart", &[("w:id", &id.to_string())]);
}
}
InlineObject::CommentEnd { id } => {
if self.doc.comments.contains_key(id) {
let s = id.to_string();
w.empty("w:commentRangeEnd", &[("w:id", &s)]);
w.open("w:r", &[]);
w.open("w:rPr", &[]);
w.val("w:rStyle", comment_reference_style(self.doc));
w.close("w:rPr");
w.empty("w:commentReference", &[("w:id", &s)]);
w.close("w:r");
}
}
InlineObject::Equation { linear, display, math } => {
// Automatic equation numbers become text: Word numbers nothing by itself.
let color = match props.color {
Some(TextColor::Rgb(c)) => Some(c),
_ => None,
};
match crate::write::math::resolve_numbers(math, *display, &mut self.eq_number) {
Some(m) => crate::write::math::write_equation(w, linear, *display, &m, color),
None => crate::write::math::write_equation(w, linear, *display, math, color),
}
}
InlineObject::Field { instr, result, locked, dirty, form, .. } => {
self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props)
}
InlineObject::FieldStart { instr, locked, dirty } => {
self.rev_open(w, props);
self.field_start(w, instr, *locked, *dirty, "", props);
self.rev_close(w, props);
}
InlineObject::FieldEnd => {
self.rev_open(w, props);
field_run(w, props, &|w| w.empty("w:fldChar", &[("w:fldCharType", "end")]));
self.rev_close(w, props);
}
InlineObject::NoteRef { kind, id, custom } => {
let foot = *kind == NoteKind::Footnote;
if self.current_note == Some((foot, *id)) {
// The note's own number is the w:footnoteRef / w:endnoteRef mark, written once
// per note: here for its first reference to itself, never as a reference.
if let Some(mark) = self.pending_mark.take() {
let mut p = props.clone();
if p.style.is_none() && p.vert_align.is_none() {
p.vert_align = Some(wordcraft_doc::props::VertAlign::Superscript);
}
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, &p);
// A custom mark is plain text; the placeholder would turn it into a number.
if custom.is_empty() {
w.empty(mark, &[]);
} else {
w.leaf("w:t", &[("xml:space", "preserve")], custom);
}
w.close("w:r");
self.rev_close(w, props);
}
return;
}
let nid = self.note_id(*id, foot);
let mut p = props.clone();
if p.style.is_none() && p.vert_align.is_none() {
p.vert_align = Some(wordcraft_doc::props::VertAlign::Superscript);
}
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, &p);
let tag = if foot { "w:footnoteReference" } else { "w:endnoteReference" };
if custom.is_empty() {
w.empty(tag, &[("w:id", &nid)]);
} else {
w.empty(tag, &[("w:customMarkFollows", "1"), ("w:id", &nid)]);
w.leaf("w:t", &[("xml:space", "preserve")], custom);
}
w.close("w:r");
self.rev_close(w, props);
}
// Editable charts and SmartArt keep main's model writer; opaque graphics and native
// ink keep their original source and related parts.
InlineObject::Graphic { .. } => self.write_graphic(w, o, props, rels, depth),
InlineObject::Image { .. } => self.write_image(w, o, props, rels, depth),
InlineObject::Shape { extra, float, .. } if extra.hr.is_some() && float.wrap == Wrap::Inline && !float.hidden => {
self.write_hr(w, o, props)
}
InlineObject::Shape { .. } => self.write_shape(w, o, props, rels, depth),
InlineObject::Group { .. } => self.write_group(w, o, props, rels, depth),
InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => {
// ECMA-376 §17.3.3.25: a run holding w:ruby (properties, ruby text, base text).
let t = if self.is_del(props) { "w:delText" } else { "w:t" };
let fin = |v: f32, d: f32| if v.is_finite() { v } else { d };
let hps = |v: f32, min: f32| n(wordcraft_geom::to_half_points(v.clamp(min, 1584.0)));
let size = fin(*size, 5.0).clamp(1.0, 1584.0);
let base_size = if base_size.is_finite() && *base_size > 0.0 { *base_size } else { props.size.unwrap_or(10.5) };
self.rev_open(w, props);
w.open("w:r", &[]);
rpr(w, props);
w.open("w:ruby", &[]);
w.open("w:rubyPr", &[]);
w.val("w:rubyAlign", align.ooxml());
w.val("w:hps", &hps(size, 1.0));
w.val("w:hpsRaise", &hps(fin(*raise, 0.0), 0.0));
w.val("w:hpsBaseText", &hps(fin(base_size, 10.5), 1.0));
w.val("w:lid", if lang.is_empty() { "ja-JP" } else { lang });
w.close("w:rubyPr");
let mut rt_props = props.clone().overlaid(ruby_props);
rt_props.size = Some(size);
for (tag, p, text) in [("w:rt", &rt_props, ruby), ("w:rubyBase", props, base)] {
w.open(tag, &[]);
w.open("w:r", &[]);
rpr(w, p);
w.leaf(t, &[("xml:space", "preserve")], text);
w.close("w:r");
w.close(tag);
}
w.close("w:ruby");
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::PositionalTab { align, relative_to, leader } => {
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.empty(
"w:ptab",
&[("w:relativeTo", relative_to.ooxml()), ("w:alignment", align.ooxml()), ("w:leader", crate::write::props::tab_leader(*leader))],
);
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Opaque { format, xml, text } => {
if format == "docx" && crate::xml::parse(xml.as_bytes()).is_ok() {
w.raw(xml);
} else if !text.is_empty() {
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.leaf("w:t", &[("xml:space", "preserve")], text);
w.close("w:r");
self.rev_close(w, props);
}
}
}
}
fn write_graphic(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
let InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } = o else { 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(),
// Ink in a group member's form (no drawing of its own) can't stand alone: left
// out before its parts are queued, so no orphan part or relationship is written.
(None, None, Some(src)) if ink && !standalone_ink(src) => {
log::warn!("docx: group-member ink outside its group can't be written; it is left out");
None
}
(None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))),
(None, None, None) => None,
};
let Some(mut root) = root else { return };
let direct_ink = ink && root.name == "w:contentPart";
if ink && !direct_ink {
Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0);
}
// Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture.
let alternate = (root.name == "mc:AlternateContent").then_some(&root);
let fallback = alternate.and_then(|e| e.child("mc:Fallback"));
let choice = alternate.and_then(|e| e.child("mc:Choice"));
let native = choice.and_then(|e| e.find("a:graphic"));
if alternate.is_some() && native.is_none() {
return;
}
// A chart's picture gets the chart's current frame too; ink's VML preview is resized in place.
let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
if direct_ink {
w.raw(&root.to_xml());
} else {
if fallback.is_some() {
let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() };
w.open("mc:AlternateContent", &[]);
w.open("mc:Choice", &[("Requires", requires)]);
}
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}"),
};
let z = self.z;
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
if let Some(fallback) = fallback {
w.close("mc:Choice");
match picture {
Some(picture) => {
// Both branches are one object: same id and stacking order.
self.z = z;
w.open("mc:Fallback", &[]);
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
w.close("mc:Fallback");
}
None => w.raw(&fallback.to_xml()),
}
w.close("mc:AlternateContent");
}
}
w.close("w:r");
self.rev_close(w, props);
}
fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return };
// An OLE object read from a file: the object itself, at its current size.
if let Some(src) = ole.as_deref()
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
{
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.raw(&obj);
w.close("w:r");
self.rev_close(w, props);
return;
}
let Some(file) = self.media_files.get(media).cloned() else { return };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("Picture {docpr}");
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
self.pic(w, o, &file, (0.0, 0.0), rels);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
/// A horizontal rule goes back as the VML rule it was read from: DrawingML has none, and a
/// fixed-size shape would lose its line-wide width.
fn write_hr(&mut self, w: &mut W, o: &InlineObject, props: &CharProps) {
let InlineObject::Shape { w: sw, h: sh, fill, stroke, stroke_width, extra, float, .. } = o else { return };
let Some(hr) = extra.hr else { return };
let align = match hr.align {
wordcraft_doc::props::Align::Center => "center",
wordcraft_doc::props::Align::Right => "right",
_ => "left",
};
let width = if hr.pct > 0.0 { "0".to_string() } else { format!("{:.2}pt", sw.max(0.0)) };
let mut style = format!("width:{width};height:{:.2}pt", sh.max(0.0));
let spin = float.spin();
if spin.deg != 0.0 {
style.push_str(&format!(";rotation:{}", spin.deg));
}
match (spin.flip_h, spin.flip_v) {
(true, true) => style.push_str(";flip:x y"),
(true, false) => style.push_str(";flip:x"),
(false, true) => style.push_str(";flip:y"),
(false, false) => {}
}
let pct = format!("{}", (hr.pct * 10.0).round().max(0.0) as u32);
let fill_hex = fill.as_ref().map(|f| format!("#{}", f.main_color().hex()));
let stroke_hex = stroke.map(|c| format!("#{}", c.hex()));
let weight = format!("{stroke_width:.2}pt");
let mut attrs: Vec<(&str, &str)> = vec![
("xmlns:v", "urn:schemas-microsoft-com:vml"),
("xmlns:o", "urn:schemas-microsoft-com:office:office"),
("style", &style),
("o:hrpct", &pct),
("o:hralign", align),
("o:hr", "t"),
];
if hr.standard {
attrs.push(("o:hrstd", "t"));
}
if let Some(f) = &fill_hex {
attrs.push(("fillcolor", f));
} else {
attrs.push(("filled", "f"));
}
match &stroke_hex {
Some(s) => {
attrs.push(("strokecolor", s));
attrs.push(("strokeweight", &weight));
}
None => attrs.push(("stroked", "f")),
}
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.open("w:pict", &[]);
// A see-through fill keeps its opacity, which `fillcolor` can't say.
match fill.as_ref() {
Some(wordcraft_doc::fill::Fill::Solid { transparency, .. }) if *transparency > 0.0 => {
let opacity = format!("{:.4}", (1.0 - transparency / 100.0).clamp(0.0, 1.0));
w.open("v:rect", &attrs);
w.empty("v:fill", &[("opacity", &opacity)]);
w.close("v:rect");
}
_ => w.empty("v:rect", &attrs),
}
w.close("w:pict");
w.close("w:r");
self.rev_close(w, props);
}
fn write_shape(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
let InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } = o else { return };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.shape_docpr(extra.id);
// Ink is told apart by its name (and its pen), which reading looks for.
let name = shape_name(*kind, freeform.as_deref(), &docpr);
// The effect extent leaves room for the shadow and glow.
let mut float = float.clone();
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
*e = wordcraft_geom::finite(*e).max(fx);
}
// Tight and Through wrapping need a polygon: the shape's outline unless it has one.
if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() {
float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner)
.and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts))
.map(std::sync::Arc::new);
}
let float = &float;
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
self.wsp(w, o, None, (0.0, 0.0), rels, depth);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
fn write_group(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
let InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } = o else { return };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
let outer = match canvas {
Some(c) => {
// A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then
// its members, at offsets in the canvas's own (unscaled) space.
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]);
w.open("wpc:wpc", &[]);
w.open("wpc:bg", &[]);
write_fill(w, c.fill.as_ref());
w.close("wpc:bg");
w.open("wpc:whole", &[]);
match c.stroke {
Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]),
None => w.open("a:ln", &[]),
}
write_fill(w, c.stroke.map(Fill::solid).as_ref());
w.close("a:ln");
w.close("wpc:whole");
"wpc:wpc"
}
None => {
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]);
// ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt`
// maps onto the group's `a:off`/`a:ext`.
w.open("wpg:wgp", &[]);
w.empty("wpg:cNvGrpSpPr", &[]);
w.open("wpg:grpSpPr", &[]);
w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
w.empty("a:off", &[("x", "0"), ("y", "0")]);
w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]);
w.empty("a:chOff", &[("x", "0"), ("y", "0")]);
w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]);
w.close("a:xfrm");
w.close("wpg:grpSpPr");
"wpg:wgp"
}
};
for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) {
let at = (c.x, c.y);
match &c.obj {
InlineObject::Image { media, .. } => {
if let Some(file) = self.media_files.get(media).cloned() {
self.pic(w, &c.obj, &file, at, rels);
}
}
InlineObject::Shape { extra, .. } => {
let id = self.shape_docpr(extra.id);
self.wsp(w, &c.obj, Some(&id), at, rels, depth);
}
// Native ink kept from the file, with its fallback, at the member's place.
InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => {
if let Some(src) = graphic.source.as_deref()
&& let Some(mut root) = self.embedded_tree(src, rels, None)
{
Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0);
w.raw(&root.to_xml());
}
}
_ => {}
}
}
if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() {
// The canvas schema has no transform of its own: keep its turn in our
// extension (Word draws it unturned, as it can't turn a canvas).
w.open("wpc:extLst", &[]);
w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]);
xfrm_spin_only(w, float.spin());
w.close("a:ext");
w.close("wpc:extLst");
}
w.close(outer);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
}
pub(super) fn ink_name(tool: wordcraft_doc::freeform::InkTool) -> &'static str {
match tool {
wordcraft_doc::freeform::InkTool::Pen => "Pen",
wordcraft_doc::freeform::InkTool::Pencil => "Pencil",
wordcraft_doc::freeform::InkTool::Highlighter => "Highlighter",
}
}
/// Is kept ink `src` in a form the drawing-level writer writes: its `a:graphic`, a direct run-level
/// `w:contentPart`, or an `mc:AlternateContent` whose choice holds a drawing (not a group member's
/// bare `w14:contentPart`, nor one in `mc:AlternateContent` with a `pic:pic` fallback)?
fn standalone_ink(src: &wordcraft_doc::graphic::Embedded) -> bool {
match crate::xml::parse(src.xml.as_bytes()) {
Ok(root) => match root.name.as_str() {
"a:graphic" | "w:contentPart" => true,
"mc:AlternateContent" => root.child("mc:Choice").and_then(|c| c.child("w:drawing")).is_some_and(|d| d.find("a:graphic").is_some()),
_ => false,
},
Err(_) => false,
}
}
#[cfg(test)]
mod standalone_ink_tests {
use super::standalone_ink;
use wordcraft_doc::graphic::Embedded;
fn emb(xml: &str) -> Embedded {
Embedded { xml: xml.into(), ..Default::default() }
}
/// Review: an alternate whose choice has a drawing but no `a:graphic` is not writable as ink.
#[test]
fn alternate_content_needs_a_graphic() {
let ns = r#"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
let with =
format!("<mc:AlternateContent {ns}><mc:Choice Requires=\"w14\"><w:drawing><a:graphic/></w:drawing></mc:Choice></mc:AlternateContent>");
let without = format!("<mc:AlternateContent {ns}><mc:Choice Requires=\"w14\"><w:drawing/></mc:Choice></mc:AlternateContent>");
assert!(standalone_ink(&emb(&with)));
assert!(!standalone_ink(&emb(&without)));
}
}