Merge remote-tracking branch 'origin/main' into pr1104

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
FredApps
2026-11-02 18:25:52 +01:00
31 changed files with 1039 additions and 38 deletions
+2 -1
View File
@@ -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 = []
+33
View File
@@ -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 {
+1
View File
@@ -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;
+191
View File
@@ -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
View File
@@ -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()?;
+17
View File
@@ -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])]);
+16
View File
@@ -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.
+71
View File
@@ -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);
+83
View File
@@ -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 &paras(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]);
}
+4
View File
@@ -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"];
+1
View File
@@ -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);
+9
View File
@@ -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");
+1 -1
View File
@@ -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 { .. })) => {
+15 -1
View File
@@ -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)))
));
}
+10
View File
@@ -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) {
+2 -2
View File
@@ -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 };
+13
View File
@@ -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 {
+11
View File
@@ -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
+5
View File
@@ -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();
+1 -1
View File
@@ -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>,
+1 -1
View File
@@ -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
View File
@@ -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;
+8 -19
View File
@@ -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(&para(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.
+24
View File
@@ -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:?}");
}
+2 -2
View File
@@ -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);
}
}
+161
View File
@@ -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);
}
}
+3
View File
@@ -76,3 +76,6 @@ use picture::{load_image, load_image_within};
#[cfg(test)]
mod tests;
#[cfg(test)]
mod fill_alpha_tests;
+123
View File
@@ -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:?}");
}
}
}
}
}
+31 -4
View File
@@ -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