diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index aff28f8d91..6da6ec45f7 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -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 = [] diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs index 67791967b0..8f69ee35ae 100644 --- a/crates/doc/src/connector.rs +++ b/crates/doc/src/connector.rs @@ -213,6 +213,39 @@ pub struct ShapeExtra { /// main colour (the shape's `stroke`). #[serde(skip_serializing_if = "Option::is_none")] pub outline_fill: Option, + /// A horizontal rule (VML `o:hr`: a web page's `
` 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, +} + +/// 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 { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9d0100eb0d..bcdd02948e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/zip_guard.rs b/crates/doc/src/zip_guard.rs new file mode 100644 index 0000000000..0610cac1e3 --- /dev/null +++ b/crates/doc/src/zip_guard.rs @@ -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 { + // 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 { + 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 = (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"))); + } +} diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index e964b0d142..1667bc1c2f 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -56,11 +56,19 @@ pub fn read_xml(bytes: &[u8]) -> Result { /// 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(package: R, max_part: u64) -> Option { +/// 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(package: R, max_part: u64) -> Option { 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()?; diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 0f91ef8a87..3835bdc0b6 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -213,6 +213,8 @@ impl Package { } pub fn open_limited(bytes: &[u8], max_part: u64, max_total: u64) -> Result { + // 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#"Plan"#.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])]); diff --git a/crates/docx/src/read/story/vml.rs b/crates/docx/src/read/story/vml.rs index 06fccdfa91..ebb2353b6a 100644 --- a/crates/docx/src/read/story/vml.rs +++ b/crates/docx/src/read/story/vml.rs @@ -295,6 +295,22 @@ impl Reader<'_> { } let kind = kind?; let mut extra = ShapeExtra { cap, ..Default::default() }; + // A horizontal rule (`o:hr`, a pasted `
`): 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::().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. diff --git a/crates/docx/src/write/story/object.rs b/crates/docx/src/write/story/object.rs index 340ca8fce3..cee8a045c1 100644 --- a/crates/docx/src/write/story/object.rs +++ b/crates/docx/src/write/story/object.rs @@ -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); diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 83a8d4cbf1..82d506793e 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -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#""#; + let d = read_body(&format!("{}", anchored_wsp("Wash", 1270000, 635000, fill, "", ""))); + 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!("")), "{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 `
` 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#""#); + 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, 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##""##, + ); + 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::>(), [None, None]); +} diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index 2e293bf8c6..1320c30bbb 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -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"]; diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index 61d4460045..39742d3e83 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -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, Option, 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); diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 8e352dd925..53c7fca84f 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -201,6 +201,7 @@ struct Package<'a> { impl<'a> Package<'a> { fn open(bytes: &'a [u8]) -> Result { + 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"); diff --git a/crates/layout/src/display/lines.rs b/crates/layout/src/display/lines.rs index 857a81aac2..04e49bc41d 100644 --- a/crates/layout/src/display/lines.rs +++ b/crates/layout/src/display/lines.rs @@ -583,7 +583,7 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec) { } 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 { .. })) => { diff --git a/crates/layout/src/display/objects.rs b/crates/layout/src/display/objects.rs index c6119e525a..7fe6d857fd 100644 --- a/crates/layout/src/display/objects.rs +++ b/crates/layout/src/display/objects.rs @@ -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; diff --git a/crates/layout/src/display/objects/fill_alpha_tests.rs b/crates/layout/src/display/objects/fill_alpha_tests.rs new file mode 100644 index 0000000000..25f175c9ee --- /dev/null +++ b/crates/layout/src/display/objects/fill_alpha_tests.rs @@ -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))) + )); +} diff --git a/crates/layout/src/display/paint.rs b/crates/layout/src/display/paint.rs index b5cb62f7da..7509a50625 100644 --- a/crates/layout/src/display/paint.rs +++ b/crates/layout/src/display/paint.rs @@ -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) { diff --git a/crates/layout/src/floats.rs b/crates/layout/src/floats.rs index 6a7d3551b7..876b4c32ad 100644 --- a/crates/layout/src/floats.rs +++ b/crates/layout/src/floats.rs @@ -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 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 { diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index f5fdb3c670..1a67b2fe41 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -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 diff --git a/crates/layout/src/para/proof.rs b/crates/layout/src/para/proof.rs index 1cec32e4de..b9ffa911c8 100644 --- a/crates/layout/src/para/proof.rs +++ b/crates/layout/src/para/proof.rs @@ -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(); diff --git a/crates/layout/src/placed.rs b/crates/layout/src/placed.rs index cf55bccf94..e6ce2a2da7 100644 --- a/crates/layout/src/placed.rs +++ b/crates/layout/src/placed.rs @@ -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, /// 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, diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 5e7904e39f..d999f801db 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -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; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 5caf63784a..6879f5c378 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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 { + 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, 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; diff --git a/crates/layout/src/tests/linear.rs b/crates/layout/src/tests/linear.rs index 30db6ec008..993dcbedd1 100644 --- a/crates/layout/src/tests/linear.rs +++ b/crates/layout/src/tests/linear.rs @@ -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. diff --git a/crates/layout/src/tests/shape_alpha.rs b/crates/layout/src/tests/shape_alpha.rs new file mode 100644 index 0000000000..49bedc553e --- /dev/null +++ b/crates/layout/src/tests/shape_alpha.rs @@ -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:?}"); +} diff --git a/crates/pdf/src/exporter/draw.rs b/crates/pdf/src/exporter/draw.rs index 73b6d5e1a5..f2e9d0ab52 100644 --- a/crates/pdf/src/exporter/draw.rs +++ b/crates/pdf/src/exporter/draw.rs @@ -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); } } diff --git a/crates/pdf/src/fill_alpha_tests.rs b/crates/pdf/src/fill_alpha_tests.rs new file mode 100644 index 0000000000..b90381f1cf --- /dev/null +++ b/crates/pdf/src/fill_alpha_tests.rs @@ -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, + strokes: Vec, + groups: Vec, +} + +impl Device<'_> for Paints { + fn set_soft_mask(&mut self, _: Option>) {} + 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>, _: 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::() + } +} + +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); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 78a27d35e8..739108e1b8 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -76,3 +76,6 @@ use picture::{load_image, load_image_within}; #[cfg(test)] mod tests; + +#[cfg(test)] +mod fill_alpha_tests; diff --git a/crates/render/src/fill_alpha_tests.rs b/crates/render/src/fill_alpha_tests.rs new file mode 100644 index 0000000000..e27776e95c --- /dev/null +++ b/crates/render/src/fill_alpha_tests.rs @@ -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:?}"); + } + } + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8534735086..3704b2f433 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, + 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; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d9c9ab962f..3be026eaff 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; previously trivial: DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -283,6 +283,8 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| | 2026-11-02 | minor | `w:altChunk` content (MHT, HTML, RTF, text, nested documents) brought in on open, where it stands; `.mht`/`.mhtml` open; HTML reads `style` element rules, paragraph margins, indents and line height, cell widths and borders, and an opened page takes Word's web-page defaults. Insert › Text from File keeps the file's pictures and lists | +| 2026-11-02 | trivial | DOCX: a VML horizontal rule (`v:rect o:hr`, a web page's `
` pasted into Word) is drawn across its line as Word draws it — the whole width inside the paragraph's indents, or `o:hrpct` of it, or its own width, placed by `o:hralign` — instead of as a 1 pt dot; in a table cell it follows the column; it saves back as the same VML rule | +| 2026-11-02 | trivial | Soft edges on a see-through solid fill fade from the fill's own opacity, not from fully opaque, in the raster, PDF and caret-background paths; opaque fills draw as before (#1600). Synthetic raster/PDF tests; no new Word comparison | | 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle | | 2026-10-31 | trivial | .doc import: text naming no font takes the stylesheet's standard fonts (rgftcStandardChpStsh, ftcBi), not the font table's first entry; runs' East Asian and complex-script fonts are read (#5066) |