mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 00:40:33 -06:00
Merge remote-tracking branch 'origin/main' into pr1104
# Conflicts: # docs/file-format-parity.md
This commit is contained in:
@@ -9,7 +9,8 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
|
||||
# `zip_guard::zip64_entry_count_sample`); turned on only as a
|
||||
# dev-dependency, so never built into the app.
|
||||
test-support = []
|
||||
|
||||
|
||||
@@ -213,6 +213,39 @@ pub struct ShapeExtra {
|
||||
/// main colour (the shape's `stroke`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_fill: Option<crate::fill::Fill>,
|
||||
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` pasted into Word): it takes its whole
|
||||
/// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hr: Option<HorizontalRule>,
|
||||
}
|
||||
|
||||
/// How a horizontal rule sits on its line (VML `o:hrpct`, `o:hralign`), as Word draws one:
|
||||
/// across `pct` percent of the line's width (the paragraph's, inside its indents), or at the
|
||||
/// shape's own width when `pct` is 0; `align` places it when it is narrower than the line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct HorizontalRule {
|
||||
pub pct: f32,
|
||||
pub align: crate::props::Align,
|
||||
/// Word's standard look (`o:hrstd`), kept for saving.
|
||||
pub standard: bool,
|
||||
}
|
||||
|
||||
impl HorizontalRule {
|
||||
/// The rule's width on a line `line` wide, for a shape `own` wide.
|
||||
pub fn width(&self, line: f32, own: f32) -> f32 {
|
||||
let line = line.max(0.0);
|
||||
if self.pct.is_finite() && self.pct > 0.0 { line * (self.pct.min(100.0) / 100.0) } else { own.clamp(0.0, line) }
|
||||
}
|
||||
|
||||
/// Where a rule `w` wide starts on a line from `x`, `line` wide.
|
||||
pub fn left(&self, x: f32, line: f32, w: f32) -> f32 {
|
||||
match self.align {
|
||||
crate::props::Align::Center => x + (line - w) / 2.0,
|
||||
crate::props::Align::Right => x + line - w,
|
||||
_ => x,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
|
||||
@@ -48,6 +48,7 @@ pub mod symbol_fonts;
|
||||
pub mod table;
|
||||
pub mod wordart;
|
||||
pub mod wrap;
|
||||
pub mod zip_guard;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! A bytes-only check of a ZIP package's declared entry count before the `zip` crate opens it
|
||||
//! (#3660). Every reader of `.docx`, `.odt` and spreadsheet packages calls [`check`] first.
|
||||
//!
|
||||
//! `zip::ZipArchive::new` reserves room for, and parses, as many central directory headers as the
|
||||
//! end record declares before its caller can look at `len()`. A ZIP64 end record can declare
|
||||
//! millions of entries for a file of a few hundred megabytes (only 46 bytes each have to fit), and
|
||||
//! each parsed entry costs several times that in memory. Here the end records are found the way
|
||||
//! `zip` finds them (APPNOTE.TXT §4.3.14 to §4.3.16), and a package declaring more than the
|
||||
//! reader's limit is refused before `zip` parses anything. Anything else that is wrong is left for
|
||||
//! `zip` to report.
|
||||
|
||||
const EOCD: &[u8; 4] = b"PK\x05\x06";
|
||||
const EOCD64_LOCATOR: &[u8; 4] = b"PK\x06\x07";
|
||||
const EOCD64: &[u8; 4] = b"PK\x06\x06";
|
||||
const CENTRAL_HEADER: &[u8; 4] = b"PK\x01\x02";
|
||||
|
||||
/// End records looked at, at most. A package has one; a few more can turn up inside stored data.
|
||||
const MAX_END_RECORDS: usize = 16;
|
||||
|
||||
/// Refuse `bytes` when the end record `zip` would use declares more than `max_entries` entries.
|
||||
///
|
||||
/// `zip` searches the file backwards for an end of central directory record. A ZIP64 one points
|
||||
/// through its locator at the ZIP64 end record, which `zip` looks for from the locator's offset
|
||||
/// on: every such record there is checked, and as `zip` may fall back to an earlier end record
|
||||
/// when one doesn't parse, the search goes on. A plain one is used when it declares no entries, or
|
||||
/// a central directory header lies between its directory offset and itself: it is the last one
|
||||
/// looked at.
|
||||
pub fn check(bytes: &[u8], max_entries: u64) -> Result<(), String> {
|
||||
let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]));
|
||||
let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
let u64_at = |at: usize| bytes.get(at..at.checked_add(8)?).and_then(|b| <[u8; 8]>::try_from(b).ok()).map(u64::from_le_bytes);
|
||||
let refuse = |n: u64| -> Result<(), String> {
|
||||
if n > max_entries { Err(format!("the package declares {n} entries, more than {max_entries}")) } else { Ok(()) }
|
||||
};
|
||||
let mut end = bytes.len();
|
||||
let mut seen = 0;
|
||||
while let Some(at) = bytes.get(..end).and_then(|b| b.windows(4).rposition(|w| w == EOCD)) {
|
||||
end = at;
|
||||
seen += 1;
|
||||
if seen > MAX_END_RECORDS {
|
||||
return Err(format!("more than {MAX_END_RECORDS} end of central directory records"));
|
||||
}
|
||||
// `zip` skips a record whose comment runs past the end of the file.
|
||||
let Some(comment) = u16_at(at + 20) else { continue };
|
||||
if at + 22 + usize::from(comment) > bytes.len() {
|
||||
continue;
|
||||
}
|
||||
let (Some(on_disk), Some(total), Some(cd_offset)) = (u16_at(at + 8), u16_at(at + 10), u32_at(at + 16)) else { continue };
|
||||
let zip64 = total == u16::MAX || cd_offset == u32::MAX;
|
||||
if zip64
|
||||
&& let Some(locator) = at.checked_sub(20)
|
||||
&& bytes.get(locator..locator + 4) == Some(EOCD64_LOCATOR.as_slice())
|
||||
{
|
||||
let (Some(eocd64), Some(disks)) = (u64_at(locator + 8), u32_at(locator + 16)) else { continue };
|
||||
let Some(from) = usize::try_from(eocd64).ok().filter(|&f| f < locator) else { continue };
|
||||
if disks > 1 {
|
||||
continue;
|
||||
}
|
||||
let records = bytes.get(from..locator).unwrap_or_default().windows(4).enumerate().filter(|(_, w)| *w == EOCD64);
|
||||
for (i, _) in records {
|
||||
let p = from + i;
|
||||
refuse(u64_at(p + 24).unwrap_or(0).max(u64_at(p + 32).unwrap_or(0)))?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if total == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let cd = cd_offset as usize;
|
||||
if cd < at && bytes.get(cd..at).is_some_and(|b| b.windows(4).any(|w| w == CENTRAL_HEADER)) {
|
||||
return refuse(u64::from(on_disk.max(total)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A package of one empty stored file, `a`, whose central directory lists it `entries` times (at
|
||||
/// most as many as fit in 64 MiB) under a ZIP64 end record that declares them all: `zip` 3.0.0
|
||||
/// parses and keeps every one of them before its caller can count them. Regression input for the
|
||||
/// readers' tests: built only for tests, this crate's and (through the `test-support` feature,
|
||||
/// which they turn on as a dev-dependency) the readers', never into the app.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn zip64_entry_count_sample(entries: u64) -> Vec<u8> {
|
||||
// Local file header (APPNOTE.TXT §4.3.7): version 10, stored, no data, name "a".
|
||||
let mut f = b"PK\x03\x04".to_vec();
|
||||
f.extend_from_slice(&[10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // version, flags, method, time, date
|
||||
f.extend_from_slice(&[0; 12]); // crc, sizes
|
||||
f.extend_from_slice(&[1, 0, 0, 0]); // name and extra lengths
|
||||
f.push(b'a');
|
||||
let cd_at = f.len() as u64;
|
||||
// Its central header (§4.3.12), 47 bytes.
|
||||
let mut header = CENTRAL_HEADER.to_vec();
|
||||
header.extend_from_slice(&[10, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // versions, flags, method, time, date
|
||||
header.extend_from_slice(&[0; 12]); // crc, sizes
|
||||
header.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // name, extra, comment, disk, attributes
|
||||
header.extend_from_slice(&[0; 4]); // local header offset
|
||||
header.push(b'a');
|
||||
let count = entries.min((64 << 20) / header.len() as u64);
|
||||
for _ in 0..count {
|
||||
f.extend_from_slice(&header);
|
||||
}
|
||||
let cd_len = f.len() as u64 - cd_at;
|
||||
let eocd64_at = f.len() as u64;
|
||||
// ZIP64 end of central directory record (§4.3.14), 56 bytes.
|
||||
f.extend_from_slice(EOCD64);
|
||||
f.extend_from_slice(&44u64.to_le_bytes()); // size of the rest of the record
|
||||
f.extend_from_slice(&[45, 0, 45, 0]); // versions
|
||||
f.extend_from_slice(&[0; 8]); // this disk, disk with the central directory
|
||||
f.extend_from_slice(&entries.to_le_bytes()); // entries on this disk
|
||||
f.extend_from_slice(&entries.to_le_bytes()); // entries
|
||||
f.extend_from_slice(&cd_len.to_le_bytes());
|
||||
f.extend_from_slice(&cd_at.to_le_bytes());
|
||||
// Its locator (§4.3.15), 20 bytes.
|
||||
f.extend_from_slice(EOCD64_LOCATOR);
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&eocd64_at.to_le_bytes());
|
||||
f.extend_from_slice(&1u32.to_le_bytes());
|
||||
// End of central directory record (§4.3.16), 22 bytes, each count and offset "see ZIP64".
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0; 4]);
|
||||
f.extend_from_slice(&[0xFF; 12]);
|
||||
f.extend_from_slice(&[0; 2]);
|
||||
f
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CENTRAL_HEADER, EOCD, check, zip64_entry_count_sample};
|
||||
|
||||
/// A plain package's tail: one central header at 0, then an end record declaring `entries`.
|
||||
fn plain(entries: u16, comment: &[u8]) -> Vec<u8> {
|
||||
let mut f = CENTRAL_HEADER.to_vec();
|
||||
f.resize(46, 0);
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&entries.to_le_bytes());
|
||||
f.extend_from_slice(&entries.to_le_bytes());
|
||||
f.extend_from_slice(&46u32.to_le_bytes());
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&(comment.len() as u16).to_le_bytes());
|
||||
f.extend_from_slice(comment);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zip64_end_record_declaring_too_many_entries_is_refused() {
|
||||
// #3660: `zip` would reserve and parse them all before the reader's cap.
|
||||
let sample = zip64_entry_count_sample(20_001);
|
||||
assert!(check(&sample, 20_000).is_err_and(|e| e.contains("20001 entries")));
|
||||
assert!(check(&zip64_entry_count_sample(5), 20_000).is_ok());
|
||||
// Junk before the package (a self-extracting stub) doesn't hide it.
|
||||
let mut prefixed = vec![0x55u8; 4096];
|
||||
prefixed.extend_from_slice(&sample);
|
||||
assert!(check(&prefixed, 20_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_end_record_is_checked_as_zip_reads_it() {
|
||||
assert!(check(&plain(3, b""), 20_000).is_ok());
|
||||
assert!(check(&plain(30_000, b"a comment"), 20_000).is_err_and(|e| e.contains("30000 entries")));
|
||||
// A record whose comment runs past the end is not the one `zip` uses.
|
||||
let mut cut = plain(30_000, b"");
|
||||
cut.truncate(cut.len() - 2);
|
||||
cut.extend_from_slice(&500u16.to_le_bytes());
|
||||
assert!(check(&cut, 20_000).is_ok());
|
||||
assert!(check(b"not a package", 20_000).is_ok());
|
||||
assert!(check(b"", 20_000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fake_end_record_after_the_real_one_does_not_hide_it() {
|
||||
// `zip` skips a last record with no central header before it and uses the one before.
|
||||
let mut f = zip64_entry_count_sample(1_000_000);
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&7u16.to_le_bytes());
|
||||
f.extend_from_slice(&7u16.to_le_bytes());
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&((f.len() - 12) as u32).to_le_bytes());
|
||||
f.extend_from_slice(&0u16.to_le_bytes());
|
||||
assert!(check(&f, 20_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_end_records_are_refused() {
|
||||
// Each declares one entry and has no central header before it, so `zip` would try them all.
|
||||
let record = [0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
let f: Vec<u8> = (0..20).flat_map(|_| EOCD.iter().copied().chain(record)).collect();
|
||||
assert!(check(&f, 20_000).is_err_and(|e| e.contains("end of central directory records")));
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -56,11 +56,19 @@ pub fn read_xml(bytes: &[u8]) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
|
||||
/// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376
|
||||
/// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's
|
||||
/// directory and at most `max_part` bytes of that part are read: for listing templates by name
|
||||
/// without reading the whole package.
|
||||
pub fn package_title<R: std::io::Read + std::io::Seek>(package: R, max_part: u64) -> Option<String> {
|
||||
/// directory and at most `max_part` bytes of that part are inflated: for listing templates by
|
||||
/// name without unpacking the whole package. A package over 64 MiB, or one declaring more entries
|
||||
/// than `MAX_ENTRIES` (20,000), has none (#3660).
|
||||
pub fn package_title<R: std::io::Read>(package: R, max_part: u64) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let mut zip = zip::ZipArchive::new(package).ok()?;
|
||||
const MAX_PACKAGE: u64 = 64 << 20;
|
||||
let mut bytes = Vec::new();
|
||||
package.take(MAX_PACKAGE + 1).read_to_end(&mut bytes).ok()?;
|
||||
if bytes.len() as u64 > MAX_PACKAGE {
|
||||
return None;
|
||||
}
|
||||
wordcraft_doc::zip_guard::check(&bytes, package::MAX_ENTRIES as u64).ok()?;
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?;
|
||||
let part = zip.by_name("docProps/core.xml").ok()?;
|
||||
let mut xml = Vec::new();
|
||||
part.take(max_part).read_to_end(&mut xml).ok()?;
|
||||
|
||||
@@ -213,6 +213,8 @@ impl Package {
|
||||
}
|
||||
|
||||
pub fn open_limited(bytes: &[u8], max_part: u64, max_total: u64) -> Result<Package, DocxError> {
|
||||
// Before `zip` parses every entry the end record declares (#3660).
|
||||
wordcraft_doc::zip_guard::check(bytes, MAX_ENTRIES as u64).map_err(DocxError::Limit)?;
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| DocxError::Zip(e.to_string()))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(DocxError::Limit(format!("{} zip entries", zip.len())));
|
||||
@@ -530,6 +532,21 @@ mod tests {
|
||||
assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_package_declaring_too_many_entries_is_refused_before_zip_reads_them() {
|
||||
// #3660: a ZIP64 end record declaring more than MAX_ENTRIES.
|
||||
let bomb = wordcraft_doc::zip_guard::zip64_entry_count_sample(MAX_ENTRIES as u64 + 1);
|
||||
assert!(matches!(Package::open(&bomb), Err(DocxError::Limit(e)) if e.contains("declares")));
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(bomb), 1 << 20), None);
|
||||
// A real package still opens, and its title still reads.
|
||||
let z = zip_of(&[(
|
||||
"docProps/core.xml",
|
||||
br#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Plan</dc:title></cp:coreProperties>"#.to_vec(),
|
||||
)]);
|
||||
assert!(Package::open(&z).is_ok());
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
|
||||
@@ -295,6 +295,22 @@ impl Reader<'_> {
|
||||
}
|
||||
let kind = kind?;
|
||||
let mut extra = ShapeExtra { cap, ..Default::default() };
|
||||
// A horizontal rule (`o:hr`, a pasted `<hr>`): across `o:hrpct` tenths of a percent of its
|
||||
// line, or its own width (`o:hrpct="0"`); a width of 0 with no percentage is the whole
|
||||
// line. `o:hralign` places a shorter one (Word, measured: 50 % centred, left, right).
|
||||
// Only a rule in the text, shown: a positioned or hidden one stays a plain shape, which
|
||||
// keeps its placement and visibility when saved.
|
||||
if attr("o:hr").is_some_and(|v| matches!(v.trim(), "t" | "true" | "1")) && float.wrap == Wrap::Inline && !float.hidden {
|
||||
let tenths = attr("o:hrpct").and_then(|v| v.trim().parse::<f32>().ok()).filter(|p| p.is_finite() && *p > 0.0);
|
||||
let pct = tenths.map_or(if w > 0.0 { 0.0 } else { 100.0 }, |t| (t / 10.0).min(100.0));
|
||||
let align = match attr("o:hralign").map(|a| a.trim().to_ascii_lowercase()).as_deref() {
|
||||
Some("center") => wordcraft_doc::props::Align::Center,
|
||||
Some("right") => wordcraft_doc::props::Align::Right,
|
||||
_ => wordcraft_doc::props::Align::Left,
|
||||
};
|
||||
let standard = attr("o:hrstd").is_some_and(|v| matches!(v.trim(), "t" | "true" | "1"));
|
||||
extra.hr = Some(wordcraft_doc::connector::HorizontalRule { pct, align, standard });
|
||||
}
|
||||
if kind == ShapeKind::Line {
|
||||
// Arrowheads (`v:stroke` startarrow / endarrow, with their width and length): any but
|
||||
// `none` is drawn.
|
||||
|
||||
@@ -115,6 +115,9 @@ impl Writer<'_> {
|
||||
// ink keep their original source and related parts.
|
||||
InlineObject::Graphic { .. } => self.write_graphic(w, o, props, rels, depth),
|
||||
InlineObject::Image { .. } => self.write_image(w, o, props, rels, depth),
|
||||
InlineObject::Shape { extra, float, .. } if extra.hr.is_some() && float.wrap == Wrap::Inline && !float.hidden => {
|
||||
self.write_hr(w, o, props)
|
||||
}
|
||||
InlineObject::Shape { .. } => self.write_shape(w, o, props, rels, depth),
|
||||
InlineObject::Group { .. } => self.write_group(w, o, props, rels, depth),
|
||||
InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => {
|
||||
@@ -277,6 +280,74 @@ impl Writer<'_> {
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
|
||||
/// A horizontal rule goes back as the VML rule it was read from: DrawingML has none, and a
|
||||
/// fixed-size shape would lose its line-wide width.
|
||||
fn write_hr(&mut self, w: &mut W, o: &InlineObject, props: &CharProps) {
|
||||
let InlineObject::Shape { w: sw, h: sh, fill, stroke, stroke_width, extra, float, .. } = o else { return };
|
||||
let Some(hr) = extra.hr else { return };
|
||||
let align = match hr.align {
|
||||
wordcraft_doc::props::Align::Center => "center",
|
||||
wordcraft_doc::props::Align::Right => "right",
|
||||
_ => "left",
|
||||
};
|
||||
let width = if hr.pct > 0.0 { "0".to_string() } else { format!("{:.2}pt", sw.max(0.0)) };
|
||||
let mut style = format!("width:{width};height:{:.2}pt", sh.max(0.0));
|
||||
let spin = float.spin();
|
||||
if spin.deg != 0.0 {
|
||||
style.push_str(&format!(";rotation:{}", spin.deg));
|
||||
}
|
||||
match (spin.flip_h, spin.flip_v) {
|
||||
(true, true) => style.push_str(";flip:x y"),
|
||||
(true, false) => style.push_str(";flip:x"),
|
||||
(false, true) => style.push_str(";flip:y"),
|
||||
(false, false) => {}
|
||||
}
|
||||
let pct = format!("{}", (hr.pct * 10.0).round().max(0.0) as u32);
|
||||
let fill_hex = fill.as_ref().map(|f| format!("#{}", f.main_color().hex()));
|
||||
let stroke_hex = stroke.map(|c| format!("#{}", c.hex()));
|
||||
let weight = format!("{stroke_width:.2}pt");
|
||||
let mut attrs: Vec<(&str, &str)> = vec![
|
||||
("xmlns:v", "urn:schemas-microsoft-com:vml"),
|
||||
("xmlns:o", "urn:schemas-microsoft-com:office:office"),
|
||||
("style", &style),
|
||||
("o:hrpct", &pct),
|
||||
("o:hralign", align),
|
||||
("o:hr", "t"),
|
||||
];
|
||||
if hr.standard {
|
||||
attrs.push(("o:hrstd", "t"));
|
||||
}
|
||||
if let Some(f) = &fill_hex {
|
||||
attrs.push(("fillcolor", f));
|
||||
} else {
|
||||
attrs.push(("filled", "f"));
|
||||
}
|
||||
match &stroke_hex {
|
||||
Some(s) => {
|
||||
attrs.push(("strokecolor", s));
|
||||
attrs.push(("strokeweight", &weight));
|
||||
}
|
||||
None => attrs.push(("stroked", "f")),
|
||||
}
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.open("w:pict", &[]);
|
||||
// A see-through fill keeps its opacity, which `fillcolor` can't say.
|
||||
match fill.as_ref() {
|
||||
Some(wordcraft_doc::fill::Fill::Solid { transparency, .. }) if *transparency > 0.0 => {
|
||||
let opacity = format!("{:.4}", (1.0 - transparency / 100.0).clamp(0.0, 1.0));
|
||||
w.open("v:rect", &attrs);
|
||||
w.empty("v:fill", &[("opacity", &opacity)]);
|
||||
w.close("v:rect");
|
||||
}
|
||||
_ => w.empty("v:rect", &attrs),
|
||||
}
|
||||
w.close("w:pict");
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
|
||||
fn write_shape(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
|
||||
let InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } = o else { return };
|
||||
self.rev_open(w, props);
|
||||
|
||||
@@ -4092,6 +4092,30 @@ fn equation_in_alternate_content_is_read() {
|
||||
assert!(!p.plain_text().contains("[picture]"));
|
||||
}
|
||||
|
||||
/// A fill's `a:alpha` is its opacity: a translucent white wash over a picture stayed opaque and
|
||||
/// hid the picture.
|
||||
#[test]
|
||||
fn a_shape_fill_keeps_its_transparency() {
|
||||
let fill =
|
||||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="30980"/></a:srgbClr></a:solidFill>"#;
|
||||
let d = read_body(&format!("<w:p>{}</w:p>", anchored_wsp("Wash", 1270000, 635000, fill, "", "<wps:cNvSpPr/>")));
|
||||
let transparency = |d: &Document| match ¶s(d)[0].objects[0] {
|
||||
InlineObject::Shape { fill: Some(wordcraft_doc::fill::Fill::Solid { color, transparency }), .. } => (Some(*color), *transparency),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
const FILL_ALPHA: u32 = 30_980;
|
||||
const ALPHA_SCALE: f32 = 100_000.0;
|
||||
// Canonical Fill stores the percentage computed from opacity, including f32 rounding.
|
||||
let expected = 100.0 - (FILL_ALPHA as f32 / ALPHA_SCALE) * 100.0;
|
||||
assert_eq!(transparency(&d), (Some(Rgb(255, 255, 255)), expected));
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let parts = unzip(&bytes);
|
||||
let xml = String::from_utf8_lossy(parts.get("word/document.xml").unwrap());
|
||||
assert!(xml.contains(&format!("<a:alpha val=\"{FILL_ALPHA}\"/>")), "{xml}");
|
||||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(transparency(&again), (Some(Rgb(255, 255, 255)), expected));
|
||||
}
|
||||
|
||||
/// #1500: gradient fills and transparency on shapes (the cases in the issue), from the shape's
|
||||
/// own properties, the theme's fill styles (`a:fillRef` 3 and 1003, as Word's cover pages use),
|
||||
/// a group's fill (`a:grpFill`), an outline gradient and VML's `v:fill`. Each is read as the
|
||||
@@ -4288,3 +4312,62 @@ fn vml_textbox_style_anchor_and_fit() {
|
||||
assert_eq!(tb.body.anchor, wordcraft_doc::props::VAlign::Center);
|
||||
assert!(tb.body.auto_fit);
|
||||
}
|
||||
|
||||
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
|
||||
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
|
||||
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
|
||||
#[test]
|
||||
fn vml_horizontal_rules_are_read_and_kept() {
|
||||
use wordcraft_doc::connector::HorizontalRule;
|
||||
use wordcraft_doc::props::Align;
|
||||
let hr = |attrs: &str, style: &str| format!(r#"<w:p><w:r><w:pict><v:rect style="{style}" {attrs}/></w:pict></w:r></w:p>"#);
|
||||
let body = [
|
||||
hr(r#"o:hralign="center" o:hrstd="t" o:hr="t""#, "width:0;height:1.5pt"),
|
||||
hr(r#"o:hrpct="500" o:hralign="right" o:hr="t""#, "width:0;height:1.5pt"),
|
||||
hr(r#"o:hrpct="0" o:hralign="center" o:hr="t""#, "width:100pt;height:3pt"),
|
||||
hr("", "width:100pt;height:3pt"),
|
||||
]
|
||||
.concat();
|
||||
let rules = |d: &Document| -> Vec<(Option<HorizontalRule>, f32)> {
|
||||
paras(d)
|
||||
.iter()
|
||||
.filter_map(|p| p.objects.iter().find_map(|o| if let InlineObject::Shape { extra, w, .. } = o { Some((extra.hr, *w)) } else { None }))
|
||||
.collect()
|
||||
};
|
||||
let d = read_body(&body);
|
||||
let got = rules(&d);
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
(Some(HorizontalRule { pct: 100.0, align: Align::Center, standard: true }), 0.0),
|
||||
(Some(HorizontalRule { pct: 50.0, align: Align::Right, standard: false }), 0.0),
|
||||
(Some(HorizontalRule { pct: 0.0, align: Align::Center, standard: false }), 100.0),
|
||||
(None, 100.0),
|
||||
]
|
||||
);
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(rules(&again)[..3], got[..3]);
|
||||
// A rule's rotation and flips are kept.
|
||||
let spin = |d: &Document| paras(d)[0].objects.iter().find_map(|o| o.frame()).map(|(_, _, f)| f.spin());
|
||||
let d = read_body(&hr(r#"o:hrpct="500" o:hr="t""#, "width:0;height:1.5pt;rotation:45;flip:x y"));
|
||||
assert_eq!(spin(&d), Some(wordcraft_geom::Spin::new(45.0, true, true)));
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(spin(&again), spin(&d));
|
||||
// A see-through rule stays see-through.
|
||||
let fill = |d: &Document| paras(d)[0].objects.iter().find_map(|o| if let InlineObject::Shape { fill, .. } = o { fill.clone() } else { None });
|
||||
let d = read_body(
|
||||
r##"<w:p><w:r><w:pict><v:rect style="width:0;height:1.5pt" o:hr="t" fillcolor="#ff0000"><v:fill opacity="0.25"/></v:rect></w:pict></w:r></w:p>"##,
|
||||
);
|
||||
let Some(wordcraft_doc::fill::Fill::Solid { transparency, .. }) = fill(&d) else { panic!("{:?}", fill(&d)) };
|
||||
assert!((transparency - 75.0).abs() < 0.01, "{transparency}");
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(fill(&again), fill(&d));
|
||||
// A positioned or hidden rule stays a plain shape, keeping its placement and visibility.
|
||||
let body = [
|
||||
hr(r#"o:hralign="center" o:hr="t""#, "position:absolute;margin-left:10pt;margin-top:20pt;width:100pt;height:2pt"),
|
||||
hr(r#"o:hralign="center" o:hr="t""#, "visibility:hidden;width:0;height:2pt"),
|
||||
]
|
||||
.concat();
|
||||
let d = read_body(&body);
|
||||
assert_eq!(rules(&d).iter().map(|r| r.0).collect::<Vec<_>>(), [None, None]);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ pub mod txt;
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
/// Most entries an ODT or spreadsheet package may declare, as many as a Word package
|
||||
/// (`wordcraft_docx::package::MAX_ENTRIES`); checked before `zip` parses them (#3660).
|
||||
pub const MAX_ZIP_ENTRIES: u64 = 20_000;
|
||||
|
||||
/// Extensions this crate handles.
|
||||
pub const EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "html", "htm", "xhtml", "mht", "mhtml", "rtf", "odt", "tex", "latex", "ltx"];
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> {
|
||||
|
||||
/// [`parse_full`], plus whether change tracking is on (`None`: the document has no tracked changes).
|
||||
fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option<bool>, Option<String>, page_parts::Parts), String> {
|
||||
wordcraft_doc::zip_guard::check(bytes, crate::MAX_ZIP_ENTRIES).map_err(|e| format!("ODT: {e}"))?;
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?;
|
||||
if let Some(mt) = read_entry(&mut z, "mimetype") {
|
||||
let mt = String::from_utf8_lossy(&mt);
|
||||
|
||||
@@ -201,6 +201,7 @@ struct Package<'a> {
|
||||
|
||||
impl<'a> Package<'a> {
|
||||
fn open(bytes: &'a [u8]) -> Result<Self, String> {
|
||||
wordcraft_doc::zip_guard::check(bytes, crate::MAX_ZIP_ENTRIES)?;
|
||||
let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a valid spreadsheet package ({e})"))?;
|
||||
Ok(Package { zip, total: 0 })
|
||||
}
|
||||
@@ -401,6 +402,14 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn a_package_declaring_too_many_entries_is_refused() {
|
||||
// #3660: refused before `zip` reserves and parses them.
|
||||
let bomb = wordcraft_doc::zip_guard::zip64_entry_count_sample(crate::MAX_ZIP_ENTRIES + 1);
|
||||
assert!(sheet_names(&bomb).is_err_and(|e| e.contains("declares")));
|
||||
assert!(crate::odt::parse(&bomb).is_err_and(|e| e.contains("declares")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_zero_formats_as_zero() {
|
||||
assert_eq!(number(-0.0), "0");
|
||||
|
||||
@@ -583,7 +583,7 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec<Draw>) {
|
||||
}
|
||||
let cx = x + line.cl_left(k).unwrap_or(0.0);
|
||||
let obj = para.and_then(|p| p.objects.get(oi));
|
||||
let rect = inline_rect(obj, cx, base, c.adv, c.obj_h);
|
||||
let rect = inline_rect(obj, cx, base, c.adv, c.obj_h, pl.rule_room(line, k));
|
||||
let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default();
|
||||
match obj {
|
||||
Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => {
|
||||
|
||||
@@ -479,10 +479,21 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp
|
||||
/// Where inline object `obj` is drawn, given its cluster's box (`adv` × `obj_h` standing on the
|
||||
/// baseline at `cx`): inside the room kept for its effects.
|
||||
/// A rotated one's unrotated frame, centred in that room.
|
||||
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32) -> Rect {
|
||||
///
|
||||
/// `line` is the room a horizontal rule is placed in, from `cx`.
|
||||
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32, line: f32) -> Rect {
|
||||
let frame = obj.and_then(InlineObject::frame);
|
||||
let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent());
|
||||
let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0));
|
||||
// A horizontal rule is as wide as its share of the paragraph's width, whatever narrows its
|
||||
// line, and is placed by its alignment in what the line has left: it runs past the margin
|
||||
// beside a float or a first-line indent, as in Word (see `ParaLayout::rule_room`).
|
||||
if let Some(InlineObject::Shape { w, extra, .. }) = obj
|
||||
&& let Some(hr) = extra.hr
|
||||
{
|
||||
let rw = hr.width(room.w, *w);
|
||||
return Rect::new(hr.left(room.x, line, rw), room.y, rw, room.h);
|
||||
}
|
||||
let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room };
|
||||
// The room is the rotated bounds of the frame: undo the rotation.
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0));
|
||||
@@ -506,3 +517,6 @@ fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw {
|
||||
cap: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fill_alpha_tests;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::connector::ShapeExtra;
|
||||
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool};
|
||||
|
||||
#[test]
|
||||
fn freeform_fill_has_own_alpha() {
|
||||
let rect = Rect::new(4.0, 4.0, 24.0, 24.0);
|
||||
for transparency in [0.0, 50.0, 100.0] {
|
||||
for closed in [false, true] {
|
||||
let freeform = Freeform {
|
||||
w: 24.0,
|
||||
h: 24.0,
|
||||
paths: vec![FreePath { pts: vec![[0.0, 0.0], [24.0, 0.0], [24.0, 24.0]], closed }],
|
||||
alpha: 0.25,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
shape_draws(
|
||||
rect,
|
||||
&ShapeLook {
|
||||
kind: ShapeKind::Freeform,
|
||||
fill: Some(&wordcraft_doc::fill::Fill::Solid { color: Rgb::BLACK, transparency }),
|
||||
stroke: Some(Rgb::WHITE),
|
||||
stroke_width: 2.0,
|
||||
effects: Default::default(),
|
||||
freeform: Some(&freeform),
|
||||
extra: &ShapeExtra::default(),
|
||||
},
|
||||
&DisplayOptions::default(),
|
||||
&mut out,
|
||||
);
|
||||
let Draw::Path { fill, stroke, stroke_width, .. } = &out[0] else { panic!("expected freeform path") };
|
||||
match fill {
|
||||
Some(Paint::Solid(color, alpha)) if closed => {
|
||||
assert_eq!(*color, Rgb::BLACK);
|
||||
assert_eq!(*alpha, 1.0 - transparency / 100.0);
|
||||
}
|
||||
None if !closed => {}
|
||||
_ => panic!("unexpected freeform paint: {fill:?}"),
|
||||
}
|
||||
assert_eq!((*stroke, *stroke_width), (Some(Rgb::WHITE), 2.0));
|
||||
}
|
||||
}
|
||||
let ink = Freeform::ink(InkTool::Highlighter, 24.0, 24.0, vec![[0.0, 0.0], [24.0, 24.0]]);
|
||||
let mut out = Vec::new();
|
||||
shape_draws(
|
||||
rect,
|
||||
&ShapeLook {
|
||||
kind: ShapeKind::Freeform,
|
||||
fill: Some(&wordcraft_doc::fill::Fill::Solid { color: Rgb::BLACK, transparency: 100.0 }),
|
||||
stroke: Some(Rgb::WHITE),
|
||||
stroke_width: 2.0,
|
||||
effects: Default::default(),
|
||||
freeform: Some(&ink),
|
||||
extra: &ShapeExtra::default(),
|
||||
},
|
||||
&DisplayOptions::default(),
|
||||
&mut out,
|
||||
);
|
||||
assert!(matches!(out.as_slice(), [Draw::Ink { alpha, .. }] if *alpha == 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_path_alpha_defaults() {
|
||||
let rect = Rect::new(4.0, 4.0, 24.0, 24.0);
|
||||
let graphic = Graphic {
|
||||
items: vec![GraphicItem::Path {
|
||||
segs: vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(24.0, 24.0)],
|
||||
fill: Some(Rgb::BLACK),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = graphic_draws(&Document::new(), &graphic, rect, 1.0);
|
||||
out.push(path_arrowhead(&[(4.0, 4.0), (28.0, 28.0)], Rgb::BLACK, 2.0, ArrowStyle::default()).unwrap());
|
||||
let extra = ShapeExtra { arrow_start: true, arrow_end: true, ..Default::default() };
|
||||
line_draws(rect, ShapeKind::ElbowConnector, Some(Rgb::BLACK), 2.0, Default::default(), &extra, &mut out);
|
||||
assert_eq!(out.len(), 5);
|
||||
assert!(out.iter().all(
|
||||
|item| matches!(item, Draw::Path { fill, .. } if fill.as_ref().is_none_or(|paint| matches!(paint, Paint::Solid(_, alpha) if *alpha == 1.0)))
|
||||
));
|
||||
}
|
||||
@@ -32,6 +32,16 @@ impl Paint {
|
||||
Paint::Radial { c, r, stops } => Paint::Radial { c: *c, r: *r, stops: fade(stops) },
|
||||
}
|
||||
}
|
||||
/// Inset soft-edge paints. Solid bands composite to the fill's target opacity.
|
||||
pub fn soft_bands(&self, radius: f32) -> Vec<(f32, Paint)> {
|
||||
match self {
|
||||
Paint::Solid(color, alpha) => {
|
||||
let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 };
|
||||
wordcraft_doc::effects::bands(-radius, 0.0, alpha).into_iter().map(|(grow, band)| (grow, Paint::Solid(*color, band))).collect()
|
||||
}
|
||||
_ => wordcraft_doc::effects::bands(-radius, 0.0, 1.0).into_iter().map(|(grow, alpha)| (grow, self.faded(alpha))).collect(),
|
||||
}
|
||||
}
|
||||
/// The colour and opacity this paint gives the point (`x`, `y`): stops blended, padded past
|
||||
/// the ends.
|
||||
pub fn at(&self, x: f32, y: f32) -> (Rgb, f32) {
|
||||
|
||||
@@ -258,7 +258,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR
|
||||
effects: *effects,
|
||||
freeform: freeform.clone(),
|
||||
spin,
|
||||
extra: extra.clone(),
|
||||
extra: Box::new(extra.clone()),
|
||||
object: Some(object),
|
||||
alt: shape_figure_alt(o).unwrap_or_default().to_string(),
|
||||
}]
|
||||
@@ -491,7 +491,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, w, h, float))
|
||||
} else {
|
||||
let cx = x + line.cl_left(k).unwrap_or(0.0);
|
||||
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, c.obj_h)
|
||||
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, c.obj_h, pl.rule_room(line, k))
|
||||
};
|
||||
let (mut behind, mut front) = (Vec::new(), Vec::new());
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
|
||||
@@ -97,6 +97,19 @@ pub struct ParaLayout {
|
||||
}
|
||||
|
||||
impl ParaLayout {
|
||||
/// The room a horizontal rule at cluster `k` is placed in, from its left edge to the end of
|
||||
/// what the line has left. A first-line indent moves that end along (Word, measured with
|
||||
/// 50 % and fixed-width rules beside floats on either side and with first-line and hanging
|
||||
/// indents: 36 pt of first-line indent ends the room 36 pt past the right margin).
|
||||
pub(crate) fn rule_room(&self, line: &Line, k: usize) -> f32 {
|
||||
let adv = self.clusters.get(k).map_or(0.0, |c| c.adv);
|
||||
if line.rtl {
|
||||
return adv;
|
||||
}
|
||||
let first = if line.c0 == 0 { self.rp.indent_first } else { 0.0 };
|
||||
(line.right + first - line.cl_left(k).unwrap_or(0.0)).max(0.0)
|
||||
}
|
||||
|
||||
/// Returns whether `off` is a legal caret and edit boundary, that is, not the interior start of a
|
||||
/// joined grapheme cluster.
|
||||
fn is_edit_boundary(&self, off: usize) -> bool {
|
||||
|
||||
@@ -326,6 +326,17 @@ impl<'a> Shaped<'a> {
|
||||
// the margin and the page edge clips it (see `ParaEnv::fit_objects`).
|
||||
let maxw = (env.width - cx.rp.indent_left.max(0.0) - cx.rp.indent_right.max(0.0)).max(18.0);
|
||||
let h = env.fitted.iter().find(|(i, _)| *i == k).map_or(saved_h, |(_, h)| *h);
|
||||
// A horizontal rule takes its whole line; display draws it at its share. Measured for
|
||||
// a table column (an unbounded line) a share of its line asks for no width, following
|
||||
// the column rather than widening it; one of its own width asks for that.
|
||||
let w = match o {
|
||||
InlineObject::Shape { extra, .. } => match extra.hr {
|
||||
Some(_) if env.width < crate::table::MEASURE_WIDE => maxw,
|
||||
Some(hr) if hr.pct > 0.0 => 1.0,
|
||||
_ => w,
|
||||
},
|
||||
_ => w,
|
||||
};
|
||||
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
|
||||
let s = if env.fit_objects && w > maxw { maxw / w } else { 1.0 };
|
||||
// The line makes room for effects (shadows) around the picture too, and
|
||||
|
||||
@@ -79,10 +79,15 @@ pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String {
|
||||
let mut runs = p.runs.iter();
|
||||
let mut run = runs.next();
|
||||
let mut end = run.map_or(0, |r| r.len);
|
||||
// Each run this walk looks at is a step ([`crate::work`]), so the linear-time test counts
|
||||
// them instead of timing the walk: a caller that went back to the first run for every
|
||||
// character would count the runs once per character and show up as a quadratic count (#5185).
|
||||
crate::work::steps(usize::from(run.is_some()));
|
||||
for (i, c) in p.text.char_indices() {
|
||||
while run.is_some() && i >= end {
|
||||
run = runs.next();
|
||||
end = end.saturating_add(run.map_or(0, |r| r.len));
|
||||
crate::work::steps(usize::from(run.is_some()));
|
||||
}
|
||||
let props = run.map_or(&p.mark, |r| &r.props);
|
||||
let deleted = c == wordcraft_doc::para::OBJ || props.del.is_some();
|
||||
|
||||
@@ -48,7 +48,7 @@ pub enum Placed {
|
||||
effects: wordcraft_doc::effects::ShapeEffects,
|
||||
spin: Spin,
|
||||
/// Arrowheads and text warp (see [`wordcraft_doc::connector::ShapeExtra`]).
|
||||
extra: wordcraft_doc::connector::ShapeExtra,
|
||||
extra: Box<wordcraft_doc::connector::ShapeExtra>,
|
||||
/// The floating object it draws (a Drawing Canvas's members: the canvas's), for leaving
|
||||
/// exactly one object out of a page (see [`crate::display::DisplayOptions::hide_object`]).
|
||||
object: Option<wordcraft_doc::Pos>,
|
||||
|
||||
@@ -145,7 +145,7 @@ pub fn cell_margins(doc: &wordcraft_doc::Document, t: &Table, row: usize, cell:
|
||||
/// rows only, so AutoFit stays quick.
|
||||
const MEASURE_BUDGET: usize = 4000;
|
||||
/// The line length paragraphs are measured at: wide enough that nothing real wraps.
|
||||
const MEASURE_WIDE: f32 = 15_840.0;
|
||||
pub(crate) const MEASURE_WIDE: f32 = 15_840.0;
|
||||
/// What an empty cell's text needs (about a paragraph mark), points.
|
||||
const EMPTY_TEXT: f32 = 12.0;
|
||||
|
||||
|
||||
+102
-1
@@ -5051,7 +5051,7 @@ fn inline_picture_keeps_room_for_its_effects() {
|
||||
assert_eq!((c.adv, c.obj_h), (124.0, 89.0), "picture plus effect extents");
|
||||
// Drawn inside that room.
|
||||
let obj = picture(100.0, 50.0, shadow);
|
||||
let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, c.obj_h);
|
||||
let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, c.obj_h, c.adv);
|
||||
assert_eq!((r.x, r.y, r.w, r.h), (16.0, 123.0, 100.0, 50.0));
|
||||
}
|
||||
|
||||
@@ -12326,6 +12326,105 @@ fn a_row_leaves_room_for_the_border_below_it() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal rule (VML `o:hr`) takes its whole line and is drawn across its share of it,
|
||||
/// placed by its alignment (Word, measured: 50 % centred, left, right). In a table cell it
|
||||
/// follows its column instead of widening the table.
|
||||
#[test]
|
||||
fn horizontal_rules_take_their_line() {
|
||||
use wordcraft_doc::connector::HorizontalRule;
|
||||
use wordcraft_doc::props::Align;
|
||||
let rule = |pct: f32, align: Align| {
|
||||
let mut o = rect_shape(0.0, 1.5, Default::default());
|
||||
if let InlineObject::Shape { extra, .. } = &mut o {
|
||||
extra.hr = Some(HorizontalRule { pct, align, standard: true });
|
||||
}
|
||||
o
|
||||
};
|
||||
let drawn = |d: &Document| -> Vec<wordcraft_geom::Rect> {
|
||||
let l = lay(d);
|
||||
display::page_display(d, &l.pages[0], &display::DisplayOptions::default())
|
||||
.iter()
|
||||
.filter_map(
|
||||
|i| if let display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, .. } = i { Some(*rect) } else { None },
|
||||
)
|
||||
.collect()
|
||||
};
|
||||
let mut d = Document::from_text("a\nb");
|
||||
d.insert_object(&Pos::body(1, 0), rule(50.0, Align::Center), &Default::default()).unwrap();
|
||||
d.insert_object(&Pos::body(0, 0), rule(100.0, Align::Left), &Default::default()).unwrap();
|
||||
let s = drawn(&d);
|
||||
let (full, half) = (s[0], s[1]);
|
||||
assert!(full.w > 400.0, "the whole line: {full:?}");
|
||||
assert!((half.w - full.w / 2.0).abs() < 0.01 && (half.x - (full.x + full.w / 4.0)).abs() < 0.01, "{half:?} in {full:?}");
|
||||
let mut cell = Table::new(1, 1, 200.0);
|
||||
let mut p = Paragraph::default();
|
||||
p.insert_object(0, rule(100.0, Align::Left), &Default::default()).unwrap();
|
||||
cell.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
d.body = vec![Arc::new(Block::Table(cell))];
|
||||
let s = drawn(&d);
|
||||
assert!(s.first().is_some_and(|r| r.w > 150.0 && r.w < 200.0), "the cell's width, not the page's: {s:?}");
|
||||
// Word's numbers (A4, 1 inch margins, a 100 pt float): beside a float a rule keeps its width
|
||||
// and is placed in what the line has left, a first-line indent moving the end of that room
|
||||
// along, so a whole-line rule runs past the margin as in Word.
|
||||
let beside = |pct: f32, align: Align, side: Option<f32>, first: f32| {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let mut d = Document::from_text("a");
|
||||
if first != 0.0 {
|
||||
let mut p = d.body[0].as_para().unwrap().clone();
|
||||
p.props.indent_first = Some(first);
|
||||
d.body[0] = Arc::new(Block::Para(p));
|
||||
}
|
||||
d.insert_object(&Pos::body(0, 0), rule(pct, align), &Default::default()).unwrap();
|
||||
if let Some(x) = side {
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, 0), rect_shape(100.0, 100.0, f), &Default::default()).unwrap();
|
||||
}
|
||||
let l = lay(&d);
|
||||
display::page_display(&d, &l.pages[0], &display::DisplayOptions::default())
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, .. } if rect.h < 10.0 => {
|
||||
Some((rect.x, rect.x + rect.w))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let (x0, x1) = beside(100.0, Align::Left, None, 0.0);
|
||||
let (tw, half) = (x1 - x0, (x1 - x0) / 2.0);
|
||||
let near =
|
||||
|got: (f32, f32), want: (f32, f32)| assert!((got.0 - want.0).abs() < 0.5 && (got.1 - want.1).abs() < 0.5, "{got:?}, Word's rule {want:?}");
|
||||
// A float on the left: the room is from past it to the margin.
|
||||
let lo = x0 + 109.0;
|
||||
near(beside(50.0, Align::Left, Some(0.0), 0.0), (lo, lo + half));
|
||||
near(beside(50.0, Align::Center, Some(0.0), 0.0), ((lo + x1 - half) / 2.0, (lo + x1 + half) / 2.0));
|
||||
near(beside(50.0, Align::Right, Some(0.0), 0.0), (x1 - half, x1));
|
||||
near(beside(100.0, Align::Left, Some(0.0), 0.0), (lo, lo + tw));
|
||||
// A float on the right: from the margin to before it.
|
||||
let hi = x1 - 109.0;
|
||||
near(beside(50.0, Align::Center, Some(tw - 100.0), 0.0), ((x0 + hi - half) / 2.0, (x0 + hi + half) / 2.0));
|
||||
near(beside(50.0, Align::Right, Some(tw - 100.0), 0.0), (hi - half, hi));
|
||||
near(beside(100.0, Align::Left, Some(tw - 100.0), 0.0), (x0, x1));
|
||||
// A first-line indent moves the room along.
|
||||
near(beside(100.0, Align::Left, None, 36.0), (x0 + 36.0, x1 + 36.0));
|
||||
near(beside(50.0, Align::Center, None, 36.0), (x0 + 36.0 + half / 2.0, x1 + 36.0 - half / 2.0));
|
||||
near(beside(50.0, Align::Right, None, 36.0), (x1 + 36.0 - half, x1 + 36.0));
|
||||
// Measuring a table column: a rule of its own width asks for it, a share of the line for none.
|
||||
let measured = |pct: f32| {
|
||||
let mut o = rect_shape(150.0, 1.5, Default::default());
|
||||
if let InlineObject::Shape { extra, .. } = &mut o {
|
||||
extra.hr = Some(HorizontalRule { pct, align: Align::Left, standard: false });
|
||||
}
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
let mut p = Paragraph::default();
|
||||
p.insert_object(0, o, &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
crate::autofit_widths(&crate::measure_table_columns(&d, &t), 451.0)[0]
|
||||
};
|
||||
assert!(measured(0.0) >= 150.0, "{}", measured(0.0));
|
||||
assert!(measured(100.0) < 150.0, "{}", measured(100.0));
|
||||
}
|
||||
|
||||
/// A kept-together paragraph starting a continuous section low on a page, with room there for
|
||||
/// one of its lines, moves whole to the next page, where it fits (#3907 review).
|
||||
#[test]
|
||||
@@ -12762,3 +12861,5 @@ fn header_margin_table_area() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod shape_alpha;
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
//! looked up (#3692, #3706): an input 8x the size must cost about 8x as much, not 64x.
|
||||
//!
|
||||
//! The cost is the steps the lookups' walks count ([`crate::work`]), not a timing, so a busy
|
||||
//! machine can't make a linear lookup look quadratic or the other way round. Only proofing text,
|
||||
//! whose walk is the document model's, is timed.
|
||||
//! machine can't make a linear lookup look quadratic or the other way round.
|
||||
|
||||
use super::{SCALE, cost_ratio};
|
||||
use super::SCALE;
|
||||
use crate::{
|
||||
Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, Page, ParaLayout, Paragraph, Path,
|
||||
Placed, SectionProps, SectionStart, StoryRef, display, layout, para, work,
|
||||
@@ -56,22 +55,12 @@ fn proof_text_walks_the_runs_once() {
|
||||
p
|
||||
};
|
||||
assert_eq!(para::proof_text(¶(5)), "a\u{1}a\u{1}a");
|
||||
// The runs are the document model's, whose walk counts no steps, so this one is timed: the
|
||||
// CPU time this thread ran (`cost_ratio`). At 50,000 runs the old walk took 64x as long for
|
||||
// 8x the runs, and the new one about 8x; a limit of 24x stays far from both, and a ratio over
|
||||
// it is measured again, twice, before the test fails, so a busy spell can't fail it alone.
|
||||
let (small, big) = (para(50_000), para(SCALE * 50_000));
|
||||
let proof_8 = |p: &Paragraph| (0..8).for_each(|_| drop(std::hint::black_box(para::proof_text(p))));
|
||||
let mut ratios = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let (t1, tk, ratio) = cost_ratio(SCALE, || proof_8(&small), || proof_8(&big));
|
||||
eprintln!("proof_text: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}");
|
||||
if ratio < 24.0 {
|
||||
return;
|
||||
}
|
||||
ratios.push(ratio);
|
||||
}
|
||||
panic!("proof_text: 8x the runs took {ratios:.1?}x the time, three times over: not linear");
|
||||
// The runs the walk looks at are counted now ([`crate::work`]), as every other walk here is,
|
||||
// so this no longer depends on a timing: the ratio was creeping over its limit when the
|
||||
// crate's tests ran in parallel, from cache and memory contention rather than from any
|
||||
// regression (#5185). Going back to the first run for every character counts the runs once
|
||||
// per character, which is the quadratic walk this watches for.
|
||||
assert_linear_steps("proof_text", 50_000, para, |p| drop(std::hint::black_box(para::proof_text(p))));
|
||||
}
|
||||
|
||||
/// A right-to-left paragraph whose one line holds `n` hidden letters: caret offsets with no width.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_translucent_shape_fill_is_drawn_translucent() {
|
||||
let mut d = Document::from_text("Wash");
|
||||
let wash = InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
fill: Some(wordcraft_doc::fill::Fill::Solid { color: wordcraft_doc::props::Rgb::WHITE, transparency: 69.0 }),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), wash, &Default::default()).unwrap();
|
||||
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
|
||||
let alpha =
|
||||
draws.iter().find_map(|x| if let display::Draw::Shape { fill: Some(display::Paint::Solid(_, alpha)), .. } = x { Some(*alpha) } else { None });
|
||||
assert!(alpha.is_some_and(|a| (a - 0.31).abs() < 0.001), "fill opacity {alpha:?}");
|
||||
}
|
||||
@@ -104,9 +104,9 @@ impl Exporter<'_> {
|
||||
if let (Some(rad), Some(c), true) = (fx.soft_edge, f.as_ref(), can_fill) {
|
||||
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
|
||||
s.set_stroke(None);
|
||||
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
|
||||
for (g, paint) in c.soft_bands(rad) {
|
||||
if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0), *corner) {
|
||||
s.set_fill(Some(paint_fill(&c.faded(a))));
|
||||
s.set_fill(Some(paint_fill(&paint)));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use super::*;
|
||||
use hayro_interpret::font::Glyph;
|
||||
use hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page,
|
||||
};
|
||||
use kurbo::Shape;
|
||||
use wordcraft_doc::effects::ShapeEffects;
|
||||
use wordcraft_doc::freeform::{FreePath, Freeform};
|
||||
use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::{CharProps, Document, Paragraph, para_block};
|
||||
|
||||
const ALPHA_TOLERANCE: f32 = 0.015;
|
||||
const RED: wordcraft_doc::Rgb = wordcraft_doc::Rgb(255, 0, 0);
|
||||
const GREEN: wordcraft_doc::Rgb = wordcraft_doc::Rgb(0, 255, 0);
|
||||
|
||||
struct PaintedPath {
|
||||
path: kurbo::BezPath,
|
||||
color: [u8; 4],
|
||||
}
|
||||
|
||||
struct Paints {
|
||||
fills: Vec<PaintedPath>,
|
||||
strokes: Vec<PaintedPath>,
|
||||
groups: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Device<'_> for Paints {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, path: &kurbo::BezPath, transform: kurbo::Affine, paint: &Paint<'_>, mode: &PathDrawMode) {
|
||||
let Paint::Color(color) = paint else { panic!("solid fixture paint") };
|
||||
let mut color = color.to_rgba().to_rgba8();
|
||||
color[3] = (f32::from(color[3]) * self.groups.last().copied().unwrap_or(1.0)).round() as u8;
|
||||
let record = PaintedPath { path: transform * path.clone(), color };
|
||||
match mode {
|
||||
PathDrawMode::Fill(_) => self.fills.push(record),
|
||||
PathDrawMode::Stroke(_) => self.strokes.push(record),
|
||||
}
|
||||
}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn push_transparency_group(&mut self, alpha: f32, _: Option<SoftMask<'_>>, _: BlendMode) {
|
||||
self.groups.push(self.groups.last().copied().unwrap_or(1.0) * alpha);
|
||||
}
|
||||
fn pop_transparency_group(&mut self) {
|
||||
assert!(self.groups.len() > 1);
|
||||
let _ = self.groups.pop();
|
||||
}
|
||||
fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {}
|
||||
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
|
||||
}
|
||||
|
||||
impl Paints {
|
||||
fn red_coverage(&self, point: (f64, f64)) -> f32 {
|
||||
1.0 - self
|
||||
.fills
|
||||
.iter()
|
||||
.filter(|r| r.color[..3] == [255, 0, 0] && r.path.contains(point.into()))
|
||||
.map(|r| 1.0 - f32::from(r.color[3]) / 255.0)
|
||||
.product::<f32>()
|
||||
}
|
||||
}
|
||||
|
||||
fn paints(shape: InlineObject) -> Paints {
|
||||
let mut doc = Document::new();
|
||||
let mut para = Paragraph::new();
|
||||
para.insert_object(0, shape, &CharProps::default()).unwrap();
|
||||
doc.body = vec![para_block(para)];
|
||||
let bytes = export(&doc, &PdfOptions { tagged: false, ..Default::default() }).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
|
||||
let cache = InterpreterCache::new();
|
||||
let mut paints = Paints { fills: Vec::new(), strokes: Vec::new(), groups: vec![1.0] };
|
||||
for page in pdf.pages().iter() {
|
||||
let bounds = page.media_box();
|
||||
let (width, height) = (bounds.width(), bounds.height());
|
||||
// PDF uses bottom-up coordinates; the fixture points use the layout's top-down frame.
|
||||
let top_down = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, height]);
|
||||
let mut ctx = Context::new(top_down, kurbo::Rect::new(0.0, 0.0, width, height), &cache, pdf.xref(), InterpreterSettings::default());
|
||||
interpret_page(page, &mut ctx, &mut paints);
|
||||
}
|
||||
assert_eq!(paints.groups, [1.0]);
|
||||
paints
|
||||
}
|
||||
|
||||
fn shape(kind: ShapeKind, transparency: f32, effects: ShapeEffects) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind,
|
||||
w: 24.0,
|
||||
h: 24.0,
|
||||
fill: Some(wordcraft_doc::fill::Fill::Solid { color: RED, transparency }),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, ..Default::default() },
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(actual: f32, want: f32) {
|
||||
assert!((actual - want).abs() < ALPHA_TOLERANCE, "{actual} != {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_fill_keeps_outline() {
|
||||
for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0)] {
|
||||
let mut item = shape(ShapeKind::Freeform, transparency, ShapeEffects::default());
|
||||
if let InlineObject::Shape { freeform, stroke, stroke_width, .. } = &mut item {
|
||||
*freeform = Some(std::sync::Arc::new(Freeform {
|
||||
w: 24.0,
|
||||
h: 24.0,
|
||||
paths: vec![FreePath { pts: vec![[0.0, 0.0], [24.0, 0.0], [24.0, 24.0], [0.0, 24.0]], closed: true }],
|
||||
alpha: 0.25,
|
||||
..Default::default()
|
||||
}));
|
||||
*stroke = Some(GREEN);
|
||||
*stroke_width = 2.0;
|
||||
}
|
||||
let paints = paints(item);
|
||||
close(paints.red_coverage((112.0, 112.0)), alpha);
|
||||
assert_eq!(paints.strokes.len(), 1);
|
||||
assert_eq!(paints.strokes[0].color, [0, 255, 0, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_edge_target_alpha() {
|
||||
for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] {
|
||||
for (radius, count) in [(3.0, 3.0), (6.0, 4.0)] {
|
||||
for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0), (f32::NAN, 1.0)] {
|
||||
let effects = ShapeEffects { soft_edge: Some(radius), ..Default::default() };
|
||||
let paints = paints(shape(kind, transparency, effects));
|
||||
close(paints.red_coverage((112.0, 112.0)), alpha);
|
||||
close(paints.red_coverage((101.125, 112.0)), alpha / count);
|
||||
close(paints.red_coverage((99.0, 112.0)), 0.0);
|
||||
assert!(paints.strokes.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_edge_keeps_effect_paint() {
|
||||
let effects = ShapeEffects {
|
||||
shadow: Some(wordcraft_doc::effects::Shadow::default()),
|
||||
glow: Some(wordcraft_doc::effects::Glow::default()),
|
||||
soft_edge: Some(6.0),
|
||||
};
|
||||
let opaque = paints(shape(ShapeKind::Rectangle, 0.0, effects));
|
||||
let clear = paints(shape(ShapeKind::Rectangle, 100.0, effects));
|
||||
let other = |r: &&PaintedPath| r.color[..3] != [255, 0, 0];
|
||||
let before: Vec<_> = opaque.fills.iter().filter(other).map(|r| (&r.path, r.color)).collect();
|
||||
let after: Vec<_> = clear.fills.iter().filter(other).map(|r| (&r.path, r.color)).collect();
|
||||
assert!(!before.is_empty());
|
||||
assert_eq!(before, after);
|
||||
assert!(opaque.strokes.is_empty() && clear.strokes.is_empty());
|
||||
for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0)] {
|
||||
close(paints(shape(ShapeKind::Rectangle, transparency, ShapeEffects::default())).red_coverage((112.0, 112.0)), alpha);
|
||||
}
|
||||
}
|
||||
@@ -76,3 +76,6 @@ use picture::{load_image, load_image_within};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fill_alpha_tests;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::graphic::PathSeg;
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
const PIXEL_TOLERANCE: u8 = 3;
|
||||
const BLUE: Rgb = Rgb(0, 0, 255);
|
||||
const RED: Rgb = Rgb(255, 0, 0);
|
||||
const GREEN: Rgb = Rgb(0, 255, 0);
|
||||
|
||||
fn image(item: Draw) -> Rendered {
|
||||
let items = [Draw::Fill { rect: Rect::new(0.0, 0.0, 40.0, 40.0), color: BLUE, alpha: 1.0, text_bg: false }, item];
|
||||
render_items(&Document::new(), &items, Rect::new(0.0, 0.0, 40.0, 40.0), 4.0, &RenderOptions::default())
|
||||
}
|
||||
|
||||
fn close(pixel: [u8; 4], alpha: f32) {
|
||||
let red = (255.0 * alpha).round() as u8;
|
||||
let blue = (255.0 * (1.0 - alpha)).round() as u8;
|
||||
let want = [red, 0, blue, 255];
|
||||
assert!(pixel.into_iter().zip(want).all(|(a, b)| a.abs_diff(b) <= PIXEL_TOLERANCE), "{pixel:?} != {want:?}");
|
||||
}
|
||||
|
||||
fn soft_shape(kind: ShapeKind, alpha: f32, radius: f32, effects: ShapeEffects) -> Draw {
|
||||
Draw::Shape {
|
||||
rect: Rect::new(4.0, 4.0, 24.0, 24.0),
|
||||
kind,
|
||||
fill: Some(wordcraft_layout::display::Paint::Solid(RED, alpha)),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
corner: None,
|
||||
effects: ShapeEffects { soft_edge: Some(radius), ..effects },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_fill_keeps_stroke_alpha() {
|
||||
for alpha in [0.0, 0.5, 1.0] {
|
||||
let item = Draw::Path {
|
||||
segs: vec![PathSeg::Move(4.0, 4.0), PathSeg::Line(28.0, 4.0), PathSeg::Line(28.0, 28.0), PathSeg::Line(4.0, 28.0), PathSeg::Close],
|
||||
fill: Some(wordcraft_layout::display::Paint::Solid(RED, alpha)),
|
||||
stroke: Some(GREEN),
|
||||
stroke_width: 2.0,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
};
|
||||
let rendered = image(item);
|
||||
close(rendered.pixel(64, 64), alpha);
|
||||
assert_eq!(rendered.pixel(16, 64), [0, 255, 0, 255], "stroke at fill alpha {alpha}");
|
||||
}
|
||||
let ink = image(Draw::Ink { pts: vec![(4.0, 16.0), (28.0, 16.0)], color: RED, width: 4.0, alpha: 0.5 });
|
||||
close(ink.pixel(64, 64), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_edge_has_target_alpha() {
|
||||
for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] {
|
||||
for (radius, count) in [(3.0, 3.0), (6.0, 4.0)] {
|
||||
for alpha in [0.0, 0.5, 1.0] {
|
||||
let rendered = image(soft_shape(kind, alpha, radius, ShapeEffects::default()));
|
||||
close(rendered.pixel(64, 64), alpha);
|
||||
close(rendered.pixel(20, 64), alpha / count);
|
||||
assert_eq!(rendered.pixel(0, 64), [0, 0, 255, 255]);
|
||||
}
|
||||
}
|
||||
let opaque = image(soft_shape(kind, 1.0, 6.0, ShapeEffects::default()));
|
||||
for alpha in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 2.0] {
|
||||
assert_eq!(image(soft_shape(kind, alpha, 6.0, ShapeEffects::default())).pixels, opaque.pixels);
|
||||
}
|
||||
assert_eq!(
|
||||
image(soft_shape(kind, -1.0, 6.0, ShapeEffects::default())).pixels,
|
||||
image(soft_shape(kind, 0.0, 6.0, ShapeEffects::default())).pixels,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_edge_keeps_effects() {
|
||||
let effects = ShapeEffects { shadow: Some(Shadow::default()), glow: Some(Glow::default()), soft_edge: None };
|
||||
let opaque = image(soft_shape(ShapeKind::Rectangle, 1.0, 6.0, effects));
|
||||
let clear = image(soft_shape(ShapeKind::Rectangle, 0.0, 6.0, effects));
|
||||
let mut effect_pixels = 0;
|
||||
for y in 0..160 {
|
||||
for x in 0..160 {
|
||||
if (16..112).contains(&x) && (16..112).contains(&y) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(opaque.pixel(x, y), clear.pixel(x, y));
|
||||
effect_pixels += usize::from(opaque.pixel(x, y) != [0, 0, 255, 255]);
|
||||
}
|
||||
}
|
||||
assert!(effect_pixels > 0);
|
||||
let stroke = |alpha| {
|
||||
let mut item = soft_shape(ShapeKind::Rectangle, alpha, 6.0, ShapeEffects::default());
|
||||
if let Draw::Shape { fill, stroke, stroke_width, .. } = &mut item {
|
||||
*fill = None;
|
||||
*stroke = Some(GREEN);
|
||||
*stroke_width = 2.0;
|
||||
}
|
||||
image(item)
|
||||
};
|
||||
assert_eq!(stroke(0.0).pixels, stroke(1.0).pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_samples_soft_edge_bands() {
|
||||
for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] {
|
||||
for alpha in [0.0, 0.5, 1.0] {
|
||||
let shape = soft_shape(kind, alpha, 6.0, ShapeEffects::default());
|
||||
let rendered = image(shape.clone());
|
||||
for (x, y) in [(18, 64), (22, 64), (30, 64), (64, 64)] {
|
||||
let mut bg = [0.0, 0.0, 255.0];
|
||||
let point = wordcraft_geom::Point::new((x as f32 + 0.5) / 4.0, (y as f32 + 0.5) / 4.0);
|
||||
blend_backgrounds(std::slice::from_ref(&shape), point, &RenderOptions::default(), &mut bg, 0);
|
||||
let pixel = rendered.pixel(x, y);
|
||||
for (sample, channel) in bg.into_iter().zip(pixel) {
|
||||
assert!((sample - channel as f32).abs() <= PIXEL_TOLERANCE as f32, "{kind:?}, alpha={alpha}, ({x},{y}): {bg:?} vs {pixel:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,28 @@ pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32,
|
||||
Rgb(c(bg[0]), c(bg[1]), c(bg[2]))
|
||||
}
|
||||
|
||||
// Sample the same inset silhouettes and paints used by the raster and PDF.
|
||||
fn soft_fill_alpha(
|
||||
rect: wordcraft_geom::Rect,
|
||||
kind: ShapeKind,
|
||||
corner: Option<u32>,
|
||||
radius: f32,
|
||||
fill: &wordcraft_layout::display::Paint,
|
||||
p: wordcraft_geom::Point,
|
||||
) -> f32 {
|
||||
let radius = radius.min(rect.w.min(rect.h) / 2.0).max(0.0);
|
||||
let frame = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
let point = kurbo::Point::new(p.x as f64, p.y as f64);
|
||||
let mut opacity = 0.0;
|
||||
for (grow, paint) in fill.soft_bands(radius) {
|
||||
let inset = frame.inflate(grow as f64, grow as f64);
|
||||
if inset.width() > 0.0 && inset.height() > 0.0 && kurbo::Shape::contains(&shape_path(kind, inset, corner), point) {
|
||||
opacity += (1.0 - opacity) * paint.at(p.x, p.y).1;
|
||||
}
|
||||
}
|
||||
opacity
|
||||
}
|
||||
|
||||
fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOptions, bg: &mut [f32; 3], depth: u32) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
@@ -351,13 +373,15 @@ fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOpti
|
||||
Draw::Fill { rect, color, alpha, .. } if rect.contains(p) => (*color, *alpha),
|
||||
// The shape's own outline (an ellipse's corners are not inside it), in its fill's
|
||||
// colour and opacity there (#1500: gradients and transparency).
|
||||
Draw::Shape { rect, kind, fill: Some(fill), corner, .. }
|
||||
Draw::Shape { rect, kind, fill: Some(fill), corner, effects, .. }
|
||||
if *kind != ShapeKind::Line && rect.contains(p) && {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
kurbo::Shape::contains(&shape_path(*kind, r, *corner), kurbo::Point::new(p.x as f64, p.y as f64))
|
||||
} =>
|
||||
{
|
||||
fill.at(p.x, p.y)
|
||||
let (color, alpha) = fill.at(p.x, p.y);
|
||||
let alpha = effects.sanitized().soft_edge.map_or(alpha, |radius| soft_fill_alpha(*rect, *kind, *corner, radius, fill, p));
|
||||
(color, alpha)
|
||||
}
|
||||
Draw::Clip { rect, items } if rect.contains(p) => {
|
||||
blend_backgrounds(items, p, opts, bg, depth + 1);
|
||||
@@ -913,10 +937,10 @@ fn shape_shadow_and_glow(ctx: &mut RenderContext, fr: &Frame, (geom, sw): (&Shap
|
||||
|
||||
/// Soft edges: the fill fades out toward the outline (which fades with it).
|
||||
fn shape_soft_edges(ctx: &mut RenderContext, fr: &Frame, geom: &ShapeGeom, (rad, f): (f32, &Paint)) {
|
||||
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
|
||||
for (g, paint) in f.soft_bands(rad) {
|
||||
let rr = geom.r.inflate(g as f64, g as f64);
|
||||
if rr.width() > 0.0 && rr.height() > 0.0 {
|
||||
set_paint(ctx, &f.faded(a), fr.opts);
|
||||
set_paint(ctx, &paint, fr.opts);
|
||||
ctx.fill_path(&shape_path(geom.kind, rr, geom.corner));
|
||||
}
|
||||
}
|
||||
@@ -1103,3 +1127,6 @@ mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod cache_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fill_alpha_tests;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user