Files
wordcraft/crates/docx/src/write/watermark.rs
T
0e662cacc0 #3994: watermark font names round-trip through the textpath's CSS style (#4071)
* #3994: watermark font names round-trip through the textpath's CSS style

The writer deleted ", ', ; and : from a watermark's font name, so a name
like "Bob's Serif" was saved under a different name, a trailing backslash
escaped the style's closing quote (the font lost its size), and a control
character made the CSS invalid. The four characters a quoted CSS string
can't carry raw are now CSS escapes (\22, \3b, \3a, \5c, each with its
terminating space), control characters are dropped, and the apostrophe —
legal inside a double-quoted string — is kept. Both textpath font-family
readers map the escapes back, so the name round-trips.

Co-Authored-By: GLM-5.3 <noreply@z.ai>

* #3994: a font name's own apostrophes survive the textpath reader

Both textpath font-family readers trimmed every quote and apostrophe off
the ends of the value, so a font named 'Serif' (or Trailing') came back
without them. The reader now removes only the one pair of quotes around
the value; the round-trip test covers both cases.

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

---------

Co-authored-by: GLM-5.3 <noreply@z.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:00:38 -04:00

67 lines
3.4 KiB
Rust

//! The document's text watermark as a VML text shape in the headers (ECMA-376 Part 4, VML:
//! `shape`, `shapetype`, `path`, `textpath`, `fill`), which is how a WordprocessingML consumer
//! draws text behind every page. The reader (`read::story::vml_watermark`) maps it back.
use wordcraft_doc::{SectionProps, Watermark};
use crate::xml::esc;
/// The shape type the watermark shape uses: two straight subpaths, the box's top and bottom
/// edges, between which the `v:textpath` text is stretched to fit the shape (`fitshape`). A single
/// path gives the text no height, and Word draws nothing.
const SHAPETYPE_ID: &str = "_x0000_t136";
const SHAPETYPE_PATH: &str = "m0,0l21600,0m0,21600l21600,21600e";
/// The watermark text as written: what the reader accepts (non-blank, at most 200 characters).
pub(super) fn text(wm: &Watermark) -> Option<String> {
let t: String = wm.text.chars().take(200).collect();
(!t.trim().is_empty()).then_some(t)
}
/// A `w:r` holding the watermark shape (and its shape type), for the first paragraph of header
/// part number `n`, in its box ([`Watermark::frame`]: its own, or Word's default for the page).
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
let text = text(wm)?;
let [width, height] = wm.frame(page.page_w, page.page_h);
// A font name can't break out of the textpath's CSS-like style or make it malformed (#3994):
// the characters a quoted CSS string can't carry raw (`\22 ` is `"`, `\3b ` `;`, `\3a ` `:`,
// `\5c ` `\`) are escaped and control characters are dropped (no font name carries one); the
// reader (`read::story::css_string`) maps the escapes back. An apostrophe is legal inside a
// double-quoted string, so it is kept.
let mut font = String::with_capacity(wm.font.len());
for c in wm.font.chars().filter(|c| !c.is_control()).take(100) {
match c {
'"' => font.push_str(r"\22 "),
';' => font.push_str(r"\3b "),
':' => font.push_str(r"\3a "),
'\\' => font.push_str(r"\5c "),
c => font.push(c),
}
}
let font = if font.trim().is_empty() { Watermark::default().font } else { font };
let rotation = if wm.diagonal { "rotation:315;" } else { "" };
let style = format!(
"position:absolute;margin-left:0;margin-top:0;width:{width:.1}pt;height:{height:.1}pt;{rotation}z-index:-251654144;\
mso-position-horizontal:center;mso-position-horizontal-relative:margin;\
mso-position-vertical:center;mso-position-vertical-relative:margin"
);
let fill = if wm.semitransparent { r#"<v:fill opacity=".5"/>"# } else { "" };
Some(format!(
concat!(
r#"<w:r><w:rPr><w:noProof/></w:rPr><w:pict>"#,
r#"<v:shapetype id="{st}" coordsize="21600,21600" o:spt="136" path="{path}">"#,
r#"<v:path textpathok="t"/><v:textpath on="t" fitshape="t"/></v:shapetype>"#,
r##"<v:shape id="PowerPlusWaterMarkObject{n}" type="#{st}" style="{style}" o:allowincell="f" fillcolor="#{color}" stroked="f">"##,
r#"{fill}<v:textpath style="{tstyle}" string="{text}"/></v:shape></w:pict></w:r>"#
),
st = SHAPETYPE_ID,
path = SHAPETYPE_PATH,
n = n,
style = esc(&style),
color = wm.color.hex(),
fill = fill,
tstyle = esc(&format!("font-family:\"{font}\";font-size:1pt")),
text = esc(&text),
))
}