Compare commits

..
11 Commits
Author SHA1 Message Date
Martin Ball c36e6e680d Merge branch 'main' into land/3001 2026-11-06 15:54:21 +01:00
Martin BallandClaude Sonnet 5.5 9c36365f69 #3001: split the watermark plan, design.watermark, ODT export and dialog into helpers (Sonar complexity)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 14:43:34 +00:00
Martin BallandClaude Opus 5.5 b6a9775f60 #3001: file-format parity names when a header picture becomes the watermark
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 12:56:48 +00:00
Martin BallandClaude Opus 5.5 cb32d95a92 #3001: one PDF image reader for the tests (pdf test-support); ROADMAP lists the dialog's scales
The engine watermark test read PDF images with a copy of the pdf crate's test helper (SonarCloud duplication). The helper is now wordcraft_pdf::test_support behind a test-support feature, and it no longer panics on input. Engine drops its hayro dev-dependencies. The ROADMAP row names the dialog's scales (Auto, 50-500 %); the command takes 1-500 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 12:46:19 +00:00
Martin Ball 2c43f61537 Merge remote-tracking branch 'origin/main' into land/3001
; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
;	docs/layout-parity.md
;	docs/ui-parity.md
2026-11-06 12:28:13 +00:00
Martin Ball 58148b25c9 Merge remote-tracking branch 'origin/main' into land/3001
; Conflicts:
;	ROADMAP.md
;	docs/file-format-parity.md
;	docs/gaps.md
;	docs/layout-parity.md
2026-11-06 08:18:24 +00:00
Martin BallandClaude Opus 5.5 569f519372 #3001: rerun CI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:17:59 +00:00
eb2cb3a7d9 Watermark: ODT keeps a picture of unknown size in its header (#3001)
`picture_plan` chose the header frames as the document's watermark without
checking that `own_size` can read the picture. A picture shown in a header whose
size is not a raster's (an EMF or WMF) was taken out of the headers, then
`add` could not place it: the picture was lost. The plan now returns None for
such a picture, so the frames stay header pictures, as in the DOCX plan.

Co-Authored-By: C00kieMonsta <22135261+C00kieMonsta@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 01:20:46 +00:00
be2fef40a4 Watermark: ODT watermark edits and reads (#3001)
Review fixes for the picture watermark.

- ODT reader: frames named WordPictureWatermark are the document's watermark
  only by the DOCX plan's rule (the same picture in every header of every master
  page, none in a footer, no text watermark); otherwise they stay pictures in
  their headers. Headers are read once to find out, again only if they qualify.
- ODT: `pages_fingerprint` now includes the watermark, so a watermark added to
  an opened ODT is saved (it was dropped, leaving an orphan Pictures/ file).
- ODT writer: the watermark picture goes into Pictures/ once, not per slot.
- DOCX reader: the plan's reference picture is the first shown header's, not
  HashMap order.
- design.watermark: the picture joins the media only after every parameter check.

Co-Authored-By: C00kieMonsta <22135261+C00kieMonsta@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 00:57:07 +00:00
48b63611cf Watermark: picture watermarks (#3001)
Ported from #3001 by hand onto current main. Design > Watermark > Custom
Watermark gains No watermark / Picture watermark (Select Picture, Scale Auto or
a percentage, Washout) / Text watermark. The picture is drawn centred behind the
text on screen and in PDF; DOCX saves Word's VML picture watermark in every
header and reads Word's back when every page shows the same one.

Changed vs the PR:
- Mirror margins: the picture is centred in the page's own text area
  (`page.body`, as the text watermark), the same rect on screen and in PDF.
- Auto size is one answer: the room of the first section naming the page's
  header (`Document::watermark_fit`), used by screen, PDF and DOCX.
- Washout goes through the picture-corrections path (`WatermarkPicture::fx()` =
  PicFx::from_vml gain/blacklevel, `media::pixmap_for` / `Exporter::image`);
  the PR's washout()/4096 px resize copies are gone, so memory is that of any
  picture. The reader detects Washout from gain/blacklevel with PicFx.
- ODT writes the picture (a frame, `draw:color-mode="watermark"`) and reads it
  back, instead of writing the watermark's text.
- A watermark in a tracked deletion is not the document's (text and picture).
- `design.watermark` checks base64 length before decoding, caps pictures at
  64 MB, reads files through read_file_capped (wasm stub added), validates
  before touching the document.
- The dialog lives in dialogs/dialogs_watermark.rs; strings stay English.

Co-Authored-By: C00kieMonsta <22135261+C00kieMonsta@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 00:34:47 +00:00
0261e92d10 Watermark: text watermark transparency (#3001)
Ported from #3001 by hand onto current main (the PR was 1857 commits behind).
The text watermark gets a transparency in percent next to Word's Semitransparent
check box: 0 opaque, 50 Semitransparent, any other value kept (`v:fill opacity`).

- doc: `Watermark::transparency` (Option<u8>), `transparency()`, `set_transparency()`,
  `opacity()`; older documents without it follow `semitransparent`.
- DOCX: reads any `v:fill opacity`, writes `.75`, `.2`, ... like Word writes `.5`.
- Render and PDF draw the same opacity. Changed vs the PR's base: the screen used
  35 % for Semitransparent and 80 % for opaque; both now use the real opacity
  (50 % / 100 %), as the PDF already did.
- Command `design.watermark`: `transparency` (0-100, non-finite and out-of-range
  clamped) and `semitransparent`.
- Dialog: Semitransparent check box and a percentage.
- Beyond the PR: the ODT writer and reader keep the transparency (`draw:opacity`)
  instead of only on/off.

Co-Authored-By: C00kieMonsta <22135261+C00kieMonsta@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-05 23:54:16 +00:00
55 changed files with 2590 additions and 307 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,7 +16,7 @@ workspace = true
test-support = []
[dependencies]
wordcraft-geom = { workspace = true }
wordcraft-geom = { workspace = true, features = ["image"] }
base64 = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
+10 -19
View File
@@ -7,9 +7,8 @@
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
/// than the file has sectors — every FAT sector is a sector of the file, so a longer list is
/// duplicates or names `cfb` refuses anyway, and the FAT `cfb` builds (one sector of entries per
/// listed name) stays within about the file's own size. Anything else that is wrong is left for
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
/// `cfb` to report.
pub fn check(bytes: &[u8]) -> Result<(), String> {
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
@@ -53,14 +52,12 @@ pub fn check(bytes: &[u8]) -> Result<(), String> {
while difat.last() == Some(&FREE) {
difat.pop();
}
// Each listed FAT sector adds a whole sector of entries. A real file lists no more FAT
// sectors than the file has — every FAT sector is a sector of the file, and none of them
// twice (checked below) — so the FAT `cfb` builds stays within about the file's own size.
// The #3642 blowup listed 2048 × 127 names in a ~1 MB file. A tighter bound (one FAT sector
// per `sector_len / 4` sectors plus a spare) refused real files, whose writers reserve FAT
// for growth: an encrypted package of 78 sectors lists 4 and didn't open (#5109).
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
if listed.len() > num_sectors {
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
if listed.len() > needed {
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
}
let mut seen = std::collections::HashSet::with_capacity(listed.len());
@@ -126,16 +123,10 @@ mod tests {
}
#[test]
fn a_fat_sector_listed_twice_or_more_than_the_file_has_is_refused() {
// A duplicate, and a list longer than the file has sectors: every FAT sector is a
// sector of the file, so a longer list is one of the two.
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
// Each list is shorter than the file has sectors, which the guard used to accept.
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
assert!(
super::check(&header_difat(10, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])).is_err_and(|e| e.contains("11 FAT sectors in a file of 10 sectors"))
);
// A writer may reserve FAT for growth: a real encrypted package of 78 sectors lists
// 4 FAT sectors and didn't open since the #3642 guard landed (#5109).
assert!(super::check(&header_difat(78, &[0, 1, 2, 3])).is_ok());
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
assert!(super::check(&header_difat(10, &[0])).is_ok());
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
}
+2 -1
View File
@@ -479,7 +479,7 @@ fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, V
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
/// picture, the watermark, and the notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
@@ -492,6 +492,7 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
parts,
&s.page_color,
&s.page_picture,
&s.watermark,
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
+2
View File
@@ -48,6 +48,7 @@ pub mod smart_art;
pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod watermark;
pub mod wordart;
pub mod work;
pub mod wrap;
@@ -92,6 +93,7 @@ pub use settings::{
};
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
pub use watermark::WatermarkPicture;
/// A document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+58
View File
@@ -234,12 +234,20 @@ pub struct Watermark {
pub font: String,
pub color: Rgb,
pub diagonal: bool,
/// Word's Semitransparent check box (the text 50 % transparent).
pub semitransparent: bool,
/// The text's transparency in percent when it is neither 0 nor 50 (see
/// [`Watermark::transparency`]); `None` follows `semitransparent`, as older documents do.
#[serde(skip_serializing_if = "Option::is_none")]
pub transparency: Option<u8>,
/// The shape's box (its `style` width and height, points), which the text is stretched to
/// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives
/// ([`Watermark::frame`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
/// A picture watermark, drawn instead of the text (which is kept for the dialog).
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<crate::watermark::WatermarkPicture>,
}
impl Default for Watermark {
@@ -250,12 +258,35 @@ impl Default for Watermark {
color: Rgb(0xC0, 0xC0, 0xC0),
diagonal: true,
semitransparent: true,
transparency: None,
size: None,
picture: None,
}
}
}
impl Watermark {
/// The text's transparency in percent: 0 is opaque, 100 invisible, Semitransparent is 50.
pub fn transparency(&self) -> u8 {
match self.transparency {
Some(t) => t.min(100),
None if self.semitransparent => 50,
None => 0,
}
}
/// Set the text's transparency in percent (at most 100). Semitransparent follows it.
pub fn set_transparency(&mut self, percent: u8) {
let t = percent.min(100);
self.semitransparent = t > 0;
self.transparency = (t != 0 && t != 50).then_some(t);
}
/// The text's opacity, 0 to 1.
pub fn opacity(&self) -> f32 {
1.0 - f32::from(self.transparency()) / 100.0
}
/// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the
/// page diagonal across (half the page width when not diagonal), as high as the text's length
/// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's
@@ -332,3 +363,30 @@ impl Default for Settings {
}
}
}
#[cfg(test)]
mod watermark_tests {
use super::Watermark;
#[test]
fn transparency_follows_semitransparent() {
let mut wm = Watermark::default();
assert_eq!((wm.transparency(), wm.opacity()), (50, 0.5));
wm.semitransparent = false;
assert_eq!(wm.transparency(), 0);
wm.set_transparency(80);
assert_eq!((wm.semitransparent, wm.transparency, wm.transparency()), (true, Some(80), 80));
// Word's two values are stored as the check box alone.
wm.set_transparency(50);
assert_eq!((wm.semitransparent, wm.transparency), (true, None));
wm.set_transparency(0);
assert_eq!((wm.semitransparent, wm.transparency), (false, None));
wm.set_transparency(255);
assert_eq!((wm.transparency(), wm.opacity()), (100, 0.0));
// Older documents have no transparency field.
let old: Watermark = serde_json::from_str(r#"{"text":"X","semitransparent":false}"#).unwrap();
assert_eq!(old.transparency(), 0);
let hostile: Watermark = serde_json::from_str(r#"{"transparency":200}"#).unwrap();
assert_eq!(hostile.transparency(), 100);
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Design › Watermark › Picture watermark: a picture drawn behind the text of every page.
//!
//! The picture's size is shared by everything that draws or saves it. A scale is a percentage of
//! the picture's own size (its pixels at 96 ppi). Auto is as large as fits inside the margins of
//! the first section that names the page's header ([`Document::watermark_fit`]), the same
//! on screen, in a PDF and in the saved file, whatever pages follow. The picture is centred in
//! the page's own text area, so mirrored margins move it as they move the text.
use serde::{Deserialize, Serialize};
use wordcraft_geom::Rect;
use crate::Document;
use crate::picfx::PicFx;
use crate::section::SectionProps;
/// Largest picture watermark scale, percent (the top of Word's Scale list).
pub const MAX_SCALE: f32 = 500.0;
/// Smallest picture watermark scale, percent.
pub const MIN_SCALE: f32 = 1.0;
/// Longest side of a placed picture watermark, points.
const MAX_SIDE: f32 = 31_680.0;
/// Word's Washout as `v:imagedata` values: `gain` 0.3 and `blacklevel` 0.35, in 65536ths.
pub const WASHOUT_GAIN: f64 = 19_661.0 / 65_536.0;
pub const WASHOUT_BLACK_LEVEL: f64 = 22_938.0 / 65_536.0;
/// A picture's own size in points (its pixels at 96 ppi), from its header: nothing is decoded.
/// None when it isn't a picture WordCraft reads.
pub fn own_size(bytes: &[u8]) -> Option<(f32, f32)> {
let p = wordcraft_geom::picture::probe(bytes).ok()?;
(p.width > 0 && p.height > 0).then_some((p.width as f32 * 0.75, p.height as f32 * 0.75))
}
/// A picture watermark.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WatermarkPicture {
/// Key into [`crate::Document::media`].
pub media: String,
/// Percent of the picture's own size (its pixels at 96 ppi); None for Auto, as large as fits
/// inside the margins.
pub scale: Option<f32>,
/// Word's Washout: the picture faded so the text over it can be read.
pub washout: bool,
}
impl Default for WatermarkPicture {
fn default() -> Self {
WatermarkPicture { media: String::new(), scale: None, washout: true }
}
}
impl WatermarkPicture {
/// The corrections the picture is drawn with: Word's Washout, else none. The renderers draw
/// it through the same picture path as any picture with corrections, so Washout costs no
/// more memory than the picture itself.
pub fn fx(&self) -> PicFx {
if self.washout { PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL) } else { PicFx::default() }
}
/// Whether `fx` (read from a file's `gain` and `blacklevel`) is Word's Washout, to within
/// rounding of the values a file writes.
pub fn is_washout(fx: PicFx) -> bool {
let w = PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL);
fx.transparency == 0 && fx.bright.abs_diff(w.bright) <= 100 && fx.contrast.abs_diff(w.contrast) <= 100
}
/// The picture's size in points, for a picture `own_w` × `own_h` points: a scale's percentage
/// of it, or at Auto as large as fits `fit` (width, height). None for an empty or unusable
/// size.
pub fn size(&self, own_w: f32, own_h: f32, fit: (f32, f32)) -> Option<(f32, f32)> {
let pos = |v: f32| v.is_finite() && v > 0.0;
if !(pos(own_w) && pos(own_h)) {
return None;
}
let k = match self.scale {
Some(s) if s.is_finite() => s.clamp(MIN_SCALE, MAX_SCALE) / 100.0,
_ => {
if !(pos(fit.0) && pos(fit.1)) {
return None;
}
(fit.0 / own_w).min(fit.1 / own_h)
}
};
let k = k.min(MAX_SIDE / own_w.max(own_h));
let (w, h) = (own_w * k, own_h * k);
(pos(w) && pos(h)).then_some((w, h))
}
/// Where the picture goes: [`WatermarkPicture::size`], centred in `body`, the page's text
/// area (a scale may spill past it).
pub fn place(&self, own_w: f32, own_h: f32, fit: (f32, f32), body: Rect) -> Option<Rect> {
let (w, h) = self.size(own_w, own_h, fit)?;
let ok = |v: f32| v.is_finite();
if !(ok(body.x) && ok(body.y) && ok(body.w) && ok(body.h)) {
return None;
}
Some(Rect::new(body.x + (body.w - w) / 2.0, body.y + (body.h - h) / 2.0, w, h))
}
}
/// The room Auto has in a section: its text area's width and height.
fn fit_of(s: &SectionProps) -> (f32, f32) {
(s.text_width(), s.text_height())
}
impl Document {
/// The room (width, height; points) a picture watermark at Auto fills on a page showing
/// header `header` (None: no header, which the saved file gives a header of its own to hold
/// the watermark): that of the first section naming the header, in document order, as the
/// DOCX writer and reader do. A header no section names is sized by the last section.
pub fn watermark_fit(&self, header: Option<u32>) -> (f32, f32) {
let even = self.settings.even_odd_headers;
// Which slots an earlier section has filled (named, or given the watermark's own header),
// that later ones inherit.
let mut filled = [false; 3];
for (_, s) in self.sections() {
let own = [s.headers.default, s.headers.first, s.headers.even];
let wants = [true, s.title_page, even];
let mut hit = false;
for ((o, w), f) in own.into_iter().zip(wants).zip(filled.iter_mut()) {
match (header, o) {
(Some(h), Some(o)) => hit |= h == o,
// A slot the pages show with no header of its own, nor an earlier one's.
(None, None) => hit |= w && !*f,
_ => {}
}
*f |= o.is_some() || w;
}
if hit {
return fit_of(s);
}
}
fit_of(&self.last_section)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Rounded to a hundredth of a point: (x, y, w, h).
fn r(r: Option<Rect>) -> Option<(f32, f32, f32, f32)> {
let q = |v: f32| (v * 100.0).round() / 100.0;
r.map(|r| (q(r.x), q(r.y), q(r.w), q(r.h)))
}
#[test]
fn picture_is_placed_in_the_text_area() {
let body = Rect::new(72.0, 72.0, 468.0, 648.0);
let fit = (468.0, 648.0);
// Auto: as large as fits, centred.
let auto = WatermarkPicture::default();
assert_eq!(r(auto.place(200.0, 100.0, fit, body)), Some((72.0, 279.0, 468.0, 234.0)));
assert_eq!(r(auto.place(100.0, 400.0, fit, body)), Some((225.0, 72.0, 162.0, 648.0)));
// A scale is a percentage of the picture's own size, and may spill past the margins.
let half = WatermarkPicture { scale: Some(50.0), ..Default::default() };
assert_eq!(r(half.place(200.0, 100.0, fit, body)), Some((256.0, 371.0, 100.0, 50.0)));
let big = WatermarkPicture { scale: Some(500.0), ..Default::default() };
assert_eq!(r(big.place(200.0, 100.0, fit, body)), Some((-194.0, 146.0, 1000.0, 500.0)));
// Mirrored margins move the page's text area, and the picture with it; Auto's size is
// still the section's.
let mirrored = Rect::new(108.0, 72.0, 468.0, 648.0);
assert_eq!(r(auto.place(200.0, 100.0, fit, mirrored)), Some((108.0, 279.0, 468.0, 234.0)));
// Hostile sizes and scales.
let wild = WatermarkPicture { scale: Some(1e30), ..Default::default() };
assert_eq!(r(wild.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(1000.0));
let tiny = WatermarkPicture { scale: Some(-5.0), ..Default::default() };
assert_eq!(r(tiny.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(2.0));
let nan = WatermarkPicture { scale: Some(f32::NAN), ..Default::default() };
assert_eq!(r(nan.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(468.0));
assert!(auto.place(0.0, 100.0, fit, body).is_none());
assert!(auto.place(f32::INFINITY, 100.0, fit, body).is_none());
assert!(auto.place(200.0, 100.0, (-1.0, 10.0), body).is_none());
let huge = auto.size(1e30, 1e30, (1e38, 1e38)).unwrap();
assert!(huge.0 <= MAX_SIDE + 1.0 && huge.1 <= MAX_SIDE + 1.0);
}
#[test]
fn washout_is_the_vml_values() {
let on = WatermarkPicture::default();
assert_eq!(on.fx(), PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL));
assert!(WatermarkPicture::is_washout(on.fx()) && WatermarkPicture::is_washout(PicFx::from_vml(0.3, 0.35)));
assert!(!WatermarkPicture::is_washout(PicFx::default()));
assert!(!WatermarkPicture::is_washout(PicFx::from_vml(0.5, 0.1)));
assert!(WatermarkPicture { washout: false, ..on }.fx().is_identity());
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-geom = { workspace = true }
wordcraft-geom = { workspace = true, features = ["image"] }
quick-xml = { workspace = true }
zip = { workspace = true }
thiserror = { workspace = true }
+5
View File
@@ -12,6 +12,7 @@ mod props;
mod sdt;
mod shape_style;
mod story;
mod watermark;
mod wordart;
use std::collections::{HashMap, HashSet};
@@ -116,6 +117,8 @@ pub(crate) struct Reader<'p> {
pub theme_fills: shape_style::ThemeFills,
/// The fill of the group being read (what its members' `a:grpFill` stands for).
pub group_fill: Option<wordcraft_doc::fill::Fill>,
/// The document's picture watermark from the headers, if any (`watermark::plan`).
picture_wm: watermark::Plan,
}
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
@@ -188,6 +191,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
txbx_links: Vec::new(),
theme_fills: Default::default(),
group_fill: None,
picture_wm: watermark::Plan::default(),
chunks,
};
r.pc.major_font = r.doc.settings.major_font.clone();
@@ -279,6 +283,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
}
}
if let Some(body) = root.child("w:body") {
r.picture_wm = watermark::plan(&r, body, &rels);
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
r.read_blocks(&mut sc, body, &rels, &mut blocks, r.chunks.depth);
+1
View File
@@ -142,6 +142,7 @@ impl Reader<'_> {
let rels = self.pkg.rels(path);
let mut sc = StoryCtx::default();
sc.in_hf = true;
sc.picture_wm = !footer && self.picture_wm_header(path);
let mut blocks = Blocks::new();
self.hf_depth += 1;
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
+7 -1
View File
@@ -25,6 +25,8 @@ mod fields;
mod runs;
mod vml;
pub(super) use vml::{vml_spin, vml_style, vml_watermark};
const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
/// The `a:graphicData` URI of an Office 2016 chart (`cx:chart`).
@@ -113,6 +115,10 @@ pub struct StoryCtx {
pub story_depth: usize,
/// Reading a header or footer (not a text box inside one): where Word keeps its watermark.
pub in_hf: bool,
/// This header's Word picture watermark is the document's watermark (see `watermark::plan`).
pub picture_wm: bool,
/// Reading the content of a tracked deletion: a watermark shape in it is not the document's.
pub in_deletion: bool,
}
/// Paragraph content under construction.
@@ -158,7 +164,7 @@ fn clean_text(s: &str) -> String {
.collect()
}
fn children_of_choice(ac: &El) -> Option<&El> {
pub(super) fn children_of_choice(ac: &El) -> Option<&El> {
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
// `m`: Office Math (a producer may guard an equation with a picture fallback, #1273).
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v", "m"];
+5 -1
View File
@@ -244,7 +244,11 @@ impl Reader<'_> {
if let Some(o) = self.read_signature_line(sc, k, rels) {
self.emit_obj(sc, pb, o, props);
} else {
for o in self.read_vml_all(sc, k, rels) {
// A watermark in a tracked deletion is not the document's: it stays a shape.
sc.in_deletion = props.del.is_some();
let objects = self.read_vml_all(sc, k, rels);
sc.in_deletion = false;
for o in objects {
self.emit_obj(sc, pb, o, props);
}
}
+9 -6
View File
@@ -21,7 +21,8 @@ impl Reader<'_> {
break;
}
if sc.in_hf
&& let Some(wm) = vml_watermark(e)
&& !sc.in_deletion
&& let Some(wm) = vml_watermark(e).or_else(|| self.picture_watermark(sc, e, rels))
{
// The document's watermark (the first one found); drawn on every page, not as
// a shape, so it is not one of the objects this picture contributes.
@@ -482,7 +483,7 @@ fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> {
})
}
fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
pub(in crate::read) 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())
}
@@ -605,10 +606,10 @@ fn vml_float(shape: &El, style: &str, in_hf: bool) -> Float {
/// Word's text watermark (Design › Watermark): a VML shape in a header whose `v:textpath` draws
/// its `string` as text (ECMA-376 Part 4, VML `textpath`), in the textpath's `font-family`, filled
/// with the shape's `fillcolor`, semitransparent when its `v:fill` has an `opacity` below 1, and
/// with the shape's `fillcolor`, as transparent as its `v:fill` `opacity` says (any value), and
/// diagonal when the shape is turned. Only Word's watermark shape counts (its `id`), or text
/// placed absolutely and centred both ways; other header WordArt (a logo or a title) is None.
fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
pub(in crate::read) fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
let tp = shape.child("v:textpath")?;
if tp.attr("on").is_some_and(|v| matches!(v, "f" | "false" | "0")) {
return None;
@@ -638,7 +639,9 @@ fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
// The box the text is stretched to (`fitshape`).
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(4000.0));
let size = len("width").zip(len("height")).map(|(w, h)| [w, h]);
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0, size })
let mut wm = wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, size, ..default };
wm.set_transparency(((1.0 - opacity) * 100.0).round().clamp(0.0, 100.0) as u8);
Some(wm)
}
/// A VML `opacity`, 0..=1.
@@ -648,7 +651,7 @@ fn vml_opacity(v: &str) -> Option<f32> {
/// A VML shape's turn: `rotation` in degrees, or 65536ths of one with an `fd` suffix; `flip`: `x`
/// and/or `y`.
fn vml_spin(style: &str) -> wordcraft_geom::Spin {
pub(in crate::read) fn vml_spin(style: &str) -> wordcraft_geom::Spin {
let get = |k: &str| vml_style(style, k);
let rot = get("rotation").and_then(|v| match v.strip_suffix("fd") {
Some(fd) => fd.trim().parse::<f32>().ok().map(|f| f / 65_536.0),
+334
View File
@@ -0,0 +1,334 @@
//! Word's picture watermark (Design › Watermark › Picture watermark): a VML picture with Word's
//! `WordPictureWatermark` id in the headers. It becomes the document's watermark only when every
//! page shows the same one, placed as Word places it; otherwise each stays a picture in its header,
//! so a cover page without it, a section with another picture, or one moved by hand keep theirs.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use wordcraft_doc::picfx::PicFx;
use wordcraft_doc::watermark::{MAX_SCALE, MIN_SCALE, own_size};
use wordcraft_doc::{SectionProps, Watermark, WatermarkPicture};
use super::Reader;
use super::props::{HfRef, sectpr};
use super::story::{StoryCtx, children_of_choice, vml_style, vml_watermark};
use crate::package::Rels;
use crate::units::{measure, vml_number};
use crate::xml::{El, MAX_DEPTH};
/// Word's watermark picture shape id starts with this.
const WORD_ID: &str = "WordPictureWatermark";
/// Most sections, and header or footer parts, looked at; past them the pictures stay put.
const MAX_SECTIONS: usize = 10_000;
const MAX_PARTS: usize = 64;
/// The document's picture watermark as the plan found it: the header parts whose Word picture
/// watermark it is (empty: none), and the scale and Washout every one of them agrees on.
#[derive(Default)]
pub(super) struct Plan {
headers: HashSet<String>,
scale: Option<f32>,
washout: bool,
}
/// A Word picture watermark shape as Word places it: absolutely, centred on the margins both
/// ways, behind the text, shown, not turned or flipped, its picture carrying Word's Washout or
/// nothing.
struct Shape<'a> {
rid: &'a str,
size: Option<(f32, f32)>,
washout: bool,
}
/// `e` as Word's picture watermark shape, if it is one placed as Word places it.
fn shape(e: &El) -> Option<Shape<'_>> {
if e.name != "v:shape" || !e.attr("id").is_some_and(|id| id.starts_with(WORD_ID)) {
return None;
}
let style = e.attr("style").unwrap_or("");
let is = |k: &str, v: &str| vml_style(style, k).is_some_and(|x| x.eq_ignore_ascii_case(v));
let placed = is("position", "absolute")
&& is("mso-position-horizontal", "center")
&& is("mso-position-horizontal-relative", "margin")
&& is("mso-position-vertical", "center")
&& is("mso-position-vertical-relative", "margin");
let behind = vml_style(style, "z-index").and_then(|z| z.trim().parse::<i64>().ok()).is_some_and(|z| z < 0);
let spin = super::story::vml_spin(style);
if !placed || !behind || is("visibility", "hidden") || spin.deg != 0.0 || spin.flip_h || spin.flip_v {
return None;
}
let data = e.child("v:imagedata")?;
if data.attrs.iter().any(|(k, _)| !matches!(k.as_str(), "r:id" | "r:pict" | "src" | "o:title" | "gain" | "blacklevel")) {
return None;
}
// Word's Washout is gain 0.3 and black level 0.35; other values stay on a header picture.
let num = |k: &str| data.attr(k).and_then(vml_number);
let fx = PicFx::from_vml(num("gain").unwrap_or(1.0), num("blacklevel").unwrap_or(0.0));
let washout = if data.attr("gain").is_none() && data.attr("blacklevel").is_none() {
false
} else if WatermarkPicture::is_washout(fx) {
true
} else {
return None;
};
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0);
let size = len("width").zip(len("height"));
Some(Shape { rid: data.attr("r:id").or_else(|| data.attr("r:pict"))?, size, washout })
}
/// One header's Word picture watermark: its picture, its width as a percentage of the picture's
/// own size (pixels at 96 ppi), whether it fills its section's margins (Auto), and Washout.
struct Seen {
bytes: Arc<Vec<u8>>,
percent: f32,
fits: bool,
washout: bool,
}
/// What shape `s` shows, its picture `bytes`, in a header whose first section has room `fit`.
/// None when the picture can't be read, or the shape has no size or is stretched out of proportion.
fn seen(bytes: &Arc<Vec<u8>>, s: &Shape, fit: (f32, f32)) -> Option<Seen> {
let (own_w, own_h) = own_size(bytes)?;
let (w, h) = s.size?;
if (h - w * own_h / own_w).abs() > 1.0 {
return None;
}
let (auto_w, _) = WatermarkPicture::default().size(own_w, own_h, fit)?;
Some(Seen { bytes: bytes.clone(), percent: w / own_w * 100.0, fits: (auto_w - w).abs() <= 1.0, washout: s.washout })
}
impl Reader<'_> {
/// The document's watermark from a header's Word picture watermark shape `e`, when the plan
/// made that header's one the document's watermark.
pub(super) fn picture_watermark(&mut self, sc: &StoryCtx, e: &El, rels: &Rels) -> Option<Watermark> {
if !sc.picture_wm {
return None;
}
let s = shape(e)?;
let media = self.media_for(rels, s.rid)?;
let pic = WatermarkPicture { media, scale: self.picture_wm.scale, washout: self.picture_wm.washout };
Some(Watermark { picture: Some(pic), ..Default::default() })
}
/// Whether header part `path`'s Word picture watermark is the document's (see [`plan`]).
pub(super) fn picture_wm_header(&self, path: &str) -> bool {
self.picture_wm.headers.contains(path)
}
}
/// The document's picture watermark, worked out from the section properties and header and
/// footer parts before the body is read. Every page shows a header (default; first page with a
/// title page; even pages with different odd and even pages, each linked to the section before
/// when it has none), each header holds at most one Word picture watermark placed as Word places
/// it and the shown ones hold one, all have the same picture and Washout, and all fill their
/// section's margins (Auto) or all have the same scale. No header or footer has a text
/// watermark, and no footer a Word picture watermark. Otherwise the plan is empty.
pub(super) fn plan(r: &Reader, body: &El, rels: &Rels) -> Plan {
let mut sects = Vec::new();
sect_prs(body, 0, true, &mut sects);
if let Some(s) = body.child("w:sectPr") {
sects.push((s, true));
}
if sects.len() > MAX_SECTIONS {
return Plan::default();
}
let parts = collect_parts(r, rels, &sects);
if !parts_viable(r, &parts) || !footers_clean(r, &parts.footers) {
return Plan::default();
}
let Some(found) = read_headers(r, &parts.headers) else { return Plan::default() };
let Some((scale, washout)) = agreed(&parts.shown, &found) else { return Plan::default() };
Plan { headers: found.into_iter().filter(|(_, w)| w.is_some()).map(|(p, _)| p.to_string()).collect(), scale, washout }
}
/// The header and footer parts the sections name, and the header each page kind shows.
#[derive(Default)]
struct Parts {
/// Each header part, with the room Auto fills in the first section naming it
/// (`Document::watermark_fit`).
headers: HashMap<String, (f32, f32)>,
footers: HashSet<String>,
/// The header part (None: linked to none) of every page kind each section ending shows.
shown: Vec<Option<String>>,
}
/// Walks the sections in order, collecting the header and footer parts they name and the headers
/// the pages show (each kind linked to the section before when a section has none).
fn collect_parts(r: &Reader, rels: &Rels, sects: &[(&El, bool)]) -> Parts {
let even = r.doc.settings.even_odd_headers;
let mut parts = Parts::default();
let mut linked: [Option<String>; 3] = Default::default();
for (s, top) in sects {
let (sp, refs) = sectpr(&r.pc, s);
for h in &refs {
note_ref(&mut parts, &mut linked, h, rels, &sp, *top);
}
if !*top {
continue;
}
let [default, first, even_page] = &linked;
parts.shown.push(default.clone());
if sp.title_page {
parts.shown.push(first.clone());
}
if even {
parts.shown.push(even_page.clone());
}
}
parts
}
/// Records header or footer reference `h` of a section with properties `sp`: the part it names,
/// and, in a section that ends one of the document's, the header kind it now links.
fn note_ref(parts: &mut Parts, linked: &mut [Option<String>; 3], h: &HfRef, rels: &Rels, sp: &SectionProps, top: bool) {
let Some(rel) = rels.by_id(&h.rid).filter(|x| !x.external) else { return };
if h.footer {
parts.footers.insert(rel.target.clone());
return;
}
parts.headers.entry(rel.target.clone()).or_insert_with(|| (sp.text_width(), sp.text_height()));
if !top {
return;
}
let slot = match h.kind.as_str() {
"first" => 1,
"even" => 2,
_ => 0,
};
if let Some(l) = linked.get_mut(slot) {
*l = Some(rel.target.clone());
}
}
/// Whether `parts` can hold the document's watermark: not too many, every page shows a header
/// and one of the headers is Word's.
fn parts_viable(r: &Reader, parts: &Parts) -> bool {
let has_word_id = |path: &str| r.pkg.get(path).is_some_and(|b| b.windows(WORD_ID.len()).any(|w| w == WORD_ID.as_bytes()));
parts.headers.len() <= MAX_PARTS
&& parts.footers.len() <= MAX_PARTS
&& !parts.shown.is_empty()
&& parts.shown.iter().all(Option::is_some)
&& parts.headers.keys().any(|p| has_word_id(p))
}
/// Whether no footer holds a watermark: a footer's text watermark is the document's watermark,
/// and a footer's picture watermark stays where it is, so either way the headers' pictures stay.
fn footers_clean(r: &Reader, footers: &HashSet<String>) -> bool {
footers.iter().all(|path| {
let Ok(Some(root)) = r.pkg.xml(path) else { return true };
let mut shapes = Vec::new();
word_shapes(&root, 0, "", false, false, &mut shapes) && shapes.is_empty()
})
}
/// What each header part's Word picture watermark shows (None: it has none). None when a part
/// can't be read, has a text watermark or several pictures, or its picture isn't placed as Word
/// places it.
fn read_headers<'a>(r: &Reader, headers: &'a HashMap<String, (f32, f32)>) -> Option<HashMap<&'a str, Option<Seen>>> {
let mut found = HashMap::new();
for (path, fit) in headers {
let root = r.pkg.xml(path).ok()??;
let mut shapes = Vec::new();
if !word_shapes(&root, 0, "", false, false, &mut shapes) || shapes.len() > 1 {
return None;
}
let wm = match shapes.first() {
None => None,
Some(e) => Some(header_seen(r, path, e, *fit)?),
};
found.insert(path.as_str(), wm);
}
Some(found)
}
/// What header part `path`'s watermark shape `e` shows, when it is placed as Word places it and
/// its picture is in the package.
fn header_seen(r: &Reader, path: &str, e: &El, fit: (f32, f32)) -> Option<Seen> {
let hrels = r.pkg.rels(path);
let s = shape(e)?;
let bytes = hrels.by_id(s.rid).filter(|x| !x.external).and_then(|x| r.pkg.get_shared(&x.target))?;
seen(bytes, &s, fit)
}
/// The Scale (None: Auto) and Washout every shown header agrees on. None when a shown header has
/// no picture, or they differ in picture, Washout, or scale.
fn agreed(shown: &[Option<String>], found: &HashMap<&str, Option<Seen>>) -> Option<(Option<f32>, bool)> {
if shown.iter().flatten().any(|p| found.get(p.as_str()).is_none_or(Option::is_none)) {
return None;
}
let all: Vec<&Seen> = found.values().flatten().collect();
// The first shown header's, whatever order the map holds them in.
let first = shown.iter().flatten().find_map(|p| found.get(p.as_str())?.as_ref())?;
if !all.iter().all(|s| s.bytes == first.bytes && s.washout == first.washout) {
return None;
}
if all.iter().all(|s| s.fits) {
return Some((None, first.washout));
}
let percent = (first.percent * 10.0).round() / 10.0;
if !(MIN_SCALE..=MAX_SCALE).contains(&percent) || all.iter().any(|s| (s.percent - first.percent).abs() > 0.1) {
return None;
}
Some((Some(percent), first.washout))
}
/// The `w:sectPr` elements the reader reads, in order, as (element, whether it ends a section of
/// the document): in paragraph properties through the containers `read_blocks` follows, and in
/// table cells (which load headers but make no section).
fn sect_prs<'a>(e: &'a El, depth: usize, top: bool, out: &mut Vec<(&'a El, bool)>) {
if depth > MAX_DEPTH || out.len() > MAX_SECTIONS {
return;
}
for k in e.els() {
match k.name.as_str() {
"w:p" => {
if let Some(s) = k.child("w:pPr").and_then(|p| p.child("w:sectPr")) {
out.push((s, top));
}
}
"w:sdt" => {
if let Some(c) = k.child("w:sdtContent") {
sect_prs(c, depth + 1, top, out);
}
}
"w:customXml" | "w:smartTag" | "w:ins" | "w:moveTo" => sect_prs(k, depth + 1, top, out),
"mc:AlternateContent" => {
if let Some(c) = children_of_choice(k) {
sect_prs(c, depth + 1, top, out);
}
}
"w:tbl" | "w:tr" | "w:tc" => sect_prs(k, depth + 1, false, out),
_ => {}
}
}
}
/// Collects the Word picture watermark shapes a header or footer would read as such (straight
/// in a `w:pict` or `w:object`, not in a tracked deletion) into `out`. False when the part has a
/// text watermark, or a Word picture watermark anywhere else (a group, a text box, alternate
/// content, a deleted run).
fn word_shapes<'a>(e: &'a El, depth: usize, parent: &str, nested: bool, deleted: bool, out: &mut Vec<&'a El>) -> bool {
if depth > MAX_DEPTH {
return true;
}
for k in e.els() {
if k.name == "v:shape" && vml_watermark(k).is_some() {
return false;
}
if k.name == "v:shape" && k.attr("id").is_some_and(|id| id.starts_with(WORD_ID)) {
if nested || deleted || !matches!(parent, "w:pict" | "w:object") {
return false;
}
out.push(k);
continue;
}
let inner = nested || matches!(k.name.as_str(), "w:txbxContent" | "mc:AlternateContent" | "v:group");
let gone = deleted || matches!(k.name.as_str(), "w:del" | "w:moveFrom");
if !word_shapes(k, depth + 1, &k.name, inner, gone, out) {
return false;
}
}
true
}
+11
View File
@@ -91,6 +91,17 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
let blocks: Blocks = doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
// The watermark goes in every header, at the start of its first paragraph.
wr.pending_watermark = match &doc.settings.watermark {
// A picture watermark is sized for the first section naming this header (or, for the
// header written for the watermark alone, the first needing it): the same size the
// screen and a PDF draw.
Some(wm) if !footer && wm.picture.is_some() => watermark::picture(doc).and_then(|(pic, own)| {
let fit = doc.watermark_fit((id != wr.wm_header).then_some(id));
pic.size(own.0, own.1, fit)?;
let file = wr.media_files.get(&pic.media)?;
let rid = prels.add(rt::IMAGE, &format!("media/{file}"), false);
wr.used_media.insert(pic.media.clone());
watermark::picture_run(pic, own, done, fit, &rid)
}),
Some(wm) if !footer => watermark::run(wm, done, &doc.last_section),
_ => None,
};
+1 -1
View File
@@ -7,7 +7,7 @@ impl Writer<'_> {
fn watermark_headers(&mut self, s: &SectionProps) -> wordcraft_doc::section::HeaderSet {
let mut set = s.headers;
let linked = &mut self.linked_headers;
if self.doc.settings.watermark.as_ref().and_then(crate::write::watermark::text).is_some() {
if crate::write::watermark::shown(self.doc) {
let id = self.wm_header;
if set.default.or(linked.default).is_none() {
set.default = Some(id);
+78 -5
View File
@@ -1,11 +1,32 @@
//! 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.
//! The document's watermark in the headers: a text watermark as a VML text shape (ECMA-376
//! Part 4, VML: `shape`, `shapetype`, `path`, `textpath`, `fill`), a picture watermark as a VML
//! picture (`imagedata`), which is how a WordprocessingML consumer draws them behind every page.
//! The reader (`read::story::vml_watermark`, `read::watermark`) maps them back.
use wordcraft_doc::{SectionProps, Watermark};
use wordcraft_doc::watermark::own_size;
use wordcraft_doc::{Document, SectionProps, Watermark, WatermarkPicture};
use crate::xml::esc;
/// Word's Washout on `v:imagedata`: `gain` 0.3 and `blacklevel` 0.35, in 65536ths.
const WASHOUT: &str = r#" gain="19661f" blacklevel="22938f""#;
/// The document's picture watermark and its picture's own size, when its picture can be read.
pub(super) fn picture(doc: &Document) -> Option<(&WatermarkPicture, (f32, f32))> {
let pic = doc.settings.watermark.as_ref()?.picture.as_ref()?;
Some((pic, doc.media.get(&pic.media).and_then(|b| own_size(b))?))
}
/// Whether the document's watermark is written: text that isn't blank, or a picture that can be
/// read.
pub(super) fn shown(doc: &Document) -> bool {
match &doc.settings.watermark {
Some(wm) if wm.picture.is_some() => picture(doc).is_some(),
Some(wm) => text(wm).is_some(),
None => false,
}
}
/// 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.
@@ -18,6 +39,18 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
(!t.trim().is_empty()).then_some(t)
}
/// The `v:fill` `opacity` for the text's transparency: Word's `.5` for Semitransparent. None when
/// the text is opaque.
fn opacity(wm: &Watermark) -> Option<String> {
let t = wm.transparency();
if t == 0 {
return None;
}
let o = format!("{:.2}", f32::from(100 - t) / 100.0);
let o = o.trim_end_matches('0').trim_end_matches('.');
Some(o.strip_prefix("0.").map_or_else(|| o.to_string(), |f| format!(".{f}")))
}
/// 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> {
@@ -45,7 +78,7 @@ pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<Strin
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 { "" };
let fill = opacity(wm).map(|o| format!(r#"<v:fill opacity="{o}"/>"#)).unwrap_or_default();
Some(format!(
concat!(
r#"<w:r><w:rPr><w:noProof/></w:rPr><w:pict>"#,
@@ -64,3 +97,43 @@ pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<Strin
text = esc(&text),
))
}
/// A `w:r` holding the picture watermark for header part number `n`, its picture relationship
/// `rid`: Word's picture shape, centred on the margins, behind the text, with Word's Washout
/// values when washed out. `fit` is the room Auto fills (see [`Document::watermark_fit`]).
pub(super) fn picture_run(pic: &WatermarkPicture, own: (f32, f32), n: usize, fit: (f32, f32), rid: &str) -> Option<String> {
let (w, h) = pic.size(own.0, own.1, fit)?;
let style = format!(
"position:absolute;margin-left:0;margin-top:0;width:{w:.3}pt;height:{h:.3}pt;z-index:-251657216;\
mso-position-horizontal:center;mso-position-horizontal-relative:margin;\
mso-position-vertical:center;mso-position-vertical-relative:margin"
);
Some(format!(
concat!(
r#"<w:r><w:rPr><w:noProof/></w:rPr><w:pict>"#,
r#"<v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m0,0l0,21600,21600,21600,21600,0xe" filled="f" stroked="f">"#,
r#"<v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"/><o:lock v:ext="edit" aspectratio="t"/></v:shapetype>"#,
r##"<v:shape id="WordPictureWatermark{n}" type="#_x0000_t75" style="{style}" o:allowincell="f">"##,
r#"<v:imagedata r:id="{rid}"{wash}/></v:shape></w:pict></w:r>"#
),
n = n,
style = esc(&style),
rid = esc(rid),
wash = if pic.washout { WASHOUT } else { "" },
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opacity_is_written_like_word() {
let mut wm = Watermark::default();
assert_eq!(opacity(&wm).as_deref(), Some(".5"));
for (t, want) in [(0, None), (25, Some(".75")), (80, Some(".2")), (99, Some(".01")), (100, Some("0"))] {
wm.set_transparency(t);
assert_eq!(opacity(&wm).as_deref(), want, "{t}");
}
}
}
+283 -1
View File
@@ -1381,7 +1381,14 @@ fn vml_textpath_in_header_is_the_watermark() {
// Horizontal, centred both ways without Word's id, opacity in 65536ths.
let flat = r##"<v:shape type="#_x0000_t136" style="position:absolute;width:300pt;height:60pt;mso-position-horizontal:center;mso-position-vertical:center" fillcolor="#336699"><v:fill opacity="32768f"/><v:textpath string="ASAP"/></v:shape>"##;
let wm = read(flat, true).0.expect("watermark");
assert_eq!((wm.color, wm.diagonal, wm.semitransparent), (Rgb(0x33, 0x66, 0x99), false, true));
assert_eq!((wm.color, wm.diagonal, wm.semitransparent, wm.transparency()), (Rgb(0x33, 0x66, 0x99), false, true, 50));
// Any opacity is read as a transparency percentage, not only Word's `.5`.
for (opacity, want) in [(".25", 75), ("0", 100), ("1", 0), ("2", 0), ("-1", 100)] {
let shape = flat.replace(r#"opacity="32768f""#, &format!(r#"opacity="{opacity}""#));
let wm = read(&shape, true).0.expect("watermark");
assert_eq!(wm.transparency(), want, "{opacity}");
}
// Not watermarks: the same shape in the body (WordArt); header WordArt that isn't centred
// and isn't Word's watermark (a logo); a textpath turned off; a watermark-like shape inside a
@@ -1498,6 +1505,281 @@ fn word_watermark_survives_save() {
assert_eq!(d2.settings.watermark, None);
}
/// #2954: Word's picture watermark (a VML picture with Word's `WordPictureWatermark` id in the
/// headers) becomes the document's picture watermark: its picture, the scale from the shape's
/// width (Auto when it fills the margins) and Washout from `gain` and `blacklevel`. The header
/// keeps its text. Another header picture, even centred, stays a picture.
#[test]
fn word_picture_watermark_is_the_documents_watermark() {
let mut png = Vec::new();
image::RgbaImage::from_pixel(400, 200, image::Rgba([10, 20, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let read = |id: &str, size: &str, wash: &str| {
let hdr = format!(
r##"<w:hdr {W_NS}><w:p><w:r><w:t>Head</w:t></w:r><w:r><w:pict><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"/><v:shape id="{id}" o:spid="_x0000_s2049" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;{size};z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" o:allowincell="f"><v:imagedata r:id="rIdP" o:title="logo"{wash}/></v:shape></w:pict></w:r></w:p></w:hdr>"##
);
let doc = format!(
r#"<w:document {W_NS}><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#
);
let (drels, hrels) = (rels(&[("rIdH", "header", "header1.xml")]), rels(&[("rIdP", "image", "media/logo.png")]));
let parts: [(&str, &[u8]); 6] = [
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", drels.as_bytes()),
("word/header1.xml", hdr.as_bytes()),
("word/_rels/header1.xml.rels", hrels.as_bytes()),
("word/media/logo.png", &png),
];
wordcraft_docx::read(&zip(&parts)).unwrap()
};
let header_objects = |d: &Document| -> usize {
let hid = d.last_section.headers.default.unwrap();
d.parts[&hid].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.objects.len()).sum()
};
// 400 × 200 px is 300 × 150 pt at 96 ppi: 150 pt wide is 50 %.
let d = read("WordPictureWatermark77", "width:150pt;height:75pt", r#" gain="19661f" blacklevel="22938f""#);
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("picture watermark");
assert_eq!((pic.scale, pic.washout), (Some(50.0), true));
assert_eq!(d.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
assert_eq!(header_objects(&d), 0);
let hid = d.last_section.headers.default.unwrap();
assert_eq!(d.parts[&hid].blocks[0].as_para().unwrap().plain_text(), "Head");
// As wide as the margins of a Letter page with 1 in margins: Auto. No Washout.
let d = read("WordPictureWatermark1", "width:468pt;height:234pt", "");
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("picture watermark");
assert_eq!((pic.scale, pic.washout), (None, false));
// Another centred header picture is not a watermark.
let d = read("Logo1", "width:150pt;height:75pt", "");
assert_eq!(d.settings.watermark, None);
assert_eq!(header_objects(&d), 1);
}
/// A 400 × 200 px PNG of one colour (300 × 150 pt at 96 ppi).
fn solid_png(c: [u8; 4]) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(400, 200, image::Rgba(c)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
png
}
/// Word's picture watermark shape: `extra` added to its style, `wash` to its `v:imagedata`.
fn wm_shape(size: &str, extra: &str, wash: &str) -> String {
format!(
r##"<w:r><w:pict><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;{size};z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin{extra}" o:allowincell="f"><v:imagedata r:id="rIdP" o:title="wm"{wash}/></v:shape></w:pict></w:r>"##
)
}
/// A document of `body` (its `w:sectPr`s refer to `rH1`, `rH2`…) whose headers are `headers`:
/// (label, runs after the label, picture file under `word/media`).
fn headers_doc(body: &str, headers: &[(&str, String, &str)], pics: &[(&str, Vec<u8>)]) -> Document {
parts_doc(body, headers, &[], pics)
}
/// [`headers_doc`] with footers too, referred to as `rF1`, `rF2`….
fn parts_doc(body: &str, headers: &[(&str, String, &str)], footers: &[(&str, String, &str)], pics: &[(&str, Vec<u8>)]) -> Document {
let mut list: Vec<(String, String, &str, String, &str)> = Vec::new();
for (kind, tag, prefix, set) in [("header", "w:hdr", "rH", headers), ("footer", "w:ftr", "rF", footers)] {
for (i, (label, runs, pic)) in set.iter().enumerate() {
let xml = format!(r#"<{tag} {W_NS}><w:p><w:r><w:t>{label}</w:t></w:r>{runs}</w:p></{tag}>"#);
list.push((format!("{prefix}{}", i + 1), format!("{kind}{}.xml", i + 1), kind, xml, pic));
}
}
let doc_rels = rels(&list.iter().map(|(id, name, kind, ..)| (id.as_str(), *kind, name.as_str())).collect::<Vec<_>>());
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let mut parts: Vec<(String, Vec<u8>)> = vec![
("_rels/.rels".into(), ROOT_RELS.as_bytes().to_vec()),
("word/document.xml".into(), doc.into_bytes()),
("word/_rels/document.xml.rels".into(), doc_rels.into_bytes()),
];
for (_, name, _, xml, pic) in &list {
parts.push((format!("word/{name}"), xml.clone().into_bytes()));
parts.push((format!("word/_rels/{name}.rels"), rels(&[("rIdP", "image", &format!("media/{pic}"))]).into_bytes()));
}
for (name, bytes) in pics {
parts.push((format!("word/media/{name}"), bytes.clone()));
}
let entries: Vec<(&str, &[u8])> = parts.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
wordcraft_docx::read(&zip(&entries)).unwrap()
}
/// Pictures in each header part, by its text.
fn header_pictures(d: &Document) -> Vec<(String, usize)> {
let mut out: Vec<(String, usize)> = d
.parts
.values()
.filter(|p| p.kind == wordcraft_doc::PartKind::Header)
.map(|p| {
let paras: Vec<&Paragraph> = p.blocks.iter().filter_map(|b| b.as_para()).collect();
let text = paras.iter().map(|p| p.plain_text()).collect::<Vec<_>>().join("").replace('\u{FFFC}', "");
let pics = paras.iter().flat_map(|p| &p.objects).filter(|o| matches!(o, InlineObject::Image { .. })).count();
(text, pics)
})
.collect();
out.sort();
out
}
const PORTRAIT: &str = r#"<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>"#;
const LANDSCAPE: &str = r#"<w:pgSz w:w="15840" w:h="12240" w:orient="landscape"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>"#;
/// #2954 review: Word's picture watermark becomes the document's watermark only when every page
/// shows the same one, placed as Word places it. A cover page whose header lacks it, a section
/// with another picture, a watermark moved, turned or with other picture settings: each picture
/// stays in its header, as before, and still does after saving and reopening.
#[test]
fn picture_watermark_stays_in_headers_unless_every_page_shows_it() {
let (a, b) = (solid_png([20, 60, 140, 255]), solid_png([140, 20, 20, 255]));
let portrait_auto = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
let landscape_auto = wm_shape("width:648pt;height:324pt", "", r#" gain="19661f" blacklevel="22938f""#);
let p = |label: &str, run: &str, pic: &'static str| (label.to_string(), run.to_string(), pic);
let read = |body: &str, hs: Vec<(String, String, &str)>| {
let hs: Vec<(&str, String, &str)> = hs.iter().map(|(l, r, p)| (l.as_str(), r.clone(), *p)).collect();
headers_doc(body, &hs, &[("a.png", a.clone()), ("b.png", b.clone())])
};
let saved = |d: &Document| wordcraft_docx::read(&wordcraft_docx::write(d).unwrap()).unwrap();
let two_sections = format!(
r#"<w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH2"/>{LANDSCAPE}</w:sectPr>"#
);
// Every page: two sections, each Auto for its own page, and a title page with both headers.
let d = read(&two_sections, vec![p("One", &portrait_auto, "a.png"), p("Two", &landscape_auto, "a.png")]);
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("the document's watermark");
assert_eq!((pic.scale, pic.washout), (None, true));
assert_eq!(header_pictures(&d), vec![("One".into(), 0), ("Two".into(), 0)]);
let title = format!(
r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/><w:headerReference w:type="first" r:id="rH2"/>{PORTRAIT}<w:titlePg/></w:sectPr>"#
);
let d = read(&title, vec![p("Main", &portrait_auto, "a.png"), p("Cover", &portrait_auto, "a.png")]);
assert!(d.settings.watermark.as_ref().is_some_and(|w| w.picture.is_some()));
// A: the cover page's header has none.
let d = read(&title, vec![p("Main", &portrait_auto, "a.png"), p("Cover", "", "a.png")]);
assert_eq!(d.settings.watermark, None);
assert_eq!(header_pictures(&d), vec![("Cover".into(), 0), ("Main".into(), 1)]);
let d = saved(&d);
assert_eq!(d.settings.watermark, None);
assert_eq!(header_pictures(&d), vec![("Cover".into(), 0), ("Main".into(), 1)], "saved");
// B: section 2 has another picture.
let d = read(&two_sections, vec![p("One", &portrait_auto, "a.png"), p("Two", &landscape_auto, "b.png")]);
assert_eq!(d.settings.watermark, None);
assert_eq!(header_pictures(&d), vec![("One".into(), 1), ("Two".into(), 1)]);
let d2 = saved(&d);
assert_eq!(d2.settings.watermark, None);
assert_eq!(header_pictures(&d2), vec![("One".into(), 1), ("Two".into(), 1)], "saved");
let media = |d: &Document| {
let mut m: Vec<Vec<u8>> = d.media.values().map(|b| b.to_vec()).collect();
m.sort();
m
};
assert_eq!(media(&d2), media(&d), "both pictures kept");
// C: moved, turned, or with picture settings other than Washout; and a header without one.
let one = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
for run in [
wm_shape("width:468pt;height:234pt", ";mso-position-horizontal:left", ""),
wm_shape("width:468pt;height:234pt", ";rotation:30", ""),
wm_shape("width:468pt;height:234pt", ";flip:x", ""),
wm_shape("width:468pt;height:234pt", "", r#" gain="0.5" blacklevel="0.1""#),
wm_shape("width:468pt;height:234pt", "", r#" grayscale="t""#),
] {
let d = read(&one, vec![p("Main", &run, "a.png")]);
assert_eq!(d.settings.watermark, None, "{run}");
assert_eq!(header_pictures(&d), vec![("Main".into(), 1)], "{run}");
}
let no_header = format!(
r#"<w:p><w:pPr><w:sectPr>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
);
let d = read(&no_header, vec![p("Two", &portrait_auto, "a.png")]);
assert_eq!(d.settings.watermark, None, "the first section's pages have no header");
}
/// #2954 second review: watermarks the plan must leave alone, as main did. A text watermark in
/// a footer stays the document's watermark (the headers' pictures stay pictures), and a Word
/// picture watermark in a footer keeps every picture where it is. A stretched shape, a scale
/// outside 1 to 500 %, other picture corrections, in front of the text, hidden, in a group,
/// or a section in tracked insertion with no header: all stay header pictures.
#[test]
fn picture_watermark_plan_leaves_other_watermarks_alone() {
let a = solid_png([20, 60, 140, 255]);
let pics = [("a.png", a.clone())];
let one = format!(
r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/><w:footerReference w:type="default" r:id="rF1"/>{PORTRAIT}</w:sectPr>"#
);
let auto = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
let text = r##"<w:r><w:pict><v:shape id="PowerPlusWaterMarkObject9" type="#_x0000_t136" style="position:absolute;width:400pt;height:100pt;rotation:315;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="silver"><v:fill opacity=".5"/><v:textpath style="font-family:&quot;Calibri&quot;" string="DRAFT"/></v:shape></w:pict></w:r>"##;
// A footer's text watermark: the document's watermark, as before; the header picture stays,
// and both survive saving.
let d = parts_doc(&one, &[("Head", auto.clone(), "a.png")], &[("Foot", text.to_string(), "a.png")], &pics);
let wm = d.settings.watermark.clone().expect("the footer's text watermark");
assert_eq!((wm.text.as_str(), wm.picture.is_none()), ("DRAFT", true));
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)]);
let d = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(d.settings.watermark.as_ref().map(|w| w.text.as_str()), Some("DRAFT"), "saved");
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)], "saved");
// A footer's picture watermark: nothing is promoted, nothing is drawn twice.
let d = parts_doc(&one, &[("Head", auto.clone(), "a.png")], &[("Foot", auto.clone(), "a.png")], &pics);
assert_eq!(d.settings.watermark, None);
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)]);
let only = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
let group = r##"<w:r><w:pict><v:group style="position:absolute;width:468pt;height:234pt;z-index:-1" coordsize="468,234"><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;width:468;height:234"><v:imagedata r:id="rIdP"/></v:shape></v:group></w:pict></w:r>"##;
for (run, why) in [
(wm_shape("width:468pt;height:100pt", "", ""), "stretched"),
(wm_shape("width:1800pt;height:900pt", "", ""), "600 %"),
(wm_shape("width:2pt;height:1pt", "", ""), "under 1 %"),
(wm_shape("width:468pt;height:234pt", "", r#" gain="79922f" blacklevel="-1966f""#), "other corrections"),
(wm_shape("width:468pt;height:234pt", "", r#" gain="1" blacklevel="0""#), "identity values"),
(wm_shape("width:468pt;height:234pt", ";z-index:5", ""), "in front of the text"),
(wm_shape("width:468pt;height:234pt", ";visibility:hidden", ""), "hidden"),
(group.to_string(), "in a group"),
] {
let d = headers_doc(&only, &[("Head", run, "a.png")], &pics);
assert_eq!(d.settings.watermark, None, "{why}");
}
// The first section, inside a tracked insertion, has no header: its pages show none.
let tracked = format!(
r#"<w:ins w:id="1" w:author="A"><w:p><w:pPr><w:sectPr>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p></w:ins><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
);
let d = headers_doc(&tracked, &[("Head", auto.clone(), "a.png")], &pics);
assert_eq!(d.settings.watermark, None, "tracked section");
// A header that only a table cell's section property loads, with another picture.
let cell = format!(
r#"<w:tbl><w:tr><w:tc><w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rH2"/>{PORTRAIT}</w:sectPr></w:pPr></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
);
let b = solid_png([140, 20, 20, 255]);
let both = [("a.png", a.clone()), ("b.png", b)];
let d = headers_doc(&cell, &[("Head", auto.clone(), "a.png"), ("Cell", auto.clone(), "b.png")], &both);
assert_eq!(d.settings.watermark, None, "a header with another picture");
// The same document with the plain watermark is promoted.
let d = headers_doc(&only, &[("Head", auto, "a.png")], &pics);
assert!(d.settings.watermark.is_some_and(|w| w.picture.is_some()));
}
/// A watermark in a tracked deletion is not the document's: a text one stays the WordArt text
/// box it is read as (not drawn on every page), and a picture one keeps the plan from making any
/// picture the document's watermark. The same watermarks outside a deletion are the document's.
#[test]
fn a_watermark_in_a_tracked_deletion_is_not_the_documents() {
let a = solid_png([20, 60, 140, 255]);
let pics = [("a.png", a)];
let one = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
let pic = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
let text = r##"<w:r><w:pict><v:shape id="PowerPlusWaterMarkObject9" type="#_x0000_t136" style="position:absolute;width:400pt;height:100pt;rotation:315;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="silver"><v:textpath style="font-family:&quot;Calibri&quot;" string="DRAFT"/></v:shape></w:pict></w:r>"##;
let deleted = |runs: &str| format!(r#"<w:del w:id="9" w:author="A" w:date="2024-01-01T00:00:00Z">{}</w:del>"#, runs);
for (runs, kind) in [(&pic, "picture"), (&text.to_string(), "text")] {
let live = headers_doc(&one, &[("Head", runs.to_string(), "a.png")], &pics);
assert!(live.settings.watermark.is_some(), "{kind}: outside a deletion it is the document's");
let gone = headers_doc(&one, &[("Head", deleted(runs), "a.png")], &pics);
assert_eq!(gone.settings.watermark, None, "{kind}: in a deletion it isn't");
}
// The picture stays in the header, marked deleted.
let gone = headers_doc(&one, &[("Head", deleted(&pic), "a.png")], &pics);
assert_eq!(header_pictures(&gone), vec![("Head".into(), 1)]);
}
/// Issue #1775: a picture watermark in a header that starts with a table is anchored in a cell
/// but placed on the page: any VML shape in a header (Word's picture watermark, `o:allowincell="f"`; user-checked
/// in Word for Mac, the attribute makes no difference) or DrawingML `layoutInCell="0"`. That survives save and reopen; a shape laid out in its cell stays so.
+147 -1
View File
@@ -12,7 +12,9 @@ use wordcraft_doc::props::{
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
use wordcraft_doc::table::{Cell, Table};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block};
use wordcraft_doc::{
Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, WatermarkPicture, para_block,
};
fn rt(doc: &Document) -> Document {
let bytes = wordcraft_docx::write(doc).expect("write");
@@ -1561,7 +1563,10 @@ fn watermark_roundtrips() {
diagonal: false,
semitransparent: false,
size: None,
..Default::default()
},
// Any transparency, not only Word's two.
Watermark { transparency: Some(80), ..Default::default() },
// A box of its own (not Word's default for the page) is kept.
Watermark { text: "COPY".into(), size: Some([300.0, 80.0]), ..Default::default() },
] {
@@ -1573,6 +1578,147 @@ fn watermark_roundtrips() {
}
}
/// A `w`×`h` px PNG of one colour.
fn wm_png(w: u32, h: u32) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([20, 60, 140, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
}
/// #2954: a picture watermark round-trips through Word's VML picture shape in every header:
/// the picture, Auto or a scale, and Washout (`gain` and `blacklevel`) on or off. The header
/// keeps its own text and gains no picture of its own.
#[test]
fn picture_watermark_roundtrips() {
let png = wm_png(40, 20);
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
d.last_section.headers.default = Some(h);
let key = d.add_media(png.clone(), "png");
for (scale, washout) in [(None, true), (Some(50.0), false), (Some(200.0), true)] {
let wm = Watermark { picture: Some(WatermarkPicture { media: key.clone(), scale, washout }), ..Watermark::default() };
d.settings.watermark = Some(wm.clone());
let bytes = wordcraft_docx::write(&d).unwrap();
let hdr = part_xml(&bytes, "word/header1.xml");
assert_eq!(hdr.matches("<v:shape ").count(), 1);
assert!(hdr.contains(r#"id="WordPictureWatermark1""#) && hdr.contains("mso-position-vertical-relative:margin"), "{hdr}");
assert_eq!(hdr.contains(r#"gain="19661f" blacklevel="22938f""#), washout);
assert!(part_xml(&bytes, "word/_rels/header1.xml.rels").contains("media/"));
let r = wordcraft_docx::read(&bytes).unwrap();
let back = r.settings.watermark.clone().expect("watermark");
let pic = back.picture.clone().expect("picture");
assert_eq!((pic.scale, pic.washout), (scale, washout));
assert_eq!(r.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
let hid = r.last_section.headers.default.unwrap();
assert_eq!(part_text(&r, Some(hid)), "Head");
assert!(r.parts[&hid].blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()), "no picture left in the header");
assert_eq!(rt(&r).settings.watermark, r.settings.watermark, "second generation");
}
// A watermark whose picture can't be read is not written.
d.media.insert(key, Arc::new(b"junk".to_vec()));
let r = rt(&d);
assert_eq!(r.settings.watermark, None);
assert!(!part_xml(&wordcraft_docx::write(&d).unwrap(), "word/header1.xml").contains("v:shape"));
}
/// A portrait first section whose header the landscape last section links to ("link to
/// previous"), with a wide picture watermark at Auto.
fn portrait_then_landscape_linked(header: bool, scale: Option<f32>) -> Document {
let mut d = Document::new();
let mut s1 = SectionProps::default();
if header {
s1.headers.default = Some(d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]));
}
let mut p1 = Paragraph::with_text("Portrait", CharProps::default());
p1.section = Some(Box::new(s1));
d.body = vec![para_block(p1), para_block(Paragraph::with_text("Landscape", CharProps::default()))];
d.last_section.set_landscape(true);
let key = d.add_media(wm_png(400, 200), "png");
d.settings.watermark = Some(Watermark { picture: Some(WatermarkPicture { media: key, scale, washout: true }), ..Watermark::default() });
d
}
/// #2954 review: Auto is sized once per header, for the first section that names it. A portrait
/// section before a landscape one that links to its header: the header holds the portrait size
/// (468 pt wide), and reads back as Auto.
#[test]
fn auto_picture_watermark_is_sized_for_the_first_section_naming_its_header() {
let d = portrait_then_landscape_linked(true, None);
// The header the landscape section links to is the portrait section's.
assert_eq!(d.watermark_fit(d.sections()[0].1.headers.default), (468.0, 648.0));
let bytes = wordcraft_docx::write(&d).unwrap();
assert!(part_xml(&bytes, "word/header1.xml").contains("width:468.000pt;height:234.000pt"));
let r = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(r.settings.watermark.and_then(|w| w.picture).map(|p| p.scale), Some(None), "still Auto");
}
/// A document of two sections, the first with no header of its own and the last landscape: the
/// watermark's own header serves both, sized for the first.
#[test]
fn auto_picture_watermark_header_written_for_the_watermark_alone() {
let d = portrait_then_landscape_linked(false, None);
assert_eq!(d.watermark_fit(None), (468.0, 648.0));
let bytes = wordcraft_docx::write(&d).unwrap();
assert!(part_xml(&bytes, "word/header1.xml").contains("width:468.000pt;height:234.000pt"));
let r = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(r.settings.watermark.and_then(|w| w.picture).map(|p| p.scale), Some(None));
}
/// #2954 second review: a watermark at a fixed scale, its header shapes the same size in a
/// portrait and a landscape section, fills one section's margins but not the other's. It is
/// still the document's watermark at that scale after saving and reopening, washed out, and
/// the headers gain no pictures.
#[test]
fn fixed_scale_watermark_across_differing_sections() {
let mut d = Document::new();
let h1 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Portrait", CharProps::default()))]);
let h2 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Landscape", CharProps::default()))]);
let mut s1 = SectionProps::default();
s1.headers.default = Some(h1);
let mut p1 = Paragraph::with_text("One", CharProps::default());
p1.section = Some(Box::new(s1));
d.body = vec![para_block(p1), para_block(Paragraph::with_text("Two", CharProps::default()))];
d.last_section.set_landscape(true);
d.last_section.headers.default = Some(h2);
let key = d.add_media(wm_png(624, 312), "png");
let wm = Watermark { picture: Some(WatermarkPicture { media: key, scale: Some(100.0), washout: true }), ..Watermark::default() };
d.settings.watermark = Some(wm);
let bytes = wordcraft_docx::write(&d).unwrap();
for name in ["word/header1.xml", "word/header2.xml"] {
assert!(part_xml(&bytes, name).contains("width:468.000pt;height:234.000pt"), "{name}");
}
let r = wordcraft_docx::read(&bytes).unwrap();
let pic = r.settings.watermark.clone().and_then(|w| w.picture).expect("still the document's watermark");
assert_eq!((pic.scale, pic.washout), (Some(100.0), true));
for id in header_ids(&r) {
assert!(r.parts[&id].blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()), "no header picture");
}
let r2 = rt(&r);
assert_eq!(r2.settings.watermark, r.settings.watermark, "second generation");
}
/// #2954: the text watermark's transparency round-trips as `v:fill` `opacity`; Word's
/// Semitransparent is `.5`, and an opaque watermark has no `v:fill`.
#[test]
fn text_watermark_transparency_roundtrips() {
for (t, fill) in [(0, None), (25, Some(".75")), (50, Some(".5")), (80, Some(".2")), (100, Some("0"))] {
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
let mut wm = Watermark::default();
wm.set_transparency(t);
d.settings.watermark = Some(wm.clone());
let bytes = wordcraft_docx::write(&d).unwrap();
let hdr = part_xml(&bytes, "word/header1.xml");
match fill {
Some(o) => assert!(hdr.contains(&format!(r#"<v:fill opacity="{o}"/>"#)), "{t}: {hdr}"),
None => assert!(!hdr.contains("v:fill"), "{t}"),
}
let r = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(r.settings.watermark, Some(wm), "{t}");
}
}
/// #3994: a watermark font name round-trips through the textpath's CSS-like style whatever it
/// carries — apostrophes, semicolons, colons, quotes, backslashes. Before, those characters
/// were deleted (the font was saved under a different name) and a trailing backslash escaped
+21
View File
@@ -17,6 +17,27 @@ fn tiny_png() -> Vec<u8> {
out.into_inner()
}
/// #2954: a picture watermark and a text watermark's transparency survive Word 2003 XML (the
/// picture as `w:binData`, the same VML shapes as in DOCX).
#[test]
fn watermarks_round_trip_through_2003() {
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text("Body", CharProps::default()))];
let key = d.add_media(tiny_png(), "png");
let pic = wordcraft_doc::WatermarkPicture { media: key, scale: Some(150.0), washout: true };
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
let back = read_xml(&write_wordml2003(&d).expect("write")).expect("read");
let got = back.settings.watermark.and_then(|w| w.picture).expect("picture watermark");
assert_eq!((got.scale, got.washout), (Some(150.0), true));
assert_eq!(back.media.get(&got.media).map(|b| b.as_slice()), Some(tiny_png().as_slice()));
let mut wm = wordcraft_doc::Watermark::default();
wm.set_transparency(70);
d.settings.watermark = Some(wm.clone());
let back = read_xml(&write_wordml2003(&d).expect("write")).expect("read");
assert_eq!(back.settings.watermark, Some(wm));
}
fn paras(doc: &Document) -> Vec<&Paragraph> {
doc.body.iter().filter_map(|b| b.as_para()).collect()
}
+2
View File
@@ -56,3 +56,5 @@ wordcraft-doc = { workspace = true, features = ["test-support"] }
encoding_rs = { workspace = true }
zip = { workspace = true }
cfb = { workspace = true }
# The PDF side of the watermark size check (what the export draws, read back).
wordcraft-pdf = { workspace = true, features = ["test-support"] }
+246 -4
View File
@@ -3,7 +3,8 @@
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
use wordcraft_doc::{Watermark, styles};
use wordcraft_doc::watermark::{MAX_SCALE, MIN_SCALE};
use wordcraft_doc::{Watermark, WatermarkPicture, styles};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@@ -71,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
.params(r#"{"text"?: string (a text watermark), "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB", "transparency"?: 0-100 (percent; Word's Semitransparent is 50), "semitransparent"?: bool (50 or 0 percent), "picture"?: {"path": string} | {"data": base64} | {"media": key} (a picture watermark; at most 64 MB) | false (back to text), "scale"?: "auto" | 1-500 (percent of the picture's own size; auto fits the margins), "washout"?: bool (faded, default on)}"#),
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
s.doc.settings.page_color = p::str(v, "color").and_then(Rgb::parse);
s.doc.settings.page_picture = None;
@@ -247,23 +248,142 @@ fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Design › Watermark: a text watermark (`text` switches to one), or a picture watermark
/// (`picture`) with its scale and Washout; the text keeps its settings meanwhile, as in Word's
/// dialog. Settings for the other kind are refused rather than silently kept.
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
if p::bool(v, "remove").unwrap_or(false) {
s.doc.settings.watermark = None;
return sel_result(s);
}
let mut wm = s.doc.settings.watermark.clone().unwrap_or_default();
// Everything is checked before the document is touched (a picture added to the media).
let scale = watermark_scale(v)?;
let picked = match v.get("picture") {
Some(Value::Bool(false)) => {
wm.picture = None;
None
}
Some(pic) if !pic.is_null() => Some(watermark_picture(s, pic)?),
_ => {
if p::str(v, "text").is_some() {
wm.picture = None;
}
None
}
};
// The picture's bytes wait: it joins the document's media only after every check below.
let pending = pick_watermark_picture(&mut wm, picked);
if let Some(t) = p::str(v, "text") {
wm.text = t.chars().take(200).collect();
}
match wm.picture.as_mut() {
Some(pic) => apply_picture_settings(v, pic, scale)?,
None => apply_text_settings(v, &mut wm)?,
}
if let (Some(bytes), Some(pic)) = (pending, wm.picture.as_mut()) {
let ext = super::insert::image_ext(&bytes);
pic.media = s.doc.add_media(bytes, ext);
}
s.doc.settings.watermark = Some(wm);
sel_result(s)
}
/// The watermark's `scale` param: None when absent, `Some(None)` for Auto, else the percentage in
/// tenths, clamped.
fn watermark_scale(v: &Value) -> Result<Option<Option<f32>>, CmdError> {
match v.get("scale") {
None => Ok(None),
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Ok(Some(None)),
Some(_) => {
let percent =
p::f32(v, "scale").filter(|p| p.is_finite()).ok_or_else(|| CmdError::Params("`scale` is \"auto\" or a percentage".into()))?;
// Tenths of a percent, as a saved file gives it back.
Ok(Some(Some((percent.clamp(MIN_SCALE, MAX_SCALE) * 10.0).round() / 10.0)))
}
}
}
/// Makes `picked` (bytes and an existing media key) the watermark's picture, keeping its other
/// settings; returns the bytes still to join the document's media, if any.
fn pick_watermark_picture(wm: &mut Watermark, picked: Option<(Vec<u8>, Option<String>)>) -> Option<Vec<u8>> {
let (bytes, key) = picked?;
let (media, pending) = match key {
Some(key) => (key, None),
None => (String::new(), Some(bytes)),
};
wm.picture = Some(WatermarkPicture { media, ..wm.picture.take().unwrap_or_default() });
pending
}
/// Applies a picture watermark's `scale` and `washout`; refuses a text watermark's settings.
fn apply_picture_settings(v: &Value, pic: &mut WatermarkPicture, scale: Option<Option<f32>>) -> Result<(), CmdError> {
if let Some(k) = ["diagonal", "color", "transparency", "semitransparent"].into_iter().find(|k| v.get(*k).is_some()) {
return Err(CmdError::Params(format!("`{k}` is for a text watermark: give `text` or `\"picture\": false` too")));
}
if let Some(scale) = scale {
pic.scale = scale;
}
if let Some(w) = p::bool(v, "washout") {
pic.washout = w;
}
Ok(())
}
/// Applies a text watermark's layout, colour and transparency; refuses a picture's settings.
fn apply_text_settings(v: &Value, wm: &mut Watermark) -> Result<(), CmdError> {
if let Some(k) = ["scale", "washout"].into_iter().find(|k| v.get(*k).is_some()) {
return Err(CmdError::Params(format!("`{k}` is for a picture watermark: give `picture` too")));
}
if let Some(d) = p::bool(v, "diagonal") {
wm.diagonal = d;
}
if let Some(c) = p::str(v, "color").and_then(Rgb::parse) {
wm.color = c;
}
s.doc.settings.watermark = Some(Watermark { ..wm });
sel_result(s)
if let Some(on) = p::bool(v, "semitransparent") {
wm.set_transparency(if on { 50 } else { 0 });
}
// Applied after `semitransparent`, so a percentage wins when both are given.
if let Some(t) = p::f32(v, "transparency") {
wm.set_transparency(if t.is_finite() { t.clamp(0.0, 100.0).round() as u8 } else { 0 });
}
Ok(())
}
/// Largest watermark picture taken, bytes. A watermark is a faded backdrop, not a photograph
/// library: far below Insert › Pictures' 200 MB, so refusing a bigger one never costs a large
/// allocation (the base64 text is measured before it is decoded).
const MAX_PICTURE_BYTES: usize = 64 << 20;
/// The picture of a watermark: a file (`path`), base64 `data`, or a picture already in the
/// document (`media`, returned as its key). Only pictures the renderers draw are taken.
fn watermark_picture(s: &Session, v: &Value) -> Result<(Vec<u8>, Option<String>), CmdError> {
let unsupported = || CmdError::Failed("not a supported picture (PNG, JPEG, GIF, WebP, BMP, TIFF)".into());
let readable = |b: &[u8]| wordcraft_doc::watermark::own_size(b).is_some();
let too_large = || CmdError::Failed(format!("the picture is larger than {} MB", MAX_PICTURE_BYTES >> 20));
if let Some(key) = p::str(v, "media") {
let bytes = s.doc.media.get(key).ok_or_else(|| CmdError::Params(format!("no picture `{key}` in the document")))?;
return if readable(bytes) { Ok((Vec::new(), Some(key.to_string()))) } else { Err(unsupported()) };
}
let bytes = if let Some(d) = p::str(v, "data") {
// base64 is 4 characters for 3 bytes: measured before anything is decoded.
if d.len() > MAX_PICTURE_BYTES / 3 * 4 + 4 {
return Err(too_large());
}
super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?
} else if let Some(path) = p::str(v, "path") {
crate::io::read_file_capped(path, MAX_PICTURE_BYTES as u64, "picture").map_err(CmdError::Failed)?
} else {
return Err(CmdError::Params("`picture` needs `path`, `data` or `media`".into()));
};
if bytes.len() > MAX_PICTURE_BYTES {
return Err(too_large());
}
if !readable(&bytes) {
return Err(unsupported());
}
Ok((bytes, None))
}
/// Page borders of the caret's section (or every section with `applyTo: "document"`).
@@ -288,3 +408,125 @@ fn page_borders(s: &mut Session, v: &Value) -> CmdResult {
}
sel_result(s)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use wordcraft_doc::{Document, Watermark, WatermarkPicture};
use super::MAX_PICTURE_BYTES;
use crate::Session;
fn png(w: u32, h: u32) -> Vec<u8> {
let mut b = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([30, 60, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png)
.unwrap();
b
}
fn wm(s: &Session) -> Watermark {
s.doc.settings.watermark.clone().unwrap()
}
/// #2954: a picture watermark from a file's bytes, its scale and Washout, then back to text;
/// each step undoes.
#[test]
fn picture_watermark_and_back_to_text() {
let mut s = Session::new(Document::new());
let data = super::super::insert::base64_encode(&png(40, 20));
s.run("design.watermark", &json!({"picture": {"data": data}})).unwrap();
let pic = wm(&s).picture.unwrap();
assert_eq!((pic.scale, pic.washout), (None, true), "Word's defaults: Auto, washed out");
assert!(s.doc.media.contains_key(&pic.media));
s.run("design.watermark", &json!({"scale": 150, "washout": false})).unwrap();
assert_eq!(wm(&s).picture, Some(WatermarkPicture { media: pic.media.clone(), scale: Some(150.0), washout: false }));
s.run("design.watermark", &json!({"scale": "AUTO"})).unwrap();
assert_eq!(wm(&s).picture.unwrap().scale, None);
// Clamped to Word's range.
s.run("design.watermark", &json!({"scale": 9000})).unwrap();
assert_eq!(wm(&s).picture.unwrap().scale, Some(500.0));
s.run("design.watermark", &json!({"scale": 0})).unwrap();
assert_eq!(wm(&s).picture.unwrap().scale, Some(1.0));
s.run("design.watermark", &json!({"scale": 33.333})).unwrap();
assert_eq!(wm(&s).picture.unwrap().scale, Some(33.3));
// A picture already in the document; text settings are refused while it is a picture.
s.run("design.watermark", &json!({"picture": {"media": pic.media}, "scale": 50})).unwrap();
assert!(s.run("design.watermark", &json!({"transparency": 30})).is_err());
assert!(s.run("design.watermark", &json!({"diagonal": false})).is_err());
// Text (or `picture: false`) makes it a text watermark again, with its text settings.
s.run("design.watermark", &json!({"text": "SAMPLE", "transparency": 30})).unwrap();
let text = wm(&s);
assert_eq!((text.picture.is_none(), text.text.as_str(), text.transparency()), (true, "SAMPLE", 30));
assert!(s.run("design.watermark", &json!({"washout": true})).is_err());
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(wm(&s).picture.unwrap().scale, Some(50.0));
s.run("design.watermark", &json!({"picture": false})).unwrap();
assert_eq!(wm(&s).picture, None);
s.run("design.watermark", &json!({"remove": true})).unwrap();
assert_eq!(s.doc.settings.watermark, None);
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc.settings.watermark.is_some());
}
/// #2954: the text's transparency in percent, kept in step with Semitransparent.
#[test]
fn text_watermark_transparency() {
let mut s = Session::new(Document::new());
s.run("design.watermark", &json!({"text": "DRAFT"})).unwrap();
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (50, true));
s.run("design.watermark", &json!({"transparency": 80})).unwrap();
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (80, true));
s.run("design.watermark", &json!({"semitransparent": false})).unwrap();
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (0, false));
s.run("design.watermark", &json!({"transparency": 250.7})).unwrap();
assert_eq!(wm(&s).transparency(), 100);
s.run("design.watermark", &json!({"transparency": -3})).unwrap();
assert_eq!(wm(&s).transparency(), 0);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(wm(&s).transparency(), 100);
}
/// #2954: pictures that can't be drawn, and missing ones, are refused and change nothing.
#[test]
fn bad_watermark_pictures_are_refused() {
let mut s = Session::new(Document::new());
let text = s.doc.add_media(b"not a picture".to_vec(), "png");
for bad in [
json!({"picture": {}}),
json!({"picture": {"data": "%%%"}}),
json!({"picture": {"data": super::super::insert::base64_encode(b"plain text")}}),
json!({"picture": {"media": "nope.png"}}),
json!({"picture": {"media": text}}),
json!({"picture": {"path": "/nonexistent/watermark.png"}}),
json!({"picture": 7}),
json!({"picture": {"data": super::super::insert::base64_encode(&png(4, 4))}, "scale": "big"}),
] {
assert!(s.run("design.watermark", &bad).is_err(), "{bad}");
assert_eq!(s.doc.settings.watermark, None, "{bad}");
assert_eq!(s.doc.media.len(), 1, "{bad}");
}
}
/// #2954 review: a picture too large is refused before anything is decoded or read: the
/// base64 text is measured first, and a file by its size.
#[test]
fn oversized_watermark_pictures_are_refused_without_a_large_allocation() {
let mut s = Session::new(Document::new());
// Longer than the largest picture's base64 text: refused by length, never decoded (the
// decoded bytes would be as large as the cap again).
let big = "A".repeat(MAX_PICTURE_BYTES / 3 * 4 + 8);
let e = s.run("design.watermark", &json!({"picture": {"data": big}})).unwrap_err();
let e = format!("{e:?}");
assert!(e.contains("larger than"), "{e}");
assert_eq!((s.doc.settings.watermark.clone(), s.doc.media.len()), (None, 0));
// A file past the cap is refused by its size (a sparse file, so nothing is written).
let path = std::env::temp_dir().join(format!("wordcraft-wm-big-{}.png", std::process::id()));
std::fs::File::create(&path).unwrap().set_len(MAX_PICTURE_BYTES as u64 + 1).unwrap();
let e = s.run("design.watermark", &json!({"picture": {"path": path.to_string_lossy()}})).unwrap_err();
let e = format!("{e:?}");
let _ = std::fs::remove_file(&path);
assert!(e.contains("larger than"), "{e}");
assert_eq!((s.doc.settings.watermark.clone(), s.doc.media.len()), (None, 0));
}
}
+8 -22
View File
@@ -652,33 +652,19 @@ pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
if para.objects.is_empty() {
return p.clone();
}
let Some(before) = para.text.get(..p.off) else { return p.clone() };
let Some(after) = para.text.get(p.off..) else { return p.clone() };
if !before.ends_with(OBJ) && !after.starts_with(OBJ) {
return p.clone();
}
let editable: HashSet<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
// Count the preceding objects once. Each adjacent marker can then use the matching
// object directly, forwards for starts and backwards for ends, even with short storage.
let index = before.chars().filter(|c| *c == OBJ).count();
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let w = OBJ.len_utf8();
let mut off = p.off;
for (ch, object) in after.chars().zip(para.objects.iter().skip(index)) {
if ch != OBJ || !matches!(object, InlineObject::PermStart { id, .. } if editable.contains(id)) {
break;
if is_start(off) {
while is_start(off) {
off += w;
}
off += w;
}
if off > p.off {
return Pos { off, ..p.clone() };
}
if let Some(objects) = para.objects.get(..index) {
for (ch, object) in before.chars().rev().zip(objects.iter().rev()) {
if ch != OBJ || !matches!(object, InlineObject::PermEnd { id } if editable.contains(id)) {
break;
}
off -= w;
}
while off >= w && is_end(off - w) {
off -= w;
}
Pos { off, ..p.clone() }
}
-156
View File
@@ -922,159 +922,3 @@ fn legacy_password_is_verified_and_unknown_forms_refused() {
assert!(e.to_string().contains(super::UNSUPPORTED_PASSWORD));
assert!(super::enforced(&s.doc));
}
/// Consecutive permission boundaries must not recount the paragraph prefix or search all
/// editable IDs once per marker. Both ends are used by typing and Enter.
#[test]
fn typing_boundary_many_permission_markers_is_not_quadratic() {
use wordcraft_doc::para::Run;
let cpu = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let measure = |n: usize, at_end: bool| {
let mut p = wordcraft_doc::Paragraph::new();
p.text.push_str("λ prefix ");
let start = p.text.len();
for i in 0..n {
p.text.push(OBJ);
p.objects.push(InlineObject::PermStart { id: i as u32, group: "everyone".into(), editor: String::new() });
}
p.text.push_str("body");
let end = p.text.len();
for i in 0..n {
p.text.push(OBJ);
p.objects.push(InlineObject::PermEnd { id: i as u32 });
}
let after = p.text.len();
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
let mut doc = Document::new();
doc.body = vec![Block::Para(p).into()];
doc.settings.protection = Some("readOnly".into());
let s = Session::new(doc);
let at = Pos::body(0, if at_end { after } else { start });
let expected = Pos::body(0, if at_end { end } else { start + n * OBJ.len_utf8() });
let before = cpu();
for _ in 0..3 {
assert_eq!(typing_pos(std::hint::black_box(&s), &at), expected);
}
match (before, cpu()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
};
for at_end in [false, true] {
let (small, large) = (measure(2_000, at_end), measure(16_000, at_end));
let (Some(a), Some(b)) = (small, large) else {
eprintln!("Typing boundary: CPU counter unavailable; semantic checks passed, timing assertion skipped");
continue;
};
eprintln!("Typing boundary end={at_end}: small={a:.9}s large={b:.9}s ratio={:.2}", b / a);
assert!(b < a * 24.0 + 0.005, "8x permission markers must not take quadratic boundary work: {a:.9}s -> {b:.9}s");
}
}
#[test]
fn typing_boundary_keeps_author_precedence_and_malformed_pairing() {
use wordcraft_doc::para::Run;
// Frozen boundary semantics from before the one-pass lookup. This oracle deliberately
// uses object_at independently of the new traversal, including malformed storage.
let original = |s: &Session, p: &Pos| {
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
return p.clone();
}
let Some(para) = s.doc.para_at(p) else { return p.clone() };
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let start = |off| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let end = |off| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let mut off = p.off;
if start(off) {
while start(off) {
off += OBJ.len_utf8();
}
} else {
while off >= OBJ.len_utf8() && end(off - OBJ.len_utf8()) {
off -= OBJ.len_utf8();
}
}
Pos { off, ..p.clone() }
};
let objects = [
InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() },
InlineObject::PermStart { id: 2, group: String::new(), editor: " ALICE ".into() },
InlineObject::PermStart { id: 3, group: String::new(), editor: "alice".into() },
InlineObject::PermEnd { id: 1 },
InlineObject::PermEnd { id: 3 },
InlineObject::BookmarkStart { name: "Original".into() },
InlineObject::PermEnd { id: 7 },
InlineObject::PermStart { id: 7, group: String::new(), editor: "Bob".into() },
];
for text in
[format!("λ{}x{}終", OBJ.to_string().repeat(4), OBJ.to_string().repeat(4)), OBJ.to_string().repeat(9), format!("λ{OBJ}{OBJ}x{OBJ}{OBJ}{OBJ}")]
{
for count in 0..=objects.len() {
for mode in [None, Some("readOnly"), Some("comments"), Some("forms"), Some("trackedChanges")] {
let mut p = wordcraft_doc::Paragraph::new();
p.text = text.clone();
p.objects = objects[..count].to_vec();
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
let mut doc = Document::new();
doc.body = vec![Block::Para(p).into()];
doc.settings.protection = mode.map(str::to_string);
let mut s = Session::new(doc);
s.author = " Alice ".into();
for off in 0..=text.len() + 1 {
let at = Pos::body(0, off);
assert_eq!(typing_pos(&s, &at), original(&s, &at), "mode={mode:?} count={count} off={off} text={text:?}");
}
}
}
}
let mut s = Session::new(Document::from_text("outside"));
s.doc.settings.protection = Some("readOnly".into());
assert_eq!(typing_pos(&s, &Pos::body(99, 0)), Pos::body(99, 0));
}
#[test]
fn typing_boundary_keeps_duplicate_ids_across_stories_and_start_precedence() {
use wordcraft_doc::para::Run;
let paragraph = |text: String, objects| wordcraft_doc::Paragraph {
runs: vec![Run { len: text.len(), props: CharProps::default() }],
text,
objects,
..Default::default()
};
let mut doc = Document::new();
doc.body = vec![
Block::Para(paragraph(
format!("{OBJ}x{OBJ}"),
vec![InlineObject::PermStart { id: 1, group: String::new(), editor: "Bob".into() }, InlineObject::PermEnd { id: 1 }],
))
.into(),
];
doc.add_part(
wordcraft_doc::PartKind::Header,
vec![
Block::Para(paragraph(
format!("{OBJ}y{OBJ}"),
vec![InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }, InlineObject::PermEnd { id: 1 }],
))
.into(),
],
);
doc.settings.protection = Some("comments".into());
let mut s = Session::new(doc);
s.author = "Alice".into();
// Preserve the existing document-wide ID membership, even for a duplicate in another story.
assert_eq!(typing_pos(&s, &Pos::body(0, 0)), Pos::body(0, OBJ.len_utf8()));
assert_eq!(typing_pos(&s, &Pos::body(0, 7)), Pos::body(0, 4));
s.doc.body = vec![
Block::Para(paragraph(
format!("{OBJ}{OBJ}x"),
vec![InlineObject::PermEnd { id: 1 }, InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }],
))
.into(),
];
// A start to the right wins over an end to the left.
assert_eq!(typing_pos(&s, &Pos::body(0, 3)), Pos::body(0, 6));
}
+5
View File
@@ -712,6 +712,11 @@ pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, Str
Ok(bytes)
}
#[cfg(target_arch = "wasm32")]
pub fn read_file_capped(path: &str, _max: u64, _what: &str) -> Result<Vec<u8>, String> {
read_file(path)
}
/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation
/// read root when there is one: enough to tell a file's format by its start. Only a regular file
/// is opened: a named pipe would block the open until a writer appeared (#3666).
+1
View File
@@ -17,6 +17,7 @@ mod fonts;
mod ime_noop;
mod revision_checks;
mod typing;
mod watermark;
mod wordart;
/// True, after saying so on stderr, when this build lacks the Language data the proofing tests
+88
View File
@@ -0,0 +1,88 @@
//! Design › Watermark: the picture watermark's size is one answer on screen, in a PDF and in a
//! saved DOCX.
use wordcraft_doc::{CharProps, Document, Paragraph, PartKind, SectionProps, Watermark, WatermarkPicture, para_block};
use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
use wordcraft_render::{RenderOptions, render_page};
/// A solid `w`×`h` px PNG.
fn solid_png(w: u32, h: u32) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([20, 60, 140, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
}
/// A portrait first section whose header the landscape last section links to ("link to
/// previous"), with a wide picture watermark at Auto (or, when `header` is false, no header at all,
/// so the file gives the watermark a header of its own).
fn portrait_then_landscape(header: bool, scale: Option<f32>) -> Document {
let mut d = Document::new();
let mut s1 = SectionProps::default();
if header {
s1.headers.default = Some(d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]));
}
let mut one = Paragraph::with_text("Portrait", CharProps::default());
one.section = Some(Box::new(s1));
d.body = vec![para_block(one), para_block(Paragraph::with_text("Landscape", CharProps::default()))];
d.last_section.set_landscape(true);
let key = d.add_media(solid_png(400, 200), "png");
d.settings.watermark = Some(Watermark { picture: Some(WatermarkPicture { media: key, scale, washout: false }), ..Default::default() });
d
}
/// The width of the blue picture on the middle row of a page as drawn on screen (points).
fn screen_width(d: &Document, page: usize) -> f32 {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
let p = &l.pages[page];
let img = render_page(d, p, 1.0, &RenderOptions::default());
let row = (p.body.y + p.body.h / 2.0) as usize;
let blue = |x: usize| {
let i = (row * img.width as usize + x) * 4;
img.pixels.get(i..i + 3).is_some_and(|c| c.iter().zip([20u8, 60, 140]).all(|(a, b)| a.abs_diff(b) <= 3))
};
(0..img.width as usize).filter(|x| blue(*x)).count() as f32
}
/// The widths of the picture watermark on each page of a PDF of `d` (points).
fn pdf_widths(d: &Document) -> Vec<f64> {
wordcraft_pdf::test_support::drawn_images(&wordcraft_pdf::export(d, &wordcraft_pdf::PdfOptions::default()).unwrap())
.iter()
.map(|(r, ..)| r.width())
.collect()
}
/// The width the first header of a saved DOCX gives the picture watermark, from its shape.
fn docx_width(d: &Document) -> f32 {
let bytes = wordcraft_docx::write(d).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/header1.xml").unwrap(), &mut xml).unwrap();
let at = xml.find("width:").expect("a sized shape") + "width:".len();
xml[at..].split("pt").next().unwrap().parse().unwrap()
}
/// #2954 review: Auto is sized from the first section that names the page's header, the same on
/// every page and in every output: a landscape section linked to a portrait section's header
/// shows the portrait-sized picture on screen, in a PDF and in a saved DOCX (not one re-fitted
/// to its wider page on screen and in a PDF while the file holds the first size).
#[test]
fn auto_picture_watermark_size_is_the_same_on_screen_in_a_pdf_and_in_a_docx() {
for header in [true, false] {
let d = portrait_then_landscape(header, None);
// 400 × 200 px is 300 × 150 pt; the portrait page's margins are 468 pt wide.
let docx = docx_width(&d);
assert!((docx - 468.0).abs() < 0.01, "header {header}: the DOCX holds {docx}");
for page in 0..2 {
let screen = screen_width(&d, page);
assert!((screen - docx).abs() <= 2.0, "header {header}: page {page} on screen {screen}, DOCX {docx}");
}
let pdf = pdf_widths(&d);
assert_eq!(pdf.len(), 2, "one per page");
assert!(pdf.iter().all(|w| (w - f64::from(docx)).abs() < 0.5), "header {header}: PDF {pdf:?}, DOCX {docx}");
// And the file reads back as Auto, not as a scale.
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(back.settings.watermark.and_then(|w| w.picture).map(|p| p.scale), Some(None), "header {header}");
}
}
+17 -2
View File
@@ -62,9 +62,19 @@ pub(super) struct Body<'a> {
forms: super::forms::FormsReader,
/// A watermark found (in a header): see [`Self::start_shape`].
pub(super) watermark: Option<wordcraft_doc::Watermark>,
/// A picture watermark found (in a header): its picture, its frame's size (points) and
/// whether it is washed out. See [`Self::end_frame`].
pub(super) watermark_pictures: Vec<(Vec<u8>, (f32, f32), bool)>,
/// Take those frames out of the header (the document's watermark), rather than leave them
/// there as pictures: set once every header and footer has been read and they qualify.
pub(super) take_watermark_pictures: bool,
/// The open frame is drawn faded (`draw:color-mode` `watermark`).
frame_washout: bool,
/// Reading a master page's header or footer (where a watermark lives).
pub(super) page_part: bool,
/// The shape being read is a watermark's, if its geometry follows its text: its size, turn,
/// colour and see-through, and whether its geometry is fontwork.
watermark_shape: Option<((f32, f32), f32, Option<wordcraft_doc::props::Rgb>, bool, bool)>,
/// colour and transparency (percent), and whether its geometry is fontwork.
watermark_shape: Option<((f32, f32), f32, Option<wordcraft_doc::props::Rgb>, u8, bool)>,
pub(super) manifest: Option<&'a HashMap<String, String>>,
containers: Vec<Vec<FBlock>>,
/// Drawings anchored in the text but outside any paragraph (LibreOffice's page-anchored
@@ -217,6 +227,10 @@ impl<'a> Body<'a> {
entries: &[],
forms: Default::default(),
watermark: None,
watermark_pictures: Vec::new(),
take_watermark_pictures: false,
frame_washout: false,
page_part: false,
watermark_shape: None,
break_next: false,
sources: Vec::new(),
@@ -1330,6 +1344,7 @@ impl Body<'_> {
self.frame_look = g.look(h.is_none(), &self.styles.defs);
self.frame_name = get(a, "draw:name").map(|n| n.chars().take(256).collect());
self.frame_clip = g.clip;
self.frame_washout = g.is_washed_out();
}
"image" | "object" | "object-ole" | "desc" | "title" | "contour-polygon" => self.frame_part(q, a),
// A columned section (ODF 1.3 §5.4) is a continuous section with its columns.
+30 -6
View File
@@ -90,6 +90,28 @@ impl Body<'_> {
pub(in crate::odt) fn end_frame(&mut self) {
if let Some((w, h, img, alt, eq, chart, object)) = self.frame.take() {
let boxed = self.frame_box.take();
let washout = std::mem::take(&mut self.frame_washout);
// A picture watermark (the header's frame of the name the writer gives it, holding a
// picture and nothing else) is noted; when every header holds the same one, no
// footer and no text watermark does, it is the document's watermark and not a picture
// in the header (see `page_parts::read`).
if boxed.is_none()
&& eq.is_none()
&& chart.is_none()
&& object.is_none()
&& self.page_part
&& self.frame_name.as_deref().is_some_and(|n| n.starts_with(crate::odt::writer::PICTURE_WATERMARK_NAME))
&& let Some((data, w, h)) = img.clone().zip(w).zip(h).map(|((d, w), h)| (d, w, h))
{
if self.watermark_pictures.len() < 4 {
self.watermark_pictures.push((data, (w, h), washout));
}
if self.take_watermark_pictures {
self.frame_clip = None;
self.frame = None;
return;
}
}
let mut float = self.frame_float.take();
if boxed.is_some()
&& self.shape_kind.is_some()
@@ -176,7 +198,7 @@ impl Body<'_> {
/// The shape ending is a watermark (see [`Self::start_shape`]) holding `blocks`: the
/// document's watermark, from its text, font, colour, size and turn, and not a shape.
fn take_watermark(&mut self, blocks: &[FBlock]) -> bool {
let Some(((w, h), rot, color, see_through, true)) = self.watermark_shape.take() else { return false };
let Some(((w, h), rot, color, transparency, true)) = self.watermark_shape.take() else { return false };
let mut text = String::new();
let mut font = None;
for b in blocks {
@@ -195,14 +217,16 @@ impl Body<'_> {
}
let turned = rot.rem_euclid(360.0);
let base = wordcraft_doc::Watermark::default();
self.watermark = Some(wordcraft_doc::Watermark {
let mut wm = wordcraft_doc::Watermark {
text,
font: font.unwrap_or(base.font),
color: color.unwrap_or(base.color),
diagonal: turned > 1.0 && turned < 359.0,
semitransparent: see_through,
size: Some([w, h]).filter(|[w, h]| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0),
});
..base
};
wm.set_transparency(transparency);
self.watermark = Some(wm);
true
}
@@ -372,8 +396,8 @@ impl Body<'_> {
self.frame_look.conn = self.conn_of(a).map(Box::new);
// Word's and LibreOffice's watermark: a shape of this name whose geometry follows its text.
self.watermark_shape = get(a, "draw:name").filter(|n| n.starts_with(crate::odt::writer::WATERMARK_NAME)).map(|_| {
let (color, see_through) = g.watermark_look();
((w, h), float.rot, color, see_through, false)
let (color, transparency) = g.watermark_look();
((w, h), float.rot, color, transparency, false)
});
self.shape_path = crate::odt::shapes::freeform(local(q), a, w, h).map(std::sync::Arc::new);
self.shape_kind = Some(kind);
+13 -3
View File
@@ -64,6 +64,9 @@ pub(super) struct Graphic {
dash: Option<String>,
gradient: Option<String>,
markers: [Option<String>; 2],
/// Washout (`draw:color-mode` `watermark`: the picture drawn faded), which a picture
/// watermark states.
washout: Option<bool>,
/// A picture's crop (`fo:clip`, §20.179): how much is cut off its top, right, bottom and
/// left, in lengths of its original size.
pub(super) clip: Option<[f32; 4]>,
@@ -165,6 +168,7 @@ impl Graphic {
markers: [s("draw:marker-start"), s("draw:marker-end")],
stroke_width: get(a, "svg:stroke-width").and_then(length).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 12.0)),
clip: get(a, "fo:clip").and_then(clip_rect),
washout: get(a, "draw:color-mode").map(|v| v.trim() == "watermark"),
wrap_contour: get(a, "style:wrap-contour").map(|v| v.trim() == "true"),
vert: get(a, "loext:writing-mode").or_else(|| get(a, "style:writing-mode")).map(|m| {
use wordcraft_doc::props::{TextDirection, TextVert};
@@ -181,9 +185,14 @@ impl Graphic {
}
}
/// A watermark's look from its shape's style: its fill colour, and whether it is see-through.
pub(super) fn watermark_look(&self) -> (Option<Rgb>, bool) {
(self.fill_color, self.opacity.is_some_and(|o| o < 100.0))
/// Whether a picture's style draws it faded (`draw:color-mode` `watermark`).
pub(super) fn is_washed_out(&self) -> bool {
self.washout == Some(true)
}
/// A watermark's look from its shape's style: its fill colour, and its transparency in percent.
pub(super) fn watermark_look(&self) -> (Option<Rgb>, u8) {
(self.fill_color, self.opacity.filter(|o| o.is_finite()).map_or(0, |o| (100.0 - o).round().clamp(0.0, 100.0) as u8))
}
/// This placement with `parent`'s filling what it leaves unsaid.
@@ -209,6 +218,7 @@ impl Graphic {
fill(&mut self.fill, &parent.fill);
self.fill_color = self.fill_color.or(parent.fill_color);
self.opacity = self.opacity.or(parent.opacity);
self.washout = self.washout.or(parent.washout);
self.background = self.background.or(parent.background);
fill(&mut self.stroke, &parent.stroke);
self.stroke_color = self.stroke_color.or(parent.stroke_color);
+41 -15
View File
@@ -251,8 +251,12 @@ fn page_parts(doc: &Document, sec: &SectionProps, w: &mut Writer) -> PageParts {
let mut out = PageParts::default();
let even = doc.settings.even_odd_headers;
// A watermark is a shape in every header (one written for it alone when there is none).
let wm = doc.settings.watermark.as_ref().filter(|w| !w.text.trim().is_empty());
let wm = doc.settings.watermark.as_ref().filter(|w| w.picture.is_some() || !w.text.trim().is_empty());
let area = (sec.page_w - sec.margin_left - sec.margin_right, sec.page_h - sec.margin_top - sec.margin_bottom);
// A picture watermark that can't be read is not written; one that can is sized for this
// master page (a master page's header is its own part in the file).
let picture = wm.and_then(|w| w.picture.as_ref()).and_then(|pic| watermark_image(doc, sec, pic));
let wm = wm.filter(|w| w.picture.is_none() || picture.is_some());
for (set, name) in [(&sec.headers, "header"), (&sec.footers, "footer")] {
let wanted =
set.default.is_some() || (even && set.even.is_some()) || (sec.title_page && set.first.is_some()) || (name == "header" && wm.is_some());
@@ -265,23 +269,15 @@ fn page_parts(doc: &Document, sec: &SectionProps, w: &mut Writer) -> PageParts {
if !on {
continue;
}
let mut body = String::new();
if let Some(part) = id.and_then(|i| doc.parts.get(&i)) {
let blocks = model::part_flow(
doc,
&part.blocks,
// Pictures, shapes, text boxes, charts and objects too, as in the body.
model::FlowOpts { fields: true, lists: true, chars: true, paras: true, boxes: true, ..Default::default() },
);
w.blocks(&blocks, &mut body, 0);
}
if body.is_empty() {
body.push_str("<text:p/>");
}
let mut body = slot_body(doc, id, w);
if name == "header"
&& let Some(wm) = wm
{
body = with_watermark(&body, &w.watermark(wm, (sec.page_w, sec.page_h), area));
let shape = match &picture {
Some((img, washout)) => w.watermark_picture(img, *washout, area),
None => w.watermark(wm, (sec.page_w, sec.page_h), area),
};
body = with_watermark(&body, &shape);
}
out.xml.push_str(&format!("<style:{el}>{body}</style:{el}>"));
}
@@ -294,6 +290,36 @@ fn page_parts(doc: &Document, sec: &SectionProps, w: &mut Writer) -> PageParts {
out
}
/// The picture watermark `pic` as an image sized for section `sec`'s text area, with its
/// Washout. None when the picture is missing, unreadable, of an unknown type or has no size.
fn watermark_image(doc: &Document, sec: &SectionProps, pic: &wordcraft_doc::WatermarkPicture) -> Option<(model::Img, bool)> {
let data = doc.media.get(&pic.media)?;
let (own_w, own_h) = wordcraft_doc::watermark::own_size(data)?;
let ext = model::sniff_image(data)?;
let (w, h) = pic.size(own_w, own_h, (sec.text_width(), sec.text_height()))?;
let img = model::Img { data: data.clone(), ext: ext.to_string(), w, h, alt: String::new(), float: None, object: None, crop: [0.0; 4] };
Some((img, pic.washout))
}
/// The ODF body of a header or footer slot: its part `id` written by `w`, or an empty paragraph
/// when the slot has no content.
fn slot_body(doc: &Document, id: Option<u32>, w: &mut Writer) -> String {
let mut body = String::new();
if let Some(part) = id.and_then(|i| doc.parts.get(&i)) {
let blocks = model::part_flow(
doc,
&part.blocks,
// Pictures, shapes, text boxes, charts and objects too, as in the body.
model::FlowOpts { fields: true, lists: true, chars: true, paras: true, boxes: true, ..Default::default() },
);
w.blocks(&blocks, &mut body, 0);
}
if body.is_empty() {
body.push_str("<text:p/>");
}
body
}
/// A header's content with `shape` (anchored to a character) at the start of its first
/// paragraph, or in a paragraph of its own before a table that starts it.
fn with_watermark(body: &str, shape: &str) -> String {
+103 -10
View File
@@ -56,6 +56,54 @@ pub(super) struct Parts {
even_odd: bool,
/// The watermark a header holds (the first found).
pub(super) watermark: Option<wordcraft_doc::Watermark>,
/// The picture watermark a header holds (the first found), if no text one is.
picture: Option<PictureRead>,
}
/// One header or footer as read.
struct SlotRead {
name: String,
kind: Slot,
text: Option<wordcraft_doc::Watermark>,
pictures: Vec<(Vec<u8>, (f32, f32), bool)>,
blocks: Vec<FBlock>,
}
/// The picture watermark the headers and footers read hold, if they make one: its picture, its
/// frame's size, Washout and the room Auto fills on the first master page (points).
fn picture_plan(reads: &[SlotRead], pages: &Pages) -> Option<(Vec<u8>, (f32, f32), bool, (f32, f32))> {
if reads.iter().all(|r| r.pictures.is_empty()) || reads.iter().any(|r| r.text.is_some()) {
return None;
}
if reads.iter().any(|r| r.kind.footer() && !r.pictures.is_empty()) {
return None;
}
// Every header holds exactly one, and every master page has its default header.
let headers: Vec<&SlotRead> = reads.iter().filter(|r| !r.kind.footer()).collect();
if headers.iter().any(|r| r.pictures.len() != 1) {
return None;
}
if !pages.masters.keys().all(|m| headers.iter().any(|r| r.name == *m && matches!(r.kind, Slot::Header))) {
return None;
}
let first = headers.first()?.pictures.first()?;
// A picture whose size can't be read is no watermark (`add` couldn't place it, and the frames
// taken out of the headers would be lost): it stays a header picture, as in the DOCX plan.
wordcraft_doc::watermark::own_size(&first.0)?;
if !headers.iter().filter_map(|r| r.pictures.first()).all(|p| p.0 == first.0 && p.2 == first.2) {
return None;
}
let fit = headers.first().and_then(|r| pages.masters.get(&r.name)).map_or((0.0, 0.0), |s| (s.text_width(), s.text_height()));
Some((first.0.clone(), first.1, first.2, fit))
}
/// A picture watermark as read: its picture, its frame's size and the room Auto fills on its
/// master page (points), and whether it is washed out.
struct PictureRead {
data: Vec<u8>,
size: (f32, f32),
fit: (f32, f32),
washout: bool,
}
impl Parts {
@@ -125,17 +173,45 @@ pub(super) fn read(
});
let none = Pages::default();
let mut out = Parts::default();
for (name, kind, evs) in found {
let mut reader = Body::new(styles, &none, fetch);
reader.in_text = true;
(reader.entries, reader.manifest) = (objects.0, Some(objects.1));
reader.spaces_left = *spaces_left;
for ev in evs {
reader.feed(ev);
// Read every slot; `take` leaves a picture watermark's frame out of the blocks.
let mut read_all = |take: bool, spaces_left: &mut usize| -> Vec<SlotRead> {
let mut reads = Vec::with_capacity(found.len());
for (name, kind, evs) in &found {
let mut reader = Body::new(styles, &none, fetch);
reader.in_text = true;
reader.page_part = true;
reader.take_watermark_pictures = take && !kind.footer();
(reader.entries, reader.manifest) = (objects.0, Some(objects.1));
reader.spaces_left = *spaces_left;
for ev in evs.iter().cloned() {
reader.feed(ev);
}
*spaces_left = reader.spaces_left;
reads.push(SlotRead {
name: name.clone(),
kind: *kind,
text: reader.watermark.take(),
pictures: std::mem::take(&mut reader.watermark_pictures),
blocks: reader.take_blocks(),
});
}
*spaces_left = reader.spaces_left;
out.watermark = out.watermark.take().or(reader.watermark.take());
out.parts.push((if kind.footer() { PartKind::Footer } else { PartKind::Header }, reader.take_blocks()));
reads
};
let spaces_start = *spaces_left;
let mut reads = read_all(false, spaces_left);
// A picture watermark is the document's only as Word's DOCX plan has it: the same picture in
// every header of every master page (one in each), none in a footer, no text watermark.
// Otherwise the frames stay as the pictures in the headers they are.
let picture = picture_plan(&reads, pages);
if picture.is_some() {
*spaces_left = spaces_start;
reads = read_all(true, spaces_left);
}
out.picture = picture.map(|(data, size, washout, fit)| PictureRead { data, size, fit, washout });
for read in reads {
let SlotRead { name, kind, text, blocks, .. } = read;
out.watermark = out.watermark.take().or(text);
out.parts.push((if kind.footer() { PartKind::Footer } else { PartKind::Header }, blocks));
if let Some(sec) = pages.masters.get_mut(&name) {
kind.set(sec, out.parts.len() as u32);
}
@@ -174,6 +250,23 @@ pub(super) fn read(
pub(super) fn add(doc: &mut Document, parts: Parts) {
if parts.watermark.is_some() {
doc.settings.watermark.clone_from(&parts.watermark);
} else if let Some(pic) = parts.picture
&& let Some((own_w, own_h)) = wordcraft_doc::watermark::own_size(&pic.data)
{
// Auto when the frame is as large as fits the margins, else its width as a percentage of
// the picture's own size, in tenths as a command gives one.
let auto = wordcraft_doc::WatermarkPicture::default().size(own_w, own_h, pic.fit).map(|s| s.0);
let scale = if auto.is_some_and(|a| (a - pic.size.0).abs() <= 1.0) {
None
} else {
Some(((pic.size.0 / own_w * 1000.0).round() / 10.0).clamp(wordcraft_doc::watermark::MIN_SCALE, wordcraft_doc::watermark::MAX_SCALE))
};
let ext = model::sniff_image(&pic.data).unwrap_or("png");
let media = doc.add_media(pic.data, ext);
doc.settings.watermark = Some(wordcraft_doc::Watermark {
picture: Some(wordcraft_doc::WatermarkPicture { media, scale, washout: pic.washout }),
..Default::default()
});
}
let mut ids: Vec<Option<u32>> = Vec::with_capacity(parts.parts.len());
for (kind, blocks) in parts.parts {
@@ -272,6 +272,15 @@ fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() {
let back = import(&saved).unwrap();
let wm = back.settings.watermark.clone().expect("the watermark");
assert_eq!((wm.text.as_str(), wm.diagonal, wm.semitransparent), ("CONFIDENTIAL", true, true));
// Any transparency is kept, not only half.
for t in [0u8, 30, 50, 80] {
let mut other = d.clone();
let mut w = wordcraft_doc::Watermark { text: "CONFIDENTIAL".into(), ..Default::default() };
w.set_transparency(t);
other.settings.watermark = Some(w.clone());
let back = import(&export(&other).unwrap()).unwrap();
assert_eq!(back.settings.watermark.map(|w| w.transparency()), Some(t), "{t}");
}
// Not a shape in the header too.
let header = back.last_section.headers.default.and_then(|id| back.parts.get(&id)).unwrap();
assert!(header.blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()));
@@ -287,6 +296,187 @@ fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() {
);
}
/// A solid `w`×`h` px PNG.
fn watermark_png(w: u32, h: u32) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([20, 60, 140, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
}
#[test]
fn a_picture_watermark_is_a_picture_frame_in_the_header_and_reads_back_as_one() {
// A picture watermark is written as its picture (not the text kept for the dialog) in a frame
// behind the text of every header, and read back as the watermark, not a picture in the header.
let png = watermark_png(400, 200);
for (scale, washout) in [(None, true), (Some(50.0), false), (Some(200.0), true)] {
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text("Body", CharProps::default()))];
let key = d.add_media(png.clone(), "png");
let pic = wordcraft_doc::WatermarkPicture { media: key, scale, washout };
d.settings.watermark = Some(wordcraft_doc::Watermark { text: "DRAFT".into(), picture: Some(pic), ..Default::default() });
let saved = export(&d).unwrap();
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(styles.contains("draw:name=\"WordPictureWatermark\"") && styles.contains("<draw:image "), "{styles}");
assert!(!styles.contains("DRAFT") && !styles.contains("draw:text-path"), "the picture, not the text: {styles}");
assert_eq!(styles.contains("draw:color-mode=\"watermark\""), washout, "{styles}");
assert!(crate::odt::tests::entry(&saved, "META-INF/manifest.xml").contains("Pictures/"), "the picture is in the package");
let back = import(&saved).unwrap();
let wm = back.settings.watermark.clone().expect("the watermark");
let pic = wm.picture.expect("a picture watermark");
assert_eq!((pic.scale, pic.washout), (scale, washout));
assert_eq!(back.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
// Not a picture in the header too.
let header = back.last_section.headers.default.and_then(|id| back.parts.get(&id)).unwrap();
assert!(header.blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()), "no picture left in the header");
assert_eq!(import(&export(&back).unwrap()).unwrap().settings.watermark, back.settings.watermark, "second generation");
}
// A picture that can't be read is not written, and the text isn't written in its place.
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text("Body", CharProps::default()))];
d.media.insert("junk.png".into(), std::sync::Arc::new(b"junk".to_vec()));
let pic = wordcraft_doc::WatermarkPicture { media: "junk.png".into(), scale: None, washout: true };
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
let styles = crate::odt::tests::entry(&export(&d).unwrap(), "styles.xml");
assert!(!styles.contains("DRAFT") && !styles.contains("WordPictureWatermark") && !styles.contains("<draw:image "), "{styles}");
}
/// A package whose `styles.xml` has one master page per entry of `masters`: (header content,
/// footer content), a picture watermark frame being `wm_frame(picture file)`; with the pictures
/// `Pictures/a.png` and `Pictures/b.png`.
fn watermark_package(masters: &[(&str, &str)]) -> Vec<u8> {
let ns = crate::odt::tests::NS_T;
let nsd = r#"xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
let pages: String = masters
.iter()
.enumerate()
.map(|(i, (head, foot))| {
let foot = if foot.is_empty() { String::new() } else { format!("<style:footer><text:p>{foot}</text:p></style:footer>") };
format!(r#"<style:master-page style:name="M{i}" style:page-layout-name="pm1"><style:header><text:p>{head}</text:p></style:header>{foot}</style:master-page>"#)
})
.collect();
let styles = format!(
r##"<office:document-styles {ns} {nsd}><office:automatic-styles><style:style style:name="Mgr1" style:family="graphic"><style:graphic-properties style:run-through="background" style:wrap="run-through"/></style:style><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/></style:page-layout></office:automatic-styles><office:master-styles>{pages}</office:master-styles></office:document-styles>"##
);
let paras: String = (0..masters.len()).map(|i| format!(r#"<text:p text:style-name="Standard">p{i}</text:p>"#)).collect();
let content = format!("<office:document-content {ns}><office:body><office:text>{paras}</office:text></office:body></office:document-content>");
let (a, b) = (watermark_png(60, 30), watermark_png(30, 30));
crate::odt::tests::zip_of(&[
("mimetype", b"application/vnd.oasis.opendocument.text"),
("content.xml", content.as_bytes()),
("styles.xml", styles.as_bytes()),
("Pictures/a.png", &a),
("Pictures/b.png", &b),
])
}
/// A frame the way a picture watermark is written, 3 × 1.5 in.
fn wm_frame(file: &str) -> String {
format!(
r#"<draw:frame draw:name="WordPictureWatermark" draw:style-name="Mgr1" text:anchor-type="char" svg:x="0in" svg:y="1in" svg:width="3in" svg:height="1.5in"><draw:image xlink:href="Pictures/{file}"/></draw:frame>"#
)
}
/// The pictures in each header and footer part, in order.
fn part_pictures(d: &Document) -> Vec<(PartKind, usize)> {
let mut v: Vec<(u32, PartKind, usize)> = d
.parts
.iter()
.filter(|(_, p)| matches!(p.kind, PartKind::Header | PartKind::Footer))
.map(|(id, p)| {
let n = p.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).filter(|o| matches!(o, InlineObject::Image { .. })).count();
(*id, p.kind, n)
})
.collect();
v.sort_by_key(|(id, ..)| *id);
v.into_iter().map(|(_, k, n)| (k, n)).collect()
}
#[test]
fn a_picture_frame_in_the_headers_is_the_watermark_only_where_every_page_shows_it() {
let a = wm_frame("a.png");
// The same picture in every header: the document's watermark, and no picture left in them.
let d = import(&watermark_package(&[(&a, ""), (&a, "")])).unwrap();
assert!(d.settings.watermark.as_ref().is_some_and(|w| w.picture.is_some()));
assert!(part_pictures(&d).iter().all(|(_, n)| *n == 0), "{:?}", part_pictures(&d));
// A different picture on the second master page: both stay pictures in their headers.
let d = import(&watermark_package(&[(&a, ""), (&wm_frame("b.png"), "")])).unwrap();
assert_eq!(d.settings.watermark, None);
assert_eq!(part_pictures(&d).iter().map(|(_, n)| n).sum::<usize>(), 2, "{:?}", part_pictures(&d));
// One in a footer too: nothing is taken, all three stay.
let d = import(&watermark_package(&[(&a, &a)])).unwrap();
assert_eq!(d.settings.watermark, None);
assert_eq!(part_pictures(&d).iter().map(|(_, n)| n).sum::<usize>(), 2, "{:?}", part_pictures(&d));
// A header without one on a second master page.
let d = import(&watermark_package(&[(&a, ""), ("Plain", "")])).unwrap();
assert_eq!(d.settings.watermark, None);
assert_eq!(part_pictures(&d).iter().map(|(_, n)| n).sum::<usize>(), 1);
// Beside a text watermark: the text one is the document's, the picture stays in its header.
let text = r##"<draw:custom-shape text:anchor-type="char" draw:name="PowerPlusWaterMarkObject" draw:style-name="Mgr1" svg:width="4in" svg:height="1in"><text:p>DRAFT</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:text-path="true" draw:type="fontwork-plain-text"/></draw:custom-shape>"##;
let d = import(&watermark_package(&[(&format!("{a}{text}"), "")])).unwrap();
let wm = d.settings.watermark.clone().expect("the text watermark");
assert_eq!((wm.text.as_str(), wm.picture.is_none()), ("DRAFT", true));
assert_eq!(part_pictures(&d).iter().map(|(_, n)| n).sum::<usize>(), 1);
}
#[test]
fn a_picture_frame_of_unknown_size_is_not_a_watermark() {
// An EMF picture shows in a header, but its size isn't a raster's, which a watermark is sized
// from: the frames stay the pictures they are (taking them out would lose them).
let mut emf = vec![0u8; 88];
emf[0..4].copy_from_slice(&1u32.to_le_bytes());
emf[4..8].copy_from_slice(&88u32.to_le_bytes());
emf[24..40].copy_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0x10, 0x27, 0, 0, 0x10, 0x27, 0, 0]);
emf[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
assert!(wordcraft_doc::watermark::own_size(&emf).is_none());
let a = wm_frame("a.emf");
let pkg = watermark_package(&[(&a, ""), (&a, "")]);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&pkg)).unwrap();
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut b = Vec::new();
std::io::Read::read_to_end(&mut f, &mut b).unwrap();
entries.push((f.name().to_string(), b));
}
entries.push(("Pictures/a.emf".into(), emf));
let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
let d = import(&crate::odt::tests::zip_of(&refs)).unwrap();
assert_eq!(d.settings.watermark, None);
assert_eq!(part_pictures(&d).iter().map(|(_, n)| n).sum::<usize>(), 2, "{:?}", part_pictures(&d));
}
#[test]
fn a_watermark_added_to_an_odt_file_is_saved() {
// Open an ODT, add a watermark, save as ODT: the watermark is written (the page setup was
// taken as unchanged because the fingerprint left the watermark out), with its picture once.
let opened = import(&watermark_package(&[("Head", ""), ("Head", "")])).unwrap();
assert_eq!(opened.settings.watermark, None);
let png = watermark_png(40, 20);
let mut d = opened.clone();
let key = d.add_media(png.clone(), "png");
d.settings.watermark = Some(wordcraft_doc::Watermark {
picture: Some(wordcraft_doc::WatermarkPicture { media: key, scale: Some(50.0), washout: true }),
..Default::default()
});
let saved = export(&d).unwrap();
let back = import(&saved).unwrap();
let pic = back.settings.watermark.clone().and_then(|w| w.picture).expect("the picture watermark was saved");
assert_eq!((pic.scale, pic.washout), (Some(50.0), true));
assert_eq!(back.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
let manifest = crate::odt::tests::entry(&saved, "META-INF/manifest.xml");
assert_eq!(manifest.matches("Pictures/").count(), 1, "one picture file for two master pages: {manifest}");
// A text watermark with its transparency.
let mut d = opened;
let mut wm = wordcraft_doc::Watermark { text: "COPY".into(), ..Default::default() };
wm.set_transparency(80);
d.settings.watermark = Some(wm);
let back = import(&export(&d).unwrap()).unwrap();
let got = back.settings.watermark.expect("the text watermark was saved");
assert_eq!((got.text.as_str(), got.transparency()), ("COPY", 80));
}
#[test]
fn libreoffices_watermark_reads_as_the_watermark() {
// As LibreOffice's Format › Watermark writes it (observed): turned, half see-through.
+1 -1
View File
@@ -11,7 +11,7 @@ mod tables;
mod tracked;
use drawing::Place;
pub(super) use drawing::WATERMARK_NAME;
pub(super) use drawing::{PICTURE_WATERMARK_NAME, WATERMARK_NAME};
use indexes::index_at;
pub(super) use indexes::{IndexKind, index_instr};
use notes::annotation_name;
+31 -2
View File
@@ -599,15 +599,44 @@ impl Writer {
/// The name a watermark shape carries (Word's and LibreOffice's: how readers know it).
pub(in crate::odt) const WATERMARK_NAME: &str = "PowerPlusWaterMarkObject";
/// The name a picture watermark's frame carries (Word's picture watermark shape id).
pub(in crate::odt) const PICTURE_WATERMARK_NAME: &str = "WordPictureWatermark";
impl Writer {
/// The document's picture watermark in a header: a frame holding the picture, `size` points
/// (width, height), behind the text, centred on a page whose text area is `area`, drawn
/// faded when `washout` (`draw:color-mode` `watermark`).
pub(in crate::odt) fn watermark_picture(&mut self, img: &model::Img, washout: bool, area: (f32, f32)) -> String {
let mode = if washout { " draw:color-mode=\"watermark\"" } else { "" };
let style = self.graphic_style(format!(
" draw:stroke=\"none\" draw:fill=\"none\"{mode} style:run-through=\"background\" style:wrap=\"run-through\" style:horizontal-pos=\"center\" style:horizontal-rel=\"page-content\" style:vertical-pos=\"middle\" style:vertical-rel=\"page-content\" style:flow-with-text=\"false\" draw:auto-grow-height=\"false\" draw:auto-grow-width=\"false\"",
));
// The picture goes into the package once, however many master pages and slots show it.
let href = match self.pictures.iter().find(|(_, data, _)| std::sync::Arc::ptr_eq(data, &img.data)) {
Some((name, ..)) => name.clone(),
None => self.picture(img),
};
let (aw, ah) = (if area.0.is_finite() { area.0 } else { img.w }, if area.1.is_finite() { area.1 } else { img.h });
format!(
"<draw:frame text:anchor-type=\"char\" draw:z-index=\"0\" draw:name=\"{PICTURE_WATERMARK_NAME}\" draw:style-name=\"{style}\" svg:x=\"{}\" svg:y=\"{}\" svg:width=\"{}\" svg:height=\"{}\"><draw:image xlink:href=\"{href}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/></draw:frame>",
pt((aw - img.w) / 2.0),
pt((ah - img.h) / 2.0),
pt(img.w),
pt(img.h)
)
}
/// The document's watermark as LibreOffice and Word write one in a header: a fontwork shape
/// (§10.3.2 `draw:custom-shape` whose geometry follows its text, `draw:text-path`) behind the
/// text, centred on a page whose text area is `area` (width, height; points), turned up 45°
/// when diagonal, filled with its colour (half transparent when semitransparent). It doesn't
/// when diagonal, filled with its colour (as transparent as the watermark says). It doesn't
/// grow to its text: its text is stretched to it.
pub(in crate::odt) fn watermark(&mut self, wm: &wordcraft_doc::Watermark, page: (f32, f32), area: (f32, f32)) -> String {
let [w, h] = wm.frame(page.0, page.1);
let opacity = if wm.semitransparent { " draw:opacity=\"50%\"" } else { "" };
let opacity = match wm.transparency() {
0 => String::new(),
t => format!(" draw:opacity=\"{}%\"", 100 - t),
};
let style = self.graphic_style(format!(
" draw:stroke=\"none\" draw:fill=\"solid\" draw:fill-color=\"#{}\"{opacity} style:run-through=\"background\" style:wrap=\"run-through\" style:horizontal-pos=\"center\" style:horizontal-rel=\"page-content\" style:vertical-pos=\"middle\" style:vertical-rel=\"page-content\" style:flow-with-text=\"false\" draw:auto-grow-height=\"false\" draw:auto-grow-width=\"false\"",
wm.color.hex().to_ascii_lowercase()
+1
View File
@@ -42,6 +42,7 @@ pub(super) fn local(q: &str) -> &str {
q.rsplit(':').next().unwrap_or(q)
}
#[derive(Clone)]
pub(super) enum Ev {
Start(String, Attrs),
End(String),
+6
View File
@@ -16,6 +16,8 @@ wordcraft-layout = { workspace = true }
wordcraft-metafile = { workspace = true }
craft-pdf = { workspace = true }
kurbo = { workspace = true }
hayro-syntax = { version = "0.7.2", optional = true }
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"], optional = true }
image = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
@@ -25,6 +27,10 @@ chrono = { workspace = true }
# The browser clock (`SystemTime::now()` panics on wasm32-unknown-unknown).
js-sys = "0.3"
[features]
# What an exported PDF draws, read back, for other crates' tests.
test-support = ["dep:hayro-syntax", "dep:hayro-interpret"]
[dev-dependencies]
hayro-syntax = "0.7.2"
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
+1
View File
@@ -106,6 +106,7 @@ fn write_attempt(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[us
tag_index: HashMap::new(),
links: Vec::new(),
bookmarks: std::cell::OnceCell::new(),
watermark_fit: HashMap::new(),
};
if let Some(o) = ex.outline(pages) {
pdf.set_outline(o);
+35 -6
View File
@@ -43,6 +43,9 @@ pub(crate) struct Exporter<'a> {
/// Bookmarks by exact name, and by ASCII-lowercased name for the case-blind fallback; the
/// first in document order wins either way.
pub(crate) bookmarks: std::cell::OnceCell<(HashMap<String, wordcraft_doc::Pos>, HashMap<String, wordcraft_doc::Pos>)>,
/// What a picture watermark at Auto fills, by the header a page shows
/// ([`wordcraft_doc::Document::watermark_fit`]), worked out once per header.
pub(crate) watermark_fit: HashMap<Option<u32>, (f32, f32)>,
}
impl Exporter<'_> {
@@ -191,11 +194,16 @@ impl Exporter<'_> {
if let Some(pic) = self.doc.settings.page_picture.clone() {
self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.page_picture(s, &pic, w, h));
}
if let Some(wm) = self.doc.settings.watermark.clone()
&& !wm.text.trim().is_empty()
{
let area = page.body;
self.tagged(s, Role::Artifact(ArtifactType::Watermark), None, |me, s| me.watermark(s, &wm, w, h, area));
match self.doc.settings.watermark.clone() {
Some(wordcraft_doc::Watermark { picture: Some(pic), .. }) => {
let (body, header) = (page.body, page.header_story);
self.tagged(s, Role::Artifact(ArtifactType::Watermark), None, |me, s| me.watermark_picture(s, &pic, body, header, (w, h)));
}
Some(wm) if !wm.text.trim().is_empty() => {
let area = page.body;
self.tagged(s, Role::Artifact(ArtifactType::Watermark), None, |me, s| me.watermark(s, &wm, w, h, area));
}
_ => {}
}
let dopts = DisplayOptions {
marks: false,
@@ -328,6 +336,27 @@ impl Exporter<'_> {
tree
}
/// A picture watermark, centred in the page's text area (`body`) at its scale or, at Auto, the
/// size the first section naming the page's `header` gives it, as on screen. Washed out, it is
/// the picture with Word's Washout baked in by the same path as any picture with corrections,
/// embedded once however many pages show it.
fn watermark_picture(&mut self, s: &mut Surface, pic: &wordcraft_doc::WatermarkPicture, body: Rect, header: Option<u32>, page: (f32, f32)) {
// The page's text area; the whole page without one.
let body = if body.w > 0.0 && body.h > 0.0 { body } else { Rect::new(0.0, 0.0, page.0, page.1.min(2000.0)) };
let Some((own_w, own_h)) = self.doc.media.get(&pic.media).and_then(|b| wordcraft_doc::watermark::own_size(b)) else { return };
let fit = if pic.scale.is_none() {
let doc = self.doc;
*self.watermark_fit.entry(header).or_insert_with(|| doc.watermark_fit(header))
} else {
(0.0, 0.0)
};
let Some(r) = pic.place(own_w, own_h, fit, body) else { return };
let (Some(img), Some(size)) = (self.image(&pic.media, pic.fx()), Size::from_wh(r.w, r.h)) else { return };
s.push_transform(&Transform::from_translate(r.x, r.y));
s.draw_image(img, size);
s.pop();
}
/// The watermark text stretched over its box (Word's `v:textpath fitshape`): its advance across
/// the box's width, its em down the box's height with the em's foot (the font's descent below
/// the baseline) on the box's bottom; the box centred on the margins (`area`, else the page)
@@ -374,7 +403,7 @@ impl Exporter<'_> {
.collect();
s.push_transform(&Transform::from_row(c * stretch, sn * stretch, -sn, c, c * dx - sn * dy + cx, sn * dx + c * dy + cy));
s.set_stroke(None);
s.set_fill(Some(fill(wm.color, if wm.semitransparent { 0.5 } else { 1.0 })));
s.set_fill(Some(fill(wm.color, wm.opacity())));
match font {
Some(font) => {
s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &text, size, false);
+3
View File
@@ -62,6 +62,9 @@ mod options;
mod paint;
mod picture;
mod tags;
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub mod test_support;
#[cfg(test)]
use export::Fit;
+49
View File
@@ -0,0 +1,49 @@
//! Test helpers other crates' tests share (the `test-support` feature): what an exported PDF
//! draws, read back.
/// Each image drawn, over every page: its rectangle in PDF space (y up), its first pixel and
/// its size in pixels.
pub fn drawn_images(bytes: &[u8]) -> Vec<(kurbo::Rect, [u8; 3], (u32, u32))> {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
use hayro_syntax::Pdf;
use kurbo::Shape;
struct Images(Vec<(kurbo::Rect, [u8; 3], (u32, u32))>);
impl Device<'_> for Images {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {}
fn draw_image(&mut self, img: Image<'_, '_>, t: kurbo::Affine) {
let Image::Raster(r) = img else { return };
let mut found = None;
r.with_rgba(
|data, _| {
if let ImageData::Rgb(d) = data {
let rect = (t * kurbo::Rect::new(0.0, 0.0, d.width as f64, d.height as f64).to_path(0.1)).bounding_box();
if let Some(&[r, g, b]) = d.data.get(..3).and_then(|p| <&[u8; 3]>::try_from(p).ok()) {
found = Some((rect, [r, g, b], (d.width, d.height)));
}
}
},
None,
);
self.0.extend(found);
}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let Ok(pdf) = Pdf::new(bytes.to_vec()) else { return Vec::new() };
let cache = InterpreterCache::new();
let mut out = Images(Vec::new());
for page in pdf.pages().iter() {
let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
interpret_page(page, &mut ctx, &mut out);
}
out.0
}
+59
View File
@@ -272,6 +272,64 @@ fn hostile_documents_export() {
assert!(export(&Document::new(), &PdfOptions::default()).unwrap().starts_with(b"%PDF"));
}
/// A solid `w`×`h` px PNG.
fn solid_png(w: u32, h: u32, c: [u8; 4]) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba(c)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
png
}
/// #2954: a picture watermark is embedded once however many pages show it, centred in the
/// margins at Auto scale (their width), with Word's Washout on its pixels as the picture
/// corrections give them; without Washout the picture's own pixels.
#[test]
fn picture_watermark_is_embedded_washed_out() {
let mut d = Document::from_text("One\nTwo");
let mut two = Paragraph::with_text("Second page", CharProps::default());
two.props.page_break_before = Some(true);
d.body.push(para_block(two));
d.media.insert("wm.png".into(), Arc::new(solid_png(40, 20, [20, 60, 140, 255])));
let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
assert_eq!(lay.pages.len(), 2);
let (page, body) = (&lay.pages[0], lay.pages[0].body);
for washed in [true, false] {
let pic = wordcraft_doc::WatermarkPicture { media: "wm.png".into(), scale: None, washout: washed };
d.settings.watermark = Some(Watermark { picture: Some(pic.clone()), ..Default::default() });
let bytes = export(&d, &PdfOptions::default()).unwrap();
let raw = String::from_utf8_lossy(&bytes);
assert_eq!(raw.matches("/Subtype/Image").count(), 1, "one embed for both pages");
let images = crate::test_support::drawn_images(&bytes);
assert_eq!(images.len(), 2, "one per page");
let (r, px, _) = images[0];
assert!((r.width() - body.w as f64).abs() < 0.5 && (r.height() - body.w as f64 / 2.0).abs() < 0.5, "{r:?}");
let (cx, cy) = (body.x as f64 + body.w as f64 / 2.0, page.h as f64 - (body.y as f64 + body.h as f64 / 2.0));
assert!((r.center().x - cx).abs() < 0.5 && (r.center().y - cy).abs() < 0.5, "{r:?}");
// The pixels are what the corrections make of the picture's: the same path as any picture.
let mut want = [20u8, 60, 140, 255];
pic.fx().apply_rgba(&mut want);
assert_eq!(px, [want[0], want[1], want[2]], "washout {washed}");
assert_eq!(washed, px != [20, 60, 140]);
}
// A picture that can't be read: no image, no failure.
d.media.insert("wm.png".into(), Arc::new(b"junk".to_vec()));
assert!(crate::test_support::drawn_images(&export(&d, &PdfOptions::default()).unwrap()).is_empty());
}
/// #2954: a scale is a percentage of the picture's own size (its pixels at 96 ppi).
#[test]
fn picture_watermark_scale_is_a_percentage_of_the_pictures_size() {
let mut d = Document::from_text("One");
d.media.insert("wm.png".into(), Arc::new(solid_png(400, 200, [20, 60, 140, 255])));
d.settings.watermark = Some(Watermark {
picture: Some(wordcraft_doc::WatermarkPicture { media: "wm.png".into(), scale: Some(50.0), washout: false }),
..Default::default()
});
let images = crate::test_support::drawn_images(&export(&d, &PdfOptions::default()).unwrap());
// 400 × 200 px is 300 × 150 pt at 96 ppi: half of it.
let (r, _, _) = images[0];
assert!((r.width() - 150.0).abs() < 0.5 && (r.height() - 75.0).abs() < 0.5, "{r:?}");
}
#[test]
fn tracked_deletions_are_left_out_without_markup() {
use wordcraft_doc::{Revision, RevisionKind};
@@ -1446,6 +1504,7 @@ fn bookmark_destination_is_the_page_of_its_start() {
tag_index: HashMap::new(),
links: Vec::new(),
bookmarks: std::cell::OnceCell::new(),
watermark_fit: HashMap::new(),
};
let (page, x, y) = ex.bookmark("target").unwrap();
assert_eq!(page, 1, "the bookmark is on the second page");
+6 -4
View File
@@ -21,7 +21,7 @@ mod media;
#[cfg(test)]
use media::{CACHE_BYTES, MediaCache, OVERSIZED_GLYPHS_NOTED, cacheable, decode_pixmap_within, note_oversized_glyphs, shrink};
pub use media::{decode_pixmap, decode_pixmap_fx, image_size, metafile_size};
use media::{draw_page_picture, draw_vector, lock, pixmap_for, premul_pixmap, vector_for};
use media::{draw_page_picture, draw_vector, draw_watermark_picture, lock, pixmap_for, premul_pixmap, vector_for};
mod color_glyph;
pub mod icon_lib;
@@ -300,8 +300,10 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin
draw_page_picture(&mut ctx, doc, page, view);
}
let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0);
if let Some(wm) = &doc.settings.watermark {
draw_watermark(&mut ctx, view, page, wm, opts);
match doc.settings.watermark.as_ref() {
Some(wordcraft_doc::Watermark { picture: Some(pic), .. }) => draw_watermark_picture(&mut ctx, doc, page, view, pic),
Some(wm) => draw_watermark(&mut ctx, view, page, wm, opts),
None => {}
}
// A dimmed layer is drawn whole, then faded once, so its overlaps don't darken.
for (alpha, items) in page_layers(doc, page, &opts.display) {
@@ -480,7 +482,7 @@ fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordc
* Affine::rotate(angle)
* Affine::translate((-bw / 2.0, bh / 2.0 - descent * bh))
* Affine::scale_non_uniform(stretch, 1.0);
ctx.set_paint(color(opts.ink(wm.color), if wm.semitransparent { 0.35 } else { 0.8 }));
ctx.set_paint(color(opts.ink(wm.color), wm.opacity()));
let db = FontDb::global();
let mut x = 0.0;
for g in &glyphs {
+25
View File
@@ -372,6 +372,31 @@ pub(crate) fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &
ctx.reset_paint_transform();
}
/// A picture watermark, centred in the page's text area behind the text, at its scale or at Auto
/// the size the first section naming the page's header gives it ([`Document::watermark_fit`]),
/// washed out as Word does when it is: drawn through the same picture path as any picture with
/// corrections ([`pixmap_for`] with [`wordcraft_doc::WatermarkPicture::fx`]).
pub(crate) fn draw_watermark_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view: Affine, pic: &wordcraft_doc::WatermarkPicture) {
let Some((own_w, own_h)) = doc.media.get(&pic.media).and_then(|b| wordcraft_doc::watermark::own_size(b)) else { return };
// The document page, not a Print Markup area beside it.
let b = page.body;
let body =
if b.w > 0.0 && b.h > 0.0 { b } else { wordcraft_geom::Rect::new(0.0, 0.0, (page.w - page.markup.max(0.0)).max(1.0), page.h.min(2000.0)) };
let fit = if pic.scale.is_none() { doc.watermark_fit(page.header_story) } else { (0.0, 0.0) };
let Some(r) = pic.place(own_w, own_h, fit, body) else { return };
let Some(pm) = pixmap_for(doc, &pic.media, pic.fx()) else { return };
let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
let rect = kurbo::Rect::new(f64::from(r.x), f64::from(r.y), f64::from(r.x) + f64::from(r.w), f64::from(r.y) + f64::from(r.h));
ctx.set_transform(view);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
});
ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / pw, rect.height() / ph));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
pub(crate) fn pixmap_for(doc: &Document, key: &str, fx: wordcraft_doc::picfx::PicFx) -> Option<Arc<Pixmap>> {
let bytes = doc.media.get(key)?;
// Corrected pixels are cached apart from the picture's own (the same picture can be shown
+1
View File
@@ -3,6 +3,7 @@ use wordcraft_layout::display::page_display;
use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
mod color_glyphs;
mod watermark;
/// The first and last pixel rows with ink in a black-on-white [`render_text_line`] raster.
fn ink_rows(img: &Rendered) -> Option<(usize, usize)> {
+143
View File
@@ -0,0 +1,143 @@
//! Design › Watermark on screen.
use super::*;
/// #2954: a text watermark at 80 % transparency is lighter than at 0 %, and invisible at 100 %.
#[test]
fn text_watermark_transparency() {
let mut d = Document::new();
let darkest = |d: &Document| {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
let img = render_page(d, &l.pages[0], 1.0, &RenderOptions::default());
img.pixels.chunks(4).map(|p| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2])).min().unwrap()
};
let mut wm = wordcraft_doc::Watermark { text: "WWW".into(), color: wordcraft_doc::props::Rgb::BLACK, diagonal: false, ..Default::default() };
let mut at = |t: u8| {
wm.set_transparency(t);
d.settings.watermark = Some(wm.clone());
darkest(&d)
};
let (opaque, half, light, gone) = (at(0), at(50), at(80), at(100));
assert!(opaque < 30, "{opaque}");
assert!(half > opaque + 300 && light > half + 150, "{opaque} {half} {light}");
assert_eq!(gone, 765);
}
/// A solid `w`×`h` PNG.
fn solid_png(w: u32, h: u32, c: [u8; 4]) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba(c)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
png
}
/// The RGB of pixel (`x`, `y`) of a raster drawn at one pixel per point.
fn px(img: &Rendered, x: f32, y: f32) -> [u8; 3] {
let i = (y as usize * img.width as usize + x as usize) * 4;
[img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]]
}
fn near(a: [u8; 3], b: [u8; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 2)
}
/// What Word's Washout does to a colour: the picture corrections a washed-out watermark is drawn
/// with, applied to its pixel.
fn washed(c: [u8; 3]) -> [u8; 3] {
let mut px = [c[0], c[1], c[2], 255];
wordcraft_doc::WatermarkPicture::default().fx().apply_rgba(&mut px);
[px[0], px[1], px[2]]
}
fn shot(d: &Document, page: usize) -> (Rendered, wordcraft_geom::Rect) {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
let p = l.pages[page].clone();
(render_page(d, &p, 1.0, &RenderOptions::default()), p.body)
}
/// #2954: a picture watermark is drawn centred in the text area behind the text: at Auto as
/// wide as the margins allow, at a scale as a percentage of its own size, and washed out
/// exactly as the picture corrections make it (the same path as any corrected picture).
#[test]
fn picture_watermark_in_the_margins_washed_out() {
let blue = [20, 60, 140];
let mut d = Document::new();
d.media.insert("wm.png".into(), Arc::new(solid_png(40, 20, [20, 60, 140, 255])));
let mut pic = wordcraft_doc::WatermarkPicture { media: "wm.png".into(), scale: None, washout: false };
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic.clone()), ..Default::default() });
let (img, body) = shot(&d, 0);
let (cx, cy) = (body.x + body.w / 2.0, body.y + body.h / 2.0);
assert!(near(px(&img, cx, cy), blue), "{:?}", px(&img, cx, cy));
// Auto: the margins' width, half as tall, centred.
assert!(near(px(&img, body.x + 3.0, cy), blue) && near(px(&img, body.right() - 3.0, cy), blue));
assert_eq!(px(&img, body.x - 3.0, cy), [255, 255, 255]);
assert!(near(px(&img, cx, cy - body.w / 4.0 + 3.0), blue));
assert_eq!(px(&img, cx, cy - body.w / 4.0 - 3.0), [255, 255, 255]);
// Washout: lighter in every channel, to the value the picture corrections give.
pic.washout = true;
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic.clone()), ..Default::default() });
let got = px(&shot(&d, 0).0, cx, cy);
assert!(near(got, washed(blue)), "{got:?} vs {:?}", washed(blue));
assert!(got.iter().zip(blue).all(|(w, b)| *w > b));
// 100 %: the picture's own size (40 × 20 px at 96 ppi = 30 × 15 pt).
pic.scale = Some(100.0);
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
let (img, _) = shot(&d, 0);
assert!(near(px(&img, cx + 13.0, cy), washed(blue)));
assert_eq!(px(&img, cx + 17.0, cy), [255, 255, 255]);
// A picture that can't be read draws nothing.
d.media.insert("wm.png".into(), Arc::new(b"junk".to_vec()));
assert_eq!(px(&shot(&d, 0).0, cx, cy), [255, 255, 255]);
}
/// #2954 review: with mirrored margins an even page's text area moves, and the picture watermark
/// with it, the same size.
#[test]
fn picture_watermark_follows_the_even_pages_mirrored_margins() {
let blue = [20, 60, 140];
let mut d = Document::from_text("One");
let mut two = wordcraft_doc::Paragraph::with_text("Two", Default::default());
two.props.page_break_before = Some(true);
d.body.push(wordcraft_doc::para_block(two));
d.settings.mirror_margins = true;
d.last_section.margin_left = 126.0;
d.last_section.margin_right = 54.0;
d.media.insert("wm.png".into(), Arc::new(solid_png(40, 20, [20, 60, 140, 255])));
let pic = wordcraft_doc::WatermarkPicture { media: "wm.png".into(), scale: None, washout: false };
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
let (odd, odd_body) = shot(&d, 0);
let (even, even_body) = shot(&d, 1);
assert!((odd_body.x - even_body.x).abs() > 50.0, "the margins mirror: {odd_body:?} {even_body:?}");
for (img, body) in [(&odd, odd_body), (&even, even_body)] {
let cy = body.y + body.h / 2.0;
assert!(near(px(img, body.x + 3.0, cy), blue) && near(px(img, body.right() - 3.0, cy), blue), "{body:?}");
assert_eq!(px(img, body.x - 3.0, cy), [255, 255, 255], "{body:?}");
assert_eq!(px(img, body.right() + 3.0, cy), [255, 255, 255], "{body:?}");
}
}
/// #2954: Auto is the room of the first section naming the page's header, on screen as in a PDF
/// and a saved file: a landscape section linked to a portrait section's header shows the
/// portrait-sized picture, not one re-fitted to its wider page.
#[test]
fn auto_watermark_in_a_linked_landscape_section_keeps_the_headers_size() {
let blue = [20, 60, 140];
let mut d = Document::new();
let head =
d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Head", Default::default()))]);
let mut s1 = wordcraft_doc::SectionProps::default();
s1.headers.default = Some(head);
let mut one = wordcraft_doc::Paragraph::with_text("Portrait", Default::default());
one.section = Some(Box::new(s1));
d.body = vec![wordcraft_doc::para_block(one), wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Landscape", Default::default()))];
d.last_section.set_landscape(true);
d.media.insert("wm.png".into(), Arc::new(solid_png(400, 200, [20, 60, 140, 255])));
let pic = wordcraft_doc::WatermarkPicture { media: "wm.png".into(), scale: None, washout: false };
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
let (img, body) = shot(&d, 1);
assert!(body.w > 600.0, "a landscape page: {body:?}");
let (cx, cy) = (body.x + body.w / 2.0, body.y + body.h / 2.0);
// 468 pt wide, centred: inside at 230 pt from the centre, outside at 238.
assert!(near(px(&img, cx + 230.0, cy), blue) && near(px(&img, cx - 230.0, cy), blue));
assert_eq!(px(&img, cx + 238.0, cy), [255, 255, 255]);
assert_eq!(px(&img, cx - 238.0, cy), [255, 255, 255]);
}
+4 -3
View File
@@ -215,9 +215,9 @@ pub enum Dialog {
Zoom {
percent: f32,
},
/// Design › Watermark › Custom Watermark (#2954).
Watermark {
text: String,
diagonal: bool,
form: Box<crate::dialogs_watermark::WatermarkForm>,
},
/// Home › Font › Phonetic Guide: ruby text over the selected text (`format.phonetic`).
Phonetic {
@@ -731,7 +731,7 @@ impl Dialog {
"bookmark" => Dialog::Bookmark { name: bookmark_at_caret(&app.session), by_location: false, hidden: false, deleted: false },
"wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() },
"zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() },
"watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true },
"watermark" => Dialog::Watermark { form: Box::new(crate::dialogs_watermark::WatermarkForm::read(app)) },
"phonetic" => {
let v = wordcraft_engine::cmd::phonetic::state(&app.session);
let f = |k: &str| v.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
@@ -906,5 +906,6 @@ pub mod dialogs_ribbon;
pub mod dialogs_smart_art;
pub mod dialogs_table_style;
pub mod dialogs_templates;
pub mod dialogs_watermark;
#[cfg(test)]
mod tests;
@@ -53,7 +53,7 @@ impl TemplatesForm {
/// Whether this host shows a file picker whose answer comes back to the dialog (the web's picker
/// opens what it picks instead).
fn can_pick(app: &WordApp) -> bool {
pub(crate) fn can_pick(app: &WordApp) -> bool {
app.services.open_async.is_none() && (app.services.file_dialog.is_some() || app.services.pick_open.is_some())
}
@@ -0,0 +1,299 @@
//! Design › Watermark › Custom Watermark… (#2954): no watermark, a picture watermark (Select
//! Picture…, Scale, Washout) or a text watermark (Text, Semitransparent with a transparency
//! percentage, Layout). OK runs `design.watermark`, so scripts and agents get the same result
//! without the dialog. Shown through [`crate::dialogs::Dialog::Watermark`].
use egui::{Ui, vec2};
use serde::Serialize;
use serde_json::{Value, json};
use crate::WordApp;
use crate::dialogs::buttons;
use crate::file_dialogs::{AfterPick, FileDialogRequest};
/// Word's Scale choices for a picture watermark, percent; None is Auto.
const SCALES: [Option<f32>; 6] = [None, Some(500.0), Some(200.0), Some(150.0), Some(100.0), Some(50.0)];
/// The Custom Watermark dialog's fields.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WatermarkForm {
/// `none`, `picture` or `text`.
pub kind: String,
/// A picture file picked with Select Picture….
pub path: Option<String>,
/// The picture of the document's picture watermark, kept when no other is picked.
pub media: Option<String>,
/// Percent of the picture's own size; None for Auto.
pub scale: Option<f32>,
pub washout: bool,
pub text: String,
pub semitransparent: bool,
/// Percent, 0 to 100; Semitransparent is 50.
pub transparency: u8,
pub diagonal: bool,
/// Why OK didn't apply.
pub message: String,
}
impl WatermarkForm {
/// The document's watermark; with none, No watermark and Word's defaults.
pub fn read(app: &mut WordApp) -> WatermarkForm {
// A file picked for a dialog that has since closed doesn't belong to this one.
app.dialog_pick = None;
let wm = app.session.doc.settings.watermark.clone();
let pic = wm.as_ref().and_then(|w| w.picture.clone());
let text = wm.clone().unwrap_or_else(|| wordcraft_doc::Watermark { text: "CONFIDENTIAL".into(), ..Default::default() });
let kind = match &wm {
None => "none",
Some(w) if w.picture.is_some() => "picture",
Some(_) => "text",
};
WatermarkForm {
kind: kind.into(),
path: None,
media: pic.as_ref().map(|p| p.media.clone()),
scale: pic.as_ref().and_then(|p| p.scale),
washout: pic.as_ref().is_none_or(|p| p.washout),
semitransparent: text.transparency() > 0,
transparency: text.transparency(),
diagonal: text.diagonal,
text: text.text,
message: String::new(),
}
}
/// `design.watermark` parameters; None for a picture watermark without a picture.
pub fn params(&self) -> Option<Value> {
match self.kind.as_str() {
"none" => Some(json!({"remove": true})),
"picture" => {
let picture = match (&self.path, &self.media) {
(Some(p), _) => json!({"path": p}),
(None, Some(m)) => json!({"media": m}),
(None, None) => return None,
};
let scale = self.scale.map_or(json!("auto"), |s| json!(s));
Some(json!({"picture": picture, "scale": scale, "washout": self.washout}))
}
_ => Some(json!({"text": self.text, "diagonal": self.diagonal, "transparency": self.transparency})),
}
}
/// The picture's name: the picked file's, or the document's picture.
fn picture_name(&self) -> String {
match (&self.path, &self.media) {
(Some(p), _) => std::path::Path::new(p).file_name().map_or_else(|| p.clone(), |n| n.to_string_lossy().into_owned()),
(None, Some(m)) => m.clone(),
(None, None) => String::new(),
}
}
}
/// A Scale choice as shown: Auto, or the percentage.
fn scale_label(s: Option<f32>) -> String {
match s {
None => tl!("Auto").to_string(),
Some(p) => format!("{p:.0}%"),
}
}
/// The picture watermark's options (Select Picture, Scale, Washout), enabled for a picture.
fn picture_options(app: &mut WordApp, ui: &mut Ui, f: &mut WatermarkForm) {
ui.add_enabled_ui(f.kind == "picture", |ui| {
ui.indent("wm_picture", |ui| {
ui.horizontal(|ui| {
if ui.add_enabled(crate::dialogs_templates::can_pick(app), egui::Button::new(tl!("Select Picture…"))).clicked() {
let _ = app.ask_file(FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::ForDialog);
}
ui.label(f.picture_name());
});
ui.horizontal(|ui| {
let label = ui.label(tl!("Scale:"));
let combo = craft_ui::ComboBox::from_id_salt("wm_scale").width(80.0).selected_text(scale_label(f.scale)).show_ui(ui, |ui| {
for s in SCALES {
ui.selectable_value(&mut f.scale, s, scale_label(s));
}
});
combo.response.labelled_by(label.id);
ui.add_space(16.0);
ui.checkbox(&mut f.washout, tl!("Washout"));
});
});
});
}
/// The text watermark's options (Text, Transparency, Layout), enabled for a text.
fn text_options(ui: &mut Ui, f: &mut WatermarkForm) {
ui.add_enabled_ui(f.kind == "text", |ui| {
ui.indent("wm_text", |ui| {
crate::rtl::grid(ui, egui::Grid::new("wm_text_grid").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
let label = ui.label(tl!("Text:"));
ui.add(egui::TextEdit::singleline(&mut f.text).desired_width(220.0).char_limit(200)).labelled_by(label.id);
ui.end_row();
ui.label(tl!("Transparency:"));
ui.horizontal(|ui| {
if ui.checkbox(&mut f.semitransparent, tl!("Semitransparent")).changed() {
f.transparency = if f.semitransparent { 50 } else { 0 };
}
if ui.add(egui::DragValue::new(&mut f.transparency).range(0..=100).suffix(" %")).changed() {
f.semitransparent = f.transparency > 0;
}
});
ui.end_row();
ui.label(tl!("Layout:"));
ui.horizontal(|ui| {
ui.radio_value(&mut f.diagonal, true, tl!("Diagonal"));
ui.radio_value(&mut f.diagonal, false, tl!("Horizontal"));
});
ui.end_row();
});
});
});
}
/// Returns true to close.
pub fn watermark(app: &mut WordApp, ui: &mut Ui, f: &mut WatermarkForm) -> bool {
if let Some(p) = app.dialog_pick.take() {
f.path = Some(p);
f.kind = "picture".into();
f.message.clear();
}
ui.radio_value(&mut f.kind, "none".to_string(), tl!("No watermark"));
ui.radio_value(&mut f.kind, "picture".to_string(), tl!("Picture watermark"));
picture_options(app, ui, f);
ui.radio_value(&mut f.kind, "text".to_string(), tl!("Text watermark"));
text_options(ui, f);
if !f.message.is_empty() {
ui.colored_label(ui.visuals().error_fg_color, f.message.as_str());
}
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let Some(params) = f.params() else {
f.message = tl!("Select a picture first.").to_string();
return false;
};
match app.run("design.watermark", params) {
Ok(_) => return true,
Err(e) => f.message = e,
}
}
cancel
}
#[cfg(test)]
mod tests {
use egui_kittest::kittest::Queryable;
use serde_json::json;
use wordcraft_engine::Session;
use super::*;
use crate::dialogs::Dialog;
use crate::{Services, WordApp};
fn png_file(name: &str) -> String {
let path = std::env::temp_dir().join(format!("wordcraft-wm-{}-{name}.png", std::process::id()));
image::RgbaImage::from_pixel(60, 30, image::Rgba([20, 90, 160, 255])).save(&path).unwrap();
path.to_string_lossy().into_owned()
}
fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> {
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
)
}
fn form(a: &WordApp) -> WatermarkForm {
let Some(Dialog::Watermark { form }) = &a.dialog else { panic!("no watermark dialog: {:?}", a.dialog) };
(**form).clone()
}
/// #2954: Select Picture… puts the picked picture in the dialog and OK makes it the
/// watermark, washed out at Auto scale; reopened, the dialog shows it.
#[test]
fn dialog_sets_a_picture_watermark() {
let path = png_file("pick");
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
let picked = path.clone();
a.services.pick_open = Some(Box::new(move |purpose| (purpose == "picture").then(|| picked.clone())));
a.run("ui.dialog", json!({"name": "watermark"})).unwrap();
assert_eq!(form(&a).kind, "none", "no watermark yet");
let mut h = harness(a);
h.run_steps(3);
h.get_by_label("Picture watermark").click();
h.run_steps(2);
h.get_by_label("Select Picture…").click();
h.run_steps(3);
let f = form(h.state());
assert_eq!((f.kind.as_str(), f.path.as_deref()), ("picture", Some(path.as_str())));
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
let wm = h.state().session.doc.settings.watermark.clone().unwrap();
let pic = wm.picture.unwrap();
assert_eq!((pic.scale, pic.washout), (None, true));
h.state_mut().run("ui.dialog", json!({"name": "watermark"})).unwrap();
let f = form(h.state());
assert_eq!((f.kind.as_str(), f.media.as_deref(), f.path), ("picture", Some(pic.media.as_str()), None));
let _ = std::fs::remove_file(&path);
}
/// #2954: the Semitransparent box and the transparency percentage follow each other, and
/// OK sets the text watermark's transparency.
#[test]
fn dialog_sets_text_transparency() {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
a.run("design.watermark", json!({"text": "DRAFT"})).unwrap();
a.run("ui.dialog", json!({"name": "watermark"})).unwrap();
let f = form(&a);
assert_eq!((f.kind.as_str(), f.semitransparent, f.transparency), ("text", true, 50));
let mut h = harness(a);
h.run_steps(3);
h.get_by_label("Semitransparent").click();
h.run_steps(2);
assert_eq!((form(h.state()).semitransparent, form(h.state()).transparency), (false, 0));
if let Some(Dialog::Watermark { form }) = &mut h.state_mut().dialog {
form.transparency = 75;
}
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
let wm = h.state().session.doc.settings.watermark.clone().unwrap();
assert_eq!((wm.transparency(), wm.picture), (75, None));
// OK with No watermark removes it.
h.state_mut().run("ui.dialog", json!({"name": "watermark"})).unwrap();
h.run_steps(3);
h.get_by_label("No watermark").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(h.state().session.doc.settings.watermark, None);
}
/// #2954: a picture watermark without a picture, or with one that can't be read, keeps the
/// dialog open with a message and changes nothing.
#[test]
fn dialog_needs_a_readable_picture() {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
a.run("ui.dialog", json!({"name": "watermark"})).unwrap();
let mut f = form(&a);
f.kind = "picture".into();
assert_eq!(f.params(), None);
f.path = Some("/nonexistent/wm.png".into());
assert!(a.run("design.watermark", f.params().unwrap()).is_err());
assert_eq!(a.session.doc.settings.watermark, None);
let mut h = harness(a);
h.run_steps(3);
h.get_by_label("Picture watermark").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(form(h.state()).message, "Select a picture first.");
assert_eq!(h.state().session.doc.settings.watermark, None);
}
}
+1 -20
View File
@@ -27,7 +27,7 @@ pub(super) fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
Dialog::Bookmark { name, by_location, hidden, deleted } => bookmark(app, ui, name, by_location, hidden, deleted),
Dialog::WordCount { .. } => word_count_dialog(app, ui, d),
Dialog::Zoom { .. } => zoom_dialog(app, ui, d),
Dialog::Watermark { .. } => watermark_dialog(app, ui, d),
Dialog::Watermark { form } => crate::dialogs_watermark::watermark(app, ui, form),
Dialog::Phonetic { .. } => phonetic_dialog(app, ui, d),
Dialog::NewStyle { .. } => new_style_dialog(app, ui, d),
Dialog::ModifyStyle { .. } => modify_style_dialog(app, ui, d),
@@ -861,25 +861,6 @@ fn zoom_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ok || cancel
}
fn watermark_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let Dialog::Watermark { text, diagonal } = d else {
return false;
};
ui.horizontal(|ui| {
ui.label(tl!("Text:"));
ui.text_edit_singleline(text);
});
ui.horizontal(|ui| {
ui.radio_value(diagonal, true, tl!("Diagonal"));
ui.radio_value(diagonal, false, tl!("Horizontal"));
});
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let _ = app.run("design.watermark", json!({"text": text, "diagonal": *diagonal}));
}
ok || cancel
}
fn phonetic_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let Dialog::Phonetic { base, ruby, align, size, offset, existing, focus } = d else {
return false;
+1
View File
@@ -103,6 +103,7 @@ pub use dialogs::dialogs_ribbon;
pub use dialogs::dialogs_smart_art;
pub use dialogs::dialogs_table_style;
pub use dialogs::dialogs_templates;
pub use dialogs::dialogs_watermark;
pub use file_dialogs::{FileDialogAnswer, FileDialogRequest, open_filters};
mod app;
File diff suppressed because one or more lines are too long
+2 -4
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -3
View File
File diff suppressed because one or more lines are too long