Files
wordcraft/crates/render/src/lib.rs
T
aafc7d058f Render: draw on-screen text like macOS, on whole pixels (#110)
* Render: draw on-screen text like macOS, on whole pixels

Pages looked lighter and thinner than in Word and other Mac apps:

- macOS thickens glyphs a little when it draws text on screen (font
  smoothing). RenderOptions::text_darkening strokes each outline by the
  amount macOS uses (from Pathfinder's measurements: 1.21% of the pixel
  size, 1.25x that vertically, capped at 0.3 px, none past 72 ppem). The
  editor turns it on for on-screen pages on macOS; PDF/PNG exports and
  other platforms are unchanged.
- Page textures were drawn at fractional positions after scrolling, so
  every glyph was resampled and blurred. They now land on whole device
  pixels at their own size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge follow-ups: page textures use #165's texel alignment; clippy as_chunks in a test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: ssamstudio <267867645+ssamstudio@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 07:24:26 -04:00

615 lines
26 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 } => {
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(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 } => {
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);
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);
}
}
}
}
/// 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());
}
#[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());
}
}
}