Compare commits

...
17 Commits
Author SHA1 Message Date
Martin Ball c36e6e680d Merge branch 'main' into land/3001 2026-11-06 15:54:21 +01:00
Martin BallandRatkoR 8c46f4fc1b Pictures: a bold insertion cursor while dragging an inline picture (#4195) (#4208)
Lands #4208.

Co-Authored-By: RatkoR <6648783+RatkoR@users.noreply.github.com>
2026-11-06 15:53:31 +01:00
Martin BallandClaude Sonnet 5.5 9c36365f69 #3001: split the watermark plan, design.watermark, ODT export and dialog into helpers (Sonar complexity)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 14:43:34 +00:00
FredApps 2a0a20aec5 Merge pull request #5776 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 14:59:01 +01:00
Martin BallandClaude Opus 5.5 b6a9775f60 #3001: file-format parity names when a header picture becomes the watermark
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 12:56:48 +00:00
Martin BallandClaude Opus 5.5 cb32d95a92 #3001: one PDF image reader for the tests (pdf test-support); ROADMAP lists the dialog's scales
The engine watermark test read PDF images with a copy of the pdf crate's test helper (SonarCloud duplication). The helper is now wordcraft_pdf::test_support behind a test-support feature, and it no longer panics on input. Engine drops its hayro dev-dependencies. The ROADMAP row names the dialog's scales (Auto, 50-500 %); the command takes 1-500 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 12:46:19 +00:00
Martin Ball 2c43f61537 Merge remote-tracking branch 'origin/main' into land/3001
; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
;	docs/layout-parity.md
;	docs/ui-parity.md
2026-11-06 12:28:13 +00:00
Martin Ball 247c7feaab i18n: craft-i18n pin follows its main (watermark strings, lv, fr, ar rows) (#5787) 2026-11-06 13:19:38 +01:00
Martin BallandRatkoR 8833c604e0 Layout: a shape dropped just after a page break stays on the new page (#4262) (#4297)
Lands #4297.

Co-Authored-By: RatkoR <6648783+RatkoR@users.noreply.github.com>
2026-11-06 13:12:16 +01:00
Martin BallandD13GOOOO cad8ede1e8 Shapes: pictures and text boxes inside a drawing canvas are selected and moved on their own (#2637) (#2670)
Lands #2670.

Co-Authored-By: D13GOOOO <106814760+D13GOOOO@users.noreply.github.com>
2026-11-06 12:44:48 +01:00
Martin BallandRatkoR beca3f3485 Layout: a float anchored on a later page of a long paragraph is drawn there (#4250) (#4267)
Lands #4267.

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

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

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

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

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

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

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

Co-Authored-By: C00kieMonsta <22135261+C00kieMonsta@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-05 23:54:16 +00:00
78 changed files with 4093 additions and 181 deletions
+8 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
1e515422afdda2f317af430c1f9d68a5f83935b4
+1 -1
View File
@@ -16,7 +16,7 @@ workspace = true
test-support = []
[dependencies]
wordcraft-geom = { workspace = true }
wordcraft-geom = { workspace = true, features = ["image"] }
base64 = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
+2 -1
View File
@@ -479,7 +479,7 @@ fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, V
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
/// picture, the watermark, and the notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
@@ -492,6 +492,7 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
parts,
&s.page_color,
&s.page_picture,
&s.watermark,
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
+2
View File
@@ -48,6 +48,7 @@ pub mod smart_art;
pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod watermark;
pub mod wordart;
pub mod work;
pub mod wrap;
@@ -92,6 +93,7 @@ pub use settings::{
};
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
pub use watermark::WatermarkPicture;
/// A document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+19 -5
View File
@@ -316,20 +316,34 @@ impl InlineObject {
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
/// (x, y, w, h) and the member. Empty for anything else.
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
let InlineObject::Group { children, .. } = self else { return Vec::new() };
self.member_rects(x, y, w, h).into_iter().filter_map(|(i, r)| Some((r, &children.get(i)?.obj))).collect()
}
/// [`InlineObject::group_rects`] with each member's index in `children` instead of the member.
pub fn member_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<(usize, [f32; 4])> {
let InlineObject::Group { children, .. } = self else { return Vec::new() };
let (sx, sy) = self.group_scale(w, h);
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
children
.iter()
.take(MAX_GROUP_CHILDREN)
.filter_map(|c| {
.enumerate()
.filter_map(|(i, c)| {
let (cw, chh, _) = c.obj.frame()?;
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
(!matches!(c.obj, InlineObject::Group { .. })).then_some((i, r))
})
.collect()
}
/// How much a group drawn `w` × `h` scales its members' space (x, y): 1 for a canvas, whose
/// members keep their size.
pub fn group_scale(&self, w: f32, h: f32) -> (f32, f32) {
let InlineObject::Group { ch_w, ch_h, .. } = self else { return (1.0, 1.0) };
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
(sx, sy)
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
+58
View File
@@ -234,12 +234,20 @@ pub struct Watermark {
pub font: String,
pub color: Rgb,
pub diagonal: bool,
/// Word's Semitransparent check box (the text 50 % transparent).
pub semitransparent: bool,
/// The text's transparency in percent when it is neither 0 nor 50 (see
/// [`Watermark::transparency`]); `None` follows `semitransparent`, as older documents do.
#[serde(skip_serializing_if = "Option::is_none")]
pub transparency: Option<u8>,
/// The shape's box (its `style` width and height, points), which the text is stretched to
/// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives
/// ([`Watermark::frame`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
/// A picture watermark, drawn instead of the text (which is kept for the dialog).
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<crate::watermark::WatermarkPicture>,
}
impl Default for Watermark {
@@ -250,12 +258,35 @@ impl Default for Watermark {
color: Rgb(0xC0, 0xC0, 0xC0),
diagonal: true,
semitransparent: true,
transparency: None,
size: None,
picture: None,
}
}
}
impl Watermark {
/// The text's transparency in percent: 0 is opaque, 100 invisible, Semitransparent is 50.
pub fn transparency(&self) -> u8 {
match self.transparency {
Some(t) => t.min(100),
None if self.semitransparent => 50,
None => 0,
}
}
/// Set the text's transparency in percent (at most 100). Semitransparent follows it.
pub fn set_transparency(&mut self, percent: u8) {
let t = percent.min(100);
self.semitransparent = t > 0;
self.transparency = (t != 0 && t != 50).then_some(t);
}
/// The text's opacity, 0 to 1.
pub fn opacity(&self) -> f32 {
1.0 - f32::from(self.transparency()) / 100.0
}
/// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the
/// page diagonal across (half the page width when not diagonal), as high as the text's length
/// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's
@@ -332,3 +363,30 @@ impl Default for Settings {
}
}
}
#[cfg(test)]
mod watermark_tests {
use super::Watermark;
#[test]
fn transparency_follows_semitransparent() {
let mut wm = Watermark::default();
assert_eq!((wm.transparency(), wm.opacity()), (50, 0.5));
wm.semitransparent = false;
assert_eq!(wm.transparency(), 0);
wm.set_transparency(80);
assert_eq!((wm.semitransparent, wm.transparency, wm.transparency()), (true, Some(80), 80));
// Word's two values are stored as the check box alone.
wm.set_transparency(50);
assert_eq!((wm.semitransparent, wm.transparency), (true, None));
wm.set_transparency(0);
assert_eq!((wm.semitransparent, wm.transparency), (false, None));
wm.set_transparency(255);
assert_eq!((wm.transparency(), wm.opacity()), (100, 0.0));
// Older documents have no transparency field.
let old: Watermark = serde_json::from_str(r#"{"text":"X","semitransparent":false}"#).unwrap();
assert_eq!(old.transparency(), 0);
let hostile: Watermark = serde_json::from_str(r#"{"transparency":200}"#).unwrap();
assert_eq!(hostile.transparency(), 100);
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Design › Watermark › Picture watermark: a picture drawn behind the text of every page.
//!
//! The picture's size is shared by everything that draws or saves it. A scale is a percentage of
//! the picture's own size (its pixels at 96 ppi). Auto is as large as fits inside the margins of
//! the first section that names the page's header ([`Document::watermark_fit`]), the same
//! on screen, in a PDF and in the saved file, whatever pages follow. The picture is centred in
//! the page's own text area, so mirrored margins move it as they move the text.
use serde::{Deserialize, Serialize};
use wordcraft_geom::Rect;
use crate::Document;
use crate::picfx::PicFx;
use crate::section::SectionProps;
/// Largest picture watermark scale, percent (the top of Word's Scale list).
pub const MAX_SCALE: f32 = 500.0;
/// Smallest picture watermark scale, percent.
pub const MIN_SCALE: f32 = 1.0;
/// Longest side of a placed picture watermark, points.
const MAX_SIDE: f32 = 31_680.0;
/// Word's Washout as `v:imagedata` values: `gain` 0.3 and `blacklevel` 0.35, in 65536ths.
pub const WASHOUT_GAIN: f64 = 19_661.0 / 65_536.0;
pub const WASHOUT_BLACK_LEVEL: f64 = 22_938.0 / 65_536.0;
/// A picture's own size in points (its pixels at 96 ppi), from its header: nothing is decoded.
/// None when it isn't a picture WordCraft reads.
pub fn own_size(bytes: &[u8]) -> Option<(f32, f32)> {
let p = wordcraft_geom::picture::probe(bytes).ok()?;
(p.width > 0 && p.height > 0).then_some((p.width as f32 * 0.75, p.height as f32 * 0.75))
}
/// A picture watermark.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WatermarkPicture {
/// Key into [`crate::Document::media`].
pub media: String,
/// Percent of the picture's own size (its pixels at 96 ppi); None for Auto, as large as fits
/// inside the margins.
pub scale: Option<f32>,
/// Word's Washout: the picture faded so the text over it can be read.
pub washout: bool,
}
impl Default for WatermarkPicture {
fn default() -> Self {
WatermarkPicture { media: String::new(), scale: None, washout: true }
}
}
impl WatermarkPicture {
/// The corrections the picture is drawn with: Word's Washout, else none. The renderers draw
/// it through the same picture path as any picture with corrections, so Washout costs no
/// more memory than the picture itself.
pub fn fx(&self) -> PicFx {
if self.washout { PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL) } else { PicFx::default() }
}
/// Whether `fx` (read from a file's `gain` and `blacklevel`) is Word's Washout, to within
/// rounding of the values a file writes.
pub fn is_washout(fx: PicFx) -> bool {
let w = PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL);
fx.transparency == 0 && fx.bright.abs_diff(w.bright) <= 100 && fx.contrast.abs_diff(w.contrast) <= 100
}
/// The picture's size in points, for a picture `own_w` × `own_h` points: a scale's percentage
/// of it, or at Auto as large as fits `fit` (width, height). None for an empty or unusable
/// size.
pub fn size(&self, own_w: f32, own_h: f32, fit: (f32, f32)) -> Option<(f32, f32)> {
let pos = |v: f32| v.is_finite() && v > 0.0;
if !(pos(own_w) && pos(own_h)) {
return None;
}
let k = match self.scale {
Some(s) if s.is_finite() => s.clamp(MIN_SCALE, MAX_SCALE) / 100.0,
_ => {
if !(pos(fit.0) && pos(fit.1)) {
return None;
}
(fit.0 / own_w).min(fit.1 / own_h)
}
};
let k = k.min(MAX_SIDE / own_w.max(own_h));
let (w, h) = (own_w * k, own_h * k);
(pos(w) && pos(h)).then_some((w, h))
}
/// Where the picture goes: [`WatermarkPicture::size`], centred in `body`, the page's text
/// area (a scale may spill past it).
pub fn place(&self, own_w: f32, own_h: f32, fit: (f32, f32), body: Rect) -> Option<Rect> {
let (w, h) = self.size(own_w, own_h, fit)?;
let ok = |v: f32| v.is_finite();
if !(ok(body.x) && ok(body.y) && ok(body.w) && ok(body.h)) {
return None;
}
Some(Rect::new(body.x + (body.w - w) / 2.0, body.y + (body.h - h) / 2.0, w, h))
}
}
/// The room Auto has in a section: its text area's width and height.
fn fit_of(s: &SectionProps) -> (f32, f32) {
(s.text_width(), s.text_height())
}
impl Document {
/// The room (width, height; points) a picture watermark at Auto fills on a page showing
/// header `header` (None: no header, which the saved file gives a header of its own to hold
/// the watermark): that of the first section naming the header, in document order, as the
/// DOCX writer and reader do. A header no section names is sized by the last section.
pub fn watermark_fit(&self, header: Option<u32>) -> (f32, f32) {
let even = self.settings.even_odd_headers;
// Which slots an earlier section has filled (named, or given the watermark's own header),
// that later ones inherit.
let mut filled = [false; 3];
for (_, s) in self.sections() {
let own = [s.headers.default, s.headers.first, s.headers.even];
let wants = [true, s.title_page, even];
let mut hit = false;
for ((o, w), f) in own.into_iter().zip(wants).zip(filled.iter_mut()) {
match (header, o) {
(Some(h), Some(o)) => hit |= h == o,
// A slot the pages show with no header of its own, nor an earlier one's.
(None, None) => hit |= w && !*f,
_ => {}
}
*f |= o.is_some() || w;
}
if hit {
return fit_of(s);
}
}
fit_of(&self.last_section)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Rounded to a hundredth of a point: (x, y, w, h).
fn r(r: Option<Rect>) -> Option<(f32, f32, f32, f32)> {
let q = |v: f32| (v * 100.0).round() / 100.0;
r.map(|r| (q(r.x), q(r.y), q(r.w), q(r.h)))
}
#[test]
fn picture_is_placed_in_the_text_area() {
let body = Rect::new(72.0, 72.0, 468.0, 648.0);
let fit = (468.0, 648.0);
// Auto: as large as fits, centred.
let auto = WatermarkPicture::default();
assert_eq!(r(auto.place(200.0, 100.0, fit, body)), Some((72.0, 279.0, 468.0, 234.0)));
assert_eq!(r(auto.place(100.0, 400.0, fit, body)), Some((225.0, 72.0, 162.0, 648.0)));
// A scale is a percentage of the picture's own size, and may spill past the margins.
let half = WatermarkPicture { scale: Some(50.0), ..Default::default() };
assert_eq!(r(half.place(200.0, 100.0, fit, body)), Some((256.0, 371.0, 100.0, 50.0)));
let big = WatermarkPicture { scale: Some(500.0), ..Default::default() };
assert_eq!(r(big.place(200.0, 100.0, fit, body)), Some((-194.0, 146.0, 1000.0, 500.0)));
// Mirrored margins move the page's text area, and the picture with it; Auto's size is
// still the section's.
let mirrored = Rect::new(108.0, 72.0, 468.0, 648.0);
assert_eq!(r(auto.place(200.0, 100.0, fit, mirrored)), Some((108.0, 279.0, 468.0, 234.0)));
// Hostile sizes and scales.
let wild = WatermarkPicture { scale: Some(1e30), ..Default::default() };
assert_eq!(r(wild.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(1000.0));
let tiny = WatermarkPicture { scale: Some(-5.0), ..Default::default() };
assert_eq!(r(tiny.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(2.0));
let nan = WatermarkPicture { scale: Some(f32::NAN), ..Default::default() };
assert_eq!(r(nan.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(468.0));
assert!(auto.place(0.0, 100.0, fit, body).is_none());
assert!(auto.place(f32::INFINITY, 100.0, fit, body).is_none());
assert!(auto.place(200.0, 100.0, (-1.0, 10.0), body).is_none());
let huge = auto.size(1e30, 1e30, (1e38, 1e38)).unwrap();
assert!(huge.0 <= MAX_SIDE + 1.0 && huge.1 <= MAX_SIDE + 1.0);
}
#[test]
fn washout_is_the_vml_values() {
let on = WatermarkPicture::default();
assert_eq!(on.fx(), PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL));
assert!(WatermarkPicture::is_washout(on.fx()) && WatermarkPicture::is_washout(PicFx::from_vml(0.3, 0.35)));
assert!(!WatermarkPicture::is_washout(PicFx::default()));
assert!(!WatermarkPicture::is_washout(PicFx::from_vml(0.5, 0.1)));
assert!(WatermarkPicture { washout: false, ..on }.fx().is_identity());
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-geom = { workspace = true }
wordcraft-geom = { workspace = true, features = ["image"] }
quick-xml = { workspace = true }
zip = { workspace = true }
thiserror = { workspace = true }
+5
View File
@@ -12,6 +12,7 @@ mod props;
mod sdt;
mod shape_style;
mod story;
mod watermark;
mod wordart;
use std::collections::{HashMap, HashSet};
@@ -116,6 +117,8 @@ pub(crate) struct Reader<'p> {
pub theme_fills: shape_style::ThemeFills,
/// The fill of the group being read (what its members' `a:grpFill` stands for).
pub group_fill: Option<wordcraft_doc::fill::Fill>,
/// The document's picture watermark from the headers, if any (`watermark::plan`).
picture_wm: watermark::Plan,
}
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
@@ -188,6 +191,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
txbx_links: Vec::new(),
theme_fills: Default::default(),
group_fill: None,
picture_wm: watermark::Plan::default(),
chunks,
};
r.pc.major_font = r.doc.settings.major_font.clone();
@@ -279,6 +283,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
}
}
if let Some(body) = root.child("w:body") {
r.picture_wm = watermark::plan(&r, body, &rels);
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
r.read_blocks(&mut sc, body, &rels, &mut blocks, r.chunks.depth);
+1
View File
@@ -142,6 +142,7 @@ impl Reader<'_> {
let rels = self.pkg.rels(path);
let mut sc = StoryCtx::default();
sc.in_hf = true;
sc.picture_wm = !footer && self.picture_wm_header(path);
let mut blocks = Blocks::new();
self.hf_depth += 1;
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
+7 -1
View File
@@ -25,6 +25,8 @@ mod fields;
mod runs;
mod vml;
pub(super) use vml::{vml_spin, vml_style, vml_watermark};
const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
/// The `a:graphicData` URI of an Office 2016 chart (`cx:chart`).
@@ -113,6 +115,10 @@ pub struct StoryCtx {
pub story_depth: usize,
/// Reading a header or footer (not a text box inside one): where Word keeps its watermark.
pub in_hf: bool,
/// This header's Word picture watermark is the document's watermark (see `watermark::plan`).
pub picture_wm: bool,
/// Reading the content of a tracked deletion: a watermark shape in it is not the document's.
pub in_deletion: bool,
}
/// Paragraph content under construction.
@@ -158,7 +164,7 @@ fn clean_text(s: &str) -> String {
.collect()
}
fn children_of_choice(ac: &El) -> Option<&El> {
pub(super) fn children_of_choice(ac: &El) -> Option<&El> {
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
// `m`: Office Math (a producer may guard an equation with a picture fallback, #1273).
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v", "m"];
+5 -1
View File
@@ -244,7 +244,11 @@ impl Reader<'_> {
if let Some(o) = self.read_signature_line(sc, k, rels) {
self.emit_obj(sc, pb, o, props);
} else {
for o in self.read_vml_all(sc, k, rels) {
// A watermark in a tracked deletion is not the document's: it stays a shape.
sc.in_deletion = props.del.is_some();
let objects = self.read_vml_all(sc, k, rels);
sc.in_deletion = false;
for o in objects {
self.emit_obj(sc, pb, o, props);
}
}
+9 -6
View File
@@ -21,7 +21,8 @@ impl Reader<'_> {
break;
}
if sc.in_hf
&& let Some(wm) = vml_watermark(e)
&& !sc.in_deletion
&& let Some(wm) = vml_watermark(e).or_else(|| self.picture_watermark(sc, e, rels))
{
// The document's watermark (the first one found); drawn on every page, not as
// a shape, so it is not one of the objects this picture contributes.
@@ -482,7 +483,7 @@ fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> {
})
}
fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
pub(in crate::read) fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
}
@@ -605,10 +606,10 @@ fn vml_float(shape: &El, style: &str, in_hf: bool) -> Float {
/// Word's text watermark (Design › Watermark): a VML shape in a header whose `v:textpath` draws
/// its `string` as text (ECMA-376 Part 4, VML `textpath`), in the textpath's `font-family`, filled
/// with the shape's `fillcolor`, semitransparent when its `v:fill` has an `opacity` below 1, and
/// with the shape's `fillcolor`, as transparent as its `v:fill` `opacity` says (any value), and
/// diagonal when the shape is turned. Only Word's watermark shape counts (its `id`), or text
/// placed absolutely and centred both ways; other header WordArt (a logo or a title) is None.
fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
pub(in crate::read) fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
let tp = shape.child("v:textpath")?;
if tp.attr("on").is_some_and(|v| matches!(v, "f" | "false" | "0")) {
return None;
@@ -638,7 +639,9 @@ fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
// The box the text is stretched to (`fitshape`).
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(4000.0));
let size = len("width").zip(len("height")).map(|(w, h)| [w, h]);
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0, size })
let mut wm = wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, size, ..default };
wm.set_transparency(((1.0 - opacity) * 100.0).round().clamp(0.0, 100.0) as u8);
Some(wm)
}
/// A VML `opacity`, 0..=1.
@@ -648,7 +651,7 @@ fn vml_opacity(v: &str) -> Option<f32> {
/// A VML shape's turn: `rotation` in degrees, or 65536ths of one with an `fd` suffix; `flip`: `x`
/// and/or `y`.
fn vml_spin(style: &str) -> wordcraft_geom::Spin {
pub(in crate::read) fn vml_spin(style: &str) -> wordcraft_geom::Spin {
let get = |k: &str| vml_style(style, k);
let rot = get("rotation").and_then(|v| match v.strip_suffix("fd") {
Some(fd) => fd.trim().parse::<f32>().ok().map(|f| f / 65_536.0),
+334
View File
@@ -0,0 +1,334 @@
//! Word's picture watermark (Design › Watermark › Picture watermark): a VML picture with Word's
//! `WordPictureWatermark` id in the headers. It becomes the document's watermark only when every
//! page shows the same one, placed as Word places it; otherwise each stays a picture in its header,
//! so a cover page without it, a section with another picture, or one moved by hand keep theirs.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use wordcraft_doc::picfx::PicFx;
use wordcraft_doc::watermark::{MAX_SCALE, MIN_SCALE, own_size};
use wordcraft_doc::{SectionProps, Watermark, WatermarkPicture};
use super::Reader;
use super::props::{HfRef, sectpr};
use super::story::{StoryCtx, children_of_choice, vml_style, vml_watermark};
use crate::package::Rels;
use crate::units::{measure, vml_number};
use crate::xml::{El, MAX_DEPTH};
/// Word's watermark picture shape id starts with this.
const WORD_ID: &str = "WordPictureWatermark";
/// Most sections, and header or footer parts, looked at; past them the pictures stay put.
const MAX_SECTIONS: usize = 10_000;
const MAX_PARTS: usize = 64;
/// The document's picture watermark as the plan found it: the header parts whose Word picture
/// watermark it is (empty: none), and the scale and Washout every one of them agrees on.
#[derive(Default)]
pub(super) struct Plan {
headers: HashSet<String>,
scale: Option<f32>,
washout: bool,
}
/// A Word picture watermark shape as Word places it: absolutely, centred on the margins both
/// ways, behind the text, shown, not turned or flipped, its picture carrying Word's Washout or
/// nothing.
struct Shape<'a> {
rid: &'a str,
size: Option<(f32, f32)>,
washout: bool,
}
/// `e` as Word's picture watermark shape, if it is one placed as Word places it.
fn shape(e: &El) -> Option<Shape<'_>> {
if e.name != "v:shape" || !e.attr("id").is_some_and(|id| id.starts_with(WORD_ID)) {
return None;
}
let style = e.attr("style").unwrap_or("");
let is = |k: &str, v: &str| vml_style(style, k).is_some_and(|x| x.eq_ignore_ascii_case(v));
let placed = is("position", "absolute")
&& is("mso-position-horizontal", "center")
&& is("mso-position-horizontal-relative", "margin")
&& is("mso-position-vertical", "center")
&& is("mso-position-vertical-relative", "margin");
let behind = vml_style(style, "z-index").and_then(|z| z.trim().parse::<i64>().ok()).is_some_and(|z| z < 0);
let spin = super::story::vml_spin(style);
if !placed || !behind || is("visibility", "hidden") || spin.deg != 0.0 || spin.flip_h || spin.flip_v {
return None;
}
let data = e.child("v:imagedata")?;
if data.attrs.iter().any(|(k, _)| !matches!(k.as_str(), "r:id" | "r:pict" | "src" | "o:title" | "gain" | "blacklevel")) {
return None;
}
// Word's Washout is gain 0.3 and black level 0.35; other values stay on a header picture.
let num = |k: &str| data.attr(k).and_then(vml_number);
let fx = PicFx::from_vml(num("gain").unwrap_or(1.0), num("blacklevel").unwrap_or(0.0));
let washout = if data.attr("gain").is_none() && data.attr("blacklevel").is_none() {
false
} else if WatermarkPicture::is_washout(fx) {
true
} else {
return None;
};
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0);
let size = len("width").zip(len("height"));
Some(Shape { rid: data.attr("r:id").or_else(|| data.attr("r:pict"))?, size, washout })
}
/// One header's Word picture watermark: its picture, its width as a percentage of the picture's
/// own size (pixels at 96 ppi), whether it fills its section's margins (Auto), and Washout.
struct Seen {
bytes: Arc<Vec<u8>>,
percent: f32,
fits: bool,
washout: bool,
}
/// What shape `s` shows, its picture `bytes`, in a header whose first section has room `fit`.
/// None when the picture can't be read, or the shape has no size or is stretched out of proportion.
fn seen(bytes: &Arc<Vec<u8>>, s: &Shape, fit: (f32, f32)) -> Option<Seen> {
let (own_w, own_h) = own_size(bytes)?;
let (w, h) = s.size?;
if (h - w * own_h / own_w).abs() > 1.0 {
return None;
}
let (auto_w, _) = WatermarkPicture::default().size(own_w, own_h, fit)?;
Some(Seen { bytes: bytes.clone(), percent: w / own_w * 100.0, fits: (auto_w - w).abs() <= 1.0, washout: s.washout })
}
impl Reader<'_> {
/// The document's watermark from a header's Word picture watermark shape `e`, when the plan
/// made that header's one the document's watermark.
pub(super) fn picture_watermark(&mut self, sc: &StoryCtx, e: &El, rels: &Rels) -> Option<Watermark> {
if !sc.picture_wm {
return None;
}
let s = shape(e)?;
let media = self.media_for(rels, s.rid)?;
let pic = WatermarkPicture { media, scale: self.picture_wm.scale, washout: self.picture_wm.washout };
Some(Watermark { picture: Some(pic), ..Default::default() })
}
/// Whether header part `path`'s Word picture watermark is the document's (see [`plan`]).
pub(super) fn picture_wm_header(&self, path: &str) -> bool {
self.picture_wm.headers.contains(path)
}
}
/// The document's picture watermark, worked out from the section properties and header and
/// footer parts before the body is read. Every page shows a header (default; first page with a
/// title page; even pages with different odd and even pages, each linked to the section before
/// when it has none), each header holds at most one Word picture watermark placed as Word places
/// it and the shown ones hold one, all have the same picture and Washout, and all fill their
/// section's margins (Auto) or all have the same scale. No header or footer has a text
/// watermark, and no footer a Word picture watermark. Otherwise the plan is empty.
pub(super) fn plan(r: &Reader, body: &El, rels: &Rels) -> Plan {
let mut sects = Vec::new();
sect_prs(body, 0, true, &mut sects);
if let Some(s) = body.child("w:sectPr") {
sects.push((s, true));
}
if sects.len() > MAX_SECTIONS {
return Plan::default();
}
let parts = collect_parts(r, rels, &sects);
if !parts_viable(r, &parts) || !footers_clean(r, &parts.footers) {
return Plan::default();
}
let Some(found) = read_headers(r, &parts.headers) else { return Plan::default() };
let Some((scale, washout)) = agreed(&parts.shown, &found) else { return Plan::default() };
Plan { headers: found.into_iter().filter(|(_, w)| w.is_some()).map(|(p, _)| p.to_string()).collect(), scale, washout }
}
/// The header and footer parts the sections name, and the header each page kind shows.
#[derive(Default)]
struct Parts {
/// Each header part, with the room Auto fills in the first section naming it
/// (`Document::watermark_fit`).
headers: HashMap<String, (f32, f32)>,
footers: HashSet<String>,
/// The header part (None: linked to none) of every page kind each section ending shows.
shown: Vec<Option<String>>,
}
/// Walks the sections in order, collecting the header and footer parts they name and the headers
/// the pages show (each kind linked to the section before when a section has none).
fn collect_parts(r: &Reader, rels: &Rels, sects: &[(&El, bool)]) -> Parts {
let even = r.doc.settings.even_odd_headers;
let mut parts = Parts::default();
let mut linked: [Option<String>; 3] = Default::default();
for (s, top) in sects {
let (sp, refs) = sectpr(&r.pc, s);
for h in &refs {
note_ref(&mut parts, &mut linked, h, rels, &sp, *top);
}
if !*top {
continue;
}
let [default, first, even_page] = &linked;
parts.shown.push(default.clone());
if sp.title_page {
parts.shown.push(first.clone());
}
if even {
parts.shown.push(even_page.clone());
}
}
parts
}
/// Records header or footer reference `h` of a section with properties `sp`: the part it names,
/// and, in a section that ends one of the document's, the header kind it now links.
fn note_ref(parts: &mut Parts, linked: &mut [Option<String>; 3], h: &HfRef, rels: &Rels, sp: &SectionProps, top: bool) {
let Some(rel) = rels.by_id(&h.rid).filter(|x| !x.external) else { return };
if h.footer {
parts.footers.insert(rel.target.clone());
return;
}
parts.headers.entry(rel.target.clone()).or_insert_with(|| (sp.text_width(), sp.text_height()));
if !top {
return;
}
let slot = match h.kind.as_str() {
"first" => 1,
"even" => 2,
_ => 0,
};
if let Some(l) = linked.get_mut(slot) {
*l = Some(rel.target.clone());
}
}
/// Whether `parts` can hold the document's watermark: not too many, every page shows a header
/// and one of the headers is Word's.
fn parts_viable(r: &Reader, parts: &Parts) -> bool {
let has_word_id = |path: &str| r.pkg.get(path).is_some_and(|b| b.windows(WORD_ID.len()).any(|w| w == WORD_ID.as_bytes()));
parts.headers.len() <= MAX_PARTS
&& parts.footers.len() <= MAX_PARTS
&& !parts.shown.is_empty()
&& parts.shown.iter().all(Option::is_some)
&& parts.headers.keys().any(|p| has_word_id(p))
}
/// Whether no footer holds a watermark: a footer's text watermark is the document's watermark,
/// and a footer's picture watermark stays where it is, so either way the headers' pictures stay.
fn footers_clean(r: &Reader, footers: &HashSet<String>) -> bool {
footers.iter().all(|path| {
let Ok(Some(root)) = r.pkg.xml(path) else { return true };
let mut shapes = Vec::new();
word_shapes(&root, 0, "", false, false, &mut shapes) && shapes.is_empty()
})
}
/// What each header part's Word picture watermark shows (None: it has none). None when a part
/// can't be read, has a text watermark or several pictures, or its picture isn't placed as Word
/// places it.
fn read_headers<'a>(r: &Reader, headers: &'a HashMap<String, (f32, f32)>) -> Option<HashMap<&'a str, Option<Seen>>> {
let mut found = HashMap::new();
for (path, fit) in headers {
let root = r.pkg.xml(path).ok()??;
let mut shapes = Vec::new();
if !word_shapes(&root, 0, "", false, false, &mut shapes) || shapes.len() > 1 {
return None;
}
let wm = match shapes.first() {
None => None,
Some(e) => Some(header_seen(r, path, e, *fit)?),
};
found.insert(path.as_str(), wm);
}
Some(found)
}
/// What header part `path`'s watermark shape `e` shows, when it is placed as Word places it and
/// its picture is in the package.
fn header_seen(r: &Reader, path: &str, e: &El, fit: (f32, f32)) -> Option<Seen> {
let hrels = r.pkg.rels(path);
let s = shape(e)?;
let bytes = hrels.by_id(s.rid).filter(|x| !x.external).and_then(|x| r.pkg.get_shared(&x.target))?;
seen(bytes, &s, fit)
}
/// The Scale (None: Auto) and Washout every shown header agrees on. None when a shown header has
/// no picture, or they differ in picture, Washout, or scale.
fn agreed(shown: &[Option<String>], found: &HashMap<&str, Option<Seen>>) -> Option<(Option<f32>, bool)> {
if shown.iter().flatten().any(|p| found.get(p.as_str()).is_none_or(Option::is_none)) {
return None;
}
let all: Vec<&Seen> = found.values().flatten().collect();
// The first shown header's, whatever order the map holds them in.
let first = shown.iter().flatten().find_map(|p| found.get(p.as_str())?.as_ref())?;
if !all.iter().all(|s| s.bytes == first.bytes && s.washout == first.washout) {
return None;
}
if all.iter().all(|s| s.fits) {
return Some((None, first.washout));
}
let percent = (first.percent * 10.0).round() / 10.0;
if !(MIN_SCALE..=MAX_SCALE).contains(&percent) || all.iter().any(|s| (s.percent - first.percent).abs() > 0.1) {
return None;
}
Some((Some(percent), first.washout))
}
/// The `w:sectPr` elements the reader reads, in order, as (element, whether it ends a section of
/// the document): in paragraph properties through the containers `read_blocks` follows, and in
/// table cells (which load headers but make no section).
fn sect_prs<'a>(e: &'a El, depth: usize, top: bool, out: &mut Vec<(&'a El, bool)>) {
if depth > MAX_DEPTH || out.len() > MAX_SECTIONS {
return;
}
for k in e.els() {
match k.name.as_str() {
"w:p" => {
if let Some(s) = k.child("w:pPr").and_then(|p| p.child("w:sectPr")) {
out.push((s, top));
}
}
"w:sdt" => {
if let Some(c) = k.child("w:sdtContent") {
sect_prs(c, depth + 1, top, out);
}
}
"w:customXml" | "w:smartTag" | "w:ins" | "w:moveTo" => sect_prs(k, depth + 1, top, out),
"mc:AlternateContent" => {
if let Some(c) = children_of_choice(k) {
sect_prs(c, depth + 1, top, out);
}
}
"w:tbl" | "w:tr" | "w:tc" => sect_prs(k, depth + 1, false, out),
_ => {}
}
}
}
/// Collects the Word picture watermark shapes a header or footer would read as such (straight
/// in a `w:pict` or `w:object`, not in a tracked deletion) into `out`. False when the part has a
/// text watermark, or a Word picture watermark anywhere else (a group, a text box, alternate
/// content, a deleted run).
fn word_shapes<'a>(e: &'a El, depth: usize, parent: &str, nested: bool, deleted: bool, out: &mut Vec<&'a El>) -> bool {
if depth > MAX_DEPTH {
return true;
}
for k in e.els() {
if k.name == "v:shape" && vml_watermark(k).is_some() {
return false;
}
if k.name == "v:shape" && k.attr("id").is_some_and(|id| id.starts_with(WORD_ID)) {
if nested || deleted || !matches!(parent, "w:pict" | "w:object") {
return false;
}
out.push(k);
continue;
}
let inner = nested || matches!(k.name.as_str(), "w:txbxContent" | "mc:AlternateContent" | "v:group");
let gone = deleted || matches!(k.name.as_str(), "w:del" | "w:moveFrom");
if !word_shapes(k, depth + 1, &k.name, inner, gone, out) {
return false;
}
}
true
}
+11
View File
@@ -91,6 +91,17 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
let blocks: Blocks = doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
// The watermark goes in every header, at the start of its first paragraph.
wr.pending_watermark = match &doc.settings.watermark {
// A picture watermark is sized for the first section naming this header (or, for the
// header written for the watermark alone, the first needing it): the same size the
// screen and a PDF draw.
Some(wm) if !footer && wm.picture.is_some() => watermark::picture(doc).and_then(|(pic, own)| {
let fit = doc.watermark_fit((id != wr.wm_header).then_some(id));
pic.size(own.0, own.1, fit)?;
let file = wr.media_files.get(&pic.media)?;
let rid = prels.add(rt::IMAGE, &format!("media/{file}"), false);
wr.used_media.insert(pic.media.clone());
watermark::picture_run(pic, own, done, fit, &rid)
}),
Some(wm) if !footer => watermark::run(wm, done, &doc.last_section),
_ => None,
};
+1 -1
View File
@@ -7,7 +7,7 @@ impl Writer<'_> {
fn watermark_headers(&mut self, s: &SectionProps) -> wordcraft_doc::section::HeaderSet {
let mut set = s.headers;
let linked = &mut self.linked_headers;
if self.doc.settings.watermark.as_ref().and_then(crate::write::watermark::text).is_some() {
if crate::write::watermark::shown(self.doc) {
let id = self.wm_header;
if set.default.or(linked.default).is_none() {
set.default = Some(id);
+78 -5
View File
@@ -1,11 +1,32 @@
//! The document's text watermark as a VML text shape in the headers (ECMA-376 Part 4, VML:
//! `shape`, `shapetype`, `path`, `textpath`, `fill`), which is how a WordprocessingML consumer
//! draws text behind every page. The reader (`read::story::vml_watermark`) maps it back.
//! The document's watermark in the headers: a text watermark as a VML text shape (ECMA-376
//! Part 4, VML: `shape`, `shapetype`, `path`, `textpath`, `fill`), a picture watermark as a VML
//! picture (`imagedata`), which is how a WordprocessingML consumer draws them behind every page.
//! The reader (`read::story::vml_watermark`, `read::watermark`) maps them back.
use wordcraft_doc::{SectionProps, Watermark};
use wordcraft_doc::watermark::own_size;
use wordcraft_doc::{Document, SectionProps, Watermark, WatermarkPicture};
use crate::xml::esc;
/// Word's Washout on `v:imagedata`: `gain` 0.3 and `blacklevel` 0.35, in 65536ths.
const WASHOUT: &str = r#" gain="19661f" blacklevel="22938f""#;
/// The document's picture watermark and its picture's own size, when its picture can be read.
pub(super) fn picture(doc: &Document) -> Option<(&WatermarkPicture, (f32, f32))> {
let pic = doc.settings.watermark.as_ref()?.picture.as_ref()?;
Some((pic, doc.media.get(&pic.media).and_then(|b| own_size(b))?))
}
/// Whether the document's watermark is written: text that isn't blank, or a picture that can be
/// read.
pub(super) fn shown(doc: &Document) -> bool {
match &doc.settings.watermark {
Some(wm) if wm.picture.is_some() => picture(doc).is_some(),
Some(wm) => text(wm).is_some(),
None => false,
}
}
/// The shape type the watermark shape uses: two straight subpaths, the box's top and bottom
/// edges, between which the `v:textpath` text is stretched to fit the shape (`fitshape`). A single
/// path gives the text no height, and Word draws nothing.
@@ -18,6 +39,18 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
(!t.trim().is_empty()).then_some(t)
}
/// The `v:fill` `opacity` for the text's transparency: Word's `.5` for Semitransparent. None when
/// the text is opaque.
fn opacity(wm: &Watermark) -> Option<String> {
let t = wm.transparency();
if t == 0 {
return None;
}
let o = format!("{:.2}", f32::from(100 - t) / 100.0);
let o = o.trim_end_matches('0').trim_end_matches('.');
Some(o.strip_prefix("0.").map_or_else(|| o.to_string(), |f| format!(".{f}")))
}
/// A `w:r` holding the watermark shape (and its shape type), for the first paragraph of header
/// part number `n`, in its box ([`Watermark::frame`]: its own, or Word's default for the page).
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
@@ -45,7 +78,7 @@ pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<Strin
mso-position-horizontal:center;mso-position-horizontal-relative:margin;\
mso-position-vertical:center;mso-position-vertical-relative:margin"
);
let fill = if wm.semitransparent { r#"<v:fill opacity=".5"/>"# } else { "" };
let fill = opacity(wm).map(|o| format!(r#"<v:fill opacity="{o}"/>"#)).unwrap_or_default();
Some(format!(
concat!(
r#"<w:r><w:rPr><w:noProof/></w:rPr><w:pict>"#,
@@ -64,3 +97,43 @@ pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<Strin
text = esc(&text),
))
}
/// A `w:r` holding the picture watermark for header part number `n`, its picture relationship
/// `rid`: Word's picture shape, centred on the margins, behind the text, with Word's Washout
/// values when washed out. `fit` is the room Auto fills (see [`Document::watermark_fit`]).
pub(super) fn picture_run(pic: &WatermarkPicture, own: (f32, f32), n: usize, fit: (f32, f32), rid: &str) -> Option<String> {
let (w, h) = pic.size(own.0, own.1, fit)?;
let style = format!(
"position:absolute;margin-left:0;margin-top:0;width:{w:.3}pt;height:{h:.3}pt;z-index:-251657216;\
mso-position-horizontal:center;mso-position-horizontal-relative:margin;\
mso-position-vertical:center;mso-position-vertical-relative:margin"
);
Some(format!(
concat!(
r#"<w:r><w:rPr><w:noProof/></w:rPr><w:pict>"#,
r#"<v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m0,0l0,21600,21600,21600,21600,0xe" filled="f" stroked="f">"#,
r#"<v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"/><o:lock v:ext="edit" aspectratio="t"/></v:shapetype>"#,
r##"<v:shape id="WordPictureWatermark{n}" type="#_x0000_t75" style="{style}" o:allowincell="f">"##,
r#"<v:imagedata r:id="{rid}"{wash}/></v:shape></w:pict></w:r>"#
),
n = n,
style = esc(&style),
rid = esc(rid),
wash = if pic.washout { WASHOUT } else { "" },
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opacity_is_written_like_word() {
let mut wm = Watermark::default();
assert_eq!(opacity(&wm).as_deref(), Some(".5"));
for (t, want) in [(0, None), (25, Some(".75")), (80, Some(".2")), (99, Some(".01")), (100, Some("0"))] {
wm.set_transparency(t);
assert_eq!(opacity(&wm).as_deref(), want, "{t}");
}
}
}
+283 -1
View File
@@ -1381,7 +1381,14 @@ fn vml_textpath_in_header_is_the_watermark() {
// Horizontal, centred both ways without Word's id, opacity in 65536ths.
let flat = r##"<v:shape type="#_x0000_t136" style="position:absolute;width:300pt;height:60pt;mso-position-horizontal:center;mso-position-vertical:center" fillcolor="#336699"><v:fill opacity="32768f"/><v:textpath string="ASAP"/></v:shape>"##;
let wm = read(flat, true).0.expect("watermark");
assert_eq!((wm.color, wm.diagonal, wm.semitransparent), (Rgb(0x33, 0x66, 0x99), false, true));
assert_eq!((wm.color, wm.diagonal, wm.semitransparent, wm.transparency()), (Rgb(0x33, 0x66, 0x99), false, true, 50));
// Any opacity is read as a transparency percentage, not only Word's `.5`.
for (opacity, want) in [(".25", 75), ("0", 100), ("1", 0), ("2", 0), ("-1", 100)] {
let shape = flat.replace(r#"opacity="32768f""#, &format!(r#"opacity="{opacity}""#));
let wm = read(&shape, true).0.expect("watermark");
assert_eq!(wm.transparency(), want, "{opacity}");
}
// Not watermarks: the same shape in the body (WordArt); header WordArt that isn't centred
// and isn't Word's watermark (a logo); a textpath turned off; a watermark-like shape inside a
@@ -1498,6 +1505,281 @@ fn word_watermark_survives_save() {
assert_eq!(d2.settings.watermark, None);
}
/// #2954: Word's picture watermark (a VML picture with Word's `WordPictureWatermark` id in the
/// headers) becomes the document's picture watermark: its picture, the scale from the shape's
/// width (Auto when it fills the margins) and Washout from `gain` and `blacklevel`. The header
/// keeps its text. Another header picture, even centred, stays a picture.
#[test]
fn word_picture_watermark_is_the_documents_watermark() {
let mut png = Vec::new();
image::RgbaImage::from_pixel(400, 200, image::Rgba([10, 20, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let read = |id: &str, size: &str, wash: &str| {
let hdr = format!(
r##"<w:hdr {W_NS}><w:p><w:r><w:t>Head</w:t></w:r><w:r><w:pict><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"/><v:shape id="{id}" o:spid="_x0000_s2049" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;{size};z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" o:allowincell="f"><v:imagedata r:id="rIdP" o:title="logo"{wash}/></v:shape></w:pict></w:r></w:p></w:hdr>"##
);
let doc = format!(
r#"<w:document {W_NS}><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#
);
let (drels, hrels) = (rels(&[("rIdH", "header", "header1.xml")]), rels(&[("rIdP", "image", "media/logo.png")]));
let parts: [(&str, &[u8]); 6] = [
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", drels.as_bytes()),
("word/header1.xml", hdr.as_bytes()),
("word/_rels/header1.xml.rels", hrels.as_bytes()),
("word/media/logo.png", &png),
];
wordcraft_docx::read(&zip(&parts)).unwrap()
};
let header_objects = |d: &Document| -> usize {
let hid = d.last_section.headers.default.unwrap();
d.parts[&hid].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.objects.len()).sum()
};
// 400 × 200 px is 300 × 150 pt at 96 ppi: 150 pt wide is 50 %.
let d = read("WordPictureWatermark77", "width:150pt;height:75pt", r#" gain="19661f" blacklevel="22938f""#);
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("picture watermark");
assert_eq!((pic.scale, pic.washout), (Some(50.0), true));
assert_eq!(d.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
assert_eq!(header_objects(&d), 0);
let hid = d.last_section.headers.default.unwrap();
assert_eq!(d.parts[&hid].blocks[0].as_para().unwrap().plain_text(), "Head");
// As wide as the margins of a Letter page with 1 in margins: Auto. No Washout.
let d = read("WordPictureWatermark1", "width:468pt;height:234pt", "");
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("picture watermark");
assert_eq!((pic.scale, pic.washout), (None, false));
// Another centred header picture is not a watermark.
let d = read("Logo1", "width:150pt;height:75pt", "");
assert_eq!(d.settings.watermark, None);
assert_eq!(header_objects(&d), 1);
}
/// A 400 × 200 px PNG of one colour (300 × 150 pt at 96 ppi).
fn solid_png(c: [u8; 4]) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(400, 200, image::Rgba(c)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
png
}
/// Word's picture watermark shape: `extra` added to its style, `wash` to its `v:imagedata`.
fn wm_shape(size: &str, extra: &str, wash: &str) -> String {
format!(
r##"<w:r><w:pict><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;{size};z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin{extra}" o:allowincell="f"><v:imagedata r:id="rIdP" o:title="wm"{wash}/></v:shape></w:pict></w:r>"##
)
}
/// A document of `body` (its `w:sectPr`s refer to `rH1`, `rH2`…) whose headers are `headers`:
/// (label, runs after the label, picture file under `word/media`).
fn headers_doc(body: &str, headers: &[(&str, String, &str)], pics: &[(&str, Vec<u8>)]) -> Document {
parts_doc(body, headers, &[], pics)
}
/// [`headers_doc`] with footers too, referred to as `rF1`, `rF2`….
fn parts_doc(body: &str, headers: &[(&str, String, &str)], footers: &[(&str, String, &str)], pics: &[(&str, Vec<u8>)]) -> Document {
let mut list: Vec<(String, String, &str, String, &str)> = Vec::new();
for (kind, tag, prefix, set) in [("header", "w:hdr", "rH", headers), ("footer", "w:ftr", "rF", footers)] {
for (i, (label, runs, pic)) in set.iter().enumerate() {
let xml = format!(r#"<{tag} {W_NS}><w:p><w:r><w:t>{label}</w:t></w:r>{runs}</w:p></{tag}>"#);
list.push((format!("{prefix}{}", i + 1), format!("{kind}{}.xml", i + 1), kind, xml, pic));
}
}
let doc_rels = rels(&list.iter().map(|(id, name, kind, ..)| (id.as_str(), *kind, name.as_str())).collect::<Vec<_>>());
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let mut parts: Vec<(String, Vec<u8>)> = vec![
("_rels/.rels".into(), ROOT_RELS.as_bytes().to_vec()),
("word/document.xml".into(), doc.into_bytes()),
("word/_rels/document.xml.rels".into(), doc_rels.into_bytes()),
];
for (_, name, _, xml, pic) in &list {
parts.push((format!("word/{name}"), xml.clone().into_bytes()));
parts.push((format!("word/_rels/{name}.rels"), rels(&[("rIdP", "image", &format!("media/{pic}"))]).into_bytes()));
}
for (name, bytes) in pics {
parts.push((format!("word/media/{name}"), bytes.clone()));
}
let entries: Vec<(&str, &[u8])> = parts.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
wordcraft_docx::read(&zip(&entries)).unwrap()
}
/// Pictures in each header part, by its text.
fn header_pictures(d: &Document) -> Vec<(String, usize)> {
let mut out: Vec<(String, usize)> = d
.parts
.values()
.filter(|p| p.kind == wordcraft_doc::PartKind::Header)
.map(|p| {
let paras: Vec<&Paragraph> = p.blocks.iter().filter_map(|b| b.as_para()).collect();
let text = paras.iter().map(|p| p.plain_text()).collect::<Vec<_>>().join("").replace('\u{FFFC}', "");
let pics = paras.iter().flat_map(|p| &p.objects).filter(|o| matches!(o, InlineObject::Image { .. })).count();
(text, pics)
})
.collect();
out.sort();
out
}
const PORTRAIT: &str = r#"<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>"#;
const LANDSCAPE: &str = r#"<w:pgSz w:w="15840" w:h="12240" w:orient="landscape"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>"#;
/// #2954 review: Word's picture watermark becomes the document's watermark only when every page
/// shows the same one, placed as Word places it. A cover page whose header lacks it, a section
/// with another picture, a watermark moved, turned or with other picture settings: each picture
/// stays in its header, as before, and still does after saving and reopening.
#[test]
fn picture_watermark_stays_in_headers_unless_every_page_shows_it() {
let (a, b) = (solid_png([20, 60, 140, 255]), solid_png([140, 20, 20, 255]));
let portrait_auto = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
let landscape_auto = wm_shape("width:648pt;height:324pt", "", r#" gain="19661f" blacklevel="22938f""#);
let p = |label: &str, run: &str, pic: &'static str| (label.to_string(), run.to_string(), pic);
let read = |body: &str, hs: Vec<(String, String, &str)>| {
let hs: Vec<(&str, String, &str)> = hs.iter().map(|(l, r, p)| (l.as_str(), r.clone(), *p)).collect();
headers_doc(body, &hs, &[("a.png", a.clone()), ("b.png", b.clone())])
};
let saved = |d: &Document| wordcraft_docx::read(&wordcraft_docx::write(d).unwrap()).unwrap();
let two_sections = format!(
r#"<w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH2"/>{LANDSCAPE}</w:sectPr>"#
);
// Every page: two sections, each Auto for its own page, and a title page with both headers.
let d = read(&two_sections, vec![p("One", &portrait_auto, "a.png"), p("Two", &landscape_auto, "a.png")]);
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("the document's watermark");
assert_eq!((pic.scale, pic.washout), (None, true));
assert_eq!(header_pictures(&d), vec![("One".into(), 0), ("Two".into(), 0)]);
let title = format!(
r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/><w:headerReference w:type="first" r:id="rH2"/>{PORTRAIT}<w:titlePg/></w:sectPr>"#
);
let d = read(&title, vec![p("Main", &portrait_auto, "a.png"), p("Cover", &portrait_auto, "a.png")]);
assert!(d.settings.watermark.as_ref().is_some_and(|w| w.picture.is_some()));
// A: the cover page's header has none.
let d = read(&title, vec![p("Main", &portrait_auto, "a.png"), p("Cover", "", "a.png")]);
assert_eq!(d.settings.watermark, None);
assert_eq!(header_pictures(&d), vec![("Cover".into(), 0), ("Main".into(), 1)]);
let d = saved(&d);
assert_eq!(d.settings.watermark, None);
assert_eq!(header_pictures(&d), vec![("Cover".into(), 0), ("Main".into(), 1)], "saved");
// B: section 2 has another picture.
let d = read(&two_sections, vec![p("One", &portrait_auto, "a.png"), p("Two", &landscape_auto, "b.png")]);
assert_eq!(d.settings.watermark, None);
assert_eq!(header_pictures(&d), vec![("One".into(), 1), ("Two".into(), 1)]);
let d2 = saved(&d);
assert_eq!(d2.settings.watermark, None);
assert_eq!(header_pictures(&d2), vec![("One".into(), 1), ("Two".into(), 1)], "saved");
let media = |d: &Document| {
let mut m: Vec<Vec<u8>> = d.media.values().map(|b| b.to_vec()).collect();
m.sort();
m
};
assert_eq!(media(&d2), media(&d), "both pictures kept");
// C: moved, turned, or with picture settings other than Washout; and a header without one.
let one = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
for run in [
wm_shape("width:468pt;height:234pt", ";mso-position-horizontal:left", ""),
wm_shape("width:468pt;height:234pt", ";rotation:30", ""),
wm_shape("width:468pt;height:234pt", ";flip:x", ""),
wm_shape("width:468pt;height:234pt", "", r#" gain="0.5" blacklevel="0.1""#),
wm_shape("width:468pt;height:234pt", "", r#" grayscale="t""#),
] {
let d = read(&one, vec![p("Main", &run, "a.png")]);
assert_eq!(d.settings.watermark, None, "{run}");
assert_eq!(header_pictures(&d), vec![("Main".into(), 1)], "{run}");
}
let no_header = format!(
r#"<w:p><w:pPr><w:sectPr>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
);
let d = read(&no_header, vec![p("Two", &portrait_auto, "a.png")]);
assert_eq!(d.settings.watermark, None, "the first section's pages have no header");
}
/// #2954 second review: watermarks the plan must leave alone, as main did. A text watermark in
/// a footer stays the document's watermark (the headers' pictures stay pictures), and a Word
/// picture watermark in a footer keeps every picture where it is. A stretched shape, a scale
/// outside 1 to 500 %, other picture corrections, in front of the text, hidden, in a group,
/// or a section in tracked insertion with no header: all stay header pictures.
#[test]
fn picture_watermark_plan_leaves_other_watermarks_alone() {
let a = solid_png([20, 60, 140, 255]);
let pics = [("a.png", a.clone())];
let one = format!(
r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/><w:footerReference w:type="default" r:id="rF1"/>{PORTRAIT}</w:sectPr>"#
);
let auto = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
let text = r##"<w:r><w:pict><v:shape id="PowerPlusWaterMarkObject9" type="#_x0000_t136" style="position:absolute;width:400pt;height:100pt;rotation:315;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="silver"><v:fill opacity=".5"/><v:textpath style="font-family:&quot;Calibri&quot;" string="DRAFT"/></v:shape></w:pict></w:r>"##;
// A footer's text watermark: the document's watermark, as before; the header picture stays,
// and both survive saving.
let d = parts_doc(&one, &[("Head", auto.clone(), "a.png")], &[("Foot", text.to_string(), "a.png")], &pics);
let wm = d.settings.watermark.clone().expect("the footer's text watermark");
assert_eq!((wm.text.as_str(), wm.picture.is_none()), ("DRAFT", true));
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)]);
let d = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(d.settings.watermark.as_ref().map(|w| w.text.as_str()), Some("DRAFT"), "saved");
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)], "saved");
// A footer's picture watermark: nothing is promoted, nothing is drawn twice.
let d = parts_doc(&one, &[("Head", auto.clone(), "a.png")], &[("Foot", auto.clone(), "a.png")], &pics);
assert_eq!(d.settings.watermark, None);
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)]);
let only = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
let group = r##"<w:r><w:pict><v:group style="position:absolute;width:468pt;height:234pt;z-index:-1" coordsize="468,234"><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;width:468;height:234"><v:imagedata r:id="rIdP"/></v:shape></v:group></w:pict></w:r>"##;
for (run, why) in [
(wm_shape("width:468pt;height:100pt", "", ""), "stretched"),
(wm_shape("width:1800pt;height:900pt", "", ""), "600 %"),
(wm_shape("width:2pt;height:1pt", "", ""), "under 1 %"),
(wm_shape("width:468pt;height:234pt", "", r#" gain="79922f" blacklevel="-1966f""#), "other corrections"),
(wm_shape("width:468pt;height:234pt", "", r#" gain="1" blacklevel="0""#), "identity values"),
(wm_shape("width:468pt;height:234pt", ";z-index:5", ""), "in front of the text"),
(wm_shape("width:468pt;height:234pt", ";visibility:hidden", ""), "hidden"),
(group.to_string(), "in a group"),
] {
let d = headers_doc(&only, &[("Head", run, "a.png")], &pics);
assert_eq!(d.settings.watermark, None, "{why}");
}
// The first section, inside a tracked insertion, has no header: its pages show none.
let tracked = format!(
r#"<w:ins w:id="1" w:author="A"><w:p><w:pPr><w:sectPr>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p></w:ins><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
);
let d = headers_doc(&tracked, &[("Head", auto.clone(), "a.png")], &pics);
assert_eq!(d.settings.watermark, None, "tracked section");
// A header that only a table cell's section property loads, with another picture.
let cell = format!(
r#"<w:tbl><w:tr><w:tc><w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rH2"/>{PORTRAIT}</w:sectPr></w:pPr></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
);
let b = solid_png([140, 20, 20, 255]);
let both = [("a.png", a.clone()), ("b.png", b)];
let d = headers_doc(&cell, &[("Head", auto.clone(), "a.png"), ("Cell", auto.clone(), "b.png")], &both);
assert_eq!(d.settings.watermark, None, "a header with another picture");
// The same document with the plain watermark is promoted.
let d = headers_doc(&only, &[("Head", auto, "a.png")], &pics);
assert!(d.settings.watermark.is_some_and(|w| w.picture.is_some()));
}
/// A watermark in a tracked deletion is not the document's: a text one stays the WordArt text
/// box it is read as (not drawn on every page), and a picture one keeps the plan from making any
/// picture the document's watermark. The same watermarks outside a deletion are the document's.
#[test]
fn a_watermark_in_a_tracked_deletion_is_not_the_documents() {
let a = solid_png([20, 60, 140, 255]);
let pics = [("a.png", a)];
let one = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
let pic = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
let text = r##"<w:r><w:pict><v:shape id="PowerPlusWaterMarkObject9" type="#_x0000_t136" style="position:absolute;width:400pt;height:100pt;rotation:315;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="silver"><v:textpath style="font-family:&quot;Calibri&quot;" string="DRAFT"/></v:shape></w:pict></w:r>"##;
let deleted = |runs: &str| format!(r#"<w:del w:id="9" w:author="A" w:date="2024-01-01T00:00:00Z">{}</w:del>"#, runs);
for (runs, kind) in [(&pic, "picture"), (&text.to_string(), "text")] {
let live = headers_doc(&one, &[("Head", runs.to_string(), "a.png")], &pics);
assert!(live.settings.watermark.is_some(), "{kind}: outside a deletion it is the document's");
let gone = headers_doc(&one, &[("Head", deleted(runs), "a.png")], &pics);
assert_eq!(gone.settings.watermark, None, "{kind}: in a deletion it isn't");
}
// The picture stays in the header, marked deleted.
let gone = headers_doc(&one, &[("Head", deleted(&pic), "a.png")], &pics);
assert_eq!(header_pictures(&gone), vec![("Head".into(), 1)]);
}
/// Issue #1775: a picture watermark in a header that starts with a table is anchored in a cell
/// but placed on the page: any VML shape in a header (Word's picture watermark, `o:allowincell="f"`; user-checked
/// in Word for Mac, the attribute makes no difference) or DrawingML `layoutInCell="0"`. That survives save and reopen; a shape laid out in its cell stays so.
+147 -1
View File
@@ -12,7 +12,9 @@ use wordcraft_doc::props::{
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
use wordcraft_doc::table::{Cell, Table};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block};
use wordcraft_doc::{
Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, WatermarkPicture, para_block,
};
fn rt(doc: &Document) -> Document {
let bytes = wordcraft_docx::write(doc).expect("write");
@@ -1561,7 +1563,10 @@ fn watermark_roundtrips() {
diagonal: false,
semitransparent: false,
size: None,
..Default::default()
},
// Any transparency, not only Word's two.
Watermark { transparency: Some(80), ..Default::default() },
// A box of its own (not Word's default for the page) is kept.
Watermark { text: "COPY".into(), size: Some([300.0, 80.0]), ..Default::default() },
] {
@@ -1573,6 +1578,147 @@ fn watermark_roundtrips() {
}
}
/// A `w`×`h` px PNG of one colour.
fn wm_png(w: u32, h: u32) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([20, 60, 140, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
}
/// #2954: a picture watermark round-trips through Word's VML picture shape in every header:
/// the picture, Auto or a scale, and Washout (`gain` and `blacklevel`) on or off. The header
/// keeps its own text and gains no picture of its own.
#[test]
fn picture_watermark_roundtrips() {
let png = wm_png(40, 20);
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
d.last_section.headers.default = Some(h);
let key = d.add_media(png.clone(), "png");
for (scale, washout) in [(None, true), (Some(50.0), false), (Some(200.0), true)] {
let wm = Watermark { picture: Some(WatermarkPicture { media: key.clone(), scale, washout }), ..Watermark::default() };
d.settings.watermark = Some(wm.clone());
let bytes = wordcraft_docx::write(&d).unwrap();
let hdr = part_xml(&bytes, "word/header1.xml");
assert_eq!(hdr.matches("<v:shape ").count(), 1);
assert!(hdr.contains(r#"id="WordPictureWatermark1""#) && hdr.contains("mso-position-vertical-relative:margin"), "{hdr}");
assert_eq!(hdr.contains(r#"gain="19661f" blacklevel="22938f""#), washout);
assert!(part_xml(&bytes, "word/_rels/header1.xml.rels").contains("media/"));
let r = wordcraft_docx::read(&bytes).unwrap();
let back = r.settings.watermark.clone().expect("watermark");
let pic = back.picture.clone().expect("picture");
assert_eq!((pic.scale, pic.washout), (scale, washout));
assert_eq!(r.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
let hid = r.last_section.headers.default.unwrap();
assert_eq!(part_text(&r, Some(hid)), "Head");
assert!(r.parts[&hid].blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()), "no picture left in the header");
assert_eq!(rt(&r).settings.watermark, r.settings.watermark, "second generation");
}
// A watermark whose picture can't be read is not written.
d.media.insert(key, Arc::new(b"junk".to_vec()));
let r = rt(&d);
assert_eq!(r.settings.watermark, None);
assert!(!part_xml(&wordcraft_docx::write(&d).unwrap(), "word/header1.xml").contains("v:shape"));
}
/// A portrait first section whose header the landscape last section links to ("link to
/// previous"), with a wide picture watermark at Auto.
fn portrait_then_landscape_linked(header: bool, scale: Option<f32>) -> Document {
let mut d = Document::new();
let mut s1 = SectionProps::default();
if header {
s1.headers.default = Some(d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]));
}
let mut p1 = Paragraph::with_text("Portrait", CharProps::default());
p1.section = Some(Box::new(s1));
d.body = vec![para_block(p1), para_block(Paragraph::with_text("Landscape", CharProps::default()))];
d.last_section.set_landscape(true);
let key = d.add_media(wm_png(400, 200), "png");
d.settings.watermark = Some(Watermark { picture: Some(WatermarkPicture { media: key, scale, washout: true }), ..Watermark::default() });
d
}
/// #2954 review: Auto is sized once per header, for the first section that names it. A portrait
/// section before a landscape one that links to its header: the header holds the portrait size
/// (468 pt wide), and reads back as Auto.
#[test]
fn auto_picture_watermark_is_sized_for_the_first_section_naming_its_header() {
let d = portrait_then_landscape_linked(true, None);
// The header the landscape section links to is the portrait section's.
assert_eq!(d.watermark_fit(d.sections()[0].1.headers.default), (468.0, 648.0));
let bytes = wordcraft_docx::write(&d).unwrap();
assert!(part_xml(&bytes, "word/header1.xml").contains("width:468.000pt;height:234.000pt"));
let r = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(r.settings.watermark.and_then(|w| w.picture).map(|p| p.scale), Some(None), "still Auto");
}
/// A document of two sections, the first with no header of its own and the last landscape: the
/// watermark's own header serves both, sized for the first.
#[test]
fn auto_picture_watermark_header_written_for_the_watermark_alone() {
let d = portrait_then_landscape_linked(false, None);
assert_eq!(d.watermark_fit(None), (468.0, 648.0));
let bytes = wordcraft_docx::write(&d).unwrap();
assert!(part_xml(&bytes, "word/header1.xml").contains("width:468.000pt;height:234.000pt"));
let r = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(r.settings.watermark.and_then(|w| w.picture).map(|p| p.scale), Some(None));
}
/// #2954 second review: a watermark at a fixed scale, its header shapes the same size in a
/// portrait and a landscape section, fills one section's margins but not the other's. It is
/// still the document's watermark at that scale after saving and reopening, washed out, and
/// the headers gain no pictures.
#[test]
fn fixed_scale_watermark_across_differing_sections() {
let mut d = Document::new();
let h1 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Portrait", CharProps::default()))]);
let h2 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Landscape", CharProps::default()))]);
let mut s1 = SectionProps::default();
s1.headers.default = Some(h1);
let mut p1 = Paragraph::with_text("One", CharProps::default());
p1.section = Some(Box::new(s1));
d.body = vec![para_block(p1), para_block(Paragraph::with_text("Two", CharProps::default()))];
d.last_section.set_landscape(true);
d.last_section.headers.default = Some(h2);
let key = d.add_media(wm_png(624, 312), "png");
let wm = Watermark { picture: Some(WatermarkPicture { media: key, scale: Some(100.0), washout: true }), ..Watermark::default() };
d.settings.watermark = Some(wm);
let bytes = wordcraft_docx::write(&d).unwrap();
for name in ["word/header1.xml", "word/header2.xml"] {
assert!(part_xml(&bytes, name).contains("width:468.000pt;height:234.000pt"), "{name}");
}
let r = wordcraft_docx::read(&bytes).unwrap();
let pic = r.settings.watermark.clone().and_then(|w| w.picture).expect("still the document's watermark");
assert_eq!((pic.scale, pic.washout), (Some(100.0), true));
for id in header_ids(&r) {
assert!(r.parts[&id].blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()), "no header picture");
}
let r2 = rt(&r);
assert_eq!(r2.settings.watermark, r.settings.watermark, "second generation");
}
/// #2954: the text watermark's transparency round-trips as `v:fill` `opacity`; Word's
/// Semitransparent is `.5`, and an opaque watermark has no `v:fill`.
#[test]
fn text_watermark_transparency_roundtrips() {
for (t, fill) in [(0, None), (25, Some(".75")), (50, Some(".5")), (80, Some(".2")), (100, Some("0"))] {
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
let mut wm = Watermark::default();
wm.set_transparency(t);
d.settings.watermark = Some(wm.clone());
let bytes = wordcraft_docx::write(&d).unwrap();
let hdr = part_xml(&bytes, "word/header1.xml");
match fill {
Some(o) => assert!(hdr.contains(&format!(r#"<v:fill opacity="{o}"/>"#)), "{t}: {hdr}"),
None => assert!(!hdr.contains("v:fill"), "{t}"),
}
let r = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(r.settings.watermark, Some(wm), "{t}");
}
}
/// #3994: a watermark font name round-trips through the textpath's CSS-like style whatever it
/// carries — apostrophes, semicolons, colons, quotes, backslashes. Before, those characters
/// were deleted (the font was saved under a different name) and a trailing backslash escaped
+21
View File
@@ -17,6 +17,27 @@ fn tiny_png() -> Vec<u8> {
out.into_inner()
}
/// #2954: a picture watermark and a text watermark's transparency survive Word 2003 XML (the
/// picture as `w:binData`, the same VML shapes as in DOCX).
#[test]
fn watermarks_round_trip_through_2003() {
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text("Body", CharProps::default()))];
let key = d.add_media(tiny_png(), "png");
let pic = wordcraft_doc::WatermarkPicture { media: key, scale: Some(150.0), washout: true };
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
let back = read_xml(&write_wordml2003(&d).expect("write")).expect("read");
let got = back.settings.watermark.and_then(|w| w.picture).expect("picture watermark");
assert_eq!((got.scale, got.washout), (Some(150.0), true));
assert_eq!(back.media.get(&got.media).map(|b| b.as_slice()), Some(tiny_png().as_slice()));
let mut wm = wordcraft_doc::Watermark::default();
wm.set_transparency(70);
d.settings.watermark = Some(wm.clone());
let back = read_xml(&write_wordml2003(&d).expect("write")).expect("read");
assert_eq!(back.settings.watermark, Some(wm));
}
fn paras(doc: &Document) -> Vec<&Paragraph> {
doc.body.iter().filter_map(|b| b.as_para()).collect()
}
+2
View File
@@ -56,3 +56,5 @@ wordcraft-doc = { workspace = true, features = ["test-support"] }
encoding_rs = { workspace = true }
zip = { workspace = true }
cfb = { workspace = true }
# The PDF side of the watermark size check (what the export draws, read back).
wordcraft-pdf = { workspace = true, features = ["test-support"] }
+6 -1
View File
@@ -169,6 +169,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
if a == b {
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
}
if canvas::selected_member(s).is_some() {
return Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into()));
}
controls::check_delete(s, &a, &b)?;
let effect = if s.doc.settings.track_changes {
if !track_delete_would_change(s, &a, &b) {
@@ -186,7 +189,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
Ok(effect)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
/// Delete the selection (respecting track changes); returns the collapsed position. With a
/// Drawing Canvas member picked (#2637) the selection is the whole canvas, which a command that
/// replaces the selection (Insert › Table, Equation, Symbol, Field…) would take away: refused.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
delete_selection_effect(s).map(|e| e.at)
}
+320
View File
@@ -5,6 +5,10 @@
//! top-left corner, unscaled). While a canvas is selected, Insert › Pictures, Shapes and Text Box
//! put the new object inside it ([`add_member`]). Fit, Expand and Scale Drawing change its frame;
//! Shape Fill and Shape Outline colour its background and border.
//!
//! A member can be picked on its own (#2637, `select.member`; a click on it does this): Shape
//! Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on that member, and Move or
//! Resize and Nudge move it inside the canvas. The canvas stays the selection in the text.
use serde_json::{Value, json};
use wordcraft_doc::para::{CanvasStyle, Float, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
@@ -14,6 +18,29 @@ use super::objects::object_selection;
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// A member of the selected canvas picked on its own: its index, and the selection it was picked
/// in (it counts only while that selection stands).
#[derive(Clone, Debug, PartialEq)]
pub struct CanvasMember {
sel: Selection,
pub index: usize,
}
/// The canvas member picked on its own: the canvas's position, the member's index and the member.
pub fn selected_member(s: &Session) -> Option<(Pos, usize, &InlineObject)> {
let m = s.member.as_ref().filter(|m| m.sel == s.sel)?;
let (pos, canvas) = selected_canvas(s)?;
let InlineObject::Group { children, .. } = canvas else { return None };
let c = children.get(m.index).filter(|c| pickable(c))?;
Some((pos, m.index, &c.obj))
}
/// Whether a member can be picked on its own: a picture, shape or text box that is drawn (a
/// group inside a canvas isn't, nor a hidden member).
pub fn pickable(c: &GroupChild) -> bool {
c.obj.is_drawing() && !matches!(c.obj, InlineObject::Group { .. }) && !c.obj.frame().is_some_and(|(_, _, f)| f.hidden)
}
/// Largest canvas side and member offset, points (well past any page).
const MAX_SIDE: f32 = 4000.0;
/// Smallest canvas side, points.
@@ -47,6 +74,10 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("canvas.scale", "Scale Drawing", "Shape Format › Drawing Canvas", scale)
.params(r#"{"factor": number (0.1–10)} (scales the frame and every member together)"#)
.when(has_canvas),
CommandSpec::new("select.member", "Select Canvas Member", "Shape Format › Drawing Canvas", select_member)
.params(r#"{"index": n | null} (picks member `index` of the selected drawing canvas on its own: Shape Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on it, and Move or Resize and Nudge move it inside the canvas; null: the canvas itself again)"#)
.when(has_canvas)
.pure(),
CommandSpec::new("canvas.member", "Canvas Member", "Shape Format › Drawing Canvas", member)
.params(r#"{"index": n, "x"?: pt, "y"?: pt, "width"?: pt, "height"?: pt, "delete"?: bool} (moves, resizes or removes member `index` of the selected canvas; x/y from the canvas's top-left; with no change, returns the members)"#)
.when(has_canvas),
@@ -224,9 +255,171 @@ fn member(s: &mut Session, v: &Value) -> CmdResult {
c.obj.set_size(nw.unwrap_or(w0).clamp(1.0, MAX_SIDE), nh.unwrap_or(h0).clamp(1.0, MAX_SIDE));
}
})?;
// The indices after a removed member shift down, so a picked member would name another one.
if delete {
s.member = None;
}
result(&o)
}
/// `select.member`: pick a member of the selected canvas on its own, or (`null`) the canvas again.
fn select_member(s: &mut Session, v: &Value) -> CmdResult {
let Some((_, InlineObject::Group { children, .. })) = selected_canvas(s) else {
return Err(CmdError::Disabled("select a drawing canvas first".into()));
};
let index = match v.get("index") {
None | Some(Value::Null) => None,
Some(_) => {
Some(p::u64(v, "index").and_then(|i| usize::try_from(i).ok()).ok_or_else(|| CmdError::Params("`index` is a member number".into()))?)
}
};
let Some(i) = index else {
s.member = None;
return sel_result(s);
};
let count = children.len();
let c = children.get(i).ok_or_else(|| CmdError::Params(format!("no member {i} (the canvas has {count})")))?;
if !pickable(c) {
return Err(CmdError::Params(format!("member {i} can't be selected on its own")));
}
let (w, h) = c.obj.frame().map(|(w, h, _)| (w, h)).unwrap_or((0.0, 0.0));
let out = json!({"index": i, "x": c.x, "y": c.y, "width": w, "height": h, "object": serde_json::to_value(&c.obj).unwrap_or(Value::Null)});
s.member = Some(CanvasMember { sel: s.sel.clone(), index: i });
Ok(out)
}
/// Delete, Backspace (and their word forms), Cut and Copy with a member picked act on the member
/// alone; the canvas stays selected. Typing and pasting, which would replace the whole canvas,
/// are disabled. `None`: another command, or no member picked.
pub(crate) fn member_command(s: &mut Session, id: &str, _: &Value) -> Option<CmdResult> {
let replaces = (id.starts_with("text.") && id != "text.overtype")
|| matches!(
id,
"edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial" | "edit.pasteClipboardItem" | "edit.pasteAllClipboard"
);
if !replaces && !matches!(id, "edit.cut" | "edit.copy") {
return None;
}
let (pos, index, _) = selected_member(s)?;
Some(match id {
"edit.copy" => copy_member(s, &pos, index),
"edit.cut" => copy_member(s, &pos, index).and_then(|r| remove_member(s, &pos, index).map(|_| r)),
"text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => {
remove_member(s, &pos, index).and_then(|_| sel_result(s))
}
_ => Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into())),
})
}
/// Copy member `index` of the canvas at `pos` alone: pasted, it is a picture or shape of its own.
fn copy_member(s: &mut Session, pos: &Pos, index: usize) -> CmdResult {
let end = Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() };
let mut f = s.doc.copy_range(pos, &end);
let mut copied = false;
for b in &mut f.blocks {
let wordcraft_doc::Block::Para(p) = b else { continue };
for o in &mut p.objects {
if let InlineObject::Group { children, canvas: Some(_), .. } = o
&& let Some(c) = children.get(index)
{
*o = c.obj.clone();
copied = true;
}
}
}
if !copied {
return Err(CmdError::Failed("the member vanished".into()));
}
s.clipboard_text = f.clipboard_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
Ok(super::edit::copy_result(s))
}
/// Take member `index` out of the canvas at `pos`; the canvas stays selected.
fn remove_member(s: &mut Session, pos: &Pos, index: usize) -> Result<(), CmdError> {
edit_canvas(s, pos, |_, _, children| {
if index < children.len() {
children.remove(index);
}
})?;
s.member = None;
Ok(())
}
/// Move or Resize with a member picked: `x`/`y` (the page point of its top-left, as dragging its
/// frame gives it) and the size become its place and size in the canvas. `None`: no member.
pub(crate) fn member_bounds(s: &mut Session, v: &Value) -> Option<CmdResult> {
let (pos, index, obj) = selected_member(s)?;
let (w0, h0, _) = obj.frame()?;
let min = super::objects::min_size(obj);
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
(Some(x), Some(y)) => Some((x, y)),
(None, None) => None,
_ => return Some(Err(CmdError::Params("`x` and `y` go together".into()))),
};
// `x`/`y` are on the canvas's page: a drop on another page would put the member outside it.
if to.is_some()
&& let Some(page) = p::u64(v, "page").and_then(|n| usize::try_from(n).ok())
&& s.layout().object(&pos, s.page_hint).is_some_and(|o| o.page != page)
{
return Some(Err(CmdError::Disabled("a shape can't leave its drawing canvas".into())));
}
let (nw, nh) = (p::f32(v, "width"), p::f32(v, "height"));
Some(edit_member(s, &pos, index, |frame, (sx, sy), c| {
if let Some((x, y)) = to {
c.x = ((x - frame.x) / sx).clamp(-MAX_SIDE, MAX_SIDE);
c.y = ((y - frame.y) / sy).clamp(-MAX_SIDE, MAX_SIDE);
}
let w = nw.map_or(w0, |w| w / sx).clamp(min, MAX_SIDE);
let h = nh.map_or(h0, |h| h / sy).clamp(min, MAX_SIDE);
if (w, h) != (w0, h0) {
c.obj.set_size(w, h);
}
}))
}
/// Nudge with a member picked: it moves inside the canvas. `None`: no member.
pub(crate) fn member_nudge(s: &mut Session, dx: f32, dy: f32) -> Option<CmdResult> {
let (pos, index, _) = selected_member(s)?;
Some(edit_member(s, &pos, index, |_, (sx, sy), c| {
c.x = (c.x + dx / sx).clamp(-MAX_SIDE, MAX_SIDE);
c.y = (c.y + dy / sy).clamp(-MAX_SIDE, MAX_SIDE);
}))
}
/// Change member `index` of the canvas at `pos`: `f` gets the canvas's frame on the page, the
/// scale of its members' space and the member.
fn edit_member(s: &mut Session, pos: &Pos, index: usize, f: impl FnOnce(wordcraft_geom::Rect, (f32, f32), &mut GroupChild)) -> CmdResult {
let frame = s.layout().object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the drawing canvas isn't laid out".into()))?.rect;
let canvas =
s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("the drawing canvas vanished".into()))?;
let scale = canvas.group_scale(frame.w, frame.h);
let mut f = Some(f);
let canvas = edit_canvas(s, pos, |_, _, children| {
if let (Some(c), Some(f)) = (children.get_mut(index), f.take()) {
f(frame, scale, c);
}
})?;
let InlineObject::Group { children, .. } = &canvas else { return result(&canvas) };
let member = children.get(index).map(|c| serde_json::to_value(&c.obj).unwrap_or(Value::Null)).unwrap_or(Value::Null);
Ok(json!({"object": member, "pos": super::pos_json(pos), "member": index}))
}
/// Change the picked member's own object (Shape Format, Size, Rotate, Alt Text) when `pos` is its
/// canvas. `None`: no member picked there.
pub(crate) fn edit_member_object(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Option<Result<InlineObject, CmdError>> {
let (at, index, _) = selected_member(s).filter(|(at, ..)| at == pos)?;
let mut out = None;
let r = edit_canvas(s, &at, |_, _, children| {
if let Some(c) = children.get_mut(index) {
f(&mut c.obj);
out = Some(c.obj.clone());
}
});
Some(r.and_then(|_| out.ok_or_else(|| CmdError::Failed("the member vanished".into()))))
}
/// Shape Fill and Shape Outline on a selected canvas colour its background and border.
pub fn style_mut(o: &mut InlineObject) -> Option<&mut CanvasStyle> {
match o {
@@ -327,4 +520,131 @@ mod tests {
fn canvas_in_text(s: &Session) -> InlineObject {
s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()).cloned().unwrap()
}
fn member_fill(o: &InlineObject, i: usize) -> Option<wordcraft_doc::fill::Fill> {
let InlineObject::Group { children, .. } = o else { panic!("{o:?}") };
match &children[i].obj {
InlineObject::Shape { fill, .. } => fill.clone(),
o => panic!("{o:?}"),
}
}
/// #2637: a shape inside a drawing canvas can be picked on its own, as in Word. Shape Format,
/// Move or Resize, Nudge, Delete and Copy then act on it rather than on the whole canvas,
/// which stays the selection in the text; the commands that place an object in the text
/// (wrapping, position) don't apply to it.
#[test]
fn a_canvas_member_is_selected_and_changed_on_its_own() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("canvas.member", &json!({"index": 0, "x": 40, "y": 30})).unwrap();
s.run("canvas.member", &json!({"index": 1, "x": 200, "y": 100})).unwrap();
let canvas_sel = s.sel.clone();
assert!(s.run("select.member", &json!({"index": 5})).is_err(), "no such member");
s.run("select.member", &json!({"index": 1})).unwrap();
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
assert_eq!(s.sel, canvas_sel, "the canvas stays the selection in the text");
// Shape Fill colours the member, not the canvas or the other member.
let other = member_fill(&canvas(&s), 0);
s.run("shape.fill", &json!({"color": "FF0000"})).unwrap();
let c = canvas(&s);
assert_eq!(member_fill(&c, 1), wordcraft_doc::props::Rgb::parse("FF0000").map(wordcraft_doc::fill::Fill::solid));
assert_eq!(member_fill(&c, 0), other);
assert_eq!(c.canvas_style().and_then(|st| st.fill.clone()), None);
assert!(selected_member(&s).is_some(), "still picked after a change");
// Dragging its frame: the page point becomes its place in the canvas.
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap().rect;
s.run("arrange.bounds", &json!({"page": 0, "x": frame.x + 10.0, "y": frame.y + 20.0, "width": 60, "height": 30})).unwrap();
assert_eq!(members(&canvas(&s))[1], (10.0, 20.0, 60.0, 30.0));
assert_eq!(canvas(&s).frame().map(|(w, h, _)| (w, h)), Some((400.0, 300.0)), "the canvas keeps its frame");
s.run("arrange.nudge", &json!({"dx": 6, "dy": -6})).unwrap();
assert_eq!(members(&canvas(&s))[1], (16.0, 14.0, 60.0, 30.0));
assert!(s.run("arrange.wrap", &json!({"wrap": "square"})).is_err(), "the canvas places its members");
// Copy puts the member alone on the clipboard; Delete takes it out of the canvas.
s.run("edit.copy", &json!({})).unwrap();
let clip = s.clipboard.clone().unwrap();
let copied: Vec<&InlineObject> = clip.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
assert!(matches!(copied.as_slice(), [InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. }]), "{copied:?}");
s.run("text.delete", &json!({})).unwrap();
assert_eq!(members(&canvas(&s)), [(40.0, 30.0, 100.0, 40.0)]);
assert!(selected_member(&s).is_none());
assert_eq!(s.sel, canvas_sel, "the canvas is still selected");
// Undo puts the member back.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(members(&canvas_in_text(&s)).len(), 2);
// Picking the canvas again (or anything else) ends the member's selection.
s.run("select.objects", &json!({"index": 0})).unwrap();
s.run("select.member", &json!({"index": 0})).unwrap();
s.run("select.member", &json!({"index": null})).unwrap();
assert!(selected_member(&s).is_none());
s.run("select.member", &json!({"index": 0})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
assert!(selected_member(&s).is_none());
// Pasted outside the canvas, the copied member is a shape of its own in the text.
s.run("edit.paste", &json!({})).unwrap();
let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
assert!(matches!(para.objects.last(), Some(InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. })), "{:?}", para.objects);
}
#[test]
fn typing_or_removing_a_member_never_hits_another_shape_or_the_canvas() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("select.member", &json!({"index": 1})).unwrap();
// Typing or pasting would replace the whole canvas: refused, and the member stays picked.
for (id, v) in [
("text.insert", json!({"text": "x"})),
("text.newParagraph", json!({})),
("edit.paste", json!({"text": "x"})),
("edit.pasteText", json!({"text": "x"})),
] {
assert!(matches!(s.run(id, &v), Err(CmdError::Disabled(_))), "{id}");
assert_eq!(members(&canvas(&s)).len(), 2, "{id}");
}
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
// Ctrl+Backspace removes the member alone, like Backspace.
s.run("text.deleteWordBack", &json!({})).unwrap();
let sizes: Vec<(f32, f32)> = members(&canvas(&s)).iter().map(|m| (m.2, m.3)).collect();
assert_eq!(sizes, [(100.0, 40.0)], "the ellipse went, the rectangle stays");
assert!(selected_member(&s).is_none());
// Removing an earlier member through `canvas.member` drops the pick: its index would now
// name the next member.
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 20, "height": 20})).unwrap();
s.run("select.member", &json!({"index": 1})).unwrap();
s.run("canvas.member", &json!({"index": 0, "delete": true})).unwrap();
assert!(selected_member(&s).is_none(), "the pick doesn't slide onto the member that moved up");
// Commands that replace the selection (the whole canvas) are refused too.
s.run("select.member", &json!({"index": 0})).unwrap();
for (id, v) in [
("insert.table", json!({"rows": 1, "cols": 1})),
("table.quick", json!({})),
("insert.equation", json!({})),
("insert.symbol", json!({"char": "§"})),
("insert.field", json!({"instr": "AUTHOR"})),
("insert.pageBreak", json!({})),
] {
assert!(s.run(id, &v).is_err(), "{id}");
assert_eq!(members(&canvas(&s)).len(), 2, "{id}: the canvas and its members stay");
}
// Moving the member: on the canvas's page it moves; a drop on another page is refused.
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap();
let before = members(&canvas(&s));
let other = frame.page + 1;
assert!(matches!(s.run("arrange.bounds", &json!({"page": other, "x": 100, "y": 100})), Err(CmdError::Disabled(_))));
assert_eq!(members(&canvas(&s)), before);
s.run("arrange.bounds", &json!({"page": frame.page, "x": frame.rect.x + 5.0, "y": frame.rect.y + 5.0})).unwrap();
assert_ne!(members(&canvas(&s)), before);
}
}
+246 -4
View File
@@ -3,7 +3,8 @@
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
use wordcraft_doc::{Watermark, styles};
use wordcraft_doc::watermark::{MAX_SCALE, MIN_SCALE};
use wordcraft_doc::{Watermark, WatermarkPicture, styles};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@@ -71,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
.params(r#"{"text"?: string (a text watermark), "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB", "transparency"?: 0-100 (percent; Word's Semitransparent is 50), "semitransparent"?: bool (50 or 0 percent), "picture"?: {"path": string} | {"data": base64} | {"media": key} (a picture watermark; at most 64 MB) | false (back to text), "scale"?: "auto" | 1-500 (percent of the picture's own size; auto fits the margins), "washout"?: bool (faded, default on)}"#),
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
s.doc.settings.page_color = p::str(v, "color").and_then(Rgb::parse);
s.doc.settings.page_picture = None;
@@ -247,23 +248,142 @@ fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Design › Watermark: a text watermark (`text` switches to one), or a picture watermark
/// (`picture`) with its scale and Washout; the text keeps its settings meanwhile, as in Word's
/// dialog. Settings for the other kind are refused rather than silently kept.
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
if p::bool(v, "remove").unwrap_or(false) {
s.doc.settings.watermark = None;
return sel_result(s);
}
let mut wm = s.doc.settings.watermark.clone().unwrap_or_default();
// Everything is checked before the document is touched (a picture added to the media).
let scale = watermark_scale(v)?;
let picked = match v.get("picture") {
Some(Value::Bool(false)) => {
wm.picture = None;
None
}
Some(pic) if !pic.is_null() => Some(watermark_picture(s, pic)?),
_ => {
if p::str(v, "text").is_some() {
wm.picture = None;
}
None
}
};
// The picture's bytes wait: it joins the document's media only after every check below.
let pending = pick_watermark_picture(&mut wm, picked);
if let Some(t) = p::str(v, "text") {
wm.text = t.chars().take(200).collect();
}
match wm.picture.as_mut() {
Some(pic) => apply_picture_settings(v, pic, scale)?,
None => apply_text_settings(v, &mut wm)?,
}
if let (Some(bytes), Some(pic)) = (pending, wm.picture.as_mut()) {
let ext = super::insert::image_ext(&bytes);
pic.media = s.doc.add_media(bytes, ext);
}
s.doc.settings.watermark = Some(wm);
sel_result(s)
}
/// The watermark's `scale` param: None when absent, `Some(None)` for Auto, else the percentage in
/// tenths, clamped.
fn watermark_scale(v: &Value) -> Result<Option<Option<f32>>, CmdError> {
match v.get("scale") {
None => Ok(None),
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Ok(Some(None)),
Some(_) => {
let percent =
p::f32(v, "scale").filter(|p| p.is_finite()).ok_or_else(|| CmdError::Params("`scale` is \"auto\" or a percentage".into()))?;
// Tenths of a percent, as a saved file gives it back.
Ok(Some(Some((percent.clamp(MIN_SCALE, MAX_SCALE) * 10.0).round() / 10.0)))
}
}
}
/// Makes `picked` (bytes and an existing media key) the watermark's picture, keeping its other
/// settings; returns the bytes still to join the document's media, if any.
fn pick_watermark_picture(wm: &mut Watermark, picked: Option<(Vec<u8>, Option<String>)>) -> Option<Vec<u8>> {
let (bytes, key) = picked?;
let (media, pending) = match key {
Some(key) => (key, None),
None => (String::new(), Some(bytes)),
};
wm.picture = Some(WatermarkPicture { media, ..wm.picture.take().unwrap_or_default() });
pending
}
/// Applies a picture watermark's `scale` and `washout`; refuses a text watermark's settings.
fn apply_picture_settings(v: &Value, pic: &mut WatermarkPicture, scale: Option<Option<f32>>) -> Result<(), CmdError> {
if let Some(k) = ["diagonal", "color", "transparency", "semitransparent"].into_iter().find(|k| v.get(*k).is_some()) {
return Err(CmdError::Params(format!("`{k}` is for a text watermark: give `text` or `\"picture\": false` too")));
}
if let Some(scale) = scale {
pic.scale = scale;
}
if let Some(w) = p::bool(v, "washout") {
pic.washout = w;
}
Ok(())
}
/// Applies a text watermark's layout, colour and transparency; refuses a picture's settings.
fn apply_text_settings(v: &Value, wm: &mut Watermark) -> Result<(), CmdError> {
if let Some(k) = ["scale", "washout"].into_iter().find(|k| v.get(*k).is_some()) {
return Err(CmdError::Params(format!("`{k}` is for a picture watermark: give `picture` too")));
}
if let Some(d) = p::bool(v, "diagonal") {
wm.diagonal = d;
}
if let Some(c) = p::str(v, "color").and_then(Rgb::parse) {
wm.color = c;
}
s.doc.settings.watermark = Some(Watermark { ..wm });
sel_result(s)
if let Some(on) = p::bool(v, "semitransparent") {
wm.set_transparency(if on { 50 } else { 0 });
}
// Applied after `semitransparent`, so a percentage wins when both are given.
if let Some(t) = p::f32(v, "transparency") {
wm.set_transparency(if t.is_finite() { t.clamp(0.0, 100.0).round() as u8 } else { 0 });
}
Ok(())
}
/// Largest watermark picture taken, bytes. A watermark is a faded backdrop, not a photograph
/// library: far below Insert › Pictures' 200 MB, so refusing a bigger one never costs a large
/// allocation (the base64 text is measured before it is decoded).
const MAX_PICTURE_BYTES: usize = 64 << 20;
/// The picture of a watermark: a file (`path`), base64 `data`, or a picture already in the
/// document (`media`, returned as its key). Only pictures the renderers draw are taken.
fn watermark_picture(s: &Session, v: &Value) -> Result<(Vec<u8>, Option<String>), CmdError> {
let unsupported = || CmdError::Failed("not a supported picture (PNG, JPEG, GIF, WebP, BMP, TIFF)".into());
let readable = |b: &[u8]| wordcraft_doc::watermark::own_size(b).is_some();
let too_large = || CmdError::Failed(format!("the picture is larger than {} MB", MAX_PICTURE_BYTES >> 20));
if let Some(key) = p::str(v, "media") {
let bytes = s.doc.media.get(key).ok_or_else(|| CmdError::Params(format!("no picture `{key}` in the document")))?;
return if readable(bytes) { Ok((Vec::new(), Some(key.to_string()))) } else { Err(unsupported()) };
}
let bytes = if let Some(d) = p::str(v, "data") {
// base64 is 4 characters for 3 bytes: measured before anything is decoded.
if d.len() > MAX_PICTURE_BYTES / 3 * 4 + 4 {
return Err(too_large());
}
super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?
} else if let Some(path) = p::str(v, "path") {
crate::io::read_file_capped(path, MAX_PICTURE_BYTES as u64, "picture").map_err(CmdError::Failed)?
} else {
return Err(CmdError::Params("`picture` needs `path`, `data` or `media`".into()));
};
if bytes.len() > MAX_PICTURE_BYTES {
return Err(too_large());
}
if !readable(&bytes) {
return Err(unsupported());
}
Ok((bytes, None))
}
/// Page borders of the caret's section (or every section with `applyTo: "document"`).
@@ -288,3 +408,125 @@ fn page_borders(s: &mut Session, v: &Value) -> CmdResult {
}
sel_result(s)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use wordcraft_doc::{Document, Watermark, WatermarkPicture};
use super::MAX_PICTURE_BYTES;
use crate::Session;
fn png(w: u32, h: u32) -> Vec<u8> {
let mut b = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([30, 60, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png)
.unwrap();
b
}
fn wm(s: &Session) -> Watermark {
s.doc.settings.watermark.clone().unwrap()
}
/// #2954: a picture watermark from a file's bytes, its scale and Washout, then back to text;
/// each step undoes.
#[test]
fn picture_watermark_and_back_to_text() {
let mut s = Session::new(Document::new());
let data = super::super::insert::base64_encode(&png(40, 20));
s.run("design.watermark", &json!({"picture": {"data": data}})).unwrap();
let pic = wm(&s).picture.unwrap();
assert_eq!((pic.scale, pic.washout), (None, true), "Word's defaults: Auto, washed out");
assert!(s.doc.media.contains_key(&pic.media));
s.run("design.watermark", &json!({"scale": 150, "washout": false})).unwrap();
assert_eq!(wm(&s).picture, Some(WatermarkPicture { media: pic.media.clone(), scale: Some(150.0), washout: false }));
s.run("design.watermark", &json!({"scale": "AUTO"})).unwrap();
assert_eq!(wm(&s).picture.unwrap().scale, None);
// Clamped to Word's range.
s.run("design.watermark", &json!({"scale": 9000})).unwrap();
assert_eq!(wm(&s).picture.unwrap().scale, Some(500.0));
s.run("design.watermark", &json!({"scale": 0})).unwrap();
assert_eq!(wm(&s).picture.unwrap().scale, Some(1.0));
s.run("design.watermark", &json!({"scale": 33.333})).unwrap();
assert_eq!(wm(&s).picture.unwrap().scale, Some(33.3));
// A picture already in the document; text settings are refused while it is a picture.
s.run("design.watermark", &json!({"picture": {"media": pic.media}, "scale": 50})).unwrap();
assert!(s.run("design.watermark", &json!({"transparency": 30})).is_err());
assert!(s.run("design.watermark", &json!({"diagonal": false})).is_err());
// Text (or `picture: false`) makes it a text watermark again, with its text settings.
s.run("design.watermark", &json!({"text": "SAMPLE", "transparency": 30})).unwrap();
let text = wm(&s);
assert_eq!((text.picture.is_none(), text.text.as_str(), text.transparency()), (true, "SAMPLE", 30));
assert!(s.run("design.watermark", &json!({"washout": true})).is_err());
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(wm(&s).picture.unwrap().scale, Some(50.0));
s.run("design.watermark", &json!({"picture": false})).unwrap();
assert_eq!(wm(&s).picture, None);
s.run("design.watermark", &json!({"remove": true})).unwrap();
assert_eq!(s.doc.settings.watermark, None);
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc.settings.watermark.is_some());
}
/// #2954: the text's transparency in percent, kept in step with Semitransparent.
#[test]
fn text_watermark_transparency() {
let mut s = Session::new(Document::new());
s.run("design.watermark", &json!({"text": "DRAFT"})).unwrap();
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (50, true));
s.run("design.watermark", &json!({"transparency": 80})).unwrap();
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (80, true));
s.run("design.watermark", &json!({"semitransparent": false})).unwrap();
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (0, false));
s.run("design.watermark", &json!({"transparency": 250.7})).unwrap();
assert_eq!(wm(&s).transparency(), 100);
s.run("design.watermark", &json!({"transparency": -3})).unwrap();
assert_eq!(wm(&s).transparency(), 0);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(wm(&s).transparency(), 100);
}
/// #2954: pictures that can't be drawn, and missing ones, are refused and change nothing.
#[test]
fn bad_watermark_pictures_are_refused() {
let mut s = Session::new(Document::new());
let text = s.doc.add_media(b"not a picture".to_vec(), "png");
for bad in [
json!({"picture": {}}),
json!({"picture": {"data": "%%%"}}),
json!({"picture": {"data": super::super::insert::base64_encode(b"plain text")}}),
json!({"picture": {"media": "nope.png"}}),
json!({"picture": {"media": text}}),
json!({"picture": {"path": "/nonexistent/watermark.png"}}),
json!({"picture": 7}),
json!({"picture": {"data": super::super::insert::base64_encode(&png(4, 4))}, "scale": "big"}),
] {
assert!(s.run("design.watermark", &bad).is_err(), "{bad}");
assert_eq!(s.doc.settings.watermark, None, "{bad}");
assert_eq!(s.doc.media.len(), 1, "{bad}");
}
}
/// #2954 review: a picture too large is refused before anything is decoded or read: the
/// base64 text is measured first, and a file by its size.
#[test]
fn oversized_watermark_pictures_are_refused_without_a_large_allocation() {
let mut s = Session::new(Document::new());
// Longer than the largest picture's base64 text: refused by length, never decoded (the
// decoded bytes would be as large as the cap again).
let big = "A".repeat(MAX_PICTURE_BYTES / 3 * 4 + 8);
let e = s.run("design.watermark", &json!({"picture": {"data": big}})).unwrap_err();
let e = format!("{e:?}");
assert!(e.contains("larger than"), "{e}");
assert_eq!((s.doc.settings.watermark.clone(), s.doc.media.len()), (None, 0));
// A file past the cap is refused by its size (a sparse file, so nothing is written).
let path = std::env::temp_dir().join(format!("wordcraft-wm-big-{}.png", std::process::id()));
std::fs::File::create(&path).unwrap().set_len(MAX_PICTURE_BYTES as u64 + 1).unwrap();
let e = s.run("design.watermark", &json!({"picture": {"path": path.to_string_lossy()}})).unwrap_err();
let e = format!("{e:?}");
let _ = std::fs::remove_file(&path);
assert!(e.contains("larger than"), "{e}");
assert_eq!((s.doc.settings.watermark.clone(), s.doc.media.len()), (None, 0));
}
}
+1 -1
View File
@@ -361,7 +361,7 @@ impl ClipHistory {
self.items.is_empty()
}
fn push(&mut self, f: &Fragment, text: &str) {
pub(crate) fn push(&mut self, f: &Fragment, text: &str) {
// Text plus per-paragraph overhead; pictures are references into the document.
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
+7 -3
View File
@@ -6,6 +6,9 @@ pub(super) const MAX_OFFSET: f32 = 4000.0;
/// Resize and/or move the selected object (dragging its frame or handles).
pub(super) fn bounds(s: &mut Session, v: &Value) -> CmdResult {
if let Some(r) = crate::cmd::canvas::member_bounds(s, v) {
return r;
}
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
(Some(x), Some(y)) => Some((x, y)),
@@ -58,7 +61,7 @@ fn landing(s: &Session, pos: &Pos, to: &Pos) -> Result<Pos, CmdError> {
/// Move the In Line with Text object at `pos` to the text position `to`, where it stays In Line
/// with Text, keeping its character formatting: Word's drag of an inline picture, which lands
/// where its dashed insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
/// where its insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
/// Returns the object's position afterwards.
fn move_in_text(s: &mut Session, pos: Pos, to: &Pos) -> Result<Pos, CmdError> {
let to = landing(s, &pos, to)?;
@@ -161,8 +164,9 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
Some(path) => (Pos { story, path, off: 0 }, false),
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
};
// Move its character to the anchor (unless it's already in that paragraph).
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page {
// Move its character to the anchor, unless it's already in that paragraph and drawn on this
// page (one anchored on a later page of it is drawn there, #4250).
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page && layout.object(pos, page).is_some_and(|o| o.page == page) {
pos.clone()
} else {
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
+24 -2
View File
@@ -12,6 +12,10 @@ pub(super) fn has_object(s: &Session) -> Option<&'static str> {
}
pub(super) fn has_floating(s: &Session) -> Option<&'static str> {
// A canvas member moves inside its canvas.
if crate::cmd::canvas::selected_member(s).is_some() {
return None;
}
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
Some((_, o)) if o.is_floating() => None,
@@ -51,6 +55,15 @@ pub(super) fn has_movable(s: &Session) -> Option<&'static str> {
}
}
/// `has_movable` for what places an object in the text (wrapping, position, alignment, Move with
/// Text): not for a member picked inside a drawing canvas, which the canvas places.
pub(super) fn has_placeable(s: &Session) -> Option<&'static str> {
if crate::cmd::canvas::selected_member(s).is_some() {
return Some("a shape inside a drawing canvas is placed by the canvas");
}
has_movable(s)
}
/// Word's own ink in a group is kept in the form a group member takes; it can't stand on its own
/// yet, so such a group stays whole.
pub(super) const INK_GROUP: &str = "a group holding Word ink can't be ungrouped yet";
@@ -99,7 +112,9 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
o.is_drawing().then_some((a, o))
}
/// The first picture/shape in the selection, or just before a collapsed caret.
/// The first picture/shape in the selection, or just before a collapsed caret. With a canvas
/// member picked ([`crate::cmd::canvas::selected_member`]), that member, at its canvas's position:
/// the commands that change it go through [`edit_obj`], which changes the member.
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
selected_all(s, 1).into_iter().next()
}
@@ -107,6 +122,9 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
/// caret.
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
if let Some((pos, _, member)) = crate::cmd::canvas::selected_member(s) {
return vec![(pos, member.clone())];
}
let mut out = Vec::new();
let (a, b) = s.sel.ordered();
let max = if a == b { 1 } else { max };
@@ -175,8 +193,12 @@ pub(super) fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdRes
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
/// Change the object at `pos`; returns it afterwards.
/// Change the object at `pos`; returns it afterwards. With a member of the canvas at `pos`
/// picked, the member changes.
pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
if let Some(r) = crate::cmd::canvas::edit_member_object(s, pos, &f) {
return r;
}
let para = s.doc.para_mut(pos.story, &pos.path)?;
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
f(o);
+13 -10
View File
@@ -2,8 +2,8 @@ use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
use super::group::{add_object, group, ungroup};
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
use super::select::{
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_placeable, has_shape,
has_shape_or_canvas, has_sizable, obj_size, objects_list, selected, with_float, with_obj,
};
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
@@ -193,17 +193,17 @@ pub fn specs() -> Vec<CommandSpec> {
with_float(s, |f| f.wrap = wrap)
})
.params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.wrapText", "Wrap Text Sides", "Layout › Arrange", |s, v| {
let side = p::str(v, "side").unwrap_or("bothSides");
let side = WrapText::from_ooxml(side).ok_or_else(|| CmdError::Params(format!("unknown side {side:?}")))?;
with_float(s, |f| f.wrap_text = side)
})
.params(r#"{"side": "bothSides|left|right|largest"} (the sides of the selected Square, Tight or Through object text flows on; largest: the wider side, line by line)"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.moveWithText", "Move with Text", "Layout › Arrange › Wrap Text", |s, v| move_with_text(s, p::bool(v, "value").unwrap_or(true)))
.params(r#"{"value": bool} (true: Move with Text, the floating object's vertical position follows its paragraph; false: Fix Position on Page. It stays where it is)"#)
.when(has_floating),
.when(|s| has_placeable(s).or_else(|| has_floating(s))),
CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| {
let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string();
let (x, y) = (p::f32(v, "x"), p::f32(v, "y"));
@@ -224,9 +224,9 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.params(r#"{"preset"?: "topLeft|topCenter|topRight|middleLeft|middleCenter|middleRight|bottomLeft|bottomCenter|bottomRight", "x"?: pt, "y"?: pt}"#)
.when(has_movable),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_movable),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_placeable),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_placeable),
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
let h = p::str(v, "value").unwrap_or("center").to_string();
let tw = crate::cmd::page::sect(s).text_width();
@@ -245,17 +245,20 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.params(r#"{"value": "left|center|right"}"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
.when(has_movable),
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
.params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
let (dx, dy) = (d("dx"), d("dy"));
if let Some(r) = crate::cmd::canvas::member_nudge(s, dx, dy) {
return r;
}
unalign(s)?;
with_float(s, |f| {
f.x = (f.x + dx).clamp(-MAX_OFFSET, MAX_OFFSET);
+63
View File
@@ -14,6 +14,69 @@ fn object_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
s.layout().find_object(0, |o| o.text_box.is_none()).unwrap()
}
/// #4250: a shape dragged into the middle of a page that only continues a long paragraph
/// stays there, as in Word: anchored at the line beside it, it's drawn on that page, not
/// thrown back to the page the paragraph starts on. One undo step puts it back.
#[test]
fn a_shape_dropped_on_a_page_a_paragraph_continues_onto_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
assert!(s.layout().pages.len() > 2);
let before = (object_area(&mut s), s.undo_depth());
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275})).unwrap();
let o = object_area(&mut s);
assert_eq!(o.page, 1, "on the page it was dropped on: {o:?}");
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 275.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
assert_eq!(s.undo_depth(), before.1 + 1);
s.run("edit.undo", &json!({})).unwrap();
let back = object_area(&mut s);
assert_eq!((back.page, back.rect), (before.0.page, before.0.rect));
}
/// #4250: and dragged back onto the page the paragraph starts on, it stays there: its anchor
/// goes back to the paragraph's start rather than staying on the later page's line.
#[test]
fn a_shape_dragged_back_to_the_page_its_paragraph_starts_on_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275, "width": 100, "height": 50})).unwrap();
let before = (object_area(&mut s), s.undo_depth());
assert!(before.0.page == 1 && before.0.off > 0, "anchored on page 2's line: {:?}", before.0);
s.run("arrange.bounds", &json!({"page": 0, "x": 120, "y": 300})).unwrap();
let o = object_area(&mut s);
assert_eq!((o.page, o.off), (0, 0), "on the page it was dropped on, anchored at the paragraph's start: {o:?}");
assert!((o.rect.x - 120.0).abs() < 0.5 && (o.rect.y - 300.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
assert_eq!(s.undo_depth(), before.1 + 1);
}
/// #4262: dropped beside the first line after a page break inside a paragraph, a shape stays
/// on the page it was dropped on, anchored in that line.
#[test]
fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
let text = "The quick brown fox jumps over the lazy dog. ";
s.run("text.insert", &json!({"text": text.repeat(20)})).unwrap();
s.run("insert.pageBreak", &json!({})).unwrap();
s.run("text.insert", &json!({"text": text.repeat(40)})).unwrap();
assert_eq!(s.doc.body.len(), 1, "one paragraph");
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 80})).unwrap();
let o = object_area(&mut s);
let after_break = text.len() * 20 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
assert_eq!((o.page, o.off), (1, after_break), "on the page it was dropped on, anchored right after the break: {o:?}");
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
/// select it, then resize and move it with `arrange.bounds`.
fn click_resize_and_drag(insert: &str, params: Value) {
+5
View File
@@ -712,6 +712,11 @@ pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, Str
Ok(bytes)
}
#[cfg(target_arch = "wasm32")]
pub fn read_file_capped(path: &str, _max: u64, _what: &str) -> Result<Vec<u8>, String> {
read_file(path)
}
/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation
/// read root when there is one: enough to tell a file's format by its start. Only a regular file
/// is opened: a named pipe would block the open until a writer appeared (#3666).
+4
View File
@@ -219,6 +219,10 @@ pub struct Session {
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
/// for Group. Cleared by any edit or selection change other than adding to it.
pub also_selected: Vec<Pos>,
/// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format,
/// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by
/// any selection change, caret movement or undo.
pub member: Option<crate::cmd::canvas::CanvasMember>,
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
pub column: Option<ColumnBlock>,
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
+4
View File
@@ -276,6 +276,8 @@ impl Session {
ime_state_exhausted: _,
pending_ime_application_change: _,
ime_origin: _,
// A picked Drawing Canvas member is cleared by a switch, not kept per document.
member: _,
others: _,
order: _,
untitled_count: _,
@@ -376,6 +378,8 @@ impl Session {
swap(&mut self.widened, widened);
swap(&mut self.edit_marks, edit_marks);
swap(&mut self.go_back_step, go_back_step);
// A picked Drawing Canvas member (#2637) belongs to the document left behind.
self.member = None;
}
/// Make document `id` active without anything else a switch by the user does.
+1
View File
@@ -150,6 +150,7 @@ impl Session {
math_latex: false,
math_normal_text: false,
also_selected,
member: None,
prefs: Prefs::default(),
system_region: Default::default(),
keymap: crate::KeyMap::default(),
+13 -1
View File
@@ -185,7 +185,10 @@ impl Session {
let mutates = spec.mutates;
let ime_caret_before = self.current_ime_caret_state();
let rev_before = self.rev;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) {
Some(r) => r,
None => crate::cmd::column::dispatch(self, id, mutates, run, params),
}));
let mut result = match result {
Ok(r) => r,
Err(p) => {
@@ -391,6 +394,15 @@ impl Session {
if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) {
self.also_selected.clear();
}
// A canvas member stays picked while its canvas does and nothing else is selected.
if id != "select.member"
&& (self.sel != *sel_before
|| id.starts_with("select.")
|| id.starts_with("caret.")
|| matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo"))
{
self.member = None;
}
self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before);
}
+3
View File
@@ -23,6 +23,7 @@ impl Session {
self.pending = None;
self.typing = None;
self.also_selected.clear();
self.member = None;
self.column = None;
self.column_mode = false;
self.widened = None;
@@ -155,6 +156,7 @@ impl Session {
#[cfg(not(target_arch = "wasm32"))]
recovery_slot: _,
also_selected: _,
member: _,
// Column selection and a widened selection's ends, like `sel`'s shape: valid only while
// `sel` matches them.
column: _,
@@ -211,6 +213,7 @@ impl Session {
self.doc = doc;
self.sel = sel;
self.also_selected.clear();
self.member = None;
self.history.truncate(history_len);
self.redo = redo;
self.typing_open = typing_open;
+2
View File
@@ -141,6 +141,8 @@ impl Session {
self.page_hint = page_hint;
self.column_mode = column_mode;
self.math = math;
// A picked Drawing Canvas member (#2637) is not part of a view's saved selection.
self.member = None;
if current {
self.hf_return = hf_return;
self.typing_open = typing_open;
+1
View File
@@ -17,6 +17,7 @@ mod fonts;
mod ime_noop;
mod revision_checks;
mod typing;
mod watermark;
mod wordart;
/// True, after saying so on stderr, when this build lacks the Language data the proofing tests
+88
View File
@@ -0,0 +1,88 @@
//! Design › Watermark: the picture watermark's size is one answer on screen, in a PDF and in a
//! saved DOCX.
use wordcraft_doc::{CharProps, Document, Paragraph, PartKind, SectionProps, Watermark, WatermarkPicture, para_block};
use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
use wordcraft_render::{RenderOptions, render_page};
/// A solid `w`×`h` px PNG.
fn solid_png(w: u32, h: u32) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([20, 60, 140, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
}
/// A portrait first section whose header the landscape last section links to ("link to
/// previous"), with a wide picture watermark at Auto (or, when `header` is false, no header at all,
/// so the file gives the watermark a header of its own).
fn portrait_then_landscape(header: bool, scale: Option<f32>) -> Document {
let mut d = Document::new();
let mut s1 = SectionProps::default();
if header {
s1.headers.default = Some(d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]));
}
let mut one = Paragraph::with_text("Portrait", CharProps::default());
one.section = Some(Box::new(s1));
d.body = vec![para_block(one), para_block(Paragraph::with_text("Landscape", CharProps::default()))];
d.last_section.set_landscape(true);
let key = d.add_media(solid_png(400, 200), "png");
d.settings.watermark = Some(Watermark { picture: Some(WatermarkPicture { media: key, scale, washout: false }), ..Default::default() });
d
}
/// The width of the blue picture on the middle row of a page as drawn on screen (points).
fn screen_width(d: &Document, page: usize) -> f32 {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
let p = &l.pages[page];
let img = render_page(d, p, 1.0, &RenderOptions::default());
let row = (p.body.y + p.body.h / 2.0) as usize;
let blue = |x: usize| {
let i = (row * img.width as usize + x) * 4;
img.pixels.get(i..i + 3).is_some_and(|c| c.iter().zip([20u8, 60, 140]).all(|(a, b)| a.abs_diff(b) <= 3))
};
(0..img.width as usize).filter(|x| blue(*x)).count() as f32
}
/// The widths of the picture watermark on each page of a PDF of `d` (points).
fn pdf_widths(d: &Document) -> Vec<f64> {
wordcraft_pdf::test_support::drawn_images(&wordcraft_pdf::export(d, &wordcraft_pdf::PdfOptions::default()).unwrap())
.iter()
.map(|(r, ..)| r.width())
.collect()
}
/// The width the first header of a saved DOCX gives the picture watermark, from its shape.
fn docx_width(d: &Document) -> f32 {
let bytes = wordcraft_docx::write(d).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/header1.xml").unwrap(), &mut xml).unwrap();
let at = xml.find("width:").expect("a sized shape") + "width:".len();
xml[at..].split("pt").next().unwrap().parse().unwrap()
}
/// #2954 review: Auto is sized from the first section that names the page's header, the same on
/// every page and in every output: a landscape section linked to a portrait section's header
/// shows the portrait-sized picture on screen, in a PDF and in a saved DOCX (not one re-fitted
/// to its wider page on screen and in a PDF while the file holds the first size).
#[test]
fn auto_picture_watermark_size_is_the_same_on_screen_in_a_pdf_and_in_a_docx() {
for header in [true, false] {
let d = portrait_then_landscape(header, None);
// 400 × 200 px is 300 × 150 pt; the portrait page's margins are 468 pt wide.
let docx = docx_width(&d);
assert!((docx - 468.0).abs() < 0.01, "header {header}: the DOCX holds {docx}");
for page in 0..2 {
let screen = screen_width(&d, page);
assert!((screen - docx).abs() <= 2.0, "header {header}: page {page} on screen {screen}, DOCX {docx}");
}
let pdf = pdf_widths(&d);
assert_eq!(pdf.len(), 2, "one per page");
assert!(pdf.iter().all(|w| (w - f64::from(docx)).abs() < 0.5), "header {header}: PDF {pdf:?}, DOCX {docx}");
// And the file reads back as Auto, not as a scale.
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(back.settings.watermark.and_then(|w| w.picture).map(|p| p.scale), Some(None), "header {header}");
}
}
+17 -2
View File
@@ -62,9 +62,19 @@ pub(super) struct Body<'a> {
forms: super::forms::FormsReader,
/// A watermark found (in a header): see [`Self::start_shape`].
pub(super) watermark: Option<wordcraft_doc::Watermark>,
/// A picture watermark found (in a header): its picture, its frame's size (points) and
/// whether it is washed out. See [`Self::end_frame`].
pub(super) watermark_pictures: Vec<(Vec<u8>, (f32, f32), bool)>,
/// Take those frames out of the header (the document's watermark), rather than leave them
/// there as pictures: set once every header and footer has been read and they qualify.
pub(super) take_watermark_pictures: bool,
/// The open frame is drawn faded (`draw:color-mode` `watermark`).
frame_washout: bool,
/// Reading a master page's header or footer (where a watermark lives).
pub(super) page_part: bool,
/// The shape being read is a watermark's, if its geometry follows its text: its size, turn,
/// colour and see-through, and whether its geometry is fontwork.
watermark_shape: Option<((f32, f32), f32, Option<wordcraft_doc::props::Rgb>, bool, bool)>,
/// colour and transparency (percent), and whether its geometry is fontwork.
watermark_shape: Option<((f32, f32), f32, Option<wordcraft_doc::props::Rgb>, u8, bool)>,
pub(super) manifest: Option<&'a HashMap<String, String>>,
containers: Vec<Vec<FBlock>>,
/// Drawings anchored in the text but outside any paragraph (LibreOffice's page-anchored
@@ -217,6 +227,10 @@ impl<'a> Body<'a> {
entries: &[],
forms: Default::default(),
watermark: None,
watermark_pictures: Vec::new(),
take_watermark_pictures: false,
frame_washout: false,
page_part: false,
watermark_shape: None,
break_next: false,
sources: Vec::new(),
@@ -1330,6 +1344,7 @@ impl Body<'_> {
self.frame_look = g.look(h.is_none(), &self.styles.defs);
self.frame_name = get(a, "draw:name").map(|n| n.chars().take(256).collect());
self.frame_clip = g.clip;
self.frame_washout = g.is_washed_out();
}
"image" | "object" | "object-ole" | "desc" | "title" | "contour-polygon" => self.frame_part(q, a),
// A columned section (ODF 1.3 §5.4) is a continuous section with its columns.
+30 -6
View File
@@ -90,6 +90,28 @@ impl Body<'_> {
pub(in crate::odt) fn end_frame(&mut self) {
if let Some((w, h, img, alt, eq, chart, object)) = self.frame.take() {
let boxed = self.frame_box.take();
let washout = std::mem::take(&mut self.frame_washout);
// A picture watermark (the header's frame of the name the writer gives it, holding a
// picture and nothing else) is noted; when every header holds the same one, no
// footer and no text watermark does, it is the document's watermark and not a picture
// in the header (see `page_parts::read`).
if boxed.is_none()
&& eq.is_none()
&& chart.is_none()
&& object.is_none()
&& self.page_part
&& self.frame_name.as_deref().is_some_and(|n| n.starts_with(crate::odt::writer::PICTURE_WATERMARK_NAME))
&& let Some((data, w, h)) = img.clone().zip(w).zip(h).map(|((d, w), h)| (d, w, h))
{
if self.watermark_pictures.len() < 4 {
self.watermark_pictures.push((data, (w, h), washout));
}
if self.take_watermark_pictures {
self.frame_clip = None;
self.frame = None;
return;
}
}
let mut float = self.frame_float.take();
if boxed.is_some()
&& self.shape_kind.is_some()
@@ -176,7 +198,7 @@ impl Body<'_> {
/// The shape ending is a watermark (see [`Self::start_shape`]) holding `blocks`: the
/// document's watermark, from its text, font, colour, size and turn, and not a shape.
fn take_watermark(&mut self, blocks: &[FBlock]) -> bool {
let Some(((w, h), rot, color, see_through, true)) = self.watermark_shape.take() else { return false };
let Some(((w, h), rot, color, transparency, true)) = self.watermark_shape.take() else { return false };
let mut text = String::new();
let mut font = None;
for b in blocks {
@@ -195,14 +217,16 @@ impl Body<'_> {
}
let turned = rot.rem_euclid(360.0);
let base = wordcraft_doc::Watermark::default();
self.watermark = Some(wordcraft_doc::Watermark {
let mut wm = wordcraft_doc::Watermark {
text,
font: font.unwrap_or(base.font),
color: color.unwrap_or(base.color),
diagonal: turned > 1.0 && turned < 359.0,
semitransparent: see_through,
size: Some([w, h]).filter(|[w, h]| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0),
});
..base
};
wm.set_transparency(transparency);
self.watermark = Some(wm);
true
}
@@ -372,8 +396,8 @@ impl Body<'_> {
self.frame_look.conn = self.conn_of(a).map(Box::new);
// Word's and LibreOffice's watermark: a shape of this name whose geometry follows its text.
self.watermark_shape = get(a, "draw:name").filter(|n| n.starts_with(crate::odt::writer::WATERMARK_NAME)).map(|_| {
let (color, see_through) = g.watermark_look();
((w, h), float.rot, color, see_through, false)
let (color, transparency) = g.watermark_look();
((w, h), float.rot, color, transparency, false)
});
self.shape_path = crate::odt::shapes::freeform(local(q), a, w, h).map(std::sync::Arc::new);
self.shape_kind = Some(kind);
+13 -3
View File
@@ -64,6 +64,9 @@ pub(super) struct Graphic {
dash: Option<String>,
gradient: Option<String>,
markers: [Option<String>; 2],
/// Washout (`draw:color-mode` `watermark`: the picture drawn faded), which a picture
/// watermark states.
washout: Option<bool>,
/// A picture's crop (`fo:clip`, §20.179): how much is cut off its top, right, bottom and
/// left, in lengths of its original size.
pub(super) clip: Option<[f32; 4]>,
@@ -165,6 +168,7 @@ impl Graphic {
markers: [s("draw:marker-start"), s("draw:marker-end")],
stroke_width: get(a, "svg:stroke-width").and_then(length).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 12.0)),
clip: get(a, "fo:clip").and_then(clip_rect),
washout: get(a, "draw:color-mode").map(|v| v.trim() == "watermark"),
wrap_contour: get(a, "style:wrap-contour").map(|v| v.trim() == "true"),
vert: get(a, "loext:writing-mode").or_else(|| get(a, "style:writing-mode")).map(|m| {
use wordcraft_doc::props::{TextDirection, TextVert};
@@ -181,9 +185,14 @@ impl Graphic {
}
}
/// A watermark's look from its shape's style: its fill colour, and whether it is see-through.
pub(super) fn watermark_look(&self) -> (Option<Rgb>, bool) {
(self.fill_color, self.opacity.is_some_and(|o| o < 100.0))
/// Whether a picture's style draws it faded (`draw:color-mode` `watermark`).
pub(super) fn is_washed_out(&self) -> bool {
self.washout == Some(true)
}
/// A watermark's look from its shape's style: its fill colour, and its transparency in percent.
pub(super) fn watermark_look(&self) -> (Option<Rgb>, u8) {
(self.fill_color, self.opacity.filter(|o| o.is_finite()).map_or(0, |o| (100.0 - o).round().clamp(0.0, 100.0) as u8))
}
/// This placement with `parent`'s filling what it leaves unsaid.
@@ -209,6 +218,7 @@ impl Graphic {
fill(&mut self.fill, &parent.fill);
self.fill_color = self.fill_color.or(parent.fill_color);
self.opacity = self.opacity.or(parent.opacity);
self.washout = self.washout.or(parent.washout);
self.background = self.background.or(parent.background);
fill(&mut self.stroke, &parent.stroke);
self.stroke_color = self.stroke_color.or(parent.stroke_color);
+41 -15
View File
@@ -251,8 +251,12 @@ fn page_parts(doc: &Document, sec: &SectionProps, w: &mut Writer) -> PageParts {
let mut out = PageParts::default();
let even = doc.settings.even_odd_headers;
// A watermark is a shape in every header (one written for it alone when there is none).
let wm = doc.settings.watermark.as_ref().filter(|w| !w.text.trim().is_empty());
let wm = doc.settings.watermark.as_ref().filter(|w| w.picture.is_some() || !w.text.trim().is_empty());
let area = (sec.page_w - sec.margin_left - sec.margin_right, sec.page_h - sec.margin_top - sec.margin_bottom);
// A picture watermark that can't be read is not written; one that can is sized for this
// master page (a master page's header is its own part in the file).
let picture = wm.and_then(|w| w.picture.as_ref()).and_then(|pic| watermark_image(doc, sec, pic));
let wm = wm.filter(|w| w.picture.is_none() || picture.is_some());
for (set, name) in [(&sec.headers, "header"), (&sec.footers, "footer")] {
let wanted =
set.default.is_some() || (even && set.even.is_some()) || (sec.title_page && set.first.is_some()) || (name == "header" && wm.is_some());
@@ -265,23 +269,15 @@ fn page_parts(doc: &Document, sec: &SectionProps, w: &mut Writer) -> PageParts {
if !on {
continue;
}
let mut body = String::new();
if let Some(part) = id.and_then(|i| doc.parts.get(&i)) {
let blocks = model::part_flow(
doc,
&part.blocks,
// Pictures, shapes, text boxes, charts and objects too, as in the body.
model::FlowOpts { fields: true, lists: true, chars: true, paras: true, boxes: true, ..Default::default() },
);
w.blocks(&blocks, &mut body, 0);
}
if body.is_empty() {
body.push_str("<text:p/>");
}
let mut body = slot_body(doc, id, w);
if name == "header"
&& let Some(wm) = wm
{
body = with_watermark(&body, &w.watermark(wm, (sec.page_w, sec.page_h), area));
let shape = match &picture {
Some((img, washout)) => w.watermark_picture(img, *washout, area),
None => w.watermark(wm, (sec.page_w, sec.page_h), area),
};
body = with_watermark(&body, &shape);
}
out.xml.push_str(&format!("<style:{el}>{body}</style:{el}>"));
}
@@ -294,6 +290,36 @@ fn page_parts(doc: &Document, sec: &SectionProps, w: &mut Writer) -> PageParts {
out
}
/// The picture watermark `pic` as an image sized for section `sec`'s text area, with its
/// Washout. None when the picture is missing, unreadable, of an unknown type or has no size.
fn watermark_image(doc: &Document, sec: &SectionProps, pic: &wordcraft_doc::WatermarkPicture) -> Option<(model::Img, bool)> {
let data = doc.media.get(&pic.media)?;
let (own_w, own_h) = wordcraft_doc::watermark::own_size(data)?;
let ext = model::sniff_image(data)?;
let (w, h) = pic.size(own_w, own_h, (sec.text_width(), sec.text_height()))?;
let img = model::Img { data: data.clone(), ext: ext.to_string(), w, h, alt: String::new(), float: None, object: None, crop: [0.0; 4] };
Some((img, pic.washout))
}
/// The ODF body of a header or footer slot: its part `id` written by `w`, or an empty paragraph
/// when the slot has no content.
fn slot_body(doc: &Document, id: Option<u32>, w: &mut Writer) -> String {
let mut body = String::new();
if let Some(part) = id.and_then(|i| doc.parts.get(&i)) {
let blocks = model::part_flow(
doc,
&part.blocks,
// Pictures, shapes, text boxes, charts and objects too, as in the body.
model::FlowOpts { fields: true, lists: true, chars: true, paras: true, boxes: true, ..Default::default() },
);
w.blocks(&blocks, &mut body, 0);
}
if body.is_empty() {
body.push_str("<text:p/>");
}
body
}
/// A header's content with `shape` (anchored to a character) at the start of its first
/// paragraph, or in a paragraph of its own before a table that starts it.
fn with_watermark(body: &str, shape: &str) -> String {
+103 -10
View File
@@ -56,6 +56,54 @@ pub(super) struct Parts {
even_odd: bool,
/// The watermark a header holds (the first found).
pub(super) watermark: Option<wordcraft_doc::Watermark>,
/// The picture watermark a header holds (the first found), if no text one is.
picture: Option<PictureRead>,
}
/// One header or footer as read.
struct SlotRead {
name: String,
kind: Slot,
text: Option<wordcraft_doc::Watermark>,
pictures: Vec<(Vec<u8>, (f32, f32), bool)>,
blocks: Vec<FBlock>,
}
/// The picture watermark the headers and footers read hold, if they make one: its picture, its
/// frame's size, Washout and the room Auto fills on the first master page (points).
fn picture_plan(reads: &[SlotRead], pages: &Pages) -> Option<(Vec<u8>, (f32, f32), bool, (f32, f32))> {
if reads.iter().all(|r| r.pictures.is_empty()) || reads.iter().any(|r| r.text.is_some()) {
return None;
}
if reads.iter().any(|r| r.kind.footer() && !r.pictures.is_empty()) {
return None;
}
// Every header holds exactly one, and every master page has its default header.
let headers: Vec<&SlotRead> = reads.iter().filter(|r| !r.kind.footer()).collect();
if headers.iter().any(|r| r.pictures.len() != 1) {
return None;
}
if !pages.masters.keys().all(|m| headers.iter().any(|r| r.name == *m && matches!(r.kind, Slot::Header))) {
return None;
}
let first = headers.first()?.pictures.first()?;
// A picture whose size can't be read is no watermark (`add` couldn't place it, and the frames
// taken out of the headers would be lost): it stays a header picture, as in the DOCX plan.
wordcraft_doc::watermark::own_size(&first.0)?;
if !headers.iter().filter_map(|r| r.pictures.first()).all(|p| p.0 == first.0 && p.2 == first.2) {
return None;
}
let fit = headers.first().and_then(|r| pages.masters.get(&r.name)).map_or((0.0, 0.0), |s| (s.text_width(), s.text_height()));
Some((first.0.clone(), first.1, first.2, fit))
}
/// A picture watermark as read: its picture, its frame's size and the room Auto fills on its
/// master page (points), and whether it is washed out.
struct PictureRead {
data: Vec<u8>,
size: (f32, f32),
fit: (f32, f32),
washout: bool,
}
impl Parts {
@@ -125,17 +173,45 @@ pub(super) fn read(
});
let none = Pages::default();
let mut out = Parts::default();
for (name, kind, evs) in found {
let mut reader = Body::new(styles, &none, fetch);
reader.in_text = true;
(reader.entries, reader.manifest) = (objects.0, Some(objects.1));
reader.spaces_left = *spaces_left;
for ev in evs {
reader.feed(ev);
// Read every slot; `take` leaves a picture watermark's frame out of the blocks.
let mut read_all = |take: bool, spaces_left: &mut usize| -> Vec<SlotRead> {
let mut reads = Vec::with_capacity(found.len());
for (name, kind, evs) in &found {
let mut reader = Body::new(styles, &none, fetch);
reader.in_text = true;
reader.page_part = true;
reader.take_watermark_pictures = take && !kind.footer();
(reader.entries, reader.manifest) = (objects.0, Some(objects.1));
reader.spaces_left = *spaces_left;
for ev in evs.iter().cloned() {
reader.feed(ev);
}
*spaces_left = reader.spaces_left;
reads.push(SlotRead {
name: name.clone(),
kind: *kind,
text: reader.watermark.take(),
pictures: std::mem::take(&mut reader.watermark_pictures),
blocks: reader.take_blocks(),
});
}
*spaces_left = reader.spaces_left;
out.watermark = out.watermark.take().or(reader.watermark.take());
out.parts.push((if kind.footer() { PartKind::Footer } else { PartKind::Header }, reader.take_blocks()));
reads
};
let spaces_start = *spaces_left;
let mut reads = read_all(false, spaces_left);
// A picture watermark is the document's only as Word's DOCX plan has it: the same picture in
// every header of every master page (one in each), none in a footer, no text watermark.
// Otherwise the frames stay as the pictures in the headers they are.
let picture = picture_plan(&reads, pages);
if picture.is_some() {
*spaces_left = spaces_start;
reads = read_all(true, spaces_left);
}
out.picture = picture.map(|(data, size, washout, fit)| PictureRead { data, size, fit, washout });
for read in reads {
let SlotRead { name, kind, text, blocks, .. } = read;
out.watermark = out.watermark.take().or(text);
out.parts.push((if kind.footer() { PartKind::Footer } else { PartKind::Header }, blocks));
if let Some(sec) = pages.masters.get_mut(&name) {
kind.set(sec, out.parts.len() as u32);
}
@@ -174,6 +250,23 @@ pub(super) fn read(
pub(super) fn add(doc: &mut Document, parts: Parts) {
if parts.watermark.is_some() {
doc.settings.watermark.clone_from(&parts.watermark);
} else if let Some(pic) = parts.picture
&& let Some((own_w, own_h)) = wordcraft_doc::watermark::own_size(&pic.data)
{
// Auto when the frame is as large as fits the margins, else its width as a percentage of
// the picture's own size, in tenths as a command gives one.
let auto = wordcraft_doc::WatermarkPicture::default().size(own_w, own_h, pic.fit).map(|s| s.0);
let scale = if auto.is_some_and(|a| (a - pic.size.0).abs() <= 1.0) {
None
} else {
Some(((pic.size.0 / own_w * 1000.0).round() / 10.0).clamp(wordcraft_doc::watermark::MIN_SCALE, wordcraft_doc::watermark::MAX_SCALE))
};
let ext = model::sniff_image(&pic.data).unwrap_or("png");
let media = doc.add_media(pic.data, ext);
doc.settings.watermark = Some(wordcraft_doc::Watermark {
picture: Some(wordcraft_doc::WatermarkPicture { media, scale, washout: pic.washout }),
..Default::default()
});
}
let mut ids: Vec<Option<u32>> = Vec::with_capacity(parts.parts.len());
for (kind, blocks) in parts.parts {
@@ -272,6 +272,15 @@ fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() {
let back = import(&saved).unwrap();
let wm = back.settings.watermark.clone().expect("the watermark");
assert_eq!((wm.text.as_str(), wm.diagonal, wm.semitransparent), ("CONFIDENTIAL", true, true));
// Any transparency is kept, not only half.
for t in [0u8, 30, 50, 80] {
let mut other = d.clone();
let mut w = wordcraft_doc::Watermark { text: "CONFIDENTIAL".into(), ..Default::default() };
w.set_transparency(t);
other.settings.watermark = Some(w.clone());
let back = import(&export(&other).unwrap()).unwrap();
assert_eq!(back.settings.watermark.map(|w| w.transparency()), Some(t), "{t}");
}
// Not a shape in the header too.
let header = back.last_section.headers.default.and_then(|id| back.parts.get(&id)).unwrap();
assert!(header.blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()));
@@ -287,6 +296,187 @@ fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() {
);
}
/// A solid `w`×`h` px PNG.
fn watermark_png(w: u32, h: u32) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([20, 60, 140, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
}
#[test]
fn a_picture_watermark_is_a_picture_frame_in_the_header_and_reads_back_as_one() {
// A picture watermark is written as its picture (not the text kept for the dialog) in a frame
// behind the text of every header, and read back as the watermark, not a picture in the header.
let png = watermark_png(400, 200);
for (scale, washout) in [(None, true), (Some(50.0), false), (Some(200.0), true)] {
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text("Body", CharProps::default()))];
let key = d.add_media(png.clone(), "png");
let pic = wordcraft_doc::WatermarkPicture { media: key, scale, washout };
d.settings.watermark = Some(wordcraft_doc::Watermark { text: "DRAFT".into(), picture: Some(pic), ..Default::default() });
let saved = export(&d).unwrap();
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(styles.contains("draw:name=\"WordPictureWatermark\"") && styles.contains("<draw:image "), "{styles}");
assert!(!styles.contains("DRAFT") && !styles.contains("draw:text-path"), "the picture, not the text: {styles}");
assert_eq!(styles.contains("draw:color-mode=\"watermark\""), washout, "{styles}");
assert!(crate::odt::tests::entry(&saved, "META-INF/manifest.xml").contains("Pictures/"), "the picture is in the package");
let back = import(&saved).unwrap();
let wm = back.settings.watermark.clone().expect("the watermark");
let pic = wm.picture.expect("a picture watermark");
assert_eq!((pic.scale, pic.washout), (scale, washout));
assert_eq!(back.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
// Not a picture in the header too.
let header = back.last_section.headers.default.and_then(|id| back.parts.get(&id)).unwrap();
assert!(header.blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()), "no picture left in the header");
assert_eq!(import(&export(&back).unwrap()).unwrap().settings.watermark, back.settings.watermark, "second generation");
}
// A picture that can't be read is not written, and the text isn't written in its place.
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text("Body", CharProps::default()))];
d.media.insert("junk.png".into(), std::sync::Arc::new(b"junk".to_vec()));
let pic = wordcraft_doc::WatermarkPicture { media: "junk.png".into(), scale: None, washout: true };
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
let styles = crate::odt::tests::entry(&export(&d).unwrap(), "styles.xml");
assert!(!styles.contains("DRAFT") && !styles.contains("WordPictureWatermark") && !styles.contains("<draw:image "), "{styles}");
}
/// A package whose `styles.xml` has one master page per entry of `masters`: (header content,
/// footer content), a picture watermark frame being `wm_frame(picture file)`; with the pictures
/// `Pictures/a.png` and `Pictures/b.png`.
fn watermark_package(masters: &[(&str, &str)]) -> Vec<u8> {
let ns = crate::odt::tests::NS_T;
let nsd = r#"xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
let pages: String = masters
.iter()
.enumerate()
.map(|(i, (head, foot))| {
let foot = if foot.is_empty() { String::new() } else { format!("<style:footer><text:p>{foot}</text:p></style:footer>") };
format!(r#"<style:master-page style:name="M{i}" style:page-layout-name="pm1"><style:header><text:p>{head}</text:p></style:header>{foot}</style:master-page>"#)
})
.collect();
let styles = format!(
r##"<office:document-styles {ns} {nsd}><office:automatic-styles><style:style style:name="Mgr1" style:family="graphic"><style:graphic-properties style:run-through="background" style:wrap="run-through"/></style:style><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/></style:page-layout></office:automatic-styles><office:master-styles>{pages}</office:master-styles></office:document-styles>"##
);
let paras: String = (0..masters.len()).map(|i| format!(r#"<text:p text:style-name="Standard">p{i}</text:p>"#)).collect();
let content = format!("<office:document-content {ns}><office:body><office:text>{paras}</office:text></office:body></office:document-content>");
let (a, b) = (watermark_png(60, 30), watermark_png(30, 30));
crate::odt::tests::zip_of(&[
("mimetype", b"application/vnd.oasis.opendocument.text"),
("content.xml", content.as_bytes()),
("styles.xml", styles.as_bytes()),
("Pictures/a.png", &a),
("Pictures/b.png", &b),
])
}
/// A frame the way a picture watermark is written, 3 × 1.5 in.
fn wm_frame(file: &str) -> String {
format!(
r#"<draw:frame draw:name="WordPictureWatermark" draw:style-name="Mgr1" text:anchor-type="char" svg:x="0in" svg:y="1in" svg:width="3in" svg:height="1.5in"><draw:image xlink:href="Pictures/{file}"/></draw:frame>"#
)
}
/// The pictures in each header and footer part, in order.
fn part_pictures(d: &Document) -> Vec<(PartKind, usize)> {
let mut v: Vec<(u32, PartKind, usize)> = d
.parts
.iter()
.filter(|(_, p)| matches!(p.kind, PartKind::Header | PartKind::Footer))
.map(|(id, p)| {
let n = p.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).filter(|o| matches!(o, InlineObject::Image { .. })).count();
(*id, p.kind, n)
})
.collect();
v.sort_by_key(|(id, ..)| *id);
v.into_iter().map(|(_, k, n)| (k, n)).collect()
}
#[test]
fn a_picture_frame_in_the_headers_is_the_watermark_only_where_every_page_shows_it() {
let a = wm_frame("a.png");
// The same picture in every header: the document's watermark, and no picture left in them.
let d = import(&watermark_package(&[(&a, ""), (&a, "")])).unwrap();
assert!(d.settings.watermark.as_ref().is_some_and(|w| w.picture.is_some()));
assert!(part_pictures(&d).iter().all(|(_, n)| *n == 0), "{:?}", part_pictures(&d));
// A different picture on the second master page: both stay pictures in their headers.
let d = import(&watermark_package(&[(&a, ""), (&wm_frame("b.png"), "")])).unwrap();
assert_eq!(d.settings.watermark, None);
assert_eq!(part_pictures(&d).iter().map(|(_, n)| n).sum::<usize>(), 2, "{:?}", part_pictures(&d));
// One in a footer too: nothing is taken, all three stay.
let d = import(&watermark_package(&[(&a, &a)])).unwrap();
assert_eq!(d.settings.watermark, None);
assert_eq!(part_pictures(&d).iter().map(|(_, n)| n).sum::<usize>(), 2, "{:?}", part_pictures(&d));
// A header without one on a second master page.
let d = import(&watermark_package(&[(&a, ""), ("Plain", "")])).unwrap();
assert_eq!(d.settings.watermark, None);
assert_eq!(part_pictures(&d).iter().map(|(_, n)| n).sum::<usize>(), 1);
// Beside a text watermark: the text one is the document's, the picture stays in its header.
let text = r##"<draw:custom-shape text:anchor-type="char" draw:name="PowerPlusWaterMarkObject" draw:style-name="Mgr1" svg:width="4in" svg:height="1in"><text:p>DRAFT</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:text-path="true" draw:type="fontwork-plain-text"/></draw:custom-shape>"##;
let d = import(&watermark_package(&[(&format!("{a}{text}"), "")])).unwrap();
let wm = d.settings.watermark.clone().expect("the text watermark");
assert_eq!((wm.text.as_str(), wm.picture.is_none()), ("DRAFT", true));
assert_eq!(part_pictures(&d).iter().map(|(_, n)| n).sum::<usize>(), 1);
}
#[test]
fn a_picture_frame_of_unknown_size_is_not_a_watermark() {
// An EMF picture shows in a header, but its size isn't a raster's, which a watermark is sized
// from: the frames stay the pictures they are (taking them out would lose them).
let mut emf = vec![0u8; 88];
emf[0..4].copy_from_slice(&1u32.to_le_bytes());
emf[4..8].copy_from_slice(&88u32.to_le_bytes());
emf[24..40].copy_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0x10, 0x27, 0, 0, 0x10, 0x27, 0, 0]);
emf[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
assert!(wordcraft_doc::watermark::own_size(&emf).is_none());
let a = wm_frame("a.emf");
let pkg = watermark_package(&[(&a, ""), (&a, "")]);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&pkg)).unwrap();
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut b = Vec::new();
std::io::Read::read_to_end(&mut f, &mut b).unwrap();
entries.push((f.name().to_string(), b));
}
entries.push(("Pictures/a.emf".into(), emf));
let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
let d = import(&crate::odt::tests::zip_of(&refs)).unwrap();
assert_eq!(d.settings.watermark, None);
assert_eq!(part_pictures(&d).iter().map(|(_, n)| n).sum::<usize>(), 2, "{:?}", part_pictures(&d));
}
#[test]
fn a_watermark_added_to_an_odt_file_is_saved() {
// Open an ODT, add a watermark, save as ODT: the watermark is written (the page setup was
// taken as unchanged because the fingerprint left the watermark out), with its picture once.
let opened = import(&watermark_package(&[("Head", ""), ("Head", "")])).unwrap();
assert_eq!(opened.settings.watermark, None);
let png = watermark_png(40, 20);
let mut d = opened.clone();
let key = d.add_media(png.clone(), "png");
d.settings.watermark = Some(wordcraft_doc::Watermark {
picture: Some(wordcraft_doc::WatermarkPicture { media: key, scale: Some(50.0), washout: true }),
..Default::default()
});
let saved = export(&d).unwrap();
let back = import(&saved).unwrap();
let pic = back.settings.watermark.clone().and_then(|w| w.picture).expect("the picture watermark was saved");
assert_eq!((pic.scale, pic.washout), (Some(50.0), true));
assert_eq!(back.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
let manifest = crate::odt::tests::entry(&saved, "META-INF/manifest.xml");
assert_eq!(manifest.matches("Pictures/").count(), 1, "one picture file for two master pages: {manifest}");
// A text watermark with its transparency.
let mut d = opened;
let mut wm = wordcraft_doc::Watermark { text: "COPY".into(), ..Default::default() };
wm.set_transparency(80);
d.settings.watermark = Some(wm);
let back = import(&export(&d).unwrap()).unwrap();
let got = back.settings.watermark.expect("the text watermark was saved");
assert_eq!((got.text.as_str(), got.transparency()), ("COPY", 80));
}
#[test]
fn libreoffices_watermark_reads_as_the_watermark() {
// As LibreOffice's Format › Watermark writes it (observed): turned, half see-through.
+1 -1
View File
@@ -11,7 +11,7 @@ mod tables;
mod tracked;
use drawing::Place;
pub(super) use drawing::WATERMARK_NAME;
pub(super) use drawing::{PICTURE_WATERMARK_NAME, WATERMARK_NAME};
use indexes::index_at;
pub(super) use indexes::{IndexKind, index_instr};
use notes::annotation_name;
+31 -2
View File
@@ -599,15 +599,44 @@ impl Writer {
/// The name a watermark shape carries (Word's and LibreOffice's: how readers know it).
pub(in crate::odt) const WATERMARK_NAME: &str = "PowerPlusWaterMarkObject";
/// The name a picture watermark's frame carries (Word's picture watermark shape id).
pub(in crate::odt) const PICTURE_WATERMARK_NAME: &str = "WordPictureWatermark";
impl Writer {
/// The document's picture watermark in a header: a frame holding the picture, `size` points
/// (width, height), behind the text, centred on a page whose text area is `area`, drawn
/// faded when `washout` (`draw:color-mode` `watermark`).
pub(in crate::odt) fn watermark_picture(&mut self, img: &model::Img, washout: bool, area: (f32, f32)) -> String {
let mode = if washout { " draw:color-mode=\"watermark\"" } else { "" };
let style = self.graphic_style(format!(
" draw:stroke=\"none\" draw:fill=\"none\"{mode} style:run-through=\"background\" style:wrap=\"run-through\" style:horizontal-pos=\"center\" style:horizontal-rel=\"page-content\" style:vertical-pos=\"middle\" style:vertical-rel=\"page-content\" style:flow-with-text=\"false\" draw:auto-grow-height=\"false\" draw:auto-grow-width=\"false\"",
));
// The picture goes into the package once, however many master pages and slots show it.
let href = match self.pictures.iter().find(|(_, data, _)| std::sync::Arc::ptr_eq(data, &img.data)) {
Some((name, ..)) => name.clone(),
None => self.picture(img),
};
let (aw, ah) = (if area.0.is_finite() { area.0 } else { img.w }, if area.1.is_finite() { area.1 } else { img.h });
format!(
"<draw:frame text:anchor-type=\"char\" draw:z-index=\"0\" draw:name=\"{PICTURE_WATERMARK_NAME}\" draw:style-name=\"{style}\" svg:x=\"{}\" svg:y=\"{}\" svg:width=\"{}\" svg:height=\"{}\"><draw:image xlink:href=\"{href}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/></draw:frame>",
pt((aw - img.w) / 2.0),
pt((ah - img.h) / 2.0),
pt(img.w),
pt(img.h)
)
}
/// The document's watermark as LibreOffice and Word write one in a header: a fontwork shape
/// (§10.3.2 `draw:custom-shape` whose geometry follows its text, `draw:text-path`) behind the
/// text, centred on a page whose text area is `area` (width, height; points), turned up 45°
/// when diagonal, filled with its colour (half transparent when semitransparent). It doesn't
/// when diagonal, filled with its colour (as transparent as the watermark says). It doesn't
/// grow to its text: its text is stretched to it.
pub(in crate::odt) fn watermark(&mut self, wm: &wordcraft_doc::Watermark, page: (f32, f32), area: (f32, f32)) -> String {
let [w, h] = wm.frame(page.0, page.1);
let opacity = if wm.semitransparent { " draw:opacity=\"50%\"" } else { "" };
let opacity = match wm.transparency() {
0 => String::new(),
t => format!(" draw:opacity=\"{}%\"", 100 - t),
};
let style = self.graphic_style(format!(
" draw:stroke=\"none\" draw:fill=\"solid\" draw:fill-color=\"#{}\"{opacity} style:run-through=\"background\" style:wrap=\"run-through\" style:horizontal-pos=\"center\" style:horizontal-rel=\"page-content\" style:vertical-pos=\"middle\" style:vertical-rel=\"page-content\" style:flow-with-text=\"false\" draw:auto-grow-height=\"false\" draw:auto-grow-width=\"false\"",
wm.color.hex().to_ascii_lowercase()
+1
View File
@@ -42,6 +42,7 @@ pub(super) fn local(q: &str) -> &str {
q.rsplit(':').next().unwrap_or(q)
}
#[derive(Clone)]
pub(super) enum Ev {
Start(String, Attrs),
End(String),
+8 -1
View File
@@ -332,7 +332,14 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
prev_end = push_para(&mut items, (story, &path), &pl, (0, pl.lines.len()), (0.0, y + lead), width, edges);
// Pictures, shapes and text boxes: drawn, their areas and text boxes' text.
if !p.objects.is_empty() {
let at = ObjFrame { page: frame, col: (0.0, width), para_y: para_top, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
let at = ObjFrame {
page: frame,
col: (0.0, width),
para_y: para_top,
table_chr: table.map(|t| &t.chr),
pinned: Some(&pinned),
late: None,
};
let (back, front) = place_objects(
ctx,
&ObjectPlacement {
+263 -12
View File
@@ -3,8 +3,8 @@ use super::page::{PageBuilder, PageFrame};
use super::placed::Placed;
use super::table_flow::footer_table_wraps;
use super::{
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, ParaLayout, Paragraph, Path, Point, Rect, Spin,
StoryRef, Wrap, WrapText, contour, display, para, textbox,
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, LineEnd, ParaLayout, Paragraph, Path, Point,
Rect, Spin, StoryRef, Wrap, WrapText, contour, display, para, textbox,
};
/// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden
@@ -336,14 +336,133 @@ fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
/// page's wrap areas (its area as placed now). Without `place` (a later piece of the paragraph
/// broken again for another column) the floats stay where its first piece placed and drew them:
/// they aren't placed again in the new column, and their record for the page they are drawn on
/// ([`EarlyFloats`]) stands.
/// ([`EarlyFloats`]) stands. Floats anchored on a later page ([`late_floats`]), and those in
/// `force`, aren't placed here: the paragraph is laid out without them, and they're returned.
pub(crate) fn anchor_floats(
ctx: &mut Ctx,
pb: &mut PageBuilder,
anchor: FloatAnchor,
force: &[usize],
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>, Vec<usize>) {
let FloatAnchor { p, block, y0, place, .. } = anchor;
ctx.fields.page = pb.number;
// Each layout of it here counts its automatic equation numbers from the same place.
let eq0 = ctx.eq_count;
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, force);
if !place || pb.web || p.objects.is_empty() {
return (rects, pl, rel, force.to_vec());
}
// Not those left out, nor those the page started with (#2048 settled them here).
let page = pb.pages.len().saturating_sub(1);
let seeded: Vec<usize> =
pb.early.get(&page).map(|v| v.iter().take(pb.seeded).filter(|(b, ..)| *b == block).map(|(_, k, _)| *k).collect()).unwrap_or_default();
let (bottom, more, col_top) = (pb.bottom, pb.cols.len().saturating_sub(pb.col + 1), pb.top);
let fit = |pl: &ParaLayout| fitting(pl, 0, y0 + pl.lines.first().map_or(0.0, |l| l.top.max(0.0)), bottom, (more, col_top));
let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.hide_deleted);
let mut late: Vec<usize> =
late_floats(p, &pl, fit(&pl)).into_iter().chain(force.iter().copied()).filter(|oi| !left_out(*oi) && !seeded.contains(oi)).collect();
late.sort_unstable();
late.dedup();
if late.as_slice() == force {
return (rects, pl, rel, late);
}
ctx.eq_count = eq0;
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &late);
// Without them in the way more of the paragraph may fit, and some of their anchors with it.
let still = late_floats(p, &pl, fit(&pl));
let fewer: Vec<usize> = late.iter().copied().filter(|oi| force.binary_search(oi).is_ok() || still.binary_search(oi).is_ok()).collect();
if fewer.len() == late.len() {
return (rects, pl, rel, late);
}
ctx.eq_count = eq0;
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &fewer);
(rects, pl, rel, fewer)
}
/// The floating objects of `p` (laid out as `pl`) whose anchor line ([`anchor_lines`]) is at or
/// after `fit`, in order: Word draws them on the page their anchor line is on (#4250).
pub(crate) fn late_floats(p: &Paragraph, pl: &ParaLayout, fit: usize) -> Vec<usize> {
let lines = anchor_lines(p, pl);
let late =
|(oi, o): (usize, &InlineObject)| (floating(o).is_some() && lines.get(oi).copied().flatten().is_some_and(|li| li >= fit)).then_some(oi);
p.objects.iter().enumerate().filter_map(late).collect()
}
/// The end of the lines of `pl` from `l0` that fit on the page: line `l0`'s top at `top` in the
/// column, whose bottom is at `bottom`, then `more` columns from `col_top` (at least one line; a
/// page break ends them, a column break its column).
pub(crate) fn fitting(pl: &ParaLayout, l0: usize, top: f32, bottom: f32, (mut more, col_top): (usize, f32)) -> usize {
let Some(first) = pl.lines.get(l0) else { return l0 };
// A line's place in its column: `shift` plus its top.
let (mut shift, mut start, mut fit) = (top - first.top, l0, l0 + 1);
for (li, l) in pl.lines.iter().enumerate().skip(l0) {
if li > start && shift + l.top + l.height > bottom + 0.01 {
if more == 0 {
break;
}
(more, start, shift) = (more - 1, li, col_top - l.top);
}
fit = li + 1;
match l.end {
LineEnd::PageBreak => break,
LineEnd::ColumnBreak if more == 0 => break,
LineEnd::ColumnBreak => {
more -= 1;
start = li + 1;
shift = pl.lines.get(start).map_or(shift, |n| col_top - n.top);
}
_ => {}
}
}
fit
}
/// Place late float `oi` of body paragraph `p` ([`LateFloats`]) in the current column,
/// positioned from `top`, where the paragraph continues; its wrap area joins the page's.
pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi: usize, top: f32, late: &mut LateFloats) {
let Some(o) = p.objects.get(oi) else { return };
let Some((w, h, float)) = floating(o) else { return };
let h = ctx.float_height(o, w, h);
let frame = PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror };
let rect = float_rect(Some(frame), (pb.col_x(), pb.col_w()), top, w, h, float);
let outline = ctx.wrap_outline(o);
let area = wrap_area(rect, float, outline.as_deref().map(Vec::as_slice)).map(|a| {
let bounds = a.0;
pb.excl.push(a);
bounds
});
late.placed.insert(oi, Some(LatePlace { rect, top, spot: (pb.pages.len(), pb.col), drawn: false, area }));
}
/// The line of `pl` each object of `p` goes with (by object index; None if it isn't laid out):
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
/// them they are on the new page with it (#4262).
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
let mut out = vec![None; p.objects.len()];
for (li, l) in pl.lines.iter().enumerate() {
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
&& !clusters.iter().any(para::takes_room);
for c in clusters {
let para::ClKind::Object(oi) = c.kind else { continue };
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
*slot = Some(if lead { li.saturating_sub(1) } else { li });
}
}
}
out
}
/// [`anchor_floats`], leaving out the floats in `late` (in order).
fn anchor_floats_but(
ctx: &mut Ctx,
pb: &mut PageBuilder,
anchor: FloatAnchor,
late: &[usize],
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>) {
let FloatAnchor { p, block, y0, before, excl_mark, label, place } = anchor;
ctx.fields.page = pb.number;
let (col_x, width) = (pb.col_x(), pb.col_w());
let mut rects = HashMap::new();
if !pb.web && place {
@@ -363,6 +482,10 @@ pub(crate) fn anchor_floats(
if left_out(oi) {
continue;
}
if late.binary_search(&oi).is_ok() {
ctx.floats_above.remove(&(block, oi));
continue;
}
let h = ctx.float_height(o, w, h);
// Positions relative to the paragraph start above its space before (Word for Windows).
let mut r = float_rect(Some(frame), (col_x, width), y0 - before, w, h, float);
@@ -414,6 +537,123 @@ pub(crate) struct ObjFrame<'a> {
/// Floats placed on the page from a header or footer cell (by object index, in page
/// coordinates): their items go to [`Ctx::pin`].
pub(crate) pinned: Option<&'a HashMap<usize, Rect>>,
/// A body paragraph's floats that go on a later page or column than its first piece.
pub(crate) late: Option<&'a LateFloats>,
}
/// Where a late float ([`LateFloats`]) was placed: its rect, the top it's positioned from, the
/// page and column, `(pages, col)`, whether it's drawn, and the bounds of its wrap area.
#[derive(Clone, Copy)]
pub(crate) struct LatePlace {
pub(crate) rect: Rect,
pub(crate) top: f32,
pub(crate) spot: (usize, usize),
pub(crate) drawn: bool,
pub(crate) area: Option<Rect>,
}
/// A body paragraph's floating objects anchored on a later page than its first line
/// ([`late_floats`]): Word draws them there, positioned from where the paragraph continues
/// (#4250). Each is placed when the paragraph gets there, and drawn with that piece.
pub(crate) struct LateFloats {
/// By object index, where it was placed, once it is.
pub(crate) placed: HashMap<usize, Option<LatePlace>>,
/// The page and column of the piece being placed.
pub(crate) piece: (usize, usize),
/// [`anchor_lines`], and the paragraph layout they're for.
pub(crate) lines: Option<(Arc<ParaLayout>, Vec<Option<usize>>)>,
/// Those a piece let go ([`LateFloats::release`]), and those it keeps all the same
/// ([`LateFloats::keep_back`]): (piece, object index).
pub(crate) held: Vec<((usize, usize), usize)>,
pub(crate) kept: Vec<((usize, usize), usize)>,
}
impl LateFloats {
pub(crate) fn new(late: &[usize]) -> Self {
LateFloats { placed: late.iter().map(|oi| (*oi, None)).collect(), piece: (0, 0), lines: None, held: Vec::new(), kept: Vec::new() }
}
/// [`anchor_lines`] for `pl`.
pub(crate) fn refresh(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>) {
if !self.lines.as_ref().is_some_and(|(of, _)| Arc::ptr_eq(of, pl)) {
self.lines = Some((pl.clone(), anchor_lines(p, pl)));
}
}
/// The line object `oi` goes with ([`anchor_lines`]).
pub(crate) fn anchor(&self, oi: usize) -> Option<usize> {
self.lines.as_ref().and_then(|(_, v)| v.get(oi).copied().flatten())
}
/// Those placed with this piece whose anchor line is past `end` after all (notes, widow
/// control or a row kept together took lines [`fitting`] counted) wait for the next piece,
/// their wrap areas out of `excl`. Whether there were any.
pub(crate) fn release(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, end: usize, excl: &mut Vec<WrapArea>) -> bool {
self.refresh(p, pl);
let piece = self.piece;
let out: Vec<usize> = self
.placed
.iter()
.filter(|(oi, at)| at.is_some_and(|a| a.spot == piece && !a.drawn) && !self.kept.contains(&(piece, **oi)))
.filter(|(oi, _)| self.anchor(**oi).is_some_and(|li| li >= end))
.map(|(oi, _)| *oi)
.collect();
for oi in &out {
if let Some(Some(a)) = self.placed.insert(*oi, None)
&& let Some(k) = a.area.and_then(|r| excl.iter().rposition(|w| w.0 == r))
{
excl.remove(k);
}
self.held.push((piece, *oi));
}
!out.is_empty()
}
/// Those this piece let go whose anchor line came back into `lines` without them (their own
/// wrap pushed it on): kept with this piece after all, as before. Whether there were any.
pub(crate) fn keep_back(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> bool {
self.refresh(p, pl);
let piece = self.piece;
let back: Vec<_> =
self.held.iter().filter(|(pc, oi)| *pc == piece && self.anchor(*oi).is_some_and(|li| lines.contains(&li))).copied().collect();
self.held.retain(|h| !back.contains(h));
self.kept.extend(&back);
!back.is_empty()
}
/// Those to place with this piece: not placed yet (nor let go by it), or placed for a column
/// the paragraph then didn't go into, with their anchor line in `lines`, in order.
pub(crate) fn due(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> Vec<usize> {
self.refresh(p, pl);
let mut due: Vec<usize> = self
.placed
.iter()
.filter(|(_, at)| at.is_none_or(|a| a.spot != self.piece && !a.drawn))
.filter(|(oi, _)| !self.held.contains(&(self.piece, **oi)) && self.anchor(**oi).is_some_and(|li| lines.contains(&li)))
.map(|(oi, _)| *oi)
.collect();
due.sort_unstable();
due
}
/// Late float `oi` if it's drawn with this piece.
pub(crate) fn here(&self, oi: usize) -> Option<LatePlace> {
self.placed.get(&oi).copied().flatten().filter(|a| a.spot == self.piece && !a.drawn)
}
/// The lines holding the anchors of those drawn with this piece (an anchor right after a
/// break is on the line after its [`anchor_lines`] one).
pub(crate) fn lines_here(&self) -> Vec<usize> {
self.placed.keys().filter(|oi| self.here(**oi).is_some()).filter_map(|oi| self.anchor(*oi)).flat_map(|li| [li, li + 1]).collect()
}
/// Those drawn with this piece are drawn.
pub(crate) fn drew(&mut self) {
let piece = self.piece;
for a in self.placed.values_mut().flatten().filter(|a| a.spot == piece) {
a.drawn = true;
}
}
}
/// What [`anchor_floats`] anchors: paragraph `p` (body block `block`) starting at `y0` with
@@ -461,15 +701,15 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
let Some(fl) = pl.lines.get(l0) else { return (Vec::new(), Vec::new()) };
// Objects left out of the layout (hidden, or deleted in the final text) aren't placed.
let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted);
// A floating object belongs to its paragraph, not to its line: Word puts it on the page the
// paragraph starts on (a cover page is a paragraph holding a page break and then the text
// box with the title; the box stays on the cover). So the paragraph's first piece places
// every floating object, whichever of its lines holds the anchor, and later pieces none;
// inline objects go with their own line.
// The paragraph's first piece draws its floating objects, whichever of its lines holds the
// anchor, except those anchored on a later page (`at.late`): the piece there draws them
// (#4250). Inline objects go with their own line.
let first = l0 == 0;
let late = at.late.filter(|l| !l.placed.is_empty());
let late_lines = late.map(LateFloats::lines_here).unwrap_or_default();
for li in 0..pl.lines.len() {
let in_piece = (l0..l1).contains(&li);
if !in_piece && !first {
if !in_piece && !first && !late_lines.contains(&li) {
continue;
}
let Some(line) = pl.lines.get(li) else { continue };
@@ -482,12 +722,23 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
let Some(obj) = p.objects.get(oi) else { continue };
let Some((w, h, float)) = obj.frame() else { continue };
let floating = float.wrap != Wrap::Inline;
if if floating { !first } else { !in_piece } {
let is_late = floating && late.is_some_and(|l| l.placed.contains_key(&oi));
let late_here = late.and_then(|l| l.here(oi)).filter(|_| floating);
let skip = if is_late {
late_here.is_none()
} else if floating {
!first
} else {
!in_piece
};
if skip {
continue;
}
let pinned = at.pinned.and_then(|m| m.get(&oi)).copied().filter(|_| floating);
let rect = if let Some(r) = pinned {
r
} else if let Some(l) = late_here {
l.rect
} else if floating {
match floats.get(&oi) {
Some(r) => *r,
@@ -522,7 +773,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
off: c.start,
text_box: shown,
wrap: float.wrap,
origin: Point::new(at.col.0, at.para_y),
origin: Point::new(at.col.0, late_here.map_or(at.para_y, |l| l.top)),
});
// Its text (a group: its text boxes', each where the group's turn carries it; the
// text itself stays upright), unless the box budget says no (a box inside itself,
+116 -11
View File
@@ -1,7 +1,10 @@
use super::boxes::{BoxInput, BoxLayout, Edges, joined, layout_box, push_para};
use super::cache::has_page_number_fields;
use super::ctx::Ctx;
use super::floats::{FloatAnchor, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, place_objects};
use super::floats::{
FloatAnchor, LateFloats, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, fitting, late_floats, place_late,
place_objects,
};
use super::notes::{DECOR_STORY, NoteBox, para_notes};
use super::page::{PageBuilder, PageFrame, item_bottom};
use super::placed::Placed;
@@ -172,6 +175,15 @@ struct Flow<'a> {
wrap_relays: usize,
/// The first line not placed yet.
l0: usize,
/// Its floats anchored on a later page or column than its first piece (#4250).
late: LateFloats,
/// Whether the floats past the first piece were checked ([`Flow::recheck_late`]), and those
/// it held back.
rechecked: bool,
forced: Vec<usize>,
/// The space before it at the top of the piece being placed (a paragraph starting with a page
/// break has it on the new page): late floats there are positioned from above it.
space_here: f32,
}
impl<'a> Flow<'a> {
@@ -185,8 +197,8 @@ impl<'a> Flow<'a> {
let guess = pb.y + space_before;
let label = ctx.next_label(p);
let eq_start = ctx.eq_count;
let (float_rects, pl, rel) =
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true });
let (float_rects, pl, rel, late) =
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true }, &[]);
Flow {
p,
block,
@@ -206,6 +218,10 @@ impl<'a> Flow<'a> {
page_relays: 0,
wrap_relays: 0,
l0: 0,
late: LateFloats::new(&late),
rechecked: false,
forced: Vec::new(),
space_here: 0.0,
}
}
@@ -213,16 +229,19 @@ impl<'a> Flow<'a> {
self.pl.lines.len()
}
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it.
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32)) {
let (rects, pl, rel) = again(ctx, self.eq_start, |ctx| {
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it,
/// leaving those in `force` for a later piece.
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32), force: &[usize]) {
let (rects, pl, rel, late) = again(ctx, self.eq_start, |ctx| {
anchor_floats(
ctx,
pb,
FloatAnchor { p: self.p, block: self.block, y0, before, excl_mark: self.excl_mark, label: &self.label, place: true },
force,
)
});
(self.float_rects, self.pl, self.rel) = (rects, pl, rel);
self.late = LateFloats::new(&late);
(self.laid_page, self.laid_width) = (pb.number, pb.col_w());
}
@@ -473,8 +492,10 @@ impl<'a> Flow<'a> {
pb.advance(self.block, self.body_top);
if self.l0 == 0 {
let y0 = pb.y;
self.reanchor(ctx, pb, (y0, 0.0));
self.reanchor(ctx, pb, (y0, 0.0), &[]);
self.anchor_y = y0;
// Its floats are checked again on the new page.
(self.rechecked, self.forced) = (false, Vec::new());
}
}
@@ -507,6 +528,13 @@ impl<'a> Flow<'a> {
}
let end = push_para(&mut items, (StoryRef::Body, &[self.block as u32]), &self.pl, (l0, l1), (x, y), self.width, edges);
pb.para_end = Some((pb.pages.len(), pb.col, end));
// Late floats whose anchor line came here after all are placed with these lines.
self.late.piece = (pb.pages.len(), pb.col);
if !pb.web && !self.late.placed.is_empty() {
let top = if l0 == 0 { self.anchor_y } else { y - self.space_here };
self.place_due(ctx, pb, 0..l1, top);
}
self.space_here = 0.0;
// Pictures, shapes and text boxes in these lines (web view: floats placed by this piece).
let at = ObjFrame {
page: Some(PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }),
@@ -514,6 +542,7 @@ impl<'a> Flow<'a> {
para_y: if pb.web { y } else { self.anchor_y },
table_chr: None,
pinned: None,
late: Some(&self.late),
};
let (behind, front) = place_objects(
ctx,
@@ -529,6 +558,7 @@ impl<'a> Flow<'a> {
depth: 0,
},
);
self.late.drew();
items.extend(front);
items.extend(line_numbers(ctx, pb, &self.pl, (l0, l1), y));
add_items(pb, behind, items);
@@ -555,7 +585,8 @@ impl<'a> Flow<'a> {
}
Some(LineEnd::PageBreak) if starts_with_break && !pb.web => {
pb.new_page(block, body_top);
pb.y += self.pl.rp.space_before.max(0.0);
self.space_here = self.pl.rp.space_before.max(0.0);
pb.y += self.space_here;
}
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
Some(LineEnd::ColumnBreak) => {
@@ -578,8 +609,8 @@ impl<'a> Flow<'a> {
let y0 = pb.y;
// The floats were placed and drawn with the first piece: they stay there.
let (p, block, excl_mark, eq_start, label) = (self.p, self.block, self.excl_mark, self.eq_start, &self.label);
let (rects, relaid, relaid_rel) = with_page_map(ctx, &mut self.page_map, |ctx| {
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }))
let (rects, relaid, relaid_rel, _) = with_page_map(ctx, &mut self.page_map, |ctx| {
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }, &[]))
});
if let Some(k) = relaid.lines.iter().position(|l| l.start == resume) {
(self.float_rects, self.pl, self.rel) = (rects, relaid, relaid_rel);
@@ -589,6 +620,72 @@ impl<'a> Flow<'a> {
}
}
/// Late floats anchored in the lines that fit in this column are placed here, so these lines
/// are broken again around them ([`Flow::relay_wrap`]).
fn place_due_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder) {
if self.l0 == 0 || pb.web || self.late.placed.is_empty() {
return;
}
self.late.piece = (pb.pages.len(), pb.col);
let lines = self.l0..fitting(&self.pl, self.l0, pb.y, pb.bottom, (0, pb.top));
self.place_due(ctx, pb, lines, pb.y - self.space_here);
}
/// Place the late floats due with this piece whose anchor line is in `lines`, positioned from
/// `top`.
fn place_due(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, lines: std::ops::Range<usize>, top: f32) {
for oi in self.late.due(self.p, &self.pl, lines) {
place_late(ctx, pb, self.p, oi, top, &mut self.late);
}
}
/// Late floats placed for lines that went on after all (past `l1`) wait for the next piece;
/// one whose own wrap pushed its line on stays, as before. Whether the lines from `l0` are to
/// be broken again without them. Does nothing once the wrap relays are used up
/// ([`MAX_WRAP_RELAYS`]).
fn settle_late(&mut self, pb: &mut PageBuilder, l1: usize) -> bool {
let l0 = self.l0;
if l0 == 0 || pb.web || self.late.placed.is_empty() || self.wrap_relays >= MAX_WRAP_RELAYS {
return false;
}
let end = if l1 == l0 && pb.fresh() { l0 + 1 } else { l1 }; // what the overflow keeps
let moved = self.late.release(self.p, &self.pl, end, &mut pb.excl) || self.late.keep_back(self.p, &self.pl, l0..end);
moved && l1 > l0
}
/// The first piece's lines end at `l1` on this page. Floats anchored past them, on the next
/// page, though [`anchor_floats`] thought they would fit here (footnotes, widow control…),
/// are held back after all, once; and those held back whose anchor came back once they were
/// out of the way are placed here as before, with the text wrapping around them rather than
/// over it (before the notes of these lines are kept: the wrap can push their references on).
/// Whether the paragraph was laid out again.
fn recheck_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, l1: usize) -> bool {
if self.l0 != 0 || pb.web {
return false;
}
if l1 < self.n() && !self.rechecked && pb.col + 1 >= pb.cols.len() {
self.rechecked = true;
self.forced = late_floats(self.p, &self.pl, l1).into_iter().filter(|oi| !self.late.placed.contains_key(oi)).collect();
if !self.forced.is_empty() {
let mut force = self.forced.clone();
force.extend(self.late.placed.keys());
force.sort_unstable();
let top = (pb.y, pb.y - self.anchor_y);
self.reanchor(ctx, pb, top, &force);
return true;
}
}
let back = if self.forced.is_empty() { Vec::new() } else { self.late.due(self.p, &self.pl, 0..l1) };
if !back.iter().any(|oi| self.forced.contains(oi)) {
return false;
}
// The others are still held back.
self.forced.retain(|oi| !back.contains(oi));
let (keep, top) = (std::mem::take(&mut self.forced), (pb.y, pb.y - self.anchor_y));
self.reanchor(ctx, pb, top, &keep);
true
}
/// Leave the space after the paragraph, for the next one to merge with.
fn finish(&self, pb: &mut PageBuilder, rooms: Rooms) {
// A mark that stayed with its page break keeps its space after on that page too, and its
@@ -730,10 +827,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
// A different top only matters when there is something to wrap around.
if spot(pb) != placed_at || ((pb.y - f.guess).abs() > 0.01 && !pb.excl.is_empty()) {
let y0 = pb.y;
f.reanchor(ctx, pb, (y0, applied_before));
f.reanchor(ctx, pb, (y0, applied_before), &[]);
f.anchor_y = y0 - applied_before;
}
while f.l0 < f.n() {
f.place_due_late(ctx, pb);
f.relay_wrap(ctx, pb);
f.relay_pages(ctx, pb);
// What is laid out for these lines from here (their footnotes) is on this page.
@@ -744,6 +842,9 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
let notes = para_notes(ctx.doc, p, &f.pl);
let mut fit = f.fit_lines(ctx, pb, &notes, lead);
fit.l1 = f.settled_end(pb, &fit);
if f.settle_late(pb, fit.l1) {
continue;
}
if fit.l1 == f.l0 {
if !pb.fresh() {
f.move_on(ctx, pb);
@@ -751,6 +852,9 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
}
fit.l1 = f.l0 + 1; // can't fit even one line on an empty page: overflow
}
if f.recheck_late(ctx, pb, fit.l1) {
continue;
}
let (forced, mark_with_break) = (fit.forced, fit.mark_with_break);
f.emit(ctx, pb, rooms, (lead, fit, &notes));
f.after_lines(ctx, pb, forced, mark_with_break);
@@ -811,6 +915,7 @@ pub(crate) fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragr
para_y: y,
table_chr: None,
pinned: None,
late: None,
};
let (behind, front) = place_objects(
ctx,
+4
View File
@@ -72,6 +72,9 @@ pub struct ObjectHit {
pub wrap: Wrap,
/// Where column- and paragraph-relative offsets start (see [`Placed::Object`]).
pub origin: Point,
/// A member of a Drawing Canvas, by its index in the canvas, when the hit is that member
/// (its frame inside the canvas at `pos`) rather than the canvas. Layout gives `None`.
pub member: Option<usize>,
}
/// Object positions in one layout, retaining each page occurrence for page-hinted lookups.
@@ -149,6 +152,7 @@ fn objects_in(items: &[Placed], index: usize) -> impl Iterator<Item = ObjectHit>
text_box: *text_box,
wrap: *wrap,
origin: *origin,
member: None,
}),
_ => None,
})
+1
View File
@@ -38,6 +38,7 @@ pub(crate) use chars::{ligature_features, remap_range, thai_run_face};
pub use grid::{Exclusion, Grid, is_east_asian};
#[cfg(test)]
use lines::break_lines;
pub(crate) use lines::takes_room;
pub use model::{CellText, ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, Line, LineEnd, PARA_MARK_WIDTH, ParaEnv, RubyLayout, StyleRun, VisCl};
pub use paragraph::layout_para;
#[cfg(test)]
+1 -1
View File
@@ -109,7 +109,7 @@ fn has_content(pl: &ParaLayout, c0: usize, j: usize) -> bool {
/// control's start, a bookmark) and floating objects' anchors take no room and don't count (Word:
/// a picture a little wider than the line, after an anchor, stays on the first line rather than
/// leaving the anchor a line of its own).
fn takes_room(c: &Cluster) -> bool {
pub(crate) fn takes_room(c: &Cluster) -> bool {
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
}
+343 -3
View File
@@ -6036,9 +6036,8 @@ fn float_follows_its_paragraph_to_the_next_page() {
assert!(para.lines[0].left >= 144.0, "text wraps beside it: {}", para.lines[0].left);
}
/// A floating object belongs to its paragraph, not to the line holding its anchor: Word draws
/// it on the page the paragraph starts on. A Word cover page is one paragraph holding a page
/// break and then the page-anchored text box with the title; the box stays on the cover.
/// A Word cover page is one paragraph holding a page break and then the page-anchored text box
/// with the title: with nothing after its anchor, the box stays on the cover.
#[test]
fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
@@ -6051,6 +6050,347 @@ fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
assert!(shapes(&l.pages[1].items).is_empty(), "and not again on page two");
}
/// #4262: a bookmark after the box's anchor (or a comment's end, a field's) takes no room: the box
/// still stays on the cover. An inline picture after it does take room: it goes on to page two,
/// and the box with it.
#[test]
fn a_cover_box_stays_on_the_cover_with_a_bookmark_after_it_but_not_with_a_picture() {
let cover = |after: InlineObject| {
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
d.insert_object(&Pos::body(0, 1), after, &Default::default()).unwrap();
d.insert_object(&Pos::body(0, 1), rect_shape(200.0, 100.0, page_float(100.0, 300.0)), &Default::default()).unwrap();
let l = lay(&d);
// The floating box; the inline picture is 20 pt wide.
let boxes = |pi: usize| shapes(&l.pages[pi].items).iter().filter(|r| r.w > 100.0).count();
(boxes(0), boxes(1))
};
assert_eq!(cover(InlineObject::BookmarkEnd { name: "_GoBack".into() }), (1, 0), "with a bookmark: on the cover");
assert_eq!(cover(rect_shape(20.0, 20.0, Default::default())), (0, 1), "with an inline picture: on page two");
}
/// #4262: with text after the anchor, though, the line after the break is on the next page, and
/// so is the float anchored at its start. Observed in Word for Mac: a shape dragged beside the
/// first line after a page break inside a paragraph is anchored right after the break and stays
/// on the new page, positioned from the paragraph's top there.
#[test]
fn a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let text = "The quick brown fox jumps over the lazy dog. ";
let mut d = Document::from_text(&format!("{}{}{}", text.repeat(5), wordcraft_doc::para::PAGE_BREAK, text.repeat(5)));
let at = text.len() * 5 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 60.0, y: 1.0, ..Default::default() };
d.insert_object(&Pos::body(0, at), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
let (top, ..) = lines_on(&l, 1)[0];
assert_eq!(shapes(&l.pages[1].items).len(), 1, "drawn once");
let r = shapes(&l.pages[1].items)[0];
assert!((r.y - (top + 1.0)).abs() < 0.5, "1 pt below the paragraph's top on page two ({top}): {r:?}");
}
/// The lines of body paragraph 0 on page `pi`: each one's (top on the page, left, start).
fn lines_on(l: &DocLayout, pi: usize) -> Vec<(f32, f32, usize)> {
let mut out = Vec::new();
for it in &l.pages[pi].items {
if let Placed::Lines { para, l0, l1, y, story: StoryRef::Body, path, .. } = it
&& path.0 == [0]
{
let first = para.lines[*l0].top;
out.extend(para.lines[*l0..*l1].iter().map(|li| (y + li.top - first, li.left, li.start)));
}
}
out
}
/// #4250: a floating object anchored in a line of a long paragraph on a later page is drawn on
/// that page, as Word does (its anchor line's page), not on the page the paragraph starts on.
/// That page's text wraps around it; the page before is laid out as without it.
#[test]
fn a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page() {
use wordcraft_doc::para::Wrap;
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let plain = lay(&d);
assert!(plain.pages.len() >= 3, "pages {}", plain.pages.len());
let anchor = lines_on(&plain, 1)[5].2;
let f = wordcraft_doc::para::Float { wrap: Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty(), "not on the page the paragraph starts on");
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).sum::<usize>(), 1, "drawn once");
let on_two = shapes(&l.pages[1].items);
assert_eq!(on_two.len(), 1, "{on_two:?}");
assert!((on_two[0].x - 72.0).abs() < 0.5 && (on_two[0].y - 300.0).abs() < 0.5, "{:?}", on_two[0]);
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text beside it wraps: {beside:?}");
let starts = |l: &DocLayout| lines_on(l, 0).iter().map(|(_, left, start)| (*left, *start)).collect::<Vec<_>>();
assert_eq!(starts(&l), starts(&plain), "page one is as without it");
// Its hit area is there too, for selecting and dragging it.
let hit = l.object(&Pos::body(0, anchor), 0).expect("laid out");
assert_eq!(hit.page, 1);
}
/// #4250: positioned from its paragraph, a float anchored on a later page of the paragraph is
/// measured from where the paragraph continues on that page (Word saves such a picture dragged
/// onto page two as `relativeFrom="paragraph"` with its offset from that page's top line).
#[test]
fn a_float_from_its_paragraph_on_a_later_page_is_measured_from_the_paragraph_s_top_there() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let plain = lay(&d);
let (top, _, _) = lines_on(&plain, 1)[0];
let anchor = lines_on(&plain, 1)[8].2;
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 120.0, y: 150.0, dist: 9.0, ..Default::default() };
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty());
let r = shapes(&l.pages[1].items)[0];
assert!((r.y - (top + 150.0)).abs() < 0.5, "150 pt below the paragraph's top on page two ({top}): {r:?}");
assert!((r.x - 192.0).abs() < 0.5, "{r:?}");
let hit = l.object(&Pos::body(0, anchor), 1).expect("laid out");
assert!((hit.origin.y - top).abs() < 0.5, "dragging measures from there too: {:?}", hit.origin);
}
/// #4250: a paragraph starting with a page break has its space before on the new page; a float
/// from its paragraph anchored there is still measured from the paragraph's top, above that
/// space, as on its first page.
#[test]
fn a_float_after_a_leading_page_break_is_measured_from_above_the_space_before() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let text = format!("Page one.\n{}{}", wordcraft_doc::para::PAGE_BREAK, "The quick brown fox jumps over the lazy dog. ".repeat(20));
let mut d = Document::from_text(&text);
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(1).map(std::sync::Arc::make_mut) {
p.props.space_before = Some(24.0);
}
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, dist: 9.0, ..Default::default() };
d.insert_object(&Pos::body(1, 200), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty());
let r = shapes(&l.pages[1].items)[0];
assert!((r.y - (72.0 + 150.0)).abs() < 0.5, "150 pt below the top margin: {r:?}");
}
/// #4250, as observed in Word for Mac: lines added at the top of page one push the text down,
/// but a picture positioned from its paragraph on page two stays where it is there.
#[test]
fn text_added_on_page_one_leaves_a_float_on_page_two_where_it_is() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let anchor = lines_on(&lay(&d), 1)[8].2;
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, ..Default::default() };
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
let before = shapes(&lay(&d).pages[1].items);
for _ in 0..3 {
d.body.insert(0, wordcraft_doc::para_block(wordcraft_doc::Paragraph::new()));
}
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty());
assert_eq!(shapes(&l.pages[1].items), before);
}
/// #4250: anchored on the third page of a paragraph, a float is drawn on the third page.
#[test]
fn a_float_anchored_two_pages_into_its_paragraph_is_drawn_on_that_page() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
let plain = lay(&d);
assert!(plain.pages.len() >= 4, "pages {}", plain.pages.len());
let anchor = lines_on(&plain, 2)[3].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
assert_eq!(per_page.iter().sum::<usize>(), 1);
}
/// A long paragraph with a long footnote on page one, and a page-placed square float anchored at
/// line `k` of page two as laid out without it.
fn footnoted_float(k: usize) -> DocLayout {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
note.props.space_after = Some(0.0);
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
d.insert_object(
&Pos::body(0, 4),
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
&Default::default(),
)
.unwrap();
let anchor = lines_on(&lay(&d), 1)[k].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
lay(&d)
}
/// #4250: a footnote on page one leaves less room there than its lines alone suggest; a float
/// anchored a few lines into page two is drawn on page two all the same.
#[test]
fn a_float_anchored_just_past_a_page_of_footnotes_is_drawn_on_the_next_page() {
let l = footnoted_float(3);
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
assert_eq!(shapes(&l.pages[1].items).len(), 1);
}
/// #4250: anchored in page two's first line, which goes back to page one once the float no longer
/// wraps the text there, a float stays on page one with the text wrapping around it, not over it.
#[test]
fn a_float_whose_anchor_comes_back_to_page_one_stays_there_with_the_text_around_it() {
let l = footnoted_float(0);
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).collect::<Vec<_>>()[..2], [1, 0]);
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text wraps around it: {beside:?}");
}
/// #4250: the text wrapping around that float again pushes a footnote reference beside its anchor
/// back to page two; the note goes with it, once, not left on page one as well.
#[test]
fn a_footnote_pushed_on_by_a_float_coming_back_is_on_its_reference_s_page_only() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let note = |text: &str| {
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()).styled("FootnoteText");
p.props.space_after = Some(0.0);
vec![wordcraft_doc::para_block(p)]
};
let noteref = |id| InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
let long = d.add_part(wordcraft_doc::PartKind::Footnote, note(&"The note text runs on for a while. ".repeat(30)));
d.insert_object(&Pos::body(0, 4), noteref(long), &Default::default()).unwrap();
let anchor = lines_on(&lay(&d), 1)[0].2;
let short = d.add_part(wordcraft_doc::PartKind::Footnote, note("A short note."));
d.insert_object(&Pos::body(0, anchor), noteref(short), &Default::default()).unwrap();
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(shapes(&l.pages[0].items).len(), 1, "the float stays on page one");
let at = anchor + wordcraft_doc::para::OBJ.len_utf8();
let ref_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= at));
assert_eq!(ref_page, Some(1), "the reference is pushed on to page two");
let notes: Vec<usize> = l
.pages
.iter()
.map(|pg| pg.items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(p), .. } if *p == short)).count())
.collect();
assert_eq!(notes[..2], [0, 1], "the note is on its reference's page, once");
}
/// #4250: of two floats held back for page two, the one whose anchor comes back to page one stays
/// there; the other is still drawn on page two.
#[test]
fn of_two_floats_held_back_only_the_one_whose_anchor_comes_back_stays_on_page_one() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
note.props.space_after = Some(0.0);
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
d.insert_object(&Pos::body(0, 4), noteref, &Default::default()).unwrap();
let lines = lines_on(&lay(&d), 1);
let f = |y| wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, y) };
d.insert_object(&Pos::body(0, lines[3].2), rect_shape(100.0, 50.0, f(400.0)), &Default::default()).unwrap();
d.insert_object(&Pos::body(0, lines[0].2), rect_shape(100.0, 50.0, f(300.0)), &Default::default()).unwrap();
let l = lay(&d);
let ys = |pi: usize| shapes(&l.pages[pi].items).iter().map(|r| r.y).collect::<Vec<_>>();
assert_eq!((ys(0).len(), ys(1).len()), (1, 1), "one on each page: {:?} {:?}", ys(0), ys(1));
}
/// #4250: a footnote referenced on page two leaves less room there than its lines alone suggest;
/// a float anchored on the first line it pushes on to page three is drawn on page three.
#[test]
fn a_float_anchored_on_a_line_a_footnote_pushes_on_is_drawn_on_the_next_page() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
note.props.space_after = Some(0.0);
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
let at = lines_on(&lay(&d), 1)[1].2;
d.insert_object(&Pos::body(0, at), noteref, &Default::default()).unwrap();
// A word into the line: an anchor at its very start goes with the line before.
let anchor = lines_on(&lay(&d), 2)[0].2 + "The ".len();
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(anchor_page, Some(2), "the anchor is on page three");
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left < 1.0), "page two doesn't wrap around it: {beside:?}");
}
/// #4250: widow control takes the line before a paragraph's last on to the next page with it; a
/// float anchored there is drawn on that page.
#[test]
fn a_float_anchored_on_a_line_widow_control_takes_on_is_drawn_on_the_next_page() {
// A paragraph of three pages whose last two lines are on the third only by widow control.
let (mut d, l) = (150..300)
.map(|k| Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(k)))
.map(|d| {
let l = lay(&d);
(d, l)
})
.find(|(_, l)| l.pages.len() == 3 && lines_on(l, 2).len() == 2 && lines_on(l, 1).len() < lines_on(l, 0).len())
.expect("a paragraph ending in a widow");
let anchor = lines_on(&l, 2)[0].2 + "The ".len();
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, page_float(72.0, 300.0)), &Default::default()).unwrap();
let per_page: Vec<usize> = lay(&d).pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(per_page, [0, 0, 1]);
}
/// #4250: a float whose own wrap pushes its anchor line on to the next page stays on the page
/// whose lines it was placed with, as before, with the text there wrapping around it.
#[test]
fn a_float_whose_wrap_pushes_its_own_line_on_stays_with_the_text_around_it() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
let last = *lines_on(&lay(&d), 1).last().unwrap();
let anchor = last.2 + "The ".len();
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(200.0, 100.0, f), &Default::default()).unwrap();
let l = lay(&d);
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
assert_eq!(anchor_page, Some(2), "its wrap pushes the anchor on to page three");
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(&per_page[..3], [0, 1, 0], "{per_page:?}");
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 400.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 200.0), "the text wraps around it: {beside:?}");
}
/// #4250: holding back a float for a later page lays its paragraph out again; its automatic
/// equation numbers aren't counted twice: they read (1), (2).
#[test]
fn a_paragraph_with_a_float_for_a_later_page_keeps_its_equation_numbers() {
let eq = |s: &str| InlineObject::Equation {
linear: s.into(),
display: true,
math: wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(s), ..Default::default() },
};
let mut d = Document::from_text(&format!("{}\n", "The quick brown fox jumps over the lazy dog. ".repeat(400)));
let anchor = lines_on(&lay(&d), 1)[5].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let n = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().len();
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_object(n, eq("a=b#"), &Default::default()).unwrap();
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().insert_object(0, eq("c=d#"), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(shapes(&l.pages[1].items).len(), 1, "held back for page two");
assert_eq!(equation_numbers(&l), ["(1)", "(2)"]);
}
/// #4250: in two columns, a float anchored where the paragraph continues in the second column of
/// the same page is placed with its first piece, as before, so the first column wraps around it.
#[test]
fn a_float_anchored_in_the_next_column_of_the_page_stays_with_its_first_piece() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(120));
d.last_section.columns.count = 2;
let plain = lay(&d);
let lines = lines_on(&plain, 0);
let second = lines.iter().position(|w| w.0 < lines[0].0 + 0.5 && w.2 > 0).expect("a second column on page one");
let anchor = lines[second + 2].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 200.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(shapes(&l.pages[0].items).len(), 1, "on page one");
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, left, start)| *y > 200.0 && *y < 250.0 && *start < anchor && *left >= 0.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the first column wraps around it: {beside:?}");
}
/// Floating objects are drawn in their stacking order (`relativeHeight`), not in the order of
/// their anchors: a panel anchored after the text box it frames, but lower in the stack, is
/// drawn first so the box stays visible over it. Objects without an order keep theirs.
+14
View File
@@ -324,6 +324,20 @@ fn floating_box_that_resizes_to_fit_takes_its_texts_height() {
assert!((shrunk.h - grown.h).abs() < 0.5, "{shrunk:?} {grown:?}");
}
/// #4250: on a later page of its paragraph, a floating box that resizes to fit its text takes its
/// text's height too.
#[test]
fn a_floating_box_on_a_later_page_of_its_paragraph_fits_its_text() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..Default::default() };
let id = text_box_with(&mut d, 0, "Inside", 144.0, 1.0, TextBody { auto_fit: true, ..Default::default() }, float);
let l = lay(&d);
assert!(l.pages.len() >= 3, "pages {}", l.pages.len());
let hit = l.find_object(0, |o| o.text_box == Some(id)).unwrap();
assert!(hit.page > 0, "drawn on the page of its anchor");
assert!(hit.rect.h > 15.0 && hit.rect.h < 30.0, "one line and the margins: {:?}", hit.rect);
}
#[test]
fn a_turned_linked_box_turns_only_its_own_lines() {
// Linked boxes show the first box's story; turning the second box turns the lines it shows,
+6
View File
@@ -16,6 +16,8 @@ wordcraft-layout = { workspace = true }
wordcraft-metafile = { workspace = true }
craft-pdf = { workspace = true }
kurbo = { workspace = true }
hayro-syntax = { version = "0.7.2", optional = true }
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"], optional = true }
image = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
@@ -25,6 +27,10 @@ chrono = { workspace = true }
# The browser clock (`SystemTime::now()` panics on wasm32-unknown-unknown).
js-sys = "0.3"
[features]
# What an exported PDF draws, read back, for other crates' tests.
test-support = ["dep:hayro-syntax", "dep:hayro-interpret"]
[dev-dependencies]
hayro-syntax = "0.7.2"
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
+1
View File
@@ -106,6 +106,7 @@ fn write_attempt(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[us
tag_index: HashMap::new(),
links: Vec::new(),
bookmarks: std::cell::OnceCell::new(),
watermark_fit: HashMap::new(),
};
if let Some(o) = ex.outline(pages) {
pdf.set_outline(o);
+35 -6
View File
@@ -43,6 +43,9 @@ pub(crate) struct Exporter<'a> {
/// Bookmarks by exact name, and by ASCII-lowercased name for the case-blind fallback; the
/// first in document order wins either way.
pub(crate) bookmarks: std::cell::OnceCell<(HashMap<String, wordcraft_doc::Pos>, HashMap<String, wordcraft_doc::Pos>)>,
/// What a picture watermark at Auto fills, by the header a page shows
/// ([`wordcraft_doc::Document::watermark_fit`]), worked out once per header.
pub(crate) watermark_fit: HashMap<Option<u32>, (f32, f32)>,
}
impl Exporter<'_> {
@@ -191,11 +194,16 @@ impl Exporter<'_> {
if let Some(pic) = self.doc.settings.page_picture.clone() {
self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.page_picture(s, &pic, w, h));
}
if let Some(wm) = self.doc.settings.watermark.clone()
&& !wm.text.trim().is_empty()
{
let area = page.body;
self.tagged(s, Role::Artifact(ArtifactType::Watermark), None, |me, s| me.watermark(s, &wm, w, h, area));
match self.doc.settings.watermark.clone() {
Some(wordcraft_doc::Watermark { picture: Some(pic), .. }) => {
let (body, header) = (page.body, page.header_story);
self.tagged(s, Role::Artifact(ArtifactType::Watermark), None, |me, s| me.watermark_picture(s, &pic, body, header, (w, h)));
}
Some(wm) if !wm.text.trim().is_empty() => {
let area = page.body;
self.tagged(s, Role::Artifact(ArtifactType::Watermark), None, |me, s| me.watermark(s, &wm, w, h, area));
}
_ => {}
}
let dopts = DisplayOptions {
marks: false,
@@ -328,6 +336,27 @@ impl Exporter<'_> {
tree
}
/// A picture watermark, centred in the page's text area (`body`) at its scale or, at Auto, the
/// size the first section naming the page's `header` gives it, as on screen. Washed out, it is
/// the picture with Word's Washout baked in by the same path as any picture with corrections,
/// embedded once however many pages show it.
fn watermark_picture(&mut self, s: &mut Surface, pic: &wordcraft_doc::WatermarkPicture, body: Rect, header: Option<u32>, page: (f32, f32)) {
// The page's text area; the whole page without one.
let body = if body.w > 0.0 && body.h > 0.0 { body } else { Rect::new(0.0, 0.0, page.0, page.1.min(2000.0)) };
let Some((own_w, own_h)) = self.doc.media.get(&pic.media).and_then(|b| wordcraft_doc::watermark::own_size(b)) else { return };
let fit = if pic.scale.is_none() {
let doc = self.doc;
*self.watermark_fit.entry(header).or_insert_with(|| doc.watermark_fit(header))
} else {
(0.0, 0.0)
};
let Some(r) = pic.place(own_w, own_h, fit, body) else { return };
let (Some(img), Some(size)) = (self.image(&pic.media, pic.fx()), Size::from_wh(r.w, r.h)) else { return };
s.push_transform(&Transform::from_translate(r.x, r.y));
s.draw_image(img, size);
s.pop();
}
/// The watermark text stretched over its box (Word's `v:textpath fitshape`): its advance across
/// the box's width, its em down the box's height with the em's foot (the font's descent below
/// the baseline) on the box's bottom; the box centred on the margins (`area`, else the page)
@@ -374,7 +403,7 @@ impl Exporter<'_> {
.collect();
s.push_transform(&Transform::from_row(c * stretch, sn * stretch, -sn, c, c * dx - sn * dy + cx, sn * dx + c * dy + cy));
s.set_stroke(None);
s.set_fill(Some(fill(wm.color, if wm.semitransparent { 0.5 } else { 1.0 })));
s.set_fill(Some(fill(wm.color, wm.opacity())));
match font {
Some(font) => {
s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &text, size, false);
+3
View File
@@ -62,6 +62,9 @@ mod options;
mod paint;
mod picture;
mod tags;
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub mod test_support;
#[cfg(test)]
use export::Fit;
+49
View File
@@ -0,0 +1,49 @@
//! Test helpers other crates' tests share (the `test-support` feature): what an exported PDF
//! draws, read back.
/// Each image drawn, over every page: its rectangle in PDF space (y up), its first pixel and
/// its size in pixels.
pub fn drawn_images(bytes: &[u8]) -> Vec<(kurbo::Rect, [u8; 3], (u32, u32))> {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
use hayro_syntax::Pdf;
use kurbo::Shape;
struct Images(Vec<(kurbo::Rect, [u8; 3], (u32, u32))>);
impl Device<'_> for Images {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {}
fn draw_image(&mut self, img: Image<'_, '_>, t: kurbo::Affine) {
let Image::Raster(r) = img else { return };
let mut found = None;
r.with_rgba(
|data, _| {
if let ImageData::Rgb(d) = data {
let rect = (t * kurbo::Rect::new(0.0, 0.0, d.width as f64, d.height as f64).to_path(0.1)).bounding_box();
if let Some(&[r, g, b]) = d.data.get(..3).and_then(|p| <&[u8; 3]>::try_from(p).ok()) {
found = Some((rect, [r, g, b], (d.width, d.height)));
}
}
},
None,
);
self.0.extend(found);
}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let Ok(pdf) = Pdf::new(bytes.to_vec()) else { return Vec::new() };
let cache = InterpreterCache::new();
let mut out = Images(Vec::new());
for page in pdf.pages().iter() {
let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
interpret_page(page, &mut ctx, &mut out);
}
out.0
}
+59
View File
@@ -272,6 +272,64 @@ fn hostile_documents_export() {
assert!(export(&Document::new(), &PdfOptions::default()).unwrap().starts_with(b"%PDF"));
}
/// A solid `w`×`h` px PNG.
fn solid_png(w: u32, h: u32, c: [u8; 4]) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba(c)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
png
}
/// #2954: a picture watermark is embedded once however many pages show it, centred in the
/// margins at Auto scale (their width), with Word's Washout on its pixels as the picture
/// corrections give them; without Washout the picture's own pixels.
#[test]
fn picture_watermark_is_embedded_washed_out() {
let mut d = Document::from_text("One\nTwo");
let mut two = Paragraph::with_text("Second page", CharProps::default());
two.props.page_break_before = Some(true);
d.body.push(para_block(two));
d.media.insert("wm.png".into(), Arc::new(solid_png(40, 20, [20, 60, 140, 255])));
let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
assert_eq!(lay.pages.len(), 2);
let (page, body) = (&lay.pages[0], lay.pages[0].body);
for washed in [true, false] {
let pic = wordcraft_doc::WatermarkPicture { media: "wm.png".into(), scale: None, washout: washed };
d.settings.watermark = Some(Watermark { picture: Some(pic.clone()), ..Default::default() });
let bytes = export(&d, &PdfOptions::default()).unwrap();
let raw = String::from_utf8_lossy(&bytes);
assert_eq!(raw.matches("/Subtype/Image").count(), 1, "one embed for both pages");
let images = crate::test_support::drawn_images(&bytes);
assert_eq!(images.len(), 2, "one per page");
let (r, px, _) = images[0];
assert!((r.width() - body.w as f64).abs() < 0.5 && (r.height() - body.w as f64 / 2.0).abs() < 0.5, "{r:?}");
let (cx, cy) = (body.x as f64 + body.w as f64 / 2.0, page.h as f64 - (body.y as f64 + body.h as f64 / 2.0));
assert!((r.center().x - cx).abs() < 0.5 && (r.center().y - cy).abs() < 0.5, "{r:?}");
// The pixels are what the corrections make of the picture's: the same path as any picture.
let mut want = [20u8, 60, 140, 255];
pic.fx().apply_rgba(&mut want);
assert_eq!(px, [want[0], want[1], want[2]], "washout {washed}");
assert_eq!(washed, px != [20, 60, 140]);
}
// A picture that can't be read: no image, no failure.
d.media.insert("wm.png".into(), Arc::new(b"junk".to_vec()));
assert!(crate::test_support::drawn_images(&export(&d, &PdfOptions::default()).unwrap()).is_empty());
}
/// #2954: a scale is a percentage of the picture's own size (its pixels at 96 ppi).
#[test]
fn picture_watermark_scale_is_a_percentage_of_the_pictures_size() {
let mut d = Document::from_text("One");
d.media.insert("wm.png".into(), Arc::new(solid_png(400, 200, [20, 60, 140, 255])));
d.settings.watermark = Some(Watermark {
picture: Some(wordcraft_doc::WatermarkPicture { media: "wm.png".into(), scale: Some(50.0), washout: false }),
..Default::default()
});
let images = crate::test_support::drawn_images(&export(&d, &PdfOptions::default()).unwrap());
// 400 × 200 px is 300 × 150 pt at 96 ppi: half of it.
let (r, _, _) = images[0];
assert!((r.width() - 150.0).abs() < 0.5 && (r.height() - 75.0).abs() < 0.5, "{r:?}");
}
#[test]
fn tracked_deletions_are_left_out_without_markup() {
use wordcraft_doc::{Revision, RevisionKind};
@@ -1446,6 +1504,7 @@ fn bookmark_destination_is_the_page_of_its_start() {
tag_index: HashMap::new(),
links: Vec::new(),
bookmarks: std::cell::OnceCell::new(),
watermark_fit: HashMap::new(),
};
let (page, x, y) = ex.bookmark("target").unwrap();
assert_eq!(page, 1, "the bookmark is on the second page");
+6 -4
View File
@@ -21,7 +21,7 @@ mod media;
#[cfg(test)]
use media::{CACHE_BYTES, MediaCache, OVERSIZED_GLYPHS_NOTED, cacheable, decode_pixmap_within, note_oversized_glyphs, shrink};
pub use media::{decode_pixmap, decode_pixmap_fx, image_size, metafile_size};
use media::{draw_page_picture, draw_vector, lock, pixmap_for, premul_pixmap, vector_for};
use media::{draw_page_picture, draw_vector, draw_watermark_picture, lock, pixmap_for, premul_pixmap, vector_for};
mod color_glyph;
pub mod icon_lib;
@@ -300,8 +300,10 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin
draw_page_picture(&mut ctx, doc, page, view);
}
let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0);
if let Some(wm) = &doc.settings.watermark {
draw_watermark(&mut ctx, view, page, wm, opts);
match doc.settings.watermark.as_ref() {
Some(wordcraft_doc::Watermark { picture: Some(pic), .. }) => draw_watermark_picture(&mut ctx, doc, page, view, pic),
Some(wm) => draw_watermark(&mut ctx, view, page, wm, opts),
None => {}
}
// A dimmed layer is drawn whole, then faded once, so its overlaps don't darken.
for (alpha, items) in page_layers(doc, page, &opts.display) {
@@ -480,7 +482,7 @@ fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordc
* Affine::rotate(angle)
* Affine::translate((-bw / 2.0, bh / 2.0 - descent * bh))
* Affine::scale_non_uniform(stretch, 1.0);
ctx.set_paint(color(opts.ink(wm.color), if wm.semitransparent { 0.35 } else { 0.8 }));
ctx.set_paint(color(opts.ink(wm.color), wm.opacity()));
let db = FontDb::global();
let mut x = 0.0;
for g in &glyphs {
+25
View File
@@ -372,6 +372,31 @@ pub(crate) fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &
ctx.reset_paint_transform();
}
/// A picture watermark, centred in the page's text area behind the text, at its scale or at Auto
/// the size the first section naming the page's header gives it ([`Document::watermark_fit`]),
/// washed out as Word does when it is: drawn through the same picture path as any picture with
/// corrections ([`pixmap_for`] with [`wordcraft_doc::WatermarkPicture::fx`]).
pub(crate) fn draw_watermark_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view: Affine, pic: &wordcraft_doc::WatermarkPicture) {
let Some((own_w, own_h)) = doc.media.get(&pic.media).and_then(|b| wordcraft_doc::watermark::own_size(b)) else { return };
// The document page, not a Print Markup area beside it.
let b = page.body;
let body =
if b.w > 0.0 && b.h > 0.0 { b } else { wordcraft_geom::Rect::new(0.0, 0.0, (page.w - page.markup.max(0.0)).max(1.0), page.h.min(2000.0)) };
let fit = if pic.scale.is_none() { doc.watermark_fit(page.header_story) } else { (0.0, 0.0) };
let Some(r) = pic.place(own_w, own_h, fit, body) else { return };
let Some(pm) = pixmap_for(doc, &pic.media, pic.fx()) else { return };
let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
let rect = kurbo::Rect::new(f64::from(r.x), f64::from(r.y), f64::from(r.x) + f64::from(r.w), f64::from(r.y) + f64::from(r.h));
ctx.set_transform(view);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
});
ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / pw, rect.height() / ph));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
pub(crate) fn pixmap_for(doc: &Document, key: &str, fx: wordcraft_doc::picfx::PicFx) -> Option<Arc<Pixmap>> {
let bytes = doc.media.get(key)?;
// Corrected pixels are cached apart from the picture's own (the same picture can be shown
+1
View File
@@ -3,6 +3,7 @@ use wordcraft_layout::display::page_display;
use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
mod color_glyphs;
mod watermark;
/// The first and last pixel rows with ink in a black-on-white [`render_text_line`] raster.
fn ink_rows(img: &Rendered) -> Option<(usize, usize)> {
+143
View File
@@ -0,0 +1,143 @@
//! Design › Watermark on screen.
use super::*;
/// #2954: a text watermark at 80 % transparency is lighter than at 0 %, and invisible at 100 %.
#[test]
fn text_watermark_transparency() {
let mut d = Document::new();
let darkest = |d: &Document| {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
let img = render_page(d, &l.pages[0], 1.0, &RenderOptions::default());
img.pixels.chunks(4).map(|p| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2])).min().unwrap()
};
let mut wm = wordcraft_doc::Watermark { text: "WWW".into(), color: wordcraft_doc::props::Rgb::BLACK, diagonal: false, ..Default::default() };
let mut at = |t: u8| {
wm.set_transparency(t);
d.settings.watermark = Some(wm.clone());
darkest(&d)
};
let (opaque, half, light, gone) = (at(0), at(50), at(80), at(100));
assert!(opaque < 30, "{opaque}");
assert!(half > opaque + 300 && light > half + 150, "{opaque} {half} {light}");
assert_eq!(gone, 765);
}
/// A solid `w`×`h` PNG.
fn solid_png(w: u32, h: u32, c: [u8; 4]) -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba(c)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
png
}
/// The RGB of pixel (`x`, `y`) of a raster drawn at one pixel per point.
fn px(img: &Rendered, x: f32, y: f32) -> [u8; 3] {
let i = (y as usize * img.width as usize + x as usize) * 4;
[img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]]
}
fn near(a: [u8; 3], b: [u8; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 2)
}
/// What Word's Washout does to a colour: the picture corrections a washed-out watermark is drawn
/// with, applied to its pixel.
fn washed(c: [u8; 3]) -> [u8; 3] {
let mut px = [c[0], c[1], c[2], 255];
wordcraft_doc::WatermarkPicture::default().fx().apply_rgba(&mut px);
[px[0], px[1], px[2]]
}
fn shot(d: &Document, page: usize) -> (Rendered, wordcraft_geom::Rect) {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
let p = l.pages[page].clone();
(render_page(d, &p, 1.0, &RenderOptions::default()), p.body)
}
/// #2954: a picture watermark is drawn centred in the text area behind the text: at Auto as
/// wide as the margins allow, at a scale as a percentage of its own size, and washed out
/// exactly as the picture corrections make it (the same path as any corrected picture).
#[test]
fn picture_watermark_in_the_margins_washed_out() {
let blue = [20, 60, 140];
let mut d = Document::new();
d.media.insert("wm.png".into(), Arc::new(solid_png(40, 20, [20, 60, 140, 255])));
let mut pic = wordcraft_doc::WatermarkPicture { media: "wm.png".into(), scale: None, washout: false };
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic.clone()), ..Default::default() });
let (img, body) = shot(&d, 0);
let (cx, cy) = (body.x + body.w / 2.0, body.y + body.h / 2.0);
assert!(near(px(&img, cx, cy), blue), "{:?}", px(&img, cx, cy));
// Auto: the margins' width, half as tall, centred.
assert!(near(px(&img, body.x + 3.0, cy), blue) && near(px(&img, body.right() - 3.0, cy), blue));
assert_eq!(px(&img, body.x - 3.0, cy), [255, 255, 255]);
assert!(near(px(&img, cx, cy - body.w / 4.0 + 3.0), blue));
assert_eq!(px(&img, cx, cy - body.w / 4.0 - 3.0), [255, 255, 255]);
// Washout: lighter in every channel, to the value the picture corrections give.
pic.washout = true;
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic.clone()), ..Default::default() });
let got = px(&shot(&d, 0).0, cx, cy);
assert!(near(got, washed(blue)), "{got:?} vs {:?}", washed(blue));
assert!(got.iter().zip(blue).all(|(w, b)| *w > b));
// 100 %: the picture's own size (40 × 20 px at 96 ppi = 30 × 15 pt).
pic.scale = Some(100.0);
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
let (img, _) = shot(&d, 0);
assert!(near(px(&img, cx + 13.0, cy), washed(blue)));
assert_eq!(px(&img, cx + 17.0, cy), [255, 255, 255]);
// A picture that can't be read draws nothing.
d.media.insert("wm.png".into(), Arc::new(b"junk".to_vec()));
assert_eq!(px(&shot(&d, 0).0, cx, cy), [255, 255, 255]);
}
/// #2954 review: with mirrored margins an even page's text area moves, and the picture watermark
/// with it, the same size.
#[test]
fn picture_watermark_follows_the_even_pages_mirrored_margins() {
let blue = [20, 60, 140];
let mut d = Document::from_text("One");
let mut two = wordcraft_doc::Paragraph::with_text("Two", Default::default());
two.props.page_break_before = Some(true);
d.body.push(wordcraft_doc::para_block(two));
d.settings.mirror_margins = true;
d.last_section.margin_left = 126.0;
d.last_section.margin_right = 54.0;
d.media.insert("wm.png".into(), Arc::new(solid_png(40, 20, [20, 60, 140, 255])));
let pic = wordcraft_doc::WatermarkPicture { media: "wm.png".into(), scale: None, washout: false };
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
let (odd, odd_body) = shot(&d, 0);
let (even, even_body) = shot(&d, 1);
assert!((odd_body.x - even_body.x).abs() > 50.0, "the margins mirror: {odd_body:?} {even_body:?}");
for (img, body) in [(&odd, odd_body), (&even, even_body)] {
let cy = body.y + body.h / 2.0;
assert!(near(px(img, body.x + 3.0, cy), blue) && near(px(img, body.right() - 3.0, cy), blue), "{body:?}");
assert_eq!(px(img, body.x - 3.0, cy), [255, 255, 255], "{body:?}");
assert_eq!(px(img, body.right() + 3.0, cy), [255, 255, 255], "{body:?}");
}
}
/// #2954: Auto is the room of the first section naming the page's header, on screen as in a PDF
/// and a saved file: a landscape section linked to a portrait section's header shows the
/// portrait-sized picture, not one re-fitted to its wider page.
#[test]
fn auto_watermark_in_a_linked_landscape_section_keeps_the_headers_size() {
let blue = [20, 60, 140];
let mut d = Document::new();
let head =
d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Head", Default::default()))]);
let mut s1 = wordcraft_doc::SectionProps::default();
s1.headers.default = Some(head);
let mut one = wordcraft_doc::Paragraph::with_text("Portrait", Default::default());
one.section = Some(Box::new(s1));
d.body = vec![wordcraft_doc::para_block(one), wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Landscape", Default::default()))];
d.last_section.set_landscape(true);
d.media.insert("wm.png".into(), Arc::new(solid_png(400, 200, [20, 60, 140, 255])));
let pic = wordcraft_doc::WatermarkPicture { media: "wm.png".into(), scale: None, washout: false };
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
let (img, body) = shot(&d, 1);
assert!(body.w > 600.0, "a landscape page: {body:?}");
let (cx, cy) = (body.x + body.w / 2.0, body.y + body.h / 2.0);
// 468 pt wide, centred: inside at 230 pt from the centre, outside at 238.
assert!(near(px(&img, cx + 230.0, cy), blue) && near(px(&img, cx - 230.0, cy), blue));
assert_eq!(px(&img, cx + 238.0, cy), [255, 255, 255]);
assert_eq!(px(&img, cx - 238.0, cy), [255, 255, 255]);
}
+4 -3
View File
@@ -215,9 +215,9 @@ pub enum Dialog {
Zoom {
percent: f32,
},
/// Design › Watermark › Custom Watermark (#2954).
Watermark {
text: String,
diagonal: bool,
form: Box<crate::dialogs_watermark::WatermarkForm>,
},
/// Home › Font › Phonetic Guide: ruby text over the selected text (`format.phonetic`).
Phonetic {
@@ -731,7 +731,7 @@ impl Dialog {
"bookmark" => Dialog::Bookmark { name: bookmark_at_caret(&app.session), by_location: false, hidden: false, deleted: false },
"wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() },
"zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() },
"watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true },
"watermark" => Dialog::Watermark { form: Box::new(crate::dialogs_watermark::WatermarkForm::read(app)) },
"phonetic" => {
let v = wordcraft_engine::cmd::phonetic::state(&app.session);
let f = |k: &str| v.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
@@ -906,5 +906,6 @@ pub mod dialogs_ribbon;
pub mod dialogs_smart_art;
pub mod dialogs_table_style;
pub mod dialogs_templates;
pub mod dialogs_watermark;
#[cfg(test)]
mod tests;
@@ -53,7 +53,7 @@ impl TemplatesForm {
/// Whether this host shows a file picker whose answer comes back to the dialog (the web's picker
/// opens what it picks instead).
fn can_pick(app: &WordApp) -> bool {
pub(crate) fn can_pick(app: &WordApp) -> bool {
app.services.open_async.is_none() && (app.services.file_dialog.is_some() || app.services.pick_open.is_some())
}
@@ -0,0 +1,299 @@
//! Design › Watermark › Custom Watermark… (#2954): no watermark, a picture watermark (Select
//! Picture…, Scale, Washout) or a text watermark (Text, Semitransparent with a transparency
//! percentage, Layout). OK runs `design.watermark`, so scripts and agents get the same result
//! without the dialog. Shown through [`crate::dialogs::Dialog::Watermark`].
use egui::{Ui, vec2};
use serde::Serialize;
use serde_json::{Value, json};
use crate::WordApp;
use crate::dialogs::buttons;
use crate::file_dialogs::{AfterPick, FileDialogRequest};
/// Word's Scale choices for a picture watermark, percent; None is Auto.
const SCALES: [Option<f32>; 6] = [None, Some(500.0), Some(200.0), Some(150.0), Some(100.0), Some(50.0)];
/// The Custom Watermark dialog's fields.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WatermarkForm {
/// `none`, `picture` or `text`.
pub kind: String,
/// A picture file picked with Select Picture….
pub path: Option<String>,
/// The picture of the document's picture watermark, kept when no other is picked.
pub media: Option<String>,
/// Percent of the picture's own size; None for Auto.
pub scale: Option<f32>,
pub washout: bool,
pub text: String,
pub semitransparent: bool,
/// Percent, 0 to 100; Semitransparent is 50.
pub transparency: u8,
pub diagonal: bool,
/// Why OK didn't apply.
pub message: String,
}
impl WatermarkForm {
/// The document's watermark; with none, No watermark and Word's defaults.
pub fn read(app: &mut WordApp) -> WatermarkForm {
// A file picked for a dialog that has since closed doesn't belong to this one.
app.dialog_pick = None;
let wm = app.session.doc.settings.watermark.clone();
let pic = wm.as_ref().and_then(|w| w.picture.clone());
let text = wm.clone().unwrap_or_else(|| wordcraft_doc::Watermark { text: "CONFIDENTIAL".into(), ..Default::default() });
let kind = match &wm {
None => "none",
Some(w) if w.picture.is_some() => "picture",
Some(_) => "text",
};
WatermarkForm {
kind: kind.into(),
path: None,
media: pic.as_ref().map(|p| p.media.clone()),
scale: pic.as_ref().and_then(|p| p.scale),
washout: pic.as_ref().is_none_or(|p| p.washout),
semitransparent: text.transparency() > 0,
transparency: text.transparency(),
diagonal: text.diagonal,
text: text.text,
message: String::new(),
}
}
/// `design.watermark` parameters; None for a picture watermark without a picture.
pub fn params(&self) -> Option<Value> {
match self.kind.as_str() {
"none" => Some(json!({"remove": true})),
"picture" => {
let picture = match (&self.path, &self.media) {
(Some(p), _) => json!({"path": p}),
(None, Some(m)) => json!({"media": m}),
(None, None) => return None,
};
let scale = self.scale.map_or(json!("auto"), |s| json!(s));
Some(json!({"picture": picture, "scale": scale, "washout": self.washout}))
}
_ => Some(json!({"text": self.text, "diagonal": self.diagonal, "transparency": self.transparency})),
}
}
/// The picture's name: the picked file's, or the document's picture.
fn picture_name(&self) -> String {
match (&self.path, &self.media) {
(Some(p), _) => std::path::Path::new(p).file_name().map_or_else(|| p.clone(), |n| n.to_string_lossy().into_owned()),
(None, Some(m)) => m.clone(),
(None, None) => String::new(),
}
}
}
/// A Scale choice as shown: Auto, or the percentage.
fn scale_label(s: Option<f32>) -> String {
match s {
None => tl!("Auto").to_string(),
Some(p) => format!("{p:.0}%"),
}
}
/// The picture watermark's options (Select Picture, Scale, Washout), enabled for a picture.
fn picture_options(app: &mut WordApp, ui: &mut Ui, f: &mut WatermarkForm) {
ui.add_enabled_ui(f.kind == "picture", |ui| {
ui.indent("wm_picture", |ui| {
ui.horizontal(|ui| {
if ui.add_enabled(crate::dialogs_templates::can_pick(app), egui::Button::new(tl!("Select Picture…"))).clicked() {
let _ = app.ask_file(FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::ForDialog);
}
ui.label(f.picture_name());
});
ui.horizontal(|ui| {
let label = ui.label(tl!("Scale:"));
let combo = craft_ui::ComboBox::from_id_salt("wm_scale").width(80.0).selected_text(scale_label(f.scale)).show_ui(ui, |ui| {
for s in SCALES {
ui.selectable_value(&mut f.scale, s, scale_label(s));
}
});
combo.response.labelled_by(label.id);
ui.add_space(16.0);
ui.checkbox(&mut f.washout, tl!("Washout"));
});
});
});
}
/// The text watermark's options (Text, Transparency, Layout), enabled for a text.
fn text_options(ui: &mut Ui, f: &mut WatermarkForm) {
ui.add_enabled_ui(f.kind == "text", |ui| {
ui.indent("wm_text", |ui| {
crate::rtl::grid(ui, egui::Grid::new("wm_text_grid").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
let label = ui.label(tl!("Text:"));
ui.add(egui::TextEdit::singleline(&mut f.text).desired_width(220.0).char_limit(200)).labelled_by(label.id);
ui.end_row();
ui.label(tl!("Transparency:"));
ui.horizontal(|ui| {
if ui.checkbox(&mut f.semitransparent, tl!("Semitransparent")).changed() {
f.transparency = if f.semitransparent { 50 } else { 0 };
}
if ui.add(egui::DragValue::new(&mut f.transparency).range(0..=100).suffix(" %")).changed() {
f.semitransparent = f.transparency > 0;
}
});
ui.end_row();
ui.label(tl!("Layout:"));
ui.horizontal(|ui| {
ui.radio_value(&mut f.diagonal, true, tl!("Diagonal"));
ui.radio_value(&mut f.diagonal, false, tl!("Horizontal"));
});
ui.end_row();
});
});
});
}
/// Returns true to close.
pub fn watermark(app: &mut WordApp, ui: &mut Ui, f: &mut WatermarkForm) -> bool {
if let Some(p) = app.dialog_pick.take() {
f.path = Some(p);
f.kind = "picture".into();
f.message.clear();
}
ui.radio_value(&mut f.kind, "none".to_string(), tl!("No watermark"));
ui.radio_value(&mut f.kind, "picture".to_string(), tl!("Picture watermark"));
picture_options(app, ui, f);
ui.radio_value(&mut f.kind, "text".to_string(), tl!("Text watermark"));
text_options(ui, f);
if !f.message.is_empty() {
ui.colored_label(ui.visuals().error_fg_color, f.message.as_str());
}
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let Some(params) = f.params() else {
f.message = tl!("Select a picture first.").to_string();
return false;
};
match app.run("design.watermark", params) {
Ok(_) => return true,
Err(e) => f.message = e,
}
}
cancel
}
#[cfg(test)]
mod tests {
use egui_kittest::kittest::Queryable;
use serde_json::json;
use wordcraft_engine::Session;
use super::*;
use crate::dialogs::Dialog;
use crate::{Services, WordApp};
fn png_file(name: &str) -> String {
let path = std::env::temp_dir().join(format!("wordcraft-wm-{}-{name}.png", std::process::id()));
image::RgbaImage::from_pixel(60, 30, image::Rgba([20, 90, 160, 255])).save(&path).unwrap();
path.to_string_lossy().into_owned()
}
fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> {
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
)
}
fn form(a: &WordApp) -> WatermarkForm {
let Some(Dialog::Watermark { form }) = &a.dialog else { panic!("no watermark dialog: {:?}", a.dialog) };
(**form).clone()
}
/// #2954: Select Picture… puts the picked picture in the dialog and OK makes it the
/// watermark, washed out at Auto scale; reopened, the dialog shows it.
#[test]
fn dialog_sets_a_picture_watermark() {
let path = png_file("pick");
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
let picked = path.clone();
a.services.pick_open = Some(Box::new(move |purpose| (purpose == "picture").then(|| picked.clone())));
a.run("ui.dialog", json!({"name": "watermark"})).unwrap();
assert_eq!(form(&a).kind, "none", "no watermark yet");
let mut h = harness(a);
h.run_steps(3);
h.get_by_label("Picture watermark").click();
h.run_steps(2);
h.get_by_label("Select Picture…").click();
h.run_steps(3);
let f = form(h.state());
assert_eq!((f.kind.as_str(), f.path.as_deref()), ("picture", Some(path.as_str())));
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
let wm = h.state().session.doc.settings.watermark.clone().unwrap();
let pic = wm.picture.unwrap();
assert_eq!((pic.scale, pic.washout), (None, true));
h.state_mut().run("ui.dialog", json!({"name": "watermark"})).unwrap();
let f = form(h.state());
assert_eq!((f.kind.as_str(), f.media.as_deref(), f.path), ("picture", Some(pic.media.as_str()), None));
let _ = std::fs::remove_file(&path);
}
/// #2954: the Semitransparent box and the transparency percentage follow each other, and
/// OK sets the text watermark's transparency.
#[test]
fn dialog_sets_text_transparency() {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
a.run("design.watermark", json!({"text": "DRAFT"})).unwrap();
a.run("ui.dialog", json!({"name": "watermark"})).unwrap();
let f = form(&a);
assert_eq!((f.kind.as_str(), f.semitransparent, f.transparency), ("text", true, 50));
let mut h = harness(a);
h.run_steps(3);
h.get_by_label("Semitransparent").click();
h.run_steps(2);
assert_eq!((form(h.state()).semitransparent, form(h.state()).transparency), (false, 0));
if let Some(Dialog::Watermark { form }) = &mut h.state_mut().dialog {
form.transparency = 75;
}
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
let wm = h.state().session.doc.settings.watermark.clone().unwrap();
assert_eq!((wm.transparency(), wm.picture), (75, None));
// OK with No watermark removes it.
h.state_mut().run("ui.dialog", json!({"name": "watermark"})).unwrap();
h.run_steps(3);
h.get_by_label("No watermark").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(h.state().session.doc.settings.watermark, None);
}
/// #2954: a picture watermark without a picture, or with one that can't be read, keeps the
/// dialog open with a message and changes nothing.
#[test]
fn dialog_needs_a_readable_picture() {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
a.run("ui.dialog", json!({"name": "watermark"})).unwrap();
let mut f = form(&a);
f.kind = "picture".into();
assert_eq!(f.params(), None);
f.path = Some("/nonexistent/wm.png".into());
assert!(a.run("design.watermark", f.params().unwrap()).is_err());
assert_eq!(a.session.doc.settings.watermark, None);
let mut h = harness(a);
h.run_steps(3);
h.get_by_label("Picture watermark").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(form(h.state()).message, "Select a picture first.");
assert_eq!(h.state().session.doc.settings.watermark, None);
}
}
+1 -20
View File
@@ -27,7 +27,7 @@ pub(super) fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
Dialog::Bookmark { name, by_location, hidden, deleted } => bookmark(app, ui, name, by_location, hidden, deleted),
Dialog::WordCount { .. } => word_count_dialog(app, ui, d),
Dialog::Zoom { .. } => zoom_dialog(app, ui, d),
Dialog::Watermark { .. } => watermark_dialog(app, ui, d),
Dialog::Watermark { form } => crate::dialogs_watermark::watermark(app, ui, form),
Dialog::Phonetic { .. } => phonetic_dialog(app, ui, d),
Dialog::NewStyle { .. } => new_style_dialog(app, ui, d),
Dialog::ModifyStyle { .. } => modify_style_dialog(app, ui, d),
@@ -861,25 +861,6 @@ fn zoom_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ok || cancel
}
fn watermark_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let Dialog::Watermark { text, diagonal } = d else {
return false;
};
ui.horizontal(|ui| {
ui.label(tl!("Text:"));
ui.text_edit_singleline(text);
});
ui.horizontal(|ui| {
ui.radio_value(diagonal, true, tl!("Diagonal"));
ui.radio_value(diagonal, false, tl!("Horizontal"));
});
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let _ = app.run("design.watermark", json!({"text": text, "diagonal": *diagonal}));
}
ok || cancel
}
fn phonetic_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let Dialog::Phonetic { base, ruby, align, size, offset, existing, focus } = d else {
return false;
+1
View File
@@ -103,6 +103,7 @@ pub use dialogs::dialogs_ribbon;
pub use dialogs::dialogs_smart_art;
pub use dialogs::dialogs_table_style;
pub use dialogs::dialogs_templates;
pub use dialogs::dialogs_watermark;
pub use file_dialogs::{FileDialogAnswer, FileDialogRequest, open_filters};
mod app;
+172 -20
View File
@@ -9,8 +9,8 @@
//! was on is drawn again without it (see [`dragged_off_page`]), so it is not left behind where it
//! was. The drop is one command, one relayout.
//!
//! Dragging an In Line with Text object moves it within the text, as in Word: a dashed insertion
//! cursor follows the pointer through the text (#2233) and the drop puts it there, still inline
//! Dragging an In Line with Text object moves it within the text, as in Word: a bold insertion
//! cursor follows the pointer through the text (#2233, #4195) and the drop puts it there, still inline
//! (`arrange.bounds` / `arrange.copyTo` with `to`, #2234). A floating one goes where it's dropped.
//!
//! Moving a floating one snaps it to alignment guides ([`crate::guides`]): the page's edges and
@@ -19,6 +19,11 @@
//!
//! A selected connector shows a handle at each end instead of a frame: dragging one onto a shape
//! glues it to the nearest connection site there (`shape.connect`), elsewhere leaves it free.
//!
//! Inside a Drawing Canvas, a press on a member picks that member (`select.member`, #2637): it
//! gets the frame and handles, inside the canvas's frame, and is dragged, resized and turned on
//! its own; a press in a text box member's text puts the caret there. The canvas itself is
//! picked where no member is.
use egui::{Color32, Painter, Pos2, Rect, Ui};
use serde_json::json;
@@ -61,7 +66,7 @@ pub struct ObjectDrag {
/// The object drawn alone over its rotated bounds, for the preview: made once it moves.
preview: Option<egui::TextureHandle>,
/// Moving an In Line with Text object: the text position under the pointer, where it lands,
/// and the colour of the dashed insertion cursor drawn there.
/// and the colour of the bold insertion cursor drawn there.
drop_at: Option<(Pos, Color32)>,
}
@@ -128,22 +133,92 @@ fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef {
/// model for can be selected and deleted, not moved or resized, so it gets no handles, no move
/// cursor and no drag.
fn fixed(app: &WordApp, hit: &ObjectHit) -> bool {
let pos = hit.pos();
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
matches!(hit_object(app, hit), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
}
/// Whether the object under `hit` is a chart or SmartArt that WordCraft can edit: it takes
/// resize handles and is laid out again at the new size, but it can't be moved or turned.
fn size_only(app: &WordApp, hit: &ObjectHit) -> bool {
let pos = hit.pos();
matches!(
app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)),
hit_object(app, hit),
Some(InlineObject::Graphic { graphic, .. }) if graphic.chart.is_some() || graphic.smart_art.is_some()
)
}
/// The story of the selected text box.
/// The object a hit is: the object at its position, or the canvas member it names.
fn hit_object<'a>(app: &'a WordApp, hit: &ObjectHit) -> Option<&'a InlineObject> {
let pos = hit.pos();
let o = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off))?;
match (hit.member, o) {
(None, _) => Some(o),
(Some(i), InlineObject::Group { children, canvas: Some(_), .. }) => children.get(i).map(|c| &c.obj),
_ => None,
}
}
/// Member `index` of the canvas laid out at `canvas`, as a hit of its own: its frame where the
/// canvas draws it, its own turn. `None` when it isn't drawn, or the canvas is turned (its
/// members aren't picked on their own yet).
fn member_hit(app: &WordApp, canvas: &ObjectHit, index: usize) -> Option<ObjectHit> {
let (o, r) = (pickable_canvas(app, canvas)?, canvas.rect);
let (_, frame) = o.member_rects(r.x, r.y, r.w, r.h).into_iter().find(|(i, _)| *i == index)?;
member_with_frame(app, canvas, o, index, frame)
}
/// The canvas object of hit `canvas` when its members can be picked: a canvas, hit as a whole,
/// not turned.
fn pickable_canvas<'a>(app: &'a WordApp, canvas: &ObjectHit) -> Option<&'a InlineObject> {
let pos = canvas.pos();
let o = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).filter(|o| o.is_canvas())?;
(canvas.member.is_none() && canvas.spin.is_identity()).then_some(o)
}
/// [`member_hit`] for member `index` of canvas `o`, whose frame on the page (`[x, y, w, h]`) is
/// already worked out.
fn member_with_frame(app: &WordApp, canvas: &ObjectHit, o: &InlineObject, index: usize, [x, y, w, h]: [f32; 4]) -> Option<ObjectHit> {
let InlineObject::Group { children, .. } = o else { return None };
let member = children.get(index).filter(|c| wordcraft_engine::cmd::canvas::pickable(c))?;
let text_box = match &member.obj {
InlineObject::Shape { story: Some(id), .. } if app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(*id),
_ => None,
};
Some(ObjectHit {
rect: wordcraft_geom::Rect::new(x, y, w, h),
spin: member.obj.frame().map(|(_, _, f)| f.spin()).unwrap_or_default(),
text_box,
// Placed by the canvas, not the text: dragging any handle may move its top-left.
wrap: Wrap::InFrontOfText,
member: Some(index),
..canvas.clone()
})
}
/// The member of the canvas hit `canvas` under the page point (x, y), topmost first; a text box
/// member is grabbed anywhere on it too (inside, [`in_text`] tells its text from its border).
/// The members' frames are worked out once for all of them.
fn member_at(app: &WordApp, canvas: &ObjectHit, x: f32, y: f32, edge: f32) -> Option<ObjectHit> {
let (o, r) = (pickable_canvas(app, canvas)?, canvas.rect);
// Members are drawn clipped to the canvas: only what shows is grabbed.
if !r.contains(wordcraft_geom::Point::new(x, y)) {
return None;
}
o.member_rects(r.x, r.y, r.w, r.h)
.into_iter()
.rev()
.filter_map(|(i, frame)| member_with_frame(app, canvas, o, i, frame))
.find(|m| m.rect.expand(edge).contains(m.unspin(x, y)))
}
/// Whether the page point (x, y) is in a text box member's text rather than on its border.
fn in_text(m: &ObjectHit, x: f32, y: f32, edge: f32) -> bool {
m.text_box.is_some() && m.rect.w.min(m.rect.h) > edge * 3.0 && m.rect.expand(-edge).contains(m.unspin(x, y))
}
/// The story of the selected text box (a picked canvas member too).
fn selected_text_box(app: &WordApp) -> Option<u32> {
if let Some((_, _, InlineObject::Shape { story: Some(id), .. })) = wordcraft_engine::cmd::canvas::selected_member(&app.session) {
return app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox).then_some(*id);
}
match selected(app)? {
(_, InlineObject::Shape { story: Some(id), .. }) if app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(*id),
_ => None,
@@ -154,7 +229,11 @@ fn selected_text_box(app: &WordApp) -> Option<u32> {
pub fn active(app: &WordApp, layout: &DocLayout) -> Option<(ObjectHit, bool)> {
let hint = app.session.page_hint;
if let Some((pos, _)) = selected(app) {
return layout.object(&pos, hint).map(|o| (o, false));
let canvas = layout.object(&pos, hint)?;
if let Some((_, i, _)) = wordcraft_engine::cmd::canvas::selected_member(&app.session) {
return member_hit(app, &canvas, i).or(Some(canvas)).map(|o| (o, false));
}
return Some((canvas, false));
}
match app.session.sel.focus.story {
StoryRef::Part(id) if crate::canvas::in_text_box(app) => layout.text_box(id, hint).map(|o| (o, true)),
@@ -212,7 +291,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
}
if ui.input(|i| i.pointer.primary_down()) {
let shift = ui.input(|i| i.modifiers.shift);
d.copy = ui.input(|i| i.modifiers.command) && d.drag.grab == Grab::Move;
d.copy = ui.input(|i| i.modifiers.command) && d.drag.grab == Grab::Move && d.object.member.is_none();
if d.drag.grab == Grab::Rotate {
d.drag.turn_to(at, pages, scale, shift);
} else {
@@ -230,7 +309,12 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
if d.drag.grab == Grab::Move && d.drag.moved && !d.object.floating() {
// An inline one lands in the text: the insertion cursor follows the pointer.
d.drop_at = drop_point(app, layout, pages, scale, at, &d);
} else if d.drag.grab == Grab::Move && d.drag.moved && app.ui.alignment_guides && !ui.input(|i| i.modifiers.alt) {
} else if d.drag.grab == Grab::Move
&& d.drag.moved
&& d.object.member.is_none()
&& app.ui.alignment_guides
&& !ui.input(|i| i.modifiers.alt)
{
snap_move(layout, &mut d, scale, locked_axis);
}
}
@@ -279,6 +363,17 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
}
// Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group.
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
if let Some(index) = object.member.filter(|_| pressed && !adding) {
return press_member(app, layout, pages, scale, at, object, index, grab);
}
// Adding to the selection takes the whole canvas.
let object = match object.member {
Some(_) => match layout.object(&object.pos(), object.page) {
Some(o) => o,
None => return false,
},
None => object,
};
// Ctrl on the object that is already selected starts a copy-drag instead, including
// Ctrl+Shift for an aligned copy. Shift alone keeps its additive-selection behavior.
let again = selected(app).is_some_and(|(p, _)| p == object.pos())
@@ -339,13 +434,55 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
true
}
/// A press on member `index` of a canvas (`object` is the member's hit): in a text box member's
/// text it puts the caret there; elsewhere it picks the member (selecting its canvas first) and
/// starts dragging it, or the handle `grab`.
#[allow(clippy::too_many_arguments)]
fn press_member(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2, object: ObjectHit, index: usize, grab: Grab) -> bool {
let edge = EDGE / scale.max(0.01);
if grab == Grab::Move
&& let (Some(id), Some((page, x, y))) = (object.text_box, crate::canvas::page_at(pages, layout, scale, at))
&& in_text(&object, x, y, edge)
{
// A box linked from another shows (and edits) that box's story.
let id = app.session.doc.text_box_chain(id).and_then(|c| c.first().copied()).unwrap_or(id);
if let Some(pos) = layout.hit(page, x, y, StoryRef::Part(id)) {
let _ = app.run("caret.set", json!({"pos": pos}));
}
return true;
}
let pos = object.pos();
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
let _ = app.run("select.range", json!({"anchor": pos, "focus": end}));
if app.run("select.member", json!({"index": index})).is_err() {
return true;
}
let Some(o) = hit_object(app, &object) else { return true };
let (min, picture) = (wordcraft_engine::cmd::objects::min_size(o), matches!(o, InlineObject::Image { .. }));
app.canvas.obj_drag = Some(ObjectDrag {
drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at),
press_origin: pages.get(object.page).map_or(at, |p| p.min),
object,
min,
picture,
pending_behind: false,
end: None,
guides: Vec::new(),
preview: None,
copy: false,
drop_at: None,
});
true
}
/// The object a move has taken off its page (which page, and where it is in the document): the
/// page is rendered again without it while the preview follows the pointer, so the object is not
/// left behind where it was. Only for a move — resizing and turning show the object itself, and
/// dragging a connector's end onto a shape still shows that connector.
/// dragging a connector's end onto a shape still shows that connector. Nor for a member of a
/// drawing canvas: its position is the canvas's, and the canvas stays.
pub(crate) fn dragged_off_page(drag: Option<&ObjectDrag>) -> Option<(usize, Pos)> {
let d = drag?;
if !d.drag.moved || d.end.is_some() || d.drag.grab != Grab::Move {
if !d.drag.moved || d.end.is_some() || d.drag.grab != Grab::Move || d.object.member.is_some() {
return None;
}
Some((d.object.page, d.object.pos()))
@@ -480,15 +617,18 @@ fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Po
return selected_picture;
}
// Selectable: body objects (table cells too) or the header/footer's own objects;
// notes' objects aren't yet.
layout.object_at_in(page, x, y, EDGE / scale.max(0.01), story).map(|o| (o, Grab::Move))
// notes' objects aren't yet. Inside a canvas, the member there.
let edge = EDGE / scale.max(0.01);
let o = layout.object_at_in(page, x, y, edge, story)?;
Some((member_at(app, &o, x, y, edge).unwrap_or(o), Grab::Move))
})
}
/// Objects that can be dragged to a new place: those in the own paragraphs of the body or of a
/// header/footer.
fn movable(app: &WordApp, o: &ObjectHit) -> bool {
o.path.depth() == 0 && selectable_story(app, o.story)
// A canvas member moves inside its canvas, wherever the canvas is.
(o.member.is_some() || o.path.depth() == 0) && selectable_story(app, o.story)
}
/// Whether a Behind Text object of this kind is grabbed by pressing its body. Pictures, shapes and
@@ -541,7 +681,7 @@ fn preview_scale(px: f32, w: f32, h: f32, max_side: usize) -> f32 {
/// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points
/// per page point).
fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit, scale: f32) -> Option<egui::TextureHandle> {
let obj = app.session.doc.para_at(&o.pos()).and_then(|p| p.object_at(o.off))?;
let obj = hit_object(app, o)?;
let page = layout.pages.get(o.page)?;
let mut opts = crate::canvas::screen_render_options();
opts.dark = crate::canvas::dark_page(app);
@@ -668,6 +808,13 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
if let Some((o, editing)) = active(app, layout)
&& let Some(f) = screen(pages, scale, o.page, o.rect)
{
// A picked canvas member: the canvas's frame around it, without handles.
if o.member.is_some()
&& let Some(c) = layout.object(&o.pos(), o.page)
&& let Some(cf) = screen(pages, scale, c.page, c.rect)
{
frame::paint(painter, t, cf, c.spin.deg, false, false, false);
}
// Handles where it can be resized and turned (body and header/footer objects,
// not charts): else a plain frame.
let handles = selectable_story(app, o.story) && !fixed(app, &o);
@@ -675,12 +822,13 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
}
}
/// The dashed insertion cursor where a dragged In Line with Text object will land.
/// The insertion cursor where a dragged In Line with Text object will land: solid and twice as
/// wide as the caret, so it stands out as Word's does (#4195).
fn paint_drop_caret(d: &ObjectDrag, painter: &Painter, layout: &DocLayout, pages: &[Rect], scale: f32) {
let Some((pos, color)) = &d.drop_at else { return };
let Some(c) = layout.caret_on(pos, d.drag.to_page) else { return };
let Some(r) = screen(pages, scale, c.page, wordcraft_geom::Rect::new(c.x, c.top, 0.0, c.height)) else { return };
painter.extend(egui::Shape::dashed_line(&[r.left_top(), r.left_bottom()], egui::Stroke::new(1.5, *color), 2.0, 2.0));
painter.line_segment([r.left_top(), r.left_bottom()], egui::Stroke::new(3.0, *color));
}
/// A connector's end being dragged: a line from its other end to the pointer, and the connection
@@ -740,7 +888,11 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
if connector_end_at(app, layout, pages, scale, at).is_some() {
return Some(egui::CursorIcon::Crosshair);
}
grab_at(app, layout, pages, scale, at).map(|(o, g)| hover_cursor(app, &o, g))
let in_member_text = |o: &ObjectHit| {
crate::canvas::page_at(pages, layout, scale, at).is_some_and(|(_, x, y)| o.member.is_some() && in_text(o, x, y, EDGE / scale.max(0.01)))
};
grab_at(app, layout, pages, scale, at)
.map(|(o, g)| if g == Grab::Move && !fixed(app, &o) && in_member_text(&o) { egui::CursorIcon::Text } else { hover_cursor(app, &o, g) })
}
/// The cursor over part `g` of object `o`: none of a drag for an object that won't move there (a
+95 -1
View File
@@ -500,7 +500,7 @@ fn alt_pressed_during_a_drag_unsnaps_and_keeps_keytips_off() {
assert_eq!(h.state().ui.keytips, crate::keytips::Phase::Off, "Alt during a drag isn't a keytip tap");
}
/// #2233, #2234: dragging an In Line with Text picture into another paragraph shows a dashed
/// #2233, #2234: dragging an In Line with Text picture into another paragraph shows a bold
/// insertion cursor at the text under the pointer (no alignment guides), and the drop puts the
/// picture there, still inline and selected. Dropped back where it was, nothing changes.
#[test]
@@ -551,6 +551,13 @@ fn an_inline_picture_drags_to_the_insertion_cursor_and_stays_inline() {
let d = h.state().canvas.obj_drag.as_ref().expect("dragging");
assert_eq!(d.drop_at.as_ref().map(|(p, _)| p.clone()), Some(target.clone()), "the insertion cursor is at the text under the pointer");
assert!(d.guides.is_empty(), "an inline object doesn't snap to guides");
// #4195: solid and twice the caret's width, so it can be seen, as in Word.
let (top, bottom) = (at(&mut h, c.x, c.top), at(&mut h, c.x, c.top + c.height));
let bold = h.output().shapes.iter().any(|s| {
matches!(&s.shape, egui::Shape::LineSegment { points: [a, b], stroke }
if a.distance(top) < 0.5 && b.distance(bottom) < 0.5 && stroke.width == 3.0)
});
assert!(bold, "a bold insertion cursor from the line's top to its bottom");
button(&h, end, false);
steps(&mut h, 3);
assert!(h.state().canvas.obj_drag.is_none());
@@ -1010,3 +1017,90 @@ fn dragging_a_large_picture_renders_a_preview_within_the_gpu_texture_limit() {
let [width, height] = preview.size();
assert!(width <= max_side && height <= max_side, "preview is {width}×{height}, texture limit is {max_side}");
}
/// #2637: inside a Drawing Canvas the mouse works on the members, as in Word. A click on a
/// shape picks that shape (the canvas stays the selection in the text) and shows its frame;
/// dragging it moves it inside the canvas; a click in a text box member's text puts the
/// caret there. Before, the canvas took every click, as one static object.
#[test]
fn clicking_and_dragging_inside_a_canvas_works_on_its_members() {
use egui::{Event, PointerButton};
use wordcraft_engine::cmd::canvas::selected_member;
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("insert.canvas", json!({"width": 400, "height": 300})).unwrap();
a.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
a.run("canvas.member", json!({"index": 0, "x": 40, "y": 30})).unwrap();
a.run("insert.textBox", json!({"text": "Label"})).unwrap();
a.run("select.objects", json!({"index": 0})).unwrap();
a.run("canvas.member", json!({"index": 1, "x": 200, "y": 100, "width": 150, "height": 80})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
let frame = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).expect("the canvas is laid out").rect;
let at = |h: &mut egui_kittest::Harness<'_, WordApp>, x: f32, y: f32| {
crate::canvas::page_to_screen(h.state_mut(), 0, frame.x + x, frame.y + y).unwrap()
};
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let members =
|h: &egui_kittest::Harness<'_, WordApp>| match h.state().session.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()) {
Some(InlineObject::Group { children, .. }) => children.iter().map(|c| (c.x, c.y)).collect::<Vec<_>>(),
o => panic!("{o:?}"),
};
// A click on the rectangle picks it.
let on_rect = at(&mut h, 90.0, 50.0);
h.event(Event::PointerMoved(on_rect));
h.event(button(on_rect, true));
h.step();
h.event(button(on_rect, false));
for _ in 0..3 {
h.step();
}
assert_eq!(selected_member(&h.state().session).map(|(_, i, _)| i), Some(0), "the rectangle is picked, not the whole canvas");
let shown = {
let app = h.state_mut();
let layout = app.session.layout();
active(app, &layout).map(|(o, _)| (o.member, o.rect))
};
assert_eq!(shown, Some((Some(0), wordcraft_geom::Rect::new(frame.x + 40.0, frame.y + 30.0, 100.0, 40.0))), "its frame is the one shown");
// Dragged 50 pt right and 20 pt down, it moves inside the canvas; the canvas stays put.
let to = at(&mut h, 140.0, 70.0);
h.event(Event::PointerMoved(on_rect));
h.event(button(on_rect, true));
h.step();
for p in [on_rect + egui::vec2(0.0, 30.0), to + egui::vec2(0.0, 30.0), to] {
h.event(Event::PointerMoved(p));
h.step();
}
h.event(button(to, false));
for _ in 0..3 {
h.step();
}
let moved = members(&h);
let near = |(x, y): (f32, f32), (ex, ey): (f32, f32)| (x - ex).abs() < 0.01 && (y - ey).abs() < 0.01;
assert!(near(moved[0], (90.0, 50.0)) && near(moved[1], (200.0, 100.0)), "{moved:?}");
assert_eq!(h.state_mut().session.layout().object(&Pos::body(0, 0), 0).map(|o| o.rect), Some(frame));
// A click in the text box's text puts the caret in it.
let in_box = at(&mut h, 275.0, 140.0);
h.event(Event::PointerMoved(in_box));
h.event(button(in_box, true));
h.step();
h.event(button(in_box, false));
for _ in 0..3 {
h.step();
}
let story = h.state().session.sel.focus.story;
let is_box = matches!(story, StoryRef::Part(id) if h.state().session.doc.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox));
assert!(is_box, "the caret is in the text box's text: {story:?}");
}
File diff suppressed because one or more lines are too long
+8 -3
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8 -2
View File
File diff suppressed because one or more lines are too long
@@ -126,6 +126,6 @@ modules:
# the two equal).
- type: git
url: https://github.com/storytold/craft-i18n
commit: 9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
commit: 1e515422afdda2f317af430c1f9d68a5f83935b4
dest: craft-i18n
- cargo-sources.json