Files
wordcraft/crates/render/src/lib.rs
T
vangel76andClaude Opus 5.5 2f8f304fc9 Shape Format: Shape Effects — shadow, glow and soft edges (#275)
Shapes and text boxes get an effects model (outer shadow with colour,
transparency, blur, distance and angle; glow with colour, size and
transparency; soft-edge radius), the shape.effects command (presets,
custom values or null per effect, applied to every selected shape,
undoable, hostile numbers clamped), screen and PDF rendering with the
same banded approximation of a blur, DOCX a:effectLst read/write
(outerShdw, glow, softEdge) with a matching wp:effectExtent, and a
contextual Shape Format tab with Shape Fill, Shape Outline and Shape
Effects (Shadow, Glow, Soft Edges submenus) plus Arrange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:35:05 +02:00

743 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! WordCraft rasteriser: draws a page's display list with vello_cpu.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use vello_cpu::kurbo::{self, Affine, BezPath, Shape};
use vello_cpu::peniko;
use vello_cpu::{Pixmap, RenderContext, Resources};
use wordcraft_doc::Document;
use wordcraft_doc::para::ShapeKind;
use wordcraft_doc::props::Rgb;
use wordcraft_fonts::FontDb;
use wordcraft_layout::Page;
use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_display};
/// Worker threads for rasterising (0 on the web, where there are no threads).
pub fn default_threads() -> u16 {
#[cfg(target_arch = "wasm32")]
{
0
}
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).clamp(1, 8) as u16).unwrap_or(2)
}
}
/// Largest raster side vello_cpu handles comfortably.
pub const MAX_SIDE: u32 = 16_000;
/// A rendered image (premultiplied RGBA8, row-major).
#[derive(Clone, Default)]
pub struct Rendered {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
impl Rendered {
pub fn to_straight(&self) -> Vec<u8> {
let mut out = self.pixels.clone();
for px in out.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a != 0 && a != 255 {
for c in px.iter_mut().take(3) {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
out
}
/// PNG bytes (empty on failure, logged).
pub fn to_png(&self) -> Vec<u8> {
let mut px = self.to_straight();
px.resize(self.width as usize * self.height as usize * 4, 0);
let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else {
log::error!("PNG encode: bad size {}×{}", self.width, self.height);
return Vec::new();
};
let mut buf = Vec::new();
if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) {
log::error!("PNG encode: {e}");
return Vec::new();
}
buf
}
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
let rgba = self.to_straight();
let rgb: Vec<u8> = rgba
.as_chunks::<4>()
.0
.iter()
.flat_map(|p| {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
[mix(p[0]), mix(p[1]), mix(p[2])]
})
.collect();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
let _ = image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8);
buf
}
/// Straight RGBA at (x, y) (transparent outside).
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
if x >= self.width || y >= self.height {
return [0; 4];
}
let i = ((y as usize * self.width as usize) + x as usize) * 4;
let Some(p) = self.pixels.get(i..i + 4) else { return [0; 4] };
let a = p[3] as u32;
if a == 0 {
return [0; 4];
}
let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8;
[un(p[0]), un(p[1]), un(p[2]), p[3]]
}
}
fn color(c: Rgb, alpha: f32) -> peniko::Color {
peniko::Color::from_rgba8(c.0, c.1, c.2, (alpha.clamp(0.0, 1.0) * 255.0) as u8)
}
/// Decoded images by media key (and content pointer), shared across renders.
fn image_cache() -> &'static Mutex<HashMap<(String, usize), Option<Arc<Pixmap>>>> {
static C: std::sync::OnceLock<Mutex<HashMap<(String, usize), Option<Arc<Pixmap>>>>> = std::sync::OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Decode an encoded image to a premultiplied pixmap.
pub fn decode_pixmap(bytes: &[u8]) -> Option<Pixmap> {
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = img.dimensions();
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
}
let data: Vec<vello_cpu::color::PremulRgba8> = img
.pixels()
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
Some(Pixmap::from_parts(data, w as u16, h as u16))
}
/// Pixel size of an encoded image.
pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
}
fn pixmap_for(doc: &Document, key: &str) -> Option<Arc<Pixmap>> {
let bytes = doc.media.get(key)?;
let ck = (key.to_string(), Arc::as_ptr(bytes) as usize);
if let Some(v) = image_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&ck) {
return v.clone();
}
let pm = decode_pixmap(bytes).map(Arc::new);
let mut c = image_cache().lock().unwrap_or_else(|e| e.into_inner());
if c.len() > 256 {
c.clear();
}
c.insert(ck, pm.clone());
pm
}
/// Options for a page raster.
#[derive(Clone, Debug)]
pub struct RenderOptions {
pub display: DisplayOptions,
/// Paper colour (page colour from Design › Page Color, else white).
pub paper: Rgb,
/// Colour for formatting marks.
pub mark_color: Rgb,
/// Thicken glyphs slightly the way macOS draws text on screen ("font smoothing"), so pages
/// look like they do in other Mac apps. For on-screen rasters only: exports stay exact.
pub text_darkening: bool,
/// Dark page (View › Switch Modes): every colour but pictures has its lightness inverted, so
/// white paper turns black and black text white while hues stay the same. Screen only.
pub dark: bool,
/// The grey that black becomes on a dark page (white paper turns this, not pure black).
pub dark_paper: u8,
}
/// Default grey for a dark page's paper.
pub const DARK_PAPER: u8 = 0x33;
impl RenderOptions {
/// The on-screen colour for a document colour (see [`RenderOptions::dark`]).
pub fn ink(&self, c: Rgb) -> Rgb {
if !self.dark {
return c;
}
// Invert, then lift the blacks to the paper grey so the page isn't a black hole.
let Rgb(r, g, b) = invert_lightness(c);
let p = self.dark_paper as u32;
let lift = |v: u8| (p + v as u32 * (255 - p) / 255).min(255) as u8;
Rgb(lift(r), lift(g), lift(b))
}
}
/// Invert HSL lightness, keeping hue and saturation: shifting every channel by
/// `255 - max - min` maps the lightest channel to `255 - min` and the darkest to `255 - max`.
pub fn invert_lightness(c: Rgb) -> Rgb {
let (r, g, b) = (c.0 as i32, c.1 as i32, c.2 as i32);
let d = 255 - r.max(g).max(b) - r.min(g).min(b);
let f = |v: i32| (v + d).clamp(0, 255) as u8;
Rgb(f(r), f(g), f(b))
}
impl Default for RenderOptions {
fn default() -> Self {
RenderOptions {
display: DisplayOptions::default(),
paper: Rgb::WHITE,
mark_color: Rgb(0x2B, 0x57, 0x9A),
text_darkening: false,
dark: false,
dark_paper: DARK_PAPER,
}
}
}
/// Render a whole page at `scale` pixels per point.
pub fn render_page(doc: &Document, page: &Page, scale: f32, opts: &RenderOptions) -> Rendered {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let w = ((page.w * scale).ceil() as u32).clamp(1, MAX_SIDE);
let h = ((page.h.min(1e6) * scale).ceil() as u32).clamp(1, MAX_SIDE);
render_region(doc, page, w, h, Affine::scale(scale as f64), opts)
}
/// Render a page into a `w`×`h` raster with `view` (page points → pixels).
pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, opts: &RenderOptions) -> Rendered {
let (w16, h16) = (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16);
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: default_threads(), ..Default::default() });
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(color(opts.ink(doc.settings.page_color.unwrap_or(opts.paper)), 1.0));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64));
let items = page_display(doc, page, &opts.display);
let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0);
if let Some(wm) = &doc.settings.watermark {
draw_watermark(&mut ctx, view, page, wm, opts);
}
for it in &items {
draw(&mut ctx, doc, it, view, &visible, opts);
}
ctx.flush();
let mut pixels = vec![0u8; w16 as usize * h16 as usize * 4];
let mut res = Resources::new();
if let Some(pm) = vello_cpu::PixmapMut::new(w16, h16, &mut pixels) {
ctx.render(pm, &mut res);
}
Rendered { width: w16 as u32, height: h16 as u32, pixels }
}
fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordcraft_doc::Watermark, opts: &RenderOptions) {
let r = wordcraft_fonts::word::resolve(&wm.font, false, false);
let face = r.face;
let glyphs = wordcraft_fonts::shape(&face, &wm.text, &[], |c| c);
let upem = face.upem.max(1.0);
let raw_w: f64 = glyphs.iter().map(|g| g.x_advance as f64).sum::<f64>() / upem;
if raw_w <= 0.0 {
return;
}
let diag = if wm.diagonal { ((page.w as f64).powi(2) + (page.h.min(2000.0) as f64).powi(2)).sqrt() } else { page.w as f64 };
let size = (diag * 0.5 / raw_w).min(200.0);
let k = size / upem;
let angle = if wm.diagonal { -((page.h.min(2000.0) as f64) / page.w.max(1.0) as f64).atan() } else { 0.0 };
let base = Affine::translate((page.w as f64 / 2.0, page.h.min(2000.0) as f64 / 2.0))
* Affine::rotate(angle)
* Affine::translate((-raw_w * size / 2.0, size * 0.35));
ctx.set_paint(color(opts.ink(wm.color), if wm.semitransparent { 0.35 } else { 0.8 }));
let db = FontDb::global();
let mut x = 0.0;
for g in &glyphs {
let o = db.outline(&face, g.gid);
ctx.set_transform(view * base * Affine::translate((x, 0.0)) * Affine::scale(k));
ctx.fill_path(&o);
x += g.x_advance as f64 * k;
}
}
/// How far, in device pixels, macOS-style font smoothing pushes each side of a glyph outline out
/// at `ppem` device pixels per em. Values from Pathfinder (MIT/Apache-2.0), which measured them
/// against macOS: 1.21% of the pixel size horizontally and 1.25 times that vertically, each
/// capped at 0.3 px, and no darkening past 72 px per em. We stroke the outline, which grows it
/// evenly, so this is the mean of the two.
pub fn stem_darkening(ppem: f64) -> f64 {
const FACTOR: [f64; 2] = [0.0121, 0.0121 * 1.25];
const MAX_PX: f64 = 0.3;
const MAX_PPEM: f64 = 72.0;
if !ppem.is_finite() || ppem <= 0.0 || ppem > MAX_PPEM {
return 0.0;
}
FACTOR.iter().map(|f| (ppem * f).min(MAX_PX)).sum::<f64>() / 2.0
}
fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visible: &kurbo::Rect, opts: &RenderOptions) {
match it {
Draw::Fill { rect, color: c, alpha } => {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
if !r.overlaps(*visible) {
return;
}
ctx.set_transform(view);
ctx.set_paint(color(opts.ink(*c), *alpha));
ctx.fill_rect(&r);
}
Draw::Line { x0, y0, x1, y1, width, color: c, stroke, alpha } => {
ctx.set_transform(view);
ctx.set_paint(color(opts.ink(*c), *alpha));
let w = *width as f64;
let mut st = kurbo::Stroke::new(w);
match stroke {
Stroke::Dotted => st = st.with_dashes(0.0, [w, w * 2.0]),
Stroke::Dashed => st = st.with_dashes(0.0, [w * 4.0, w * 2.0]),
_ => {}
}
let line = |ctx: &mut RenderContext, dy: f64| {
let mut p = BezPath::new();
p.move_to((*x0 as f64, *y0 as f64 + dy));
p.line_to((*x1 as f64, *y1 as f64 + dy));
ctx.stroke_path(&p);
};
if *stroke == Stroke::Wave {
let mut p = BezPath::new();
let (a, b) = ((*x0).min(*x1) as f64, (*x0).max(*x1) as f64);
let y = *y0 as f64;
let amp = (w * 1.2).max(0.8);
let step = amp * 2.0;
p.move_to((a, y));
let mut x = a;
let mut up = true;
while x < b && x - a < 20_000.0 {
let nx = (x + step).min(b);
p.quad_to(((x + nx) / 2.0, if up { y - amp } else { y + amp }), (nx, y));
x = nx;
up = !up;
}
ctx.set_stroke(kurbo::Stroke::new(w * 0.8));
ctx.stroke_path(&p);
return;
}
if *stroke == Stroke::Double {
ctx.set_stroke(kurbo::Stroke::new(w * 0.6));
line(ctx, -w * 0.9);
line(ctx, w * 0.9);
return;
}
ctx.set_stroke(st);
line(ctx, 0.0);
}
Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, .. } => {
let db = FontDb::global();
let k = *size as f64 / face.upem.max(1.0);
ctx.set_paint(color(opts.ink(*c), *alpha));
let skew = if *synth_italic { Affine::new([1.0, 0.0, -0.21, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
// Outline strokes, in font units: synthetic bold, then on-screen stem darkening (a
// stroke grows the outline by half its width on each side). Darkening is skipped for
// translucent text, where the stroke overlapping the fill would show as a darker rim.
let px_per_pt = view.determinant().abs().sqrt();
let darken_px = if opts.text_darkening && *alpha >= 1.0 { stem_darkening(*size as f64 * px_per_pt) } else { 0.0 };
let darken = if darken_px > 0.0 && k * px_per_pt > 0.0 { 2.0 * darken_px / (k * px_per_pt) } else { 0.0 };
let outline_stroke = if *synth_bold { face.upem * 0.03 } else { 0.0 } + darken;
if outline_stroke > 0.0 {
let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter };
ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join));
}
for (gid, x, y) in glyphs {
let (gx, gy) = (*x as f64, *y as f64);
if gx < visible.x0 - 100.0 || gx > visible.x1 || gy < visible.y0 || gy > visible.y1 + 200.0 {
continue;
}
let o = db.outline(face, *gid);
if o.elements().is_empty() {
continue;
}
ctx.set_transform(view * Affine::translate((gx, gy)) * skew * Affine::scale(k));
ctx.fill_path(&o);
if outline_stroke > 0.0 {
ctx.stroke_path(&o);
}
}
}
Draw::Mark { x, baseline, size, ch, color: mark } => {
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
let gid = face.glyph_for(*ch);
let face = if gid == 0 {
match FontDb::global().fallback_for(*ch, face.id()) {
Some(f) => wordcraft_fonts::FaceRef::of(&f),
None => return,
}
} else {
face
};
let gid = face.glyph_for(*ch);
let o = FontDb::global().outline(&face, gid);
let k = *size as f64 / face.upem.max(1.0);
ctx.set_paint(color(opts.ink(mark.unwrap_or(opts.mark_color)), 0.9));
ctx.set_transform(view * Affine::translate((*x as f64, *baseline as f64)) * Affine::scale(k));
ctx.fill_path(&o);
}
Draw::Image { rect, media, crop, alpha } => {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
if !r.overlaps(*visible) {
return;
}
let Some(pm) = pixmap_for(doc, media) else {
ctx.set_transform(view);
ctx.set_paint(color(opts.ink(Rgb(0xD0, 0xD0, 0xD0)), 1.0));
ctx.fill_rect(&r);
return;
};
let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { v.clamp(0.0, 0.95) as f64 } else { 0.0 });
let vis_w = (1.0 - cl - cr).max(0.05);
let vis_h = (1.0 - ct - cb).max(0.05);
let sx = r.width() / (pw * vis_w);
let sy = r.height() / (ph * vis_h);
ctx.set_transform(view);
// vello_cpu panics on image paints with sampler alpha != 1, so fade through a layer.
let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 };
let faded = alpha < 1.0;
if faded {
ctx.push_opacity_layer(alpha);
}
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
});
ctx.set_paint_transform(Affine::translate((r.x0 - cl * pw * sx, r.y0 - ct * ph * sy)) * Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&r);
ctx.reset_paint_transform();
if faded {
ctx.pop_layer();
}
}
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
let path = shape_path(*kind, r);
ctx.set_transform(view);
let fx = effects.sanitized();
let can_fill = *kind != ShapeKind::Line && fill.is_some();
if !fx.is_empty() && (can_fill || stroke.is_some()) {
let sw = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) as f64 } else { 0.75 };
// The shape's silhouette grown by `g` points and moved by `d`, in the current paint.
let silhouette = |ctx: &mut RenderContext, g: f32, d: kurbo::Vec2| {
let g = g as f64;
if can_fill {
let grow = g + if stroke.is_some() { sw / 2.0 } else { 0.0 };
let rr = r.inflate(grow, grow) + d;
if rr.width() > 0.0 && rr.height() > 0.0 {
ctx.fill_path(&shape_path(*kind, rr));
}
} else if sw + 2.0 * g > 0.05 {
ctx.set_stroke(kurbo::Stroke::new(sw + 2.0 * g));
ctx.stroke_path(&shape_path(*kind, r + d));
}
};
if let Some(sh) = fx.shadow {
let (dx, dy) = sh.offset();
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
ctx.set_paint(color(opts.ink(sh.color), a));
silhouette(ctx, g, kurbo::Vec2::new(dx as f64, dy as f64));
}
}
if let Some(gl) = fx.glow {
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
ctx.set_paint(color(opts.ink(gl.color), a));
silhouette(ctx, g, kurbo::Vec2::ZERO);
}
}
}
// Soft edges: the fill fades out toward the outline (which fades with it).
if let (Some(rad), Some(f), true) = (fx.soft_edge, fill, can_fill) {
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
let rr = r.inflate(g as f64, g as f64);
if rr.width() > 0.0 && rr.height() > 0.0 {
ctx.set_paint(color(opts.ink(*f), a));
ctx.fill_path(&shape_path(*kind, rr));
}
}
return;
}
if let Some(f) = fill {
ctx.set_paint(color(opts.ink(*f), 1.0));
ctx.fill_path(&path);
}
if let Some(s) = stroke {
ctx.set_paint(color(opts.ink(*s), 1.0));
ctx.set_stroke(kurbo::Stroke::new(stroke_width.max(0.25) as f64));
ctx.stroke_path(&path);
}
}
Draw::Turned { x, y, turn, items } => {
let m = Affine::new(Draw::turn_matrix(*turn, *x, *y).map(f64::from));
let local = m.inverse().transform_rect_bbox(*visible);
for it in items {
draw(ctx, doc, it, view * m, &local, opts);
}
}
}
}
/// Outline of a basic shape in a rectangle.
pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath {
let (cx, cy) = (r.center().x, r.center().y);
let poly = |pts: &[(f64, f64)]| {
let mut p = BezPath::new();
for (i, (x, y)) in pts.iter().enumerate() {
if i == 0 {
p.move_to((*x, *y));
} else {
p.line_to((*x, *y));
}
}
p.close_path();
p
};
match kind {
ShapeKind::Rectangle | ShapeKind::TextBox => r.to_path(0.1),
ShapeKind::RoundedRectangle => kurbo::RoundedRect::from_rect(r, r.width().min(r.height()) * 0.16).to_path(0.1),
ShapeKind::Ellipse => kurbo::Ellipse::from_rect(r).to_path(0.1),
ShapeKind::Triangle => poly(&[(cx, r.y0), (r.x1, r.y1), (r.x0, r.y1)]),
ShapeKind::Diamond => poly(&[(cx, r.y0), (r.x1, cy), (cx, r.y1), (r.x0, cy)]),
ShapeKind::Line => {
let mut p = BezPath::new();
p.move_to((r.x0, r.y1));
p.line_to((r.x1, r.y0));
p
}
ShapeKind::Arrow => {
let h = r.height();
poly(&[
(r.x0, cy - h * 0.2),
(r.x1 - h * 0.5, cy - h * 0.2),
(r.x1 - h * 0.5, r.y0),
(r.x1, cy),
(r.x1 - h * 0.5, r.y1),
(r.x1 - h * 0.5, cy + h * 0.2),
(r.x0, cy + h * 0.2),
])
}
ShapeKind::Star => {
let mut pts = Vec::new();
for i in 0..10 {
let a = std::f64::consts::PI * (i as f64) / 5.0 - std::f64::consts::FRAC_PI_2;
let rad = if i % 2 == 0 { 1.0 } else { 0.4 };
pts.push((cx + a.cos() * r.width() / 2.0 * rad, cy + a.sin() * r.height() / 2.0 * rad));
}
poly(&pts)
}
ShapeKind::Heart => {
let (w, h) = (r.width(), r.height());
let mut p = BezPath::new();
p.move_to((cx, r.y0 + h * 0.3));
p.curve_to((cx, r.y0), (r.x0, r.y0), (r.x0, r.y0 + h * 0.3));
p.curve_to((r.x0, r.y0 + h * 0.6), (cx - w * 0.1, r.y0 + h * 0.75), (cx, r.y1));
p.curve_to((cx + w * 0.1, r.y0 + h * 0.75), (r.x1, r.y0 + h * 0.6), (r.x1, r.y0 + h * 0.3));
p.curve_to((r.x1, r.y0), (cx, r.y0), (cx, r.y0 + h * 0.3));
p.close_path();
p
}
}
}
/// Render the page area (`x`, `y`, `w`×`h` points) at `scale` px/pt — for previews and thumbnails.
pub fn render_area(doc: &Document, page: &Page, x: f32, y: f32, w: f32, h: f32, scale: f32, opts: &RenderOptions) -> Rendered {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let pw = ((w * scale).ceil() as u32).clamp(1, MAX_SIDE);
let ph = ((h * scale).ceil() as u32).clamp(1, MAX_SIDE);
let view = Affine::scale(scale as f64) * Affine::translate((-x as f64, -y as f64));
render_region(doc, page, pw, ph, view, opts)
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
#[test]
fn renders_text_dark_pixels() {
let d = Document::from_text("Hello WordCraft");
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default());
assert_eq!((img.width, img.height), (612, 792));
// Somewhere in the first text line there are dark pixels; the margins are white.
let dark = (72..300).flat_map(|x| (72..100).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[0] < 128).count();
assert!(dark > 30, "dark {dark}");
assert_eq!(img.pixel(5, 5), [255, 255, 255, 255]);
assert!(!img.to_png().is_empty());
}
/// Table Layout › Text Direction (#226): a turned cell's text is painted running down the
/// cell, not across it.
#[test]
fn turned_cell_text_is_painted_turned() {
use wordcraft_doc::props::TextDirection;
let ink = |dir: TextDirection| {
let mut d = Document::from_text("before\nafter");
let mut t = wordcraft_doc::Table::new(1, 2, 400.0);
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("WWWWWWWWWWWW", Default::default()))];
t.rows[0].cells[0].props.text_direction = dir;
d.insert_block(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default());
// Dark pixels inside the first cell (x 72..272, clear of its borders), well below
// the first text line.
(80..264).flat_map(|x| (140..190).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[0] < 128).count()
};
assert_eq!(ink(TextDirection::Horizontal), 0, "horizontal text stays in its line");
assert!(ink(TextDirection::Down) > 30, "down");
assert!(ink(TextDirection::Up) > 30, "up");
}
/// Shape Effects (#275): an outer shadow paints dark pixels beside the shape on the side it is
/// cast to, and none on the other; a glow surrounds it.
#[test]
fn shape_shadow_is_cast_offset_from_the_shape() {
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
use wordcraft_doc::para::InlineObject;
let draw = |effects: ShapeEffects| {
let mut d = Document::from_text("");
let shape = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 100.0,
h: 60.0,
fill: Some(Rgb(0, 0, 255)),
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: None,
effects,
};
let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 };
d.insert_object(&at, shape, &Default::default()).unwrap();
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
render_page(&d, &l.pages[0], 1.0, &RenderOptions::default())
};
let plain = draw(ShapeEffects::default());
let blue = |img: &Rendered| {
let px: Vec<(u32, u32)> = (0..612)
.flat_map(|x| (0..400).map(move |y| (x, y)))
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, _, b, _] if r < 40 && b > 200))
.collect();
let (x0, x1) = (px.iter().map(|p| p.0).min().unwrap(), px.iter().map(|p| p.0).max().unwrap());
let (y0, y1) = (px.iter().map(|p| p.1).min().unwrap(), px.iter().map(|p| p.1).max().unwrap());
(x0, y0, x1, y1)
};
let (x0, y0, x1, y1) = blue(&plain);
assert!(x1 - x0 > 90 && y1 - y0 > 50, "{:?}", (x0, y0, x1, y1));
// Grey (not white, not blue) pixels in a strip just outside an edge.
let grey = |img: &Rendered, xs: std::ops::Range<u32>, ys: std::ops::Range<u32>| {
xs.flat_map(|x| ys.clone().map(move |y| (x, y)))
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, g, b, _] if r < 200 && r == g && g == b))
.count()
};
let right = |img: &Rendered| grey(img, x1 + 2..x1 + 6, y0 + 10..y1);
let left = |img: &Rendered| grey(img, x0.saturating_sub(6)..x0.saturating_sub(2), y0 + 10..y1);
assert_eq!((right(&plain), left(&plain)), (0, 0));
let shadow = Shadow { color: Rgb::BLACK, transparency: 30.0, blur: 2.0, distance: 8.0, angle: 0.0 };
let img = draw(ShapeEffects { shadow: Some(shadow), ..Default::default() });
assert_eq!(blue(&img), (x0, y0, x1, y1), "the shape itself is unchanged");
assert!(right(&img) > 100, "shadow to the right: {}", right(&img));
assert_eq!(left(&img), 0, "nothing on the left");
let img = draw(ShapeEffects { glow: Some(Glow { color: Rgb(255, 0, 0), size: 6.0, transparency: 0.0 }), ..Default::default() });
let reddish = |x: u32, y: u32| matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 200);
assert!(reddish(x1 + 2, (y0 + y1) / 2) && reddish(x0 - 2, (y0 + y1) / 2), "glow on both sides");
}
#[test]
fn stem_darkening_follows_the_macos_curve() {
assert_eq!(stem_darkening(0.0), 0.0);
assert_eq!(stem_darkening(f64::NAN), 0.0);
assert_eq!(stem_darkening(-5.0), 0.0);
// Small text grows in proportion to its pixel size...
assert!((stem_darkening(10.0) - 10.0 * 0.0121 * 2.25 / 2.0).abs() < 1e-9);
// ...body text on a Retina screen hits the 0.3 px cap...
assert_eq!(stem_darkening(30.0), 0.3);
assert_eq!(stem_darkening(72.0), 0.3);
// ...and display sizes aren't darkened at all.
assert_eq!(stem_darkening(72.5), 0.0);
}
/// Ink in a raster: the sum of how far each pixel is from white.
fn ink(img: &Rendered) -> u64 {
img.pixels.as_chunks::<4>().0.iter().map(|p| 255 - p[0] as u64).sum()
}
#[test]
fn text_darkening_adds_ink_on_screen_only() {
let d = Document::from_text("Marketing analytics lead with twelve years of measurement work.");
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let plain = render_page(&d, &l.pages[0], 2.0, &RenderOptions::default());
let dark = render_page(&d, &l.pages[0], 2.0, &RenderOptions { text_darkening: true, ..Default::default() });
let (a, b) = (ink(&plain), ink(&dark));
// About 0.3 px more on every edge of an 11 pt line at 2x: clearly more ink, not bold.
assert!(b as f64 > a as f64 * 1.08 && (b as f64) < a as f64 * 1.6, "plain {a}, darkened {b}");
// Off by default: exports and thumbnails are untouched.
assert!(!RenderOptions::default().text_darkening);
}
#[test]
fn hostile_scale_is_clamped() {
let d = Document::new();
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let img = render_page(&d, &l.pages[0], f32::NAN, &RenderOptions::default());
assert_eq!(img.width, 612);
let big = render_page(&d, &l.pages[0], 1e9, &RenderOptions::default());
assert!(big.width <= MAX_SIDE);
assert!(decode_pixmap(b"not an image").is_none());
}
#[test]
fn dimmed_header_image_renders() {
let mut d = Document::from_text("body");
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let media = d.add_media(png, "png");
let mut hp = wordcraft_doc::Paragraph::new();
let obj = wordcraft_doc::para::InlineObject::Image { media, w: 40.0, h: 40.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] };
hp.insert_object(0, obj, &Default::default()).unwrap();
let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]);
d.last_section.headers.default = Some(id);
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let opts = RenderOptions::default();
assert!(opts.display.dim_header);
let img = render_page(&d, &l.pages[0], 1.0, &opts);
assert!(!img.to_png().is_empty());
}
#[test]
fn dark_page_inverts_lightness() {
assert_eq!(invert_lightness(Rgb::WHITE), Rgb::BLACK);
assert_eq!(invert_lightness(Rgb::BLACK), Rgb::WHITE);
// Pure hues keep their colour; dark blue turns light blue.
assert_eq!(invert_lightness(Rgb(255, 0, 0)), Rgb(255, 0, 0));
assert_eq!(invert_lightness(Rgb(0, 0, 0x80)), Rgb(0x7F, 0x7F, 0xFF));
let d = Document::from_text("Hello WordCraft");
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let opts = RenderOptions { dark: true, ..Default::default() };
assert_eq!(opts.ink(Rgb::WHITE), Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER));
assert_eq!(opts.ink(Rgb::BLACK), Rgb::WHITE);
let img = render_page(&d, &l.pages[0], 1.0, &opts);
assert_eq!(img.pixel(5, 5), [DARK_PAPER, DARK_PAPER, DARK_PAPER, 255]);
let light = (72..300).flat_map(|x| (72..100).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[0] > 128).count();
assert!(light > 30, "light {light}");
}
#[test]
fn shapes_have_paths() {
let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0);
for k in [ShapeKind::Rectangle, ShapeKind::Ellipse, ShapeKind::Star, ShapeKind::Heart, ShapeKind::Arrow, ShapeKind::Line] {
assert!(!shape_path(k, r).elements().is_empty());
}
}
}