Files
wordcraft/crates/docx/src/write/embed.rs
T
Martin BallandClaude Opus 5.5 cd683a9fd1 #3872: review — privacy rewrite keeps attribute values as written, escaping only quotes
Unescaping and escaping again turned 
 and 	 into raw characters that read
back as spaces. The raw value of well-formed markup holds no `<` and every `&` starts
a reference, so only a `"` from a single-quoted value is replaced with &quot;.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-27 09:51:52 +00:00

927 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
//! name is one this writer uses for something else: then it's renamed). An object written again
//! (copied and pasted in WordCraft) gets its own renamed copies of every part, so the copies stay
//! independent in Word; past [`MAX_COPY_BYTES`] of such copies, later ones share the first's parts.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use wordcraft_doc::graphic::Embedded;
use wordcraft_doc::para::Float;
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::{MAX_LEN_PT, emu, int};
use crate::xml::{self, El, MAX_DEPTH};
/// Most bytes written for copies of parts (objects pasted more than once).
const MAX_COPY_BYTES: u64 = 256 * 1024 * 1024;
/// Most bytes of kept markup written for all objects (objects can share one kept markup).
const MAX_MARKUP_BYTES: u64 = 128 * 1024 * 1024;
/// What one part copy costs at least against [`MAX_COPY_BYTES`] (its relationships, entry…).
const PART_COST: u64 = 4096;
/// Where the stories' parts live: every story this writer makes is a part in `word/`.
const STORY_PART: &str = "word/document.xml";
/// The embedded-object parts of a package being written.
pub(crate) struct EmbedWriter<'d> {
manifest: EmbeddedManifest,
passthrough: &'d BTreeMap<String, Arc<Vec<u8>>>,
/// Part names in use (lower case): this writer's own, and parts written so far.
taken: HashSet<String>,
/// Next suffix to try when renaming a part.
next: HashMap<String, u32>,
/// Original part name → name written, for parts written by an object's first copy.
first: HashMap<String, String>,
/// Budget left for copies.
copy_bytes: u64,
/// Budget left for kept markup.
markup_bytes: u64,
/// Parts to write: (name, bytes, content type, relationships).
pub parts: Vec<(String, Vec<u8>, String, PartRels)>,
/// Leave out the main document's kept glossary (it's written from the model instead).
pub drop_glossary: bool,
/// Kept parts written with these bytes instead of their own (see [`Self::scrub_glossary`]).
scrubbed: HashMap<String, Vec<u8>>,
/// A chart past the chart limits has been written (and the loss logged, once per save).
chart_cut: bool,
}
impl<'d> EmbedWriter<'d> {
pub fn new(doc: &'d wordcraft_doc::Document) -> EmbedWriter<'d> {
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
let mut taken: HashSet<String> = [
"word/document.xml",
"word/styles.xml",
"word/numbering.xml",
"word/settings.xml",
"word/theme/theme1.xml",
"word/footnotes.xml",
"word/endnotes.xml",
"word/comments.xml",
"word/commentsextended.xml",
"word/commentsids.xml",
"word/commentsextensible.xml",
"[content_types].xml",
]
.iter()
.map(ToString::to_string)
.collect();
taken.insert(VBA_PROJECT_PART.to_ascii_lowercase());
// The bibliography writer uses these names when sources are present (#532).
if !doc.sources.is_empty() {
taken.extend(["customxml/item1.xml".into(), "customxml/itemprops1.xml".into()]);
}
// The macro project's parts keep their names (see `write_as`).
if let Some(m) = doc.passthrough.get(VBA_RELATED) {
for line in String::from_utf8_lossy(m).lines() {
if let Some(path) = line.split('\t').nth(1) {
taken.insert(path.to_ascii_lowercase());
}
}
}
EmbedWriter {
manifest,
passthrough: &doc.passthrough,
taken,
next: HashMap::new(),
first: HashMap::new(),
copy_bytes: MAX_COPY_BYTES,
markup_bytes: MAX_MARKUP_BYTES,
parts: Vec::new(),
drop_glossary: false,
scrubbed: HashMap::new(),
chart_cut: false,
}
}
/// Write the font table kept from a file, with the fonts it embeds, relating it from `rels`.
pub fn font_table(&mut self, rels: &mut PartRels) {
let mut written = Vec::new();
let Some(root) = crate::package::font_table_key(self.passthrough).map(str::to_string) else { return };
if let Some(name) = self.copy_tree(&root, &mut HashMap::new(), &mut written) {
rels.add(rt::FONT_TABLE, &relative(STORY_PART, &name), false);
}
}
/// Reserve the names of the media files this writer writes.
pub fn reserve_media<'a>(&mut self, files: impl Iterator<Item = &'a String>) {
for f in files {
self.taken.insert(format!("word/media/{f}").to_ascii_lowercase());
}
}
/// Names this writer may give its own parts later (headers, footers, package parts).
fn reserved(lower: &str) -> bool {
lower.ends_with(".rels")
|| (lower.starts_with("docprops/") && !lower.starts_with("docprops/thumbnail."))
|| lower.starts_with("_rels/")
|| ((lower.starts_with("word/header") || lower.starts_with("word/footer")) && lower.ends_with(".xml"))
}
/// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`.
fn alloc(&mut self, orig: &str) -> String {
let clean = valid_part_name(orig);
let orig = clean.as_str();
// Suffixes cannot escape reserved directories or a .rels extension. Such a kept
// part needs a different base name, otherwise every candidate would be reserved.
let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig };
let lower = orig.to_ascii_lowercase();
if !Self::reserved(&lower) && self.taken.insert(lower) {
return orig.to_string();
}
let file_at = orig.rfind('/').map_or(0, |i| i + 1);
let (stem, ext) = match orig.rfind('.').filter(|i| *i > file_at) {
Some(i) => (orig.get(..i).unwrap_or(orig), orig.get(i..).unwrap_or("")),
None => (orig, ""),
};
loop {
let n = self.next.entry(orig.to_string()).or_insert(1);
*n = n.saturating_add(1);
let name = format!("{stem}_{n}{ext}");
let lower = name.to_ascii_lowercase();
if !Self::reserved(&lower) && self.taken.insert(lower) {
return name;
}
}
}
/// A number `n` for which every `{stem}{n}{ext}` is a free part name, with those names taken.
pub(super) fn fresh_set(&mut self, stems: &[&str], ext: &str) -> Vec<String> {
let mut n: u32 = 1;
loop {
let names: Vec<String> = stems.iter().map(|s| format!("{s}{n}{ext}")).collect();
if names.iter().all(|name| {
let lower = name.to_ascii_lowercase();
!Self::reserved(&lower) && !self.taken.contains(&lower)
}) {
for name in &names {
self.taken.insert(name.to_ascii_lowercase());
}
return names;
}
n = n.saturating_add(1);
}
}
/// A free part name `{stem}{n}{ext}` for a new part, from n = 1 up.
fn fresh(&mut self, stem: &str, ext: &str) -> String {
let mut n: u32 = 1;
loop {
let name = format!("{stem}{n}{ext}");
let lower = name.to_ascii_lowercase();
if !Self::reserved(&lower) && self.taken.insert(lower) {
return name;
}
n = n.saturating_add(1);
}
}
/// Can part `name` be written (bytes and manifest entry both kept)?
fn available(&self, name: &str) -> bool {
self.manifest.parts.contains_key(name) && self.passthrough.contains_key(name)
}
/// Reattach implicit custom XML roots, sharing their kept parts across both owners.
pub fn custom_xml_rels(&mut self, owner: &str, rels: &mut PartRels) {
let Some(roots) = self.passthrough.get(CUSTOM_XML_ROOTS).cloned() else { return };
let mut memo = std::mem::take(&mut self.first);
let mut written = Vec::new();
for line in String::from_utf8_lossy(&roots).lines().take(MAX_EMBEDDED_RELS * 2) {
let fields: Vec<&str> = line.split('\t').collect();
let [source, kind, target] = fields.as_slice() else { continue };
if *source != owner || !rel_is(kind, rt::CUSTOM_XML) {
continue;
}
if let Some(name) = self.copy_tree(target, &mut memo, &mut written) {
let source_part = if owner == "P" { "" } else { STORY_PART };
rels.add(kind, &relative(source_part, &name), false);
}
}
self.first = memo;
}
/// Reattach the parts kept by `keep_unmodelled` (`UNMODELLED_ROOTS`) to their owner: the
/// package (`P`) or the main document (`D`).
pub fn unmodelled_rels(&mut self, owner: &str, rels: &mut PartRels) {
let Some(roots) = self.passthrough.get(crate::package::UNMODELLED_ROOTS).cloned() else { return };
let mut memo = std::mem::take(&mut self.first);
let mut written = Vec::new();
for line in String::from_utf8_lossy(&roots).lines().take(MAX_EMBEDDED_RELS * 2) {
let fields: Vec<&str> = line.split('\t').collect();
let [source, kind, target] = fields.as_slice() else { continue };
if *source != owner || (self.drop_glossary && owner == "D" && rel_is(kind, rt::GLOSSARY)) {
continue;
}
if let Some(name) = self.copy_tree(target, &mut memo, &mut written) {
let source_part = if owner == "P" { "" } else { STORY_PART };
rels.add(kind, &relative(source_part, &name), false);
}
}
self.first = memo;
}
/// Whether `unmodelled_rels("D", …)` can write the main document's kept glossary back.
pub fn kept_glossary(&self) -> bool {
let Some(roots) = self.passthrough.get(crate::package::UNMODELLED_ROOTS) else { return false };
String::from_utf8_lossy(roots).lines().take(MAX_EMBEDDED_RELS * 2).any(|line| {
let fields: Vec<&str> = line.split('\t').collect();
matches!(fields.as_slice(), [source, kind, target] if *source == "D" && rel_is(kind, rt::GLOSSARY) && (self.first.contains_key(*target) || self.available(target)))
})
}
/// Have the main document's kept glossary, and the parts it reaches, written without the
/// personal information (`names`) and dates the privacy settings remove (#3692). False when a
/// part's markup can't be rewritten: the glossary is then written from the model.
pub fn scrub_glossary(&mut self, names: bool, dates: bool) -> bool {
let Some(roots) = self.passthrough.get(crate::package::UNMODELLED_ROOTS) else { return true };
let mut todo: Vec<String> = String::from_utf8_lossy(roots)
.lines()
.take(MAX_EMBEDDED_RELS * 2)
.filter_map(|line| match line.split('\t').collect::<Vec<_>>().as_slice() {
[source, kind, target] if *source == "D" && rel_is(kind, rt::GLOSSARY) => Some(target.to_string()),
_ => None,
})
.collect();
let mut seen: HashSet<String> = HashSet::new();
while let Some(p) = todo.pop() {
if seen.len() >= MAX_EMBEDDED_PARTS || !seen.insert(p.clone()) {
continue;
}
let (Some(info), Some(bytes)) = (self.manifest.parts.get(&p), self.passthrough.get(&p)) else { continue };
todo.extend(info.rels.iter().filter(|r| !r.external).map(|r| r.target.clone()));
if !(info.content_type.ends_with("xml") || p.to_ascii_lowercase().ends_with(".xml")) {
continue;
}
match scrub_private(bytes, names, dates) {
Some(b) => {
self.scrubbed.insert(p, b);
}
None => {
log::warn!(
"docx: the glossary's part {p} can't be cleaned of personal information; the glossary is written from its entries, without what WordCraft doesn't model"
);
return false;
}
}
}
true
}
/// What copying the parts reached from `roots` costs against the copy budget.
fn tree_cost(&self, roots: &[&str]) -> u64 {
let mut seen: HashSet<&str> = HashSet::new();
let mut todo: Vec<&str> = roots.to_vec();
let mut cost = 0u64;
while let Some(p) = todo.pop() {
if !seen.insert(p) || seen.len() > MAX_EMBEDDED_PARTS {
continue;
}
cost = cost.saturating_add(self.passthrough.get(p).map_or(0, |b| b.len() as u64)).saturating_add(PART_COST);
if let Some(info) = self.manifest.parts.get(p) {
todo.extend(info.rels.iter().filter(|r| !r.external).map(|r| r.target.as_str()));
}
}
cost
}
/// Write part `root` and the parts it reaches, naming them through `memo` (original → written
/// name; parts already in it aren't written again). Returns `root`'s written name and adds
/// the indices of the parts written to `written`.
fn copy_tree(&mut self, root: &str, memo: &mut HashMap<String, String>, written: &mut Vec<usize>) -> Option<String> {
if let Some(n) = memo.get(root) {
return Some(n.clone());
}
if !self.available(root) {
return None;
}
let name = self.alloc(root);
memo.insert(root.to_string(), name.clone());
let mut todo = vec![root.to_string()];
while let Some(orig) = todo.pop() {
let (Some(info), Some(bytes)) = (self.manifest.parts.get(&orig).cloned(), self.passthrough.get(&orig)) else { continue };
let Some(new) = memo.get(&orig).cloned() else { continue };
let mut rels = PartRels::default();
for r in &info.rels {
if r.external {
rels.add_with_id(&r.id, &r.kind, &r.target, true);
continue;
}
let target = match memo.get(&r.target) {
Some(t) => t.clone(),
None if self.available(&r.target) => {
let t = self.alloc(&r.target);
memo.insert(r.target.clone(), t.clone());
todo.push(r.target.clone());
t
}
// A part that wasn't kept: leave the relationship out.
None => continue,
};
rels.add_with_id(&r.id, &r.kind, &relative(&new, &target), false);
}
let ct = if info.content_type.is_empty() { "application/octet-stream".to_string() } else { info.content_type };
let bytes = self.scrubbed.get(&orig).cloned().unwrap_or_else(|| bytes.to_vec());
written.push(self.parts.len());
self.parts.push((new, bytes, ct, rels));
}
Some(name)
}
}
impl super::Writer<'_> {
/// The markup for an object kept from a file, with its relationships added to `rels` (the
/// story part's) and the parts it needs queued for writing. `resize`, for an OLE object, writes
/// 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;
};
self.embeds.markup_bytes = left;
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
let mut used = Vec::new();
rel_attr_values(&root, &mut used, 0);
// Every relationship the markup uses must be writable, or the object would be broken.
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
if !used.iter().all(writable) {
log::warn!("docx: an embedded object's parts are missing; it is left out");
return None;
}
let roots: Vec<&str> = src.refs.iter().filter(|r| !r.external).map(|r| r.target.as_str()).collect();
// Written before (a copy of this object): give this one its own parts while the budget lasts.
let again = roots.iter().any(|r| self.embeds.first.contains_key(*r));
let cost = if again { self.embeds.tree_cost(&roots) } else { 0 };
let fresh = again && cost <= self.embeds.copy_bytes;
if fresh {
self.embeds.copy_bytes -= cost;
}
let mut memo = if fresh { HashMap::new() } else { std::mem::take(&mut self.embeds.first) };
let mut written = Vec::new();
let mut ids: HashMap<String, String> = HashMap::new();
for r in &src.refs {
let new_id = if r.external {
Some(rels.add(&r.kind, &r.target, true))
} else {
self.embeds.copy_tree(&r.target, &mut memo, &mut written).map(|name| rels.add(&r.kind, &relative(STORY_PART, &name), false))
};
if let Some(id) = new_id {
ids.insert(r.id.clone(), id);
}
}
if !fresh {
self.embeds.first = memo;
}
if used.iter().any(|u| !ids.contains_key(u)) {
log::warn!("docx: an embedded object lost a part; it is left out");
return None;
}
// A SmartArt data part names its drawing's relationship (of the story part) by id.
for i in written {
if let Some((_, bytes, ct, _)) = self.embeds.parts.get_mut(i)
&& ct.contains("diagramData")
{
*bytes = patch_rel_ids(bytes, &ids);
}
}
set_rel_ids(&mut root, &ids, 0);
if let Some((w, h, float, alt)) = resize {
self.resize_object(&mut root, w, h, float, alt, 0);
}
Some(root)
}
/// The `a:graphic` of a SmartArt graphic written from its model, laid out `w` × `h` points:
/// new diagram parts (`word/diagrams/{data,layout,quickStyle,colors,drawing}N.xml`) related
/// from the story part through `rels`.
pub(super) fn smart_art_graphic(&mut self, spec: &wordcraft_doc::smart_art::SmartArtSpec, w: f32, h: f32, rels: &mut PartRels) -> String {
use crate::package::rt;
use crate::smart_art as sa;
let names = self.embeds.fresh_set(
&["word/diagrams/data", "word/diagrams/layout", "word/diagrams/quickStyle", "word/diagrams/colors", "word/diagrams/drawing"],
".xml",
);
let [data, layout, style, colors, drawing] = names.as_slice() else { return String::new() };
// The drawing first: the data part names it by the story part's relationship id.
let drawing_id = rels.add(rt::DIAGRAM_DRAWING, &relative(STORY_PART, drawing), false);
let parts = [
(data, sa::data_xml(spec, &drawing_id), sa::DATA_CT, rt::DIAGRAM_DATA),
(layout, sa::layout_xml(spec.layout), sa::LAYOUT_CT, rt::DIAGRAM_LAYOUT),
(style, sa::quick_style_xml(), sa::STYLE_CT, rt::DIAGRAM_QUICK_STYLE),
(colors, sa::colors_xml(spec.colors), sa::COLORS_CT, rt::DIAGRAM_COLORS),
(drawing, sa::drawing_xml(spec, w, h), sa::DRAWING_CT, rt::DIAGRAM_DRAWING),
];
let mut ids = Vec::new();
for (name, xml, ct, kind) in parts {
self.embeds.parts.push((name.clone(), xml.into_bytes(), ct.to_string(), PartRels::default()));
ids.push(rels.add(kind, &relative(STORY_PART, name), false));
}
let id = |i: usize| ids.get(i).cloned().unwrap_or_default();
let mut x = xml::W::default();
x.open("a:graphic", &[("xmlns:a", "http://schemas.openxmlformats.org/drawingml/2006/main")]);
x.open("a:graphicData", &[("uri", sa::DIAGRAM_URI)]);
x.empty(
"dgm:relIds",
&[
("xmlns:dgm", "http://schemas.openxmlformats.org/drawingml/2006/diagram"),
("xmlns:r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"),
("r:dm", &id(0)),
("r:lo", &id(1)),
("r:qs", &id(2)),
("r:cs", &id(3)),
],
);
x.close("a:graphicData");
x.close("a:graphic");
x.s
}
/// The `a:graphic` of a chart written from its model: a new chart part (`word/charts/chartN.xml`)
/// related from the story part through `rels`.
pub(super) fn chart_graphic(&mut self, spec: &wordcraft_doc::chart::ChartSpec, rels: &mut PartRels) -> String {
let name = self.embeds.fresh("word/charts/chart", ".xml");
let (xml, cut) = crate::chart_spec::chart_part(spec);
if cut && !self.embeds.chart_cut {
self.embeds.chart_cut = true;
log::warn!("docx: a chart has more series or points than a chart part holds; the rest are left out of the file");
}
let bytes = xml.into_bytes();
self.embeds.parts.push((name.clone(), bytes, crate::chart_spec::CHART_CT.to_string(), PartRels::default()));
let id = rels.add(crate::package::rt::CHART, &relative(STORY_PART, &name), false);
let mut w = xml::W::default();
w.open("a:graphic", &[("xmlns:a", "http://schemas.openxmlformats.org/drawingml/2006/main")]);
w.open("a:graphicData", &[("uri", crate::chart_spec::CHART_URI)]);
w.empty(
"c:chart",
&[
("xmlns:c", "http://schemas.openxmlformats.org/drawingml/2006/chart"),
("xmlns:r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"),
("r:id", &id),
],
);
w.close("a:graphicData");
w.close("a:graphic");
w.s
}
/// Write an OLE object's size (and position, when floating), rotation and flips into its VML
/// shape and DrawingML picture, with the effect extent covering the rotated bounds as for
/// other drawings, its alt text into both, and give its drawing a fresh `wp:docPr` id.
fn resize_object(&mut self, e: &mut El, w: f32, h: f32, float: &Float, alt: &str, depth: usize) {
if depth > MAX_DEPTH {
return;
}
match e.name.as_str() {
"v:shape" => {
if let Some((_, style)) = e.attrs.iter_mut().find(|(k, _)| k == "style") {
*style = vml_style_with(style, w, h, float);
}
set_attr(e, "alt", alt);
}
"wp:extent" => set_size_attrs(e, w, h),
// A turned object needs an effect extent (filled in below) right after its extent.
"wp:inline" | "wp:anchor" if !e.els().any(|c| c.name == "wp:effectExtent") && float.spin().deg != 0.0 => {
if let Some(at) = e.kids.iter().position(|k| matches!(k, xml::Node::El(c) if c.name == "wp:extent")) {
e.kids.insert(at + 1, xml::Node::El(El { name: "wp:effectExtent".to_string(), attrs: Vec::new(), kids: Vec::new() }));
}
}
"wp:effectExtent" => {
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
let (px, py) = float.spin_pad(w, h);
let [el, et, er, eb] = float.effect_extent();
let ext = [el + px, et + py, er + px, eb + py].map(emu);
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b"));
for (k, v) in ["l", "t", "r", "b"].into_iter().zip(ext) {
e.attrs.push((k.to_string(), v));
}
}
// The picture's own frame (`pic:spPr`): its size and the object's rotation and flips.
"a: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));
}
for k in e.kids.iter_mut() {
if let xml::Node::El(c) = k
&& c.name == "a:ext"
{
set_size_attrs(c, w, h);
}
}
return;
}
"wp:docPr" => {
let id = self.next_docpr();
if let Some((_, v)) = e.attrs.iter_mut().find(|(k, _)| k == "id") {
*v = id;
}
set_attr(e, "descr", alt);
}
_ => {}
}
for k in e.kids.iter_mut() {
if let xml::Node::El(c) = k {
self.resize_object(c, w, h, float, alt, depth + 1);
}
}
}
/// 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);
}
}
/// Put a group or canvas member kept from a file (native ink and its picture fallback) at
/// `frame` ([x, y, w, h] in points, in the group's space, as the group is written): its
/// `w14:xfrm` and the fallback's `a:xfrm` get that offset and extent and the member's turn.
/// It rewrites every `w14:xfrm` and `a:xfrm` under `e` and doesn't descend into them: right
/// for the forms Word writes (the content part's own transform and one `pic:pic` fallback's
/// `pic:spPr/a:xfrm`); a fallback holding several transforms (a group) would get one frame.
pub(super) fn place_member(e: &mut El, frame: [f32; 4], float: &Float, depth: usize) {
if depth > MAX_DEPTH {
return;
}
if matches!(e.name.as_str(), "w14:xfrm" | "a: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 [x, y, w, h] = frame.map(|v| v.clamp(-MAX_LEN_PT, MAX_LEN_PT));
e.kids.retain(|k| !matches!(k, xml::Node::El(c) if matches!(c.name.as_str(), "a:off" | "a:ext")));
let el = |name: &str, attrs: [(&str, String); 2]| {
xml::Node::El(El { name: name.to_string(), attrs: attrs.map(|(k, v)| (k.to_string(), v)).to_vec(), kids: Vec::new() })
};
e.kids.insert(0, el("a:ext", [("cx", emu(w.max(0.0))), ("cy", emu(h.max(0.0)))]));
e.kids.insert(0, el("a:off", [("x", emu(x)), ("y", emu(y))]));
return;
}
for c in e.kids.iter_mut().filter_map(|k| match k {
xml::Node::El(c) => Some(c),
_ => None,
}) {
Self::place_member(c, frame, float, depth + 1);
}
}
}
/// Set an extent's `cx` and `cy` (those it has) to `w` × `h` points.
fn set_size_attrs(e: &mut El, w: f32, h: f32) {
for (k, v) in e.attrs.iter_mut() {
match k.as_str() {
"cx" => *v = emu(w.max(0.0)),
"cy" => *v = emu(h.max(0.0)),
_ => {}
}
}
}
/// Set attribute `k` of `e` to `v` (added when missing; left out when both are empty).
fn set_attr(e: &mut El, k: &str, v: &str) {
match e.attrs.iter_mut().find(|(n, _)| n == k) {
Some((_, old)) => *old = v.to_string(),
None if !v.is_empty() => e.attrs.push((k.to_string(), v.to_string())),
None => {}
}
}
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (k, v) in &e.attrs {
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
out.push(v.clone());
}
}
for c in e.els() {
rel_attr_values(c, out, depth + 1);
}
}
fn set_rel_ids(e: &mut El, ids: &HashMap<String, String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
let name = e.name.clone();
for (k, v) in e.attrs.iter_mut() {
if xml::is_rel_attr(&name, k)
&& let Some(n) = ids.get(v.as_str())
{
*v = n.clone();
}
}
for k in e.kids.iter_mut() {
if let xml::Node::El(c) = k {
set_rel_ids(c, ids, depth + 1);
}
}
}
/// `bytes` with each `relId="old"` naming a remapped relationship rewritten to its new id.
fn patch_rel_ids(bytes: &[u8], ids: &HashMap<String, String>) -> Vec<u8> {
const KEY: &[u8] = b"relId=\"";
let mut out = Vec::with_capacity(bytes.len());
let mut rest = bytes;
while let Some(at) = rest.windows(KEY.len()).position(|w| w == KEY) {
let (head, tail) = rest.split_at(at + KEY.len());
out.extend_from_slice(head);
let end = tail.iter().position(|b| *b == b'"').unwrap_or(tail.len());
let (old, after) = tail.split_at(end);
match std::str::from_utf8(old).ok().and_then(|o| ids.get(o)) {
Some(new) => out.extend_from_slice(new.as_bytes()),
None => out.extend_from_slice(old),
}
rest = after;
}
out.extend_from_slice(rest);
out
}
/// `bytes` (kept XML) without the personal information and dates the privacy settings remove, as
/// the writer leaves them out of the parts it writes: annotation authors become "Author" and
/// comment initials "A" (`names`); annotation dates and UTC dates go (`dates`); and with `names`,
/// a people part's authors become "Author" and their presence information goes. Attributes are
/// matched by namespace, so any prefix is caught. `None` when the markup can't be rewritten
/// (malformed, or not UTF-8).
fn scrub_private(bytes: &[u8], names: bool, dates: bool) -> Option<Vec<u8>> {
use std::borrow::Cow;
use quick_xml::events::Event;
use quick_xml::events::attributes::Attribute;
use quick_xml::name::{QName, ResolveResult};
use quick_xml::reader::NsReader;
use quick_xml::writer::Writer;
const WML: &[u8] = b"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const WML_STRICT: &[u8] = b"http://purl.oclc.org/ooxml/wordprocessingml/main";
const W15: &[u8] = b"http://schemas.microsoft.com/office/word/2012/wordml";
// UTF-16 (or other wide encodings) would pass through unread.
if bytes.contains(&0) || bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
return None;
}
let ns_is = |r: &ResolveResult, ns: &[u8]| matches!(r, ResolveResult::Bound(n) if n.as_ref() == ns);
let wml = |r: &ResolveResult| ns_is(r, WML) || ns_is(r, WML_STRICT);
let mut r = NsReader::from_reader(bytes);
r.config_mut().check_end_names = false;
let mut out = Writer::new(Vec::with_capacity(bytes.len()));
let mut buf = Vec::new();
let mut skip = Vec::new();
loop {
buf.clear();
let (res, ev) = r.read_resolved_event_into(&mut buf).ok()?;
let (in_wml, in_w15) = (wml(&res), ns_is(&res, W15));
let ev = match ev {
Event::Eof => break,
Event::Start(ref e) | Event::Empty(ref e) if names && in_w15 && e.local_name().as_ref() == b"presenceInfo" => {
if matches!(ev, Event::Start(_)) {
let end = e.name().as_ref().to_vec();
r.read_to_end_into(QName(&end), &mut skip).ok()?;
skip.clear();
}
continue;
}
Event::Start(ref e) | Event::Empty(ref e) => {
let mut el = e.to_owned();
el.clear_attributes();
for a in e.attributes().with_checks(false) {
let a = a.ok()?;
let (ares, local) = r.resolver_mut().resolve_attribute(a.key);
// An unprefixed attribute of a WordprocessingML element is read as its own.
let wml_attr = wml(&ares) || (matches!(ares, ResolveResult::Unbound) && in_wml);
let value: Option<&[u8]> = match local.as_ref() {
b"author" if names && (wml_attr || ns_is(&ares, W15)) => Some(super::ANONYMOUS_AUTHOR.as_bytes()),
b"initials" if names && wml_attr => Some(super::ANONYMOUS_INITIALS.as_bytes()),
b"date" if dates && wml_attr => None,
b"dateUtc" if dates => None,
_ => {
// `push_attribute` writes the value as given, inside `"`. In well-formed
// markup the raw value holds no `<` and every `&` starts a valid
// reference, so only a `"` from a single-quoted value needs escaping;
// references such as `&#10;` stay as written (#3872).
let raw = a.value.as_ref();
let value = if raw.contains(&b'"') {
let mut v = Vec::with_capacity(raw.len() + 8);
for &c in raw {
if c == b'"' {
v.extend_from_slice(b"&quot;");
} else {
v.push(c);
}
}
Cow::Owned(v)
} else {
Cow::Borrowed(raw)
};
el.push_attribute(Attribute { key: a.key, value });
continue;
}
};
if let Some(v) = value {
el.push_attribute(Attribute { key: a.key, value: Cow::Borrowed(v) });
}
}
if matches!(ev, Event::Start(_)) { Event::Start(el) } else { Event::Empty(el) }
}
ev => ev,
};
out.write_event(ev).ok()?;
}
Some(out.into_inner())
}
/// A VML `style` with the object's size, rotation and flips, and its offset when it floats
/// (`position:absolute`).
fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String {
let absolute = style
.split(';')
.filter_map(|d| d.split_once(':'))
.any(|(k, v)| k.trim().eq_ignore_ascii_case("position") && v.trim().eq_ignore_ascii_case("absolute"));
let mut out: Vec<String> = style
.split(';')
.filter(|d| {
let key = d.split_once(':').map_or("", |(k, _)| k).trim().to_ascii_lowercase();
!(d.trim().is_empty()
|| matches!(key.as_str(), "width" | "height" | "rotation" | "flip")
|| (absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top")))
})
.map(|d| d.trim().to_string())
.collect();
if absolute {
out.push(format!("margin-left:{}pt", pt(float.x)));
out.push(format!("margin-top:{}pt", pt(float.y)));
}
out.push(format!("width:{}pt", pt(w.max(0.0))));
out.push(format!("height:{}pt", pt(h.max(0.0))));
// VML `rotation`: degrees clockwise; `flip`: `x` and/or `y` (as `read::story::vml_float` reads them).
let spin = float.spin();
if spin.deg != 0.0 {
out.push(format!("rotation:{}", pt(spin.deg)));
}
let flip: Vec<&str> = [(spin.flip_h, "x"), (spin.flip_v, "y")].into_iter().filter(|(on, _)| *on).map(|(_, f)| f).collect();
if !flip.is_empty() {
out.push(format!("flip:{}", flip.join(" ")));
}
out.join(";")
}
/// Points for a VML style: finite, bounded, at most two decimals.
fn pt(v: f32) -> String {
let v = wordcraft_geom::finite(v).clamp(-crate::units::MAX_LEN_PT, crate::units::MAX_LEN_PT);
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// `name` as a valid OPC part name: no backslashes, empty or dot segments, segments ending in a
/// dot, or encoded separators.
fn valid_part_name(name: &str) -> String {
let mut out = Vec::new();
for seg in name.split('/') {
let mut seg = seg.replace('\\', "_");
for enc in ["%2f", "%5c"] {
while let Some(i) = seg.to_ascii_lowercase().find(enc) {
seg.replace_range(i..i + 3, "_");
}
}
if seg.is_empty() || seg == "." || seg == ".." {
continue;
}
// A segment can't end in a dot either (`word/a.`).
if seg.ends_with('.') {
seg = format!("{}_", seg.trim_end_matches('.'));
}
out.push(seg);
}
if out.is_empty() { "word/embeddings/part.xml".to_string() } else { out.join("/") }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn charts_cut_to_the_limits_on_save_say_so_once() {
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
// out without a word (review of #3872).
use std::sync::Arc;
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, MAX_SERIES};
use wordcraft_doc::graphic::{Graphic, GraphicKind};
use wordcraft_doc::{CharProps, InlineObject, Pos};
let chart = |series: usize| {
let spec = ChartSpec {
kind: ChartType::Column,
categories: vec!["a".into(), "b".into()],
series: (0..series).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 2] }).collect(),
title: None,
legend: None,
data_labels: false,
};
let graphic = Graphic { kind: GraphicKind::Chart, w: 100.0, h: 100.0, chart: Some(Arc::new(spec)), ..Default::default() };
InlineObject::Graphic { w: 100.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }
};
let save = |charts: Vec<InlineObject>| {
let mut doc = wordcraft_doc::Document::new();
for c in charts {
doc.insert_object(&Pos::body(0, 0), c, &CharProps::default()).unwrap();
}
crate::test_log::warnings(|| crate::write(&doc).unwrap()).1
};
let cut = |w: &Vec<String>| w.iter().filter(|w| w.contains("chart")).count();
let warned = save(vec![chart(MAX_SERIES + 1), chart(MAX_SERIES + 5), chart(2)]);
assert_eq!(cut(&warned), 1, "one warning for the save, not one per chart: {warned:?}");
let warned = save(vec![chart(3)]);
assert_eq!(cut(&warned), 0, "nothing left out: {warned:?}");
}
#[test]
fn data_part_drawing_ids_are_rewritten_once() {
let ids: HashMap<String, String> = [("rId9".to_string(), "rId2".to_string()), ("rId2".to_string(), "rId3".to_string())].into_iter().collect();
let out = patch_rel_ids(br#"<x relId="rId9"/><y relId="rId2"/><z relId="rId7"/><w relId="unterminated"#, &ids);
assert_eq!(String::from_utf8(out).unwrap(), r#"<x relId="rId2"/><y relId="rId3"/><z relId="rId7"/><w relId="unterminated"#);
}
#[test]
fn vml_style_takes_the_new_size_and_offset() {
let inline = vml_style_with("width:100pt;height:50pt;mso-wrap-style:square", 120.5, 60.0, &Float::default());
assert_eq!(inline, "mso-wrap-style:square;width:120.5pt;height:60pt");
let float = Float { x: 10.0, y: 20.25, ..Float::default() };
let abs = vml_style_with("position:absolute;left:5pt;margin-left:3pt;top:1pt;width:1pt;height:1pt", 2.0, 3.0, &float);
assert_eq!(abs, "position:absolute;margin-left:10pt;margin-top:20.25pt;width:2pt;height:3pt");
assert_eq!(pt(f32::NAN), "0");
}
#[test]
fn vml_style_takes_the_rotation_and_flips() {
let mut float = Float::default();
float.set_spin(wordcraft_geom::Spin::new(-30.5, true, true));
let s = vml_style_with("width:1pt;rotation:10;flip:x;height:1pt;z-index:3", 2.0, 3.0, &float);
assert_eq!(s, "z-index:3;width:2pt;height:3pt;rotation:329.5;flip:x y");
// Unturned: no rotation or flip left behind.
assert_eq!(vml_style_with("rotation:90;flip:y;width:1pt", 2.0, 3.0, &Float::default()), "width:2pt;height:3pt");
}
#[test]
fn kept_part_names_are_valid_opc_names() {
let doc = wordcraft_doc::Document::default();
let mut w = EmbedWriter::new(&doc);
for orig in ["word/a\\b.bin", "word/..\\..\\x.bin", "word/a/../b%2Fc.bin", "word/a//./b%5Cc.bin", "word/a./b.bin", "word/a..."] {
let name = w.alloc(orig);
assert!(!name.contains('\\'), "{name}");
assert!(!name.to_ascii_lowercase().contains("%2f") && !name.to_ascii_lowercase().contains("%5c"), "{name}");
assert!(name.split('/').all(|s| !s.is_empty() && !s.ends_with('.')), "{name}");
}
}
}