mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 22:07:06 -06:00
* 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>
615 lines
26 KiB
Rust
615 lines
26 KiB
Rust
//! 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());
|
||
}
|
||
}
|
||
}
|