diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs
index 83a8d4cbf..dd5169d9a 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
diff --git a/crates/layout/src/display/objects.rs b/crates/layout/src/display/objects.rs
index c6119e525..9b5f3c075 100644
--- a/crates/layout/src/display/objects.rs
+++ b/crates/layout/src/display/objects.rs
@@ -506,3 +506,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 000000000..25f175c9e
--- /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 b5cb62f7d..7509a5062 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/tests.rs b/crates/layout/src/tests.rs
index 5caf63784..a836099af 100644
--- a/crates/layout/src/tests.rs
+++ b/crates/layout/src/tests.rs
@@ -12762,3 +12762,5 @@ fn header_margin_table_area() {
}
}
}
+
+mod shape_alpha;
diff --git a/crates/layout/src/tests/shape_alpha.rs b/crates/layout/src/tests/shape_alpha.rs
new file mode 100644
index 000000000..49bedc553
--- /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 73b6d5e1a..f2e9d0ab5 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 000000000..b90381f1c
--- /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 78a27d35e..739108e1b 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 000000000..e27776e95
--- /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 853473508..3704b2f43 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 fd2c19880..82dfd96a6 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-01 · **Change:** 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:** 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;
@@ -281,6 +281,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
| Date | Change | Summary |
|---|---|---|
+| 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) |