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