mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 18:29:46 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c36e6e680d | ||
|
|
9c36365f69 | ||
|
|
b6a9775f60 | ||
|
|
cb32d95a92 | ||
|
|
2c43f61537 | ||
|
|
58148b25c9 | ||
|
|
569f519372 | ||
|
|
eb2cb3a7d9 | ||
|
|
be2fef40a4 | ||
|
|
48b63611cf | ||
|
|
0261e92d10 |
+3
-1
File diff suppressed because one or more lines are too long
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
)) {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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, §s);
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:"Calibri"" 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:"Calibri"" 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -100,6 +100,7 @@ Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | trivial | Custom Watermark (#3001): No watermark, Picture watermark (Select Picture…, Scale Auto or 50–500 %, Washout) and Text watermark (Text, Semitransparent with a transparency percentage kept in step, Layout), opening on the document's watermark; OK runs `design.watermark` |
|
||||
| 2026-10-17 | trivial | Drawing Canvas members are selected with a click and dragged, resized, turned, nudged, formatted, deleted and copied on their own inside the canvas; a click in a text box member's text puts the caret there (#2637) |
|
||||
| 2026-11-05 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a bold, solid insertion cursor, twice the caret's width, as in Word; the dashed one was hard to see (#4195) |
|
||||
| 2026-10-31 | trivial | Dialogs are modal (#3859): while one is open, clicks on the ribbon, File or the page, typing and shortcuts do nothing, as in Word. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac. A press beside a modal dialog shakes it a little, or flashes its border with animation effects off, so it is clear why nothing behind it reacts |
|
||||
|
||||
Reference in New Issue
Block a user