mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 08:50:02 -06:00
Shapes: align soft-edge paint and caret opacity (#1600)
* Shapes: a solid fill keeps its transparency A shape's fill colour can carry a:alpha, its opacity. WordCraft dropped it, so a translucent white wash laid over a picture was drawn opaque and hid the picture. The transparency is now read into the shape, drawn on screen and in the PDF, and written back. * Fix #1600 freeform and soft-edge fill opacity * #1600: wrap canonical caret sampler signature * #1600: format canonical integration * #1600: assert canonical DOCX alpha round-trip * #1600: document canonical shape opacity API * Adapt alpha fixture to rectangle render bounds * docs: one file-format-parity row for soft-edge fill opacity, status line --------- Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Brandon Thomas
parent
242e850aaa
commit
4f2d0da04c
@@ -4092,6 +4092,30 @@ fn equation_in_alternate_content_is_read() {
|
||||
assert!(!p.plain_text().contains("[picture]"));
|
||||
}
|
||||
|
||||
/// A fill's `a:alpha` is its opacity: a translucent white wash over a picture stayed opaque and
|
||||
/// hid the picture.
|
||||
#[test]
|
||||
fn a_shape_fill_keeps_its_transparency() {
|
||||
let fill =
|
||||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="30980"/></a:srgbClr></a:solidFill>"#;
|
||||
let d = read_body(&format!("<w:p>{}</w:p>", anchored_wsp("Wash", 1270000, 635000, fill, "", "<wps:cNvSpPr/>")));
|
||||
let transparency = |d: &Document| match ¶s(d)[0].objects[0] {
|
||||
InlineObject::Shape { fill: Some(wordcraft_doc::fill::Fill::Solid { color, transparency }), .. } => (Some(*color), *transparency),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
const FILL_ALPHA: u32 = 30_980;
|
||||
const ALPHA_SCALE: f32 = 100_000.0;
|
||||
// Canonical Fill stores the percentage computed from opacity, including f32 rounding.
|
||||
let expected = 100.0 - (FILL_ALPHA as f32 / ALPHA_SCALE) * 100.0;
|
||||
assert_eq!(transparency(&d), (Some(Rgb(255, 255, 255)), expected));
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let parts = unzip(&bytes);
|
||||
let xml = String::from_utf8_lossy(parts.get("word/document.xml").unwrap());
|
||||
assert!(xml.contains(&format!("<a:alpha val=\"{FILL_ALPHA}\"/>")), "{xml}");
|
||||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(transparency(&again), (Some(Rgb(255, 255, 255)), expected));
|
||||
}
|
||||
|
||||
/// #1500: gradient fills and transparency on shapes (the cases in the issue), from the shape's
|
||||
/// own properties, the theme's fill styles (`a:fillRef` 3 and 1003, as Word's cover pages use),
|
||||
/// a group's fill (`a:grpFill`), an outline gradient and VML's `v:fill`. Each is read as the
|
||||
|
||||
@@ -506,3 +506,6 @@ fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw {
|
||||
cap: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fill_alpha_tests;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::connector::ShapeExtra;
|
||||
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool};
|
||||
|
||||
#[test]
|
||||
fn freeform_fill_has_own_alpha() {
|
||||
let rect = Rect::new(4.0, 4.0, 24.0, 24.0);
|
||||
for transparency in [0.0, 50.0, 100.0] {
|
||||
for closed in [false, true] {
|
||||
let freeform = Freeform {
|
||||
w: 24.0,
|
||||
h: 24.0,
|
||||
paths: vec![FreePath { pts: vec![[0.0, 0.0], [24.0, 0.0], [24.0, 24.0]], closed }],
|
||||
alpha: 0.25,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
shape_draws(
|
||||
rect,
|
||||
&ShapeLook {
|
||||
kind: ShapeKind::Freeform,
|
||||
fill: Some(&wordcraft_doc::fill::Fill::Solid { color: Rgb::BLACK, transparency }),
|
||||
stroke: Some(Rgb::WHITE),
|
||||
stroke_width: 2.0,
|
||||
effects: Default::default(),
|
||||
freeform: Some(&freeform),
|
||||
extra: &ShapeExtra::default(),
|
||||
},
|
||||
&DisplayOptions::default(),
|
||||
&mut out,
|
||||
);
|
||||
let Draw::Path { fill, stroke, stroke_width, .. } = &out[0] else { panic!("expected freeform path") };
|
||||
match fill {
|
||||
Some(Paint::Solid(color, alpha)) if closed => {
|
||||
assert_eq!(*color, Rgb::BLACK);
|
||||
assert_eq!(*alpha, 1.0 - transparency / 100.0);
|
||||
}
|
||||
None if !closed => {}
|
||||
_ => panic!("unexpected freeform paint: {fill:?}"),
|
||||
}
|
||||
assert_eq!((*stroke, *stroke_width), (Some(Rgb::WHITE), 2.0));
|
||||
}
|
||||
}
|
||||
let ink = Freeform::ink(InkTool::Highlighter, 24.0, 24.0, vec![[0.0, 0.0], [24.0, 24.0]]);
|
||||
let mut out = Vec::new();
|
||||
shape_draws(
|
||||
rect,
|
||||
&ShapeLook {
|
||||
kind: ShapeKind::Freeform,
|
||||
fill: Some(&wordcraft_doc::fill::Fill::Solid { color: Rgb::BLACK, transparency: 100.0 }),
|
||||
stroke: Some(Rgb::WHITE),
|
||||
stroke_width: 2.0,
|
||||
effects: Default::default(),
|
||||
freeform: Some(&ink),
|
||||
extra: &ShapeExtra::default(),
|
||||
},
|
||||
&DisplayOptions::default(),
|
||||
&mut out,
|
||||
);
|
||||
assert!(matches!(out.as_slice(), [Draw::Ink { alpha, .. }] if *alpha == 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_path_alpha_defaults() {
|
||||
let rect = Rect::new(4.0, 4.0, 24.0, 24.0);
|
||||
let graphic = Graphic {
|
||||
items: vec![GraphicItem::Path {
|
||||
segs: vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(24.0, 24.0)],
|
||||
fill: Some(Rgb::BLACK),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = graphic_draws(&Document::new(), &graphic, rect, 1.0);
|
||||
out.push(path_arrowhead(&[(4.0, 4.0), (28.0, 28.0)], Rgb::BLACK, 2.0, ArrowStyle::default()).unwrap());
|
||||
let extra = ShapeExtra { arrow_start: true, arrow_end: true, ..Default::default() };
|
||||
line_draws(rect, ShapeKind::ElbowConnector, Some(Rgb::BLACK), 2.0, Default::default(), &extra, &mut out);
|
||||
assert_eq!(out.len(), 5);
|
||||
assert!(out.iter().all(
|
||||
|item| matches!(item, Draw::Path { fill, .. } if fill.as_ref().is_none_or(|paint| matches!(paint, Paint::Solid(_, alpha) if *alpha == 1.0)))
|
||||
));
|
||||
}
|
||||
@@ -32,6 +32,16 @@ impl Paint {
|
||||
Paint::Radial { c, r, stops } => Paint::Radial { c: *c, r: *r, stops: fade(stops) },
|
||||
}
|
||||
}
|
||||
/// Inset soft-edge paints. Solid bands composite to the fill's target opacity.
|
||||
pub fn soft_bands(&self, radius: f32) -> Vec<(f32, Paint)> {
|
||||
match self {
|
||||
Paint::Solid(color, alpha) => {
|
||||
let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 };
|
||||
wordcraft_doc::effects::bands(-radius, 0.0, alpha).into_iter().map(|(grow, band)| (grow, Paint::Solid(*color, band))).collect()
|
||||
}
|
||||
_ => wordcraft_doc::effects::bands(-radius, 0.0, 1.0).into_iter().map(|(grow, alpha)| (grow, self.faded(alpha))).collect(),
|
||||
}
|
||||
}
|
||||
/// The colour and opacity this paint gives the point (`x`, `y`): stops blended, padded past
|
||||
/// the ends.
|
||||
pub fn at(&self, x: f32, y: f32) -> (Rgb, f32) {
|
||||
|
||||
@@ -12762,3 +12762,5 @@ fn header_margin_table_area() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod shape_alpha;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_translucent_shape_fill_is_drawn_translucent() {
|
||||
let mut d = Document::from_text("Wash");
|
||||
let wash = InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
fill: Some(wordcraft_doc::fill::Fill::Solid { color: wordcraft_doc::props::Rgb::WHITE, transparency: 69.0 }),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), wash, &Default::default()).unwrap();
|
||||
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
|
||||
let alpha =
|
||||
draws.iter().find_map(|x| if let display::Draw::Shape { fill: Some(display::Paint::Solid(_, alpha)), .. } = x { Some(*alpha) } else { None });
|
||||
assert!(alpha.is_some_and(|a| (a - 0.31).abs() < 0.001), "fill opacity {alpha:?}");
|
||||
}
|
||||
@@ -104,9 +104,9 @@ impl Exporter<'_> {
|
||||
if let (Some(rad), Some(c), true) = (fx.soft_edge, f.as_ref(), can_fill) {
|
||||
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
|
||||
s.set_stroke(None);
|
||||
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
|
||||
for (g, paint) in c.soft_bands(rad) {
|
||||
if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0), *corner) {
|
||||
s.set_fill(Some(paint_fill(&c.faded(a))));
|
||||
s.set_fill(Some(paint_fill(&paint)));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use super::*;
|
||||
use hayro_interpret::font::Glyph;
|
||||
use hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page,
|
||||
};
|
||||
use kurbo::Shape;
|
||||
use wordcraft_doc::effects::ShapeEffects;
|
||||
use wordcraft_doc::freeform::{FreePath, Freeform};
|
||||
use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::{CharProps, Document, Paragraph, para_block};
|
||||
|
||||
const ALPHA_TOLERANCE: f32 = 0.015;
|
||||
const RED: wordcraft_doc::Rgb = wordcraft_doc::Rgb(255, 0, 0);
|
||||
const GREEN: wordcraft_doc::Rgb = wordcraft_doc::Rgb(0, 255, 0);
|
||||
|
||||
struct PaintedPath {
|
||||
path: kurbo::BezPath,
|
||||
color: [u8; 4],
|
||||
}
|
||||
|
||||
struct Paints {
|
||||
fills: Vec<PaintedPath>,
|
||||
strokes: Vec<PaintedPath>,
|
||||
groups: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Device<'_> for Paints {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, path: &kurbo::BezPath, transform: kurbo::Affine, paint: &Paint<'_>, mode: &PathDrawMode) {
|
||||
let Paint::Color(color) = paint else { panic!("solid fixture paint") };
|
||||
let mut color = color.to_rgba().to_rgba8();
|
||||
color[3] = (f32::from(color[3]) * self.groups.last().copied().unwrap_or(1.0)).round() as u8;
|
||||
let record = PaintedPath { path: transform * path.clone(), color };
|
||||
match mode {
|
||||
PathDrawMode::Fill(_) => self.fills.push(record),
|
||||
PathDrawMode::Stroke(_) => self.strokes.push(record),
|
||||
}
|
||||
}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn push_transparency_group(&mut self, alpha: f32, _: Option<SoftMask<'_>>, _: BlendMode) {
|
||||
self.groups.push(self.groups.last().copied().unwrap_or(1.0) * alpha);
|
||||
}
|
||||
fn pop_transparency_group(&mut self) {
|
||||
assert!(self.groups.len() > 1);
|
||||
let _ = self.groups.pop();
|
||||
}
|
||||
fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {}
|
||||
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
|
||||
}
|
||||
|
||||
impl Paints {
|
||||
fn red_coverage(&self, point: (f64, f64)) -> f32 {
|
||||
1.0 - self
|
||||
.fills
|
||||
.iter()
|
||||
.filter(|r| r.color[..3] == [255, 0, 0] && r.path.contains(point.into()))
|
||||
.map(|r| 1.0 - f32::from(r.color[3]) / 255.0)
|
||||
.product::<f32>()
|
||||
}
|
||||
}
|
||||
|
||||
fn paints(shape: InlineObject) -> Paints {
|
||||
let mut doc = Document::new();
|
||||
let mut para = Paragraph::new();
|
||||
para.insert_object(0, shape, &CharProps::default()).unwrap();
|
||||
doc.body = vec![para_block(para)];
|
||||
let bytes = export(&doc, &PdfOptions { tagged: false, ..Default::default() }).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
|
||||
let cache = InterpreterCache::new();
|
||||
let mut paints = Paints { fills: Vec::new(), strokes: Vec::new(), groups: vec![1.0] };
|
||||
for page in pdf.pages().iter() {
|
||||
let bounds = page.media_box();
|
||||
let (width, height) = (bounds.width(), bounds.height());
|
||||
// PDF uses bottom-up coordinates; the fixture points use the layout's top-down frame.
|
||||
let top_down = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, height]);
|
||||
let mut ctx = Context::new(top_down, kurbo::Rect::new(0.0, 0.0, width, height), &cache, pdf.xref(), InterpreterSettings::default());
|
||||
interpret_page(page, &mut ctx, &mut paints);
|
||||
}
|
||||
assert_eq!(paints.groups, [1.0]);
|
||||
paints
|
||||
}
|
||||
|
||||
fn shape(kind: ShapeKind, transparency: f32, effects: ShapeEffects) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind,
|
||||
w: 24.0,
|
||||
h: 24.0,
|
||||
fill: Some(wordcraft_doc::fill::Fill::Solid { color: RED, transparency }),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, ..Default::default() },
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(actual: f32, want: f32) {
|
||||
assert!((actual - want).abs() < ALPHA_TOLERANCE, "{actual} != {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_fill_keeps_outline() {
|
||||
for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0)] {
|
||||
let mut item = shape(ShapeKind::Freeform, transparency, ShapeEffects::default());
|
||||
if let InlineObject::Shape { freeform, stroke, stroke_width, .. } = &mut item {
|
||||
*freeform = Some(std::sync::Arc::new(Freeform {
|
||||
w: 24.0,
|
||||
h: 24.0,
|
||||
paths: vec![FreePath { pts: vec![[0.0, 0.0], [24.0, 0.0], [24.0, 24.0], [0.0, 24.0]], closed: true }],
|
||||
alpha: 0.25,
|
||||
..Default::default()
|
||||
}));
|
||||
*stroke = Some(GREEN);
|
||||
*stroke_width = 2.0;
|
||||
}
|
||||
let paints = paints(item);
|
||||
close(paints.red_coverage((112.0, 112.0)), alpha);
|
||||
assert_eq!(paints.strokes.len(), 1);
|
||||
assert_eq!(paints.strokes[0].color, [0, 255, 0, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_edge_target_alpha() {
|
||||
for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] {
|
||||
for (radius, count) in [(3.0, 3.0), (6.0, 4.0)] {
|
||||
for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0), (f32::NAN, 1.0)] {
|
||||
let effects = ShapeEffects { soft_edge: Some(radius), ..Default::default() };
|
||||
let paints = paints(shape(kind, transparency, effects));
|
||||
close(paints.red_coverage((112.0, 112.0)), alpha);
|
||||
close(paints.red_coverage((101.125, 112.0)), alpha / count);
|
||||
close(paints.red_coverage((99.0, 112.0)), 0.0);
|
||||
assert!(paints.strokes.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_edge_keeps_effect_paint() {
|
||||
let effects = ShapeEffects {
|
||||
shadow: Some(wordcraft_doc::effects::Shadow::default()),
|
||||
glow: Some(wordcraft_doc::effects::Glow::default()),
|
||||
soft_edge: Some(6.0),
|
||||
};
|
||||
let opaque = paints(shape(ShapeKind::Rectangle, 0.0, effects));
|
||||
let clear = paints(shape(ShapeKind::Rectangle, 100.0, effects));
|
||||
let other = |r: &&PaintedPath| r.color[..3] != [255, 0, 0];
|
||||
let before: Vec<_> = opaque.fills.iter().filter(other).map(|r| (&r.path, r.color)).collect();
|
||||
let after: Vec<_> = clear.fills.iter().filter(other).map(|r| (&r.path, r.color)).collect();
|
||||
assert!(!before.is_empty());
|
||||
assert_eq!(before, after);
|
||||
assert!(opaque.strokes.is_empty() && clear.strokes.is_empty());
|
||||
for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0)] {
|
||||
close(paints(shape(ShapeKind::Rectangle, transparency, ShapeEffects::default())).red_coverage((112.0, 112.0)), alpha);
|
||||
}
|
||||
}
|
||||
@@ -76,3 +76,6 @@ use picture::{load_image, load_image_within};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fill_alpha_tests;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::graphic::PathSeg;
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
const PIXEL_TOLERANCE: u8 = 3;
|
||||
const BLUE: Rgb = Rgb(0, 0, 255);
|
||||
const RED: Rgb = Rgb(255, 0, 0);
|
||||
const GREEN: Rgb = Rgb(0, 255, 0);
|
||||
|
||||
fn image(item: Draw) -> Rendered {
|
||||
let items = [Draw::Fill { rect: Rect::new(0.0, 0.0, 40.0, 40.0), color: BLUE, alpha: 1.0, text_bg: false }, item];
|
||||
render_items(&Document::new(), &items, Rect::new(0.0, 0.0, 40.0, 40.0), 4.0, &RenderOptions::default())
|
||||
}
|
||||
|
||||
fn close(pixel: [u8; 4], alpha: f32) {
|
||||
let red = (255.0 * alpha).round() as u8;
|
||||
let blue = (255.0 * (1.0 - alpha)).round() as u8;
|
||||
let want = [red, 0, blue, 255];
|
||||
assert!(pixel.into_iter().zip(want).all(|(a, b)| a.abs_diff(b) <= PIXEL_TOLERANCE), "{pixel:?} != {want:?}");
|
||||
}
|
||||
|
||||
fn soft_shape(kind: ShapeKind, alpha: f32, radius: f32, effects: ShapeEffects) -> Draw {
|
||||
Draw::Shape {
|
||||
rect: Rect::new(4.0, 4.0, 24.0, 24.0),
|
||||
kind,
|
||||
fill: Some(wordcraft_layout::display::Paint::Solid(RED, alpha)),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
corner: None,
|
||||
effects: ShapeEffects { soft_edge: Some(radius), ..effects },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_fill_keeps_stroke_alpha() {
|
||||
for alpha in [0.0, 0.5, 1.0] {
|
||||
let item = Draw::Path {
|
||||
segs: vec![PathSeg::Move(4.0, 4.0), PathSeg::Line(28.0, 4.0), PathSeg::Line(28.0, 28.0), PathSeg::Line(4.0, 28.0), PathSeg::Close],
|
||||
fill: Some(wordcraft_layout::display::Paint::Solid(RED, alpha)),
|
||||
stroke: Some(GREEN),
|
||||
stroke_width: 2.0,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
};
|
||||
let rendered = image(item);
|
||||
close(rendered.pixel(64, 64), alpha);
|
||||
assert_eq!(rendered.pixel(16, 64), [0, 255, 0, 255], "stroke at fill alpha {alpha}");
|
||||
}
|
||||
let ink = image(Draw::Ink { pts: vec![(4.0, 16.0), (28.0, 16.0)], color: RED, width: 4.0, alpha: 0.5 });
|
||||
close(ink.pixel(64, 64), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_edge_has_target_alpha() {
|
||||
for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] {
|
||||
for (radius, count) in [(3.0, 3.0), (6.0, 4.0)] {
|
||||
for alpha in [0.0, 0.5, 1.0] {
|
||||
let rendered = image(soft_shape(kind, alpha, radius, ShapeEffects::default()));
|
||||
close(rendered.pixel(64, 64), alpha);
|
||||
close(rendered.pixel(20, 64), alpha / count);
|
||||
assert_eq!(rendered.pixel(0, 64), [0, 0, 255, 255]);
|
||||
}
|
||||
}
|
||||
let opaque = image(soft_shape(kind, 1.0, 6.0, ShapeEffects::default()));
|
||||
for alpha in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 2.0] {
|
||||
assert_eq!(image(soft_shape(kind, alpha, 6.0, ShapeEffects::default())).pixels, opaque.pixels);
|
||||
}
|
||||
assert_eq!(
|
||||
image(soft_shape(kind, -1.0, 6.0, ShapeEffects::default())).pixels,
|
||||
image(soft_shape(kind, 0.0, 6.0, ShapeEffects::default())).pixels,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_edge_keeps_effects() {
|
||||
let effects = ShapeEffects { shadow: Some(Shadow::default()), glow: Some(Glow::default()), soft_edge: None };
|
||||
let opaque = image(soft_shape(ShapeKind::Rectangle, 1.0, 6.0, effects));
|
||||
let clear = image(soft_shape(ShapeKind::Rectangle, 0.0, 6.0, effects));
|
||||
let mut effect_pixels = 0;
|
||||
for y in 0..160 {
|
||||
for x in 0..160 {
|
||||
if (16..112).contains(&x) && (16..112).contains(&y) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(opaque.pixel(x, y), clear.pixel(x, y));
|
||||
effect_pixels += usize::from(opaque.pixel(x, y) != [0, 0, 255, 255]);
|
||||
}
|
||||
}
|
||||
assert!(effect_pixels > 0);
|
||||
let stroke = |alpha| {
|
||||
let mut item = soft_shape(ShapeKind::Rectangle, alpha, 6.0, ShapeEffects::default());
|
||||
if let Draw::Shape { fill, stroke, stroke_width, .. } = &mut item {
|
||||
*fill = None;
|
||||
*stroke = Some(GREEN);
|
||||
*stroke_width = 2.0;
|
||||
}
|
||||
image(item)
|
||||
};
|
||||
assert_eq!(stroke(0.0).pixels, stroke(1.0).pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_samples_soft_edge_bands() {
|
||||
for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] {
|
||||
for alpha in [0.0, 0.5, 1.0] {
|
||||
let shape = soft_shape(kind, alpha, 6.0, ShapeEffects::default());
|
||||
let rendered = image(shape.clone());
|
||||
for (x, y) in [(18, 64), (22, 64), (30, 64), (64, 64)] {
|
||||
let mut bg = [0.0, 0.0, 255.0];
|
||||
let point = wordcraft_geom::Point::new((x as f32 + 0.5) / 4.0, (y as f32 + 0.5) / 4.0);
|
||||
blend_backgrounds(std::slice::from_ref(&shape), point, &RenderOptions::default(), &mut bg, 0);
|
||||
let pixel = rendered.pixel(x, y);
|
||||
for (sample, channel) in bg.into_iter().zip(pixel) {
|
||||
assert!((sample - channel as f32).abs() <= PIXEL_TOLERANCE as f32, "{kind:?}, alpha={alpha}, ({x},{y}): {bg:?} vs {pixel:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,28 @@ pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32,
|
||||
Rgb(c(bg[0]), c(bg[1]), c(bg[2]))
|
||||
}
|
||||
|
||||
// Sample the same inset silhouettes and paints used by the raster and PDF.
|
||||
fn soft_fill_alpha(
|
||||
rect: wordcraft_geom::Rect,
|
||||
kind: ShapeKind,
|
||||
corner: Option<u32>,
|
||||
radius: f32,
|
||||
fill: &wordcraft_layout::display::Paint,
|
||||
p: wordcraft_geom::Point,
|
||||
) -> f32 {
|
||||
let radius = radius.min(rect.w.min(rect.h) / 2.0).max(0.0);
|
||||
let frame = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
let point = kurbo::Point::new(p.x as f64, p.y as f64);
|
||||
let mut opacity = 0.0;
|
||||
for (grow, paint) in fill.soft_bands(radius) {
|
||||
let inset = frame.inflate(grow as f64, grow as f64);
|
||||
if inset.width() > 0.0 && inset.height() > 0.0 && kurbo::Shape::contains(&shape_path(kind, inset, corner), point) {
|
||||
opacity += (1.0 - opacity) * paint.at(p.x, p.y).1;
|
||||
}
|
||||
}
|
||||
opacity
|
||||
}
|
||||
|
||||
fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOptions, bg: &mut [f32; 3], depth: u32) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
@@ -351,13 +373,15 @@ fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOpti
|
||||
Draw::Fill { rect, color, alpha, .. } if rect.contains(p) => (*color, *alpha),
|
||||
// The shape's own outline (an ellipse's corners are not inside it), in its fill's
|
||||
// colour and opacity there (#1500: gradients and transparency).
|
||||
Draw::Shape { rect, kind, fill: Some(fill), corner, .. }
|
||||
Draw::Shape { rect, kind, fill: Some(fill), corner, effects, .. }
|
||||
if *kind != ShapeKind::Line && rect.contains(p) && {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
kurbo::Shape::contains(&shape_path(*kind, r, *corner), kurbo::Point::new(p.x as f64, p.y as f64))
|
||||
} =>
|
||||
{
|
||||
fill.at(p.x, p.y)
|
||||
let (color, alpha) = fill.at(p.x, p.y);
|
||||
let alpha = effects.sanitized().soft_edge.map_or(alpha, |radius| soft_fill_alpha(*rect, *kind, *corner, radius, fill, p));
|
||||
(color, alpha)
|
||||
}
|
||||
Draw::Clip { rect, items } if rect.contains(p) => {
|
||||
blend_backgrounds(items, p, opts, bg, depth + 1);
|
||||
@@ -913,10 +937,10 @@ fn shape_shadow_and_glow(ctx: &mut RenderContext, fr: &Frame, (geom, sw): (&Shap
|
||||
|
||||
/// Soft edges: the fill fades out toward the outline (which fades with it).
|
||||
fn shape_soft_edges(ctx: &mut RenderContext, fr: &Frame, geom: &ShapeGeom, (rad, f): (f32, &Paint)) {
|
||||
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
|
||||
for (g, paint) in f.soft_bands(rad) {
|
||||
let rr = geom.r.inflate(g as f64, g as f64);
|
||||
if rr.width() > 0.0 && rr.height() > 0.0 {
|
||||
set_paint(ctx, &f.faded(a), fr.opts);
|
||||
set_paint(ctx, &paint, fr.opts);
|
||||
ctx.fill_path(&shape_path(geom.kind, rr, geom.corner));
|
||||
}
|
||||
}
|
||||
@@ -1103,3 +1127,6 @@ mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod cache_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fill_alpha_tests;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user