//! 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, Weak}; 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::Paint; use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_layers}; use wordcraft_metafile::{Item, Picture, PlacedItem, Seg}; mod color_glyph; pub mod icon_lib; /// 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) } } mod context_pool; /// 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, } impl Rendered { pub fn to_straight(&self) -> Vec { 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 { let mut px = self.to_straight(); let Some(len) = wordcraft_geom::rgba_bytes(self.width, self.height).and_then(|b| usize::try_from(b).ok()) else { log::error!("PNG encode: size {}×{} overflows", self.width, self.height); return Vec::new(); }; px.resize(len, 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 { let rgba = self.to_straight(); let rgb: Vec = 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]] } } /// Paint `p` as the context's paint (gradients in page points, under the view transform). fn set_paint(ctx: &mut RenderContext, p: &Paint, opts: &RenderOptions) { set_paint_inked(ctx, p, |c| opts.ink(c)); } /// [`set_paint`] with each colour passed through `ink` (a dark page's text inking, #1252). fn set_paint_inked(ctx: &mut RenderContext, p: &Paint, ink: impl Fn(Rgb) -> Rgb) { let stops = |stops: &[(f32, Rgb, f32)]| -> Vec { stops.iter().map(|(o, c, a)| peniko::ColorStop::from((o.clamp(0.0, 1.0), color(ink(*c), *a)))).collect() }; match p { Paint::Solid(c, a) => ctx.set_paint(color(ink(*c), *a)), Paint::Linear { p0, p1, stops: s } => ctx.set_paint( peniko::Gradient::new_linear((f64::from(p0.0), f64::from(p0.1)), (f64::from(p1.0), f64::from(p1.1))).with_stops(stops(s).as_slice()), ), Paint::Radial { c, r, stops: s } => { ctx.set_paint(peniko::Gradient::new_radial((f64::from(c.0), f64::from(c.1)), *r).with_stops(stops(s).as_slice())) } } } 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) } /// Upper bound on the bytes the two decoded-media caches keep together (about 256 MB), half each; /// past its half a cache starts over. const CACHE_BYTES: usize = 256 * 1024 * 1024 / 2; /// Upper bound on cached entries per cache. const CACHE_ENTRIES: usize = 256; /// Decoded media by media key, shared across renders, with an approximate byte budget. An entry knows /// its source allocation weakly: a hit needs the same allocation, whose address can't be reused while /// the entry lives, and the source bytes are still freed with the document. struct MediaCache { map: HashMap>, bytes: usize, } struct Entry { src: Weak>, val: T, size: usize, } impl MediaCache { fn get(&self, key: &str, src: &Arc>) -> Option { let e = self.map.get(key)?; std::ptr::eq(e.src.as_ptr(), Arc::as_ptr(src)).then(|| e.val.clone()) } /// Stores `val` (about `size` bytes) for `src`. Clears the cache first when the budget or entry /// cap is hit; an entry larger than the whole budget is not kept. fn insert(&mut self, key: &str, src: &Arc>, val: T, size: usize) { if size > CACHE_BYTES { return; } if let Some(old) = self.map.remove(key) { self.bytes = self.bytes.saturating_sub(old.size); } if self.map.len() >= CACHE_ENTRIES || self.bytes.saturating_add(size) > CACHE_BYTES { self.map.clear(); self.bytes = 0; } self.map.insert(key.to_string(), Entry { src: Arc::downgrade(src), val, size }); self.bytes = self.bytes.saturating_add(size); } } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } fn images() -> &'static Mutex>>> { static C: std::sync::OnceLock>>>> = std::sync::OnceLock::new(); C.get_or_init(|| Mutex::new(MediaCache { map: HashMap::new(), bytes: 0 })) } fn vectors() -> &'static Mutex>>> { static C: std::sync::OnceLock>>>> = std::sync::OnceLock::new(); C.get_or_init(|| Mutex::new(MediaCache { map: HashMap::new(), bytes: 0 })) } /// Largest side (pixels) a document picture may decode to: what a `Pixmap` can hold (`u16` /// sides, which [`premul_pixmap`] enforces after decoding). Applying it as a decode limit rejects /// a small file that declares a bigger image at header time, before anything is allocated. const MAX_DECODE_SIDE: u32 = u16::MAX as u32; /// Decode an encoded image to a premultiplied pixmap. The dimension limit above and the default /// allocation cap (512 MB, what `load_from_memory` applied) bound the decode during paint. pub fn decode_pixmap(bytes: &[u8]) -> Option { // The shared probe also charges what the decoder holds beside its output (#3644). if !wordcraft_geom::picture::probe(bytes).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES)) { return None; } decode_pixmap_within(bytes, wordcraft_geom::MAX_DECODED_BYTES) } /// [`decode_pixmap`] with an explicit RGBA byte budget (a small one in tests). fn decode_pixmap_within(bytes: &[u8], budget: u64) -> Option { // The header gives the size; refuse before the decoder (and the RGBA copy) allocate anything. let (hw, hh) = image_size(bytes)?; if !wordcraft_geom::decode_fits(hw, hh, budget) { return None; } let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_DECODE_SIDE); limits.max_image_height = Some(MAX_DECODE_SIDE); reader.limits(limits); let img = reader.decode().ok()?.into_rgba8(); let (w, h) = img.dimensions(); premul_pixmap(w, h, img.as_raw()) } /// A premultiplied pixmap from straight RGBA8 rows. None for empty, oversized or mis-sized input /// (the pixmap constructor asserts the length). fn premul_pixmap(w: u32, h: u32, rgba: &[u8]) -> Option { if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { return None; } if rgba.len() != (w as usize * h as usize).saturating_mul(4) { return None; } let data: Vec = rgba .as_chunks::<4>() .0 .iter() .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() } /// The natural size in points of a Windows Metafile or Enhanced Metafile picture, and the file /// extension it is stored under (`"wmf"` or `"emf"`), or None for anything else (#802). Very large /// frames are scaled down to 22 inches on the longer side, keeping the shape. pub fn metafile_size(bytes: &[u8]) -> Option<(f32, f32, &'static str)> { if !wordcraft_metafile::is_metafile(bytes) { return None; } let pic = wordcraft_metafile::parse(bytes).ok()?; let (w, h) = (pic.width, pic.height); if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { return None; } let k = (1584.0 / w.max(h)).min(1.0); // An EMF starts with its header record, type 1. let ext = if bytes.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" }; Some((w * k, h * k, ext)) } /// A metafile (WMF/EMF) picture, parsed once. The geometry comes placed by the metafile crate; the /// bitmaps are pixmaps, one per picture item (None for paths and undecodable bitmaps). struct Vector { pic: Picture, pixmaps: Vec>>, } impl Vector { /// Approximate memory held: pixels and path segments. fn bytes(&self) -> usize { let px: usize = self.pixmaps.iter().flatten().map(|p| p.width() as usize * p.height() as usize * 4).sum(); let segs: usize = self .pic .items .iter() .map(|it| match it { Item::Path { segs, .. } => segs.len() * std::mem::size_of::(), Item::Bitmap { .. } => 0, Item::Text(t) => t.text.len() + t.dx.len() * 4, }) .sum(); px + segs } } /// The metafile picture for a media key. None when the bytes are not a metafile or do not parse, so /// the caller falls back to the raster path (and the grey box). fn vector_for(doc: &Document, key: &str) -> Option> { let bytes = doc.media.get(key)?; if !wordcraft_metafile::is_metafile(bytes) { return None; } if let Some(v) = lock(vectors()).get(key, bytes) { return v; } let v = build_vector(bytes).map(Arc::new); if v.is_none() { log::warn!("picture `{key}` is a metafile that could not be read"); } let size = v.as_ref().map_or(0, |v| v.bytes()); lock(vectors()).insert(key, bytes, v.clone(), size); v } /// Parses a metafile and decodes its bitmaps. None when the picture has no usable size. fn build_vector(bytes: &[u8]) -> Option { let mut pic = wordcraft_metafile::parse(bytes).ok()?; if !pic.has_size() { return None; } // The pixels move into the pixmaps; the picture keeps only the geometry. let pixmaps = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul_pixmap(w, h, &px)).map(Arc::new)).collect(); Some(Vector { pic, pixmaps }) } fn rgba_color(c: [u8; 4]) -> peniko::Color { peniko::Color::from_rgba8(c[0], c[1], c[2], c[3]) } /// Draws a metafile picture into `r` (page points): fills and strokes in page space (so strokes keep /// their width under non-uniform scaling), bitmaps stretched, all clipped to `r` and faded by `alpha`. /// Strokes have round caps and joins, as GDI draws them, and are at least one device pixel wide, so /// cosmetic (width 0) pens show as hairlines at any zoom. fn draw_vector(ctx: &mut RenderContext, v: &Vector, r: &kurbo::Rect, crop: &[f32; 4], alpha: f32, view: Affine) { ctx.set_transform(view); 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.push_clip_layer(&r.to_path(0.1)); let pixel = device_pixel(view); for it in v.pic.place(*r, *crop) { match it { PlacedItem::Path { path, fill, stroke, even_odd } => { if let Some(f) = fill { ctx.set_paint(rgba_color(f)); ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); ctx.fill_path(&path); } if let Some((s, w)) = stroke { ctx.set_paint(rgba_color(s)); ctx.set_stroke(kurbo::Stroke::new(w.max(pixel)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); ctx.stroke_path(&path); } } PlacedItem::Bitmap { index, rect } => { let Some(Some(pm)) = v.pixmaps.get(index) else { continue }; let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm.clone()), sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium), }); ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / pw, rect.height() / ph)); ctx.fill_rect(&rect); ctx.reset_paint_transform(); } PlacedItem::Text { index, tf } => { if let Some(Item::Text(t)) = v.pic.items.get(index) { draw_vector_text(ctx, t, view * tf); } ctx.set_transform(view); } } } ctx.set_fill_rule(peniko::Fill::NonZero); ctx.pop_layer(); if faded { ctx.pop_layer(); } } /// Draws a metafile text run: `tf` maps the run's own space (see [`PlacedItem::Text`]) to the device. fn draw_vector_text(ctx: &mut RenderContext, t: &wordcraft_metafile::Text, tf: Affine) { let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face; let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c); let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect(); let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return }; let k = lay.em / face.upem.max(1.0); ctx.set_paint(rgba_color(t.color)); let db = FontDb::global(); for (g, (x, y)) in shaped.iter().zip(&lay.glyphs) { let o = db.outline(&face, g.gid); ctx.set_transform(tf * Affine::translate((*x, *y)) * Affine::scale(k)); ctx.fill_path(&o); } } /// One device pixel in page points under `view` (its mean axis scale); 1 for a degenerate transform. fn device_pixel(view: Affine) -> f64 { let c = view.as_coeffs(); let scale = (c[0].hypot(c[1]) + c[2].hypot(c[3])) / 2.0; if scale.is_finite() && scale > 1e-9 { 1.0 / scale } else { 1.0 } } /// Largest page-background picture decoded, in pixels (hostile files). const MAX_BACKGROUND_PIXELS: u64 = 64 * 1024 * 1024; /// Draw the page's picture or texture fill (Design › Page Color › Fill Effects) behind the content: /// tiled at its natural size (96 dpi) for a `tile` fill, stretched over the page otherwise. fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view: Affine) { let Some(pic) = &doc.settings.page_picture else { return }; let Some(bytes) = doc.media.get(&pic.media) else { return }; let (nw, nh) = match image_size(bytes) { Some((w, h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_BACKGROUND_PIXELS => (w as f64, h as f64), _ => return, }; let Some(pm) = pixmap_for(doc, &pic.media) else { return }; let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64); let (w, h) = (page.w as f64, (page.h as f64).min(1e6)); if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { return; } let tile = pic.fill_type.eq_ignore_ascii_case("tile"); // Tiles at the picture's natural size, whatever size its cached pixmap is kept at. let (sx, sy) = if tile { (0.75 * nw / pw, 0.75 * nh / ph) } else { (w / pw, h / ph) }; let extend = if tile { peniko::Extend::Repeat } else { peniko::Extend::Pad }; ctx.set_transform(view); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium).with_extend(extend), }); ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w, h)); ctx.reset_paint_transform(); } fn pixmap_for(doc: &Document, key: &str) -> Option> { let bytes = doc.media.get(key)?; if let Some(v) = lock(images()).get(key, bytes) { return v; } let pm = decode_pixmap(bytes).map(|p| Arc::new(cacheable(p))); let size = pm.as_ref().map_or(0, |p| p.width() as usize * p.height() as usize * 4); lock(images()).insert(key, bytes, pm.clone(), size); pm } /// A decoded picture at a size the media cache keeps. One too large for the whole cache is /// box-averaged down to half of it, so it is decoded once instead of on every render (#3665); pages /// draw it scaled to its frame either way. fn cacheable(pm: Pixmap) -> Pixmap { let bytes = pm.width() as usize * pm.height() as usize * 4; if bytes <= CACHE_BYTES { return pm; } shrink(&pm, (CACHE_BYTES / 2 / 4) as u64).unwrap_or(pm) } /// `pm` averaged over square blocks, the smallest whole number of pixels a side that brings it to /// at most `max_pixels`; None when it already fits or none are allowed. Premultiplied pixels /// average without colour fringes. fn shrink(pm: &Pixmap, max_pixels: u64) -> Option { let (w, h) = (usize::from(pm.width()), usize::from(pm.height())); if (w * h) as u64 <= max_pixels || max_pixels == 0 { return None; } let mut k = (((w * h) as f64 / max_pixels as f64).sqrt().ceil() as usize).max(2); while (w.div_ceil(k) * h.div_ceil(k)) as u64 > max_pixels { k += 1; } let (nw, nh) = (w.div_ceil(k), h.div_ceil(k)); let mut out = Vec::with_capacity(nw * nh); let mut sums = vec![[0u64; 4]; nw]; for (oy, band) in pm.data().chunks(w * k).enumerate() { sums.fill([0; 4]); for row in band.chunks(w) { for (sum, block) in sums.iter_mut().zip(row.chunks(k)) { for p in block { sum[0] += u64::from(p.r); sum[1] += u64::from(p.g); sum[2] += u64::from(p.b); sum[3] += u64::from(p.a); } } } let rows = (h - oy * k).min(k); for (ox, sum) in sums.iter().enumerate() { let n = (((w - ox * k).min(k)) * rows).max(1) as u64; let [r, g, b, a] = sum.map(|s| (s / n) as u8); out.push(vello_cpu::color::PremulRgba8 { r, g, b, a }); } } Some(Pixmap::from_parts(out, nw as u16, nh as u16)) } /// 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, /// Read Mode's Page Color: paper drawn instead of the document's page color and page /// picture. `None` shows the document's page background. Screen only. pub read_paper: Option, } /// Default grey for a dark page's paper. pub const DARK_PAPER: u8 = 0x33; /// Read Mode's Sepia paper: Word's RGB (112, 66, 20) at 80% transparency over white. pub const SEPIA_PAPER: Rgb = Rgb(0xE2, 0xD9, 0xD0); impl RenderOptions { /// The on-screen color of the paper under `doc`'s pages: [`RenderOptions::read_paper`], /// else the document's page color, else [`RenderOptions::paper`]. pub fn paper_of(&self, doc: &Document) -> Rgb { self.ink(self.read_paper.or(doc.settings.page_color).unwrap_or(self.paper)) } /// 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)) } /// The on-screen colour for a text background (highlight, character shading). A dark page /// dims it toward black, keeping its hue, instead of inverting it: inverting leaves bright /// hues such as yellow bright, so the white text on them can't be read (#1252). Every /// colour lands dark enough for white text (WCAG contrast ≥ 4.5); yellow turns a dim olive. pub fn ink_bg(&self, c: Rgb) -> Rgb { if !self.dark { return c; } let f = |v: u8| (DARK_TEXT_BG_FLOOR as u32 + v as u32 * DARK_TEXT_BG_SPAN as u32 / 255).min(255) as u8; Rgb(f(c.0), f(c.1), f(c.2)) } /// The on-screen colour for text (or its underline) drawn on the text background `bg`, if /// any. A dark page dims those backgrounds ([`RenderOptions::ink_bg`]), so the text takes the /// lighter of its colour and its inverted colour (black, explicit or Automatic, turns white; /// Automatic white on a dark highlight stays white), and white if that still falls short of /// 4.5:1 against the dimmed background (pure blue or red on a dim olive). White always /// reaches it: no dimmed background is lighter than #4A4A4A. pub fn ink_text(&self, c: Rgb, bg: Option) -> Rgb { let inked = self.ink(c); let Some(bg) = bg.filter(|_| self.dark) else { return inked }; let lighter = if c.luma() > inked.luma() { c } else { inked }; if contrast(lighter, self.ink_bg(bg)) >= 4.5 { lighter } else { Rgb::WHITE } } } // The dim assumes the default dark paper grey (`DARK_PAPER`; the app never sets another // `dark_paper`): the backgrounds stay close to it and under #4A4A4A, so white text on them // reaches 4.5:1. Revisit both if the paper grey becomes configurable. /// Darkest channel of a text background on a dark page ([`RenderOptions::ink_bg`]). const DARK_TEXT_BG_FLOOR: u8 = 0x10; /// How far a full channel lifts a dark page's text background above the floor. const DARK_TEXT_BG_SPAN: u8 = 0x3A; /// WCAG 2 contrast ratio between two sRGB colours (1 to 21). fn contrast(a: Rgb, b: Rgb) -> f32 { let lin = |v: u8| { let s = v as f32 / 255.0; if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } }; let lum = |c: Rgb| 0.2126 * lin(c.0) + 0.7152 * lin(c.1) + 0.0722 * lin(c.2); let (la, lb) = (lum(a), lum(b)); (la.max(lb) + 0.05) / (la.min(lb) + 0.05) } /// 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, read_paper: None, } } } /// 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 { render_region_capped(doc, page, w, h, view, opts, wordcraft_geom::MAX_RASTER_PIXELS) } /// [`render_region`] with an explicit pixel cap (a small one in tests). fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, opts: &RenderOptions, max_pixels: u64) -> Rendered { let (w, h) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE)); let (cw, ch) = wordcraft_geom::fit_pixels(w, h, max_pixels); // Over the cap the whole picture is drawn smaller, not cropped. let view = if (cw, ch) == (w, h) { view } else { Affine::scale_non_uniform(cw as f64 / w as f64, ch as f64 / h as f64) * view }; let (w16, h16) = (cw as u16, ch as u16); let mut ctx = context_pool::CONTEXTS.take(w16, h16); ctx.set_transform(Affine::IDENTITY); ctx.set_paint(color(opts.paper_of(doc), 1.0)); ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)); if opts.read_paper.is_none() { draw_page_picture(&mut ctx, doc, page, view); } 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); } // A dimmed layer is drawn whole, then faded once, so its overlaps don't darken. for (alpha, items) in page_layers(doc, page, &opts.display) { let faded = alpha < 1.0 && !items.is_empty(); if faded { ctx.push_opacity_layer(alpha); } for it in &items { draw(&mut ctx, doc, it, view, &visible, opts); } if faded { ctx.pop_layer(); } } 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); } context_pool::CONTEXTS.give_back(ctx); Rendered { width: w16 as u32, height: h16 as u32, pixels } } /// The on-screen colour behind page point (`x`, `y`): the paper (page colour), then any /// shading, highlight or filled shape drawn over that point, blended in drawing order. Text, /// pictures and turned or rotated content are left out. The caret takes its colour from this /// so it shows against whatever lies behind it (#299). pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32, y: f32) -> Rgb { let paper = opts.paper_of(doc); if !(x.is_finite() && y.is_finite()) { return paper; } let mut bg = [paper.0 as f32, paper.1 as f32, paper.2 as f32]; for (alpha, items) in page_layers(doc, page, &opts.display) { // A faded layer: its own colour there, blended in at its opacity. let mut layer = bg; blend_backgrounds(&items, wordcraft_geom::Point::new(x, y), opts, &mut layer, 0); for (v, n) in bg.iter_mut().zip(layer) { *v += (n - *v) * alpha; } } let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; Rgb(c(bg[0]), c(bg[1]), c(bg[2])) } fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOptions, bg: &mut [f32; 3], depth: u32) { if depth > 16 { return; } for it in items { let (color, alpha) = match it { 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, .. } 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) } Draw::Clip { rect, items } if rect.contains(p) => { blend_backgrounds(items, p, opts, bg, depth + 1); continue; } Draw::Figure { draws, .. } => { blend_backgrounds(draws, p, opts, bg, depth + 1); continue; } _ => continue, }; let a = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; let c = opts.ink(color); for (v, n) in bg.iter_mut().zip([c.0, c.1, c.2]) { *v += (n as f32 - *v) * a; } } } /// One line of `text` in `face` at `size` points, black on white, in a `w`×`h` point raster at /// `scale` pixels per point, starting `margin` points in with the capitals centred in `h`: a font /// list entry. Not the face's ascent: a font with a tall one (Amiri Quran's is 1.8 em) put the /// name below the row (#2570). Outlines come straight from the face, not the shared outline /// cache, so a face read just for the preview leaves nothing behind once dropped (#848). pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32, w: f32, h: f32, margin: f32, scale: f32) -> Rendered { let fin = |v: f32, d: f32| if v.is_finite() && v >= 0.0 { v } else { d }; let (size, w, h, margin) = (fin(size, 12.0).min(1000.0) as f64, fin(w, 1.0), fin(h, 1.0), fin(margin, 0.0).min(1000.0) as f64); let scale = if scale.is_finite() && scale > 0.0 { scale.min(16.0) } else { 1.0 }; let (pw, ph) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE), ((h * scale).ceil() as u32).clamp(1, MAX_SIDE)); let (cw, ch) = wordcraft_geom::fit_pixels(pw, ph, wordcraft_geom::MAX_RASTER_PIXELS); let (w16, h16) = (cw as u16, ch as u16); let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); ctx.set_transform(Affine::IDENTITY); ctx.set_paint(color(Rgb(255, 255, 255), 1.0)); ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)); ctx.set_paint(color(Rgb(0, 0, 0), 1.0)); let upem = if face.upem.is_finite() && face.upem > 0.0 { face.upem } else { 1000.0 }; let k = size / upem; let view = Affine::scale_non_uniform(cw as f64 / pw as f64, ch as f64 / ph as f64) * Affine::scale(scale as f64); let cap = if face.cap_height.is_finite() { face.cap_height.clamp(upem * 0.3, upem) } else { upem * 0.7 }; let baseline = (h as f64 + cap * k) / 2.0; let mut x = margin; let colour = face.has_color_glyphs(); for g in wordcraft_fonts::shape(face, text, &[], |c| c) { let at = view * Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * Affine::scale(k); if colour && let Some(d) = color_glyph::drawable(face, g.gid, size * scale as f64, false) { color_glyph::draw(&mut ctx, &d, at, upem, color(Rgb(0, 0, 0), 1.0), 1.0); ctx.set_paint(color(Rgb(0, 0, 0), 1.0)); } else { ctx.set_transform(at); ctx.fill_path(&face.glyph_outline(g.gid)); } x += g.x_advance as f64 * k; if x > w as f64 { break; } } 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::() / upem; if raw_w <= 0.0 { return; } // The document page, not a Print Markup area beside it. The text stretched over its box // (Word's `v:textpath fitshape`): its advance across the box, its em down it with the em's foot // on the box's bottom; the box centred on the margins and, when diagonal, turned 45°. let pw = (page.w - page.markup.max(0.0)).max(1.0) as f64; let [bw, bh] = wm.frame(pw as f32, page.h).map(f64::from); let size = bh.min(2000.0); let k = size / upem; let stretch = bw / (raw_w * size); let descent = (wordcraft_fonts::word::line_metrics(&face).1 / upem).clamp(0.0, 1.0); let angle = if wm.diagonal { -std::f64::consts::FRAC_PI_4 } else { 0.0 }; let b = page.body; let (cx, cy) = if b.w > 0.0 && b.h > 0.0 { (f64::from(b.x + b.w / 2.0), f64::from(b.y + b.h / 2.0)) } else { (pw / 2.0, page.h.min(2000.0) as f64 / 2.0) }; let base = Affine::translate((cx, cy)) * Affine::rotate(angle) * Affine::translate((-bw / 2.0, bh / 2.0 - descent * bh)) * Affine::scale_non_uniform(stretch, 1.0); 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::() / 2.0 } /// A wavy line from `(x0, y0)` to `(x1, y1)`; at most 20 000 units long, so a huge extent can't make it endless. fn wave_path(x0: f64, y0: f64, x1: f64, y1: f64, amp: f64) -> BezPath { let mut p = BezPath::new(); let (dx, dy) = (x1 - x0, y1 - y0); let len = (dx * dx + dy * dy).sqrt(); if !len.is_finite() || len < 1e-3 { return p; } let (ux, uy) = (dx / len, dy / len); let (nx, ny) = (-uy, ux); let step = amp * 2.0; p.move_to((x0, y0)); // Past about 1e16 a step no longer changes coordinates: stop instead of pushing curves forever. if x0 + ux * step == x0 && y0 + uy * step == y0 { return p; } let mut t = 0.0; let mut up = true; while t < len && t < 20_000.0 { let nt = (t + step).min(len); if nt <= t { break; } let mid_t = (t + nt) / 2.0; let sign = if up { -1.0 } else { 1.0 }; let cx = x0 + ux * mid_t + nx * amp * sign; let cy = y0 + uy * mid_t + ny * amp * sign; let ex = x0 + ux * nt; let ey = y0 + uy * nt; p.quad_to((cx, cy), (ex, ey)); t = nt; up = !up; } p } /// Rasterize one display item with `view` mapping its current point coordinates to pixels. /// `visible` is a culling area in those point coordinates; nested clips intersect it and turns /// transform it into the child's coordinates. Shape and path strokes apply their stored caps, /// while ink uses its tool's round ends and joins. fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visible: &kurbo::Rect, opts: &RenderOptions) { match it { Draw::Figure { draws, .. } => { for d in draws { draw(ctx, doc, d, view, visible, opts); } } Draw::Fill { rect, color: c, alpha, text_bg } => { 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(if *text_bg { opts.ink_bg(*c) } else { opts.ink(*c) }, *alpha)); ctx.fill_rect(&r); } Draw::Line { x0, y0, x1, y1, width, color: c, stroke, alpha, text_bg } => { ctx.set_transform(view); ctx.set_paint(color(opts.ink_text(*c, *text_bg), *alpha)); let w = *width as f64; // Flat ends, like the PDF export and Word: a round cap sticks half the width out past // every end, blobbing over the neighbouring border at a table's corners (#2892). Wavy // underlines keep round ends, as the PDF draws them. let mut st = kurbo::Stroke::new(w).with_caps(kurbo::Cap::Butt); if let Some(d) = stroke.dashes() { st = st.with_dashes(0.0, d.iter().map(|k| f64::from(*k) * w)); } let (x0, y0, x1, y1) = (*x0 as f64, *y0 as f64, *x1 as f64, *y1 as f64); let line = |ctx: &mut RenderContext, ox: f64, oy: f64| { let mut p = BezPath::new(); p.move_to((x0 + ox, y0 + oy)); p.line_to((x1 + ox, y1 + oy)); ctx.stroke_path(&p); }; if *stroke == Stroke::Wave { let amp = (w * 1.2).max(0.8); let p = wave_path(x0, y0, x1, y1, amp); ctx.set_stroke(kurbo::Stroke::new(w * 0.8)); ctx.stroke_path(&p); return; } if *stroke == Stroke::Triple { // Three thin lines, spaced as a double's, across the line's direction (so a // vertical border's lines stand side by side). let (dx, dy) = (x1 - x0, y1 - y0); let len = (dx * dx + dy * dy).sqrt().max(1e-3); let (nx, ny) = (-dy / len * w * 1.8, dx / len * w * 1.8); ctx.set_stroke(kurbo::Stroke::new(w * 0.6)); for k in [-1.0, 0.0, 1.0] { line(ctx, nx * k, ny * k); } return; } if *stroke == Stroke::Double { let (dx, dy) = (x1 - x0, y1 - y0); let len = (dx * dx + dy * dy).sqrt().max(1e-3); let (nx, ny) = (-dy / len * w * 0.9, dx / len * w * 0.9); ctx.set_stroke(kurbo::Stroke::new(w * 0.6).with_caps(kurbo::Cap::Butt)); line(ctx, nx, ny); line(ctx, -nx, -ny); return; } ctx.set_stroke(st); line(ctx, 0.0, 0.0); } Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, text_bg, x_scale, .. } => { // Invisible glyphs (WordArt letters are drawn from their outlines) carry text only. if *alpha <= 0.0 { return; } let db = FontDb::global(); let k = *size as f64 / face.upem.max(1.0); ctx.set_paint(color(opts.ink_text(*c, *text_bg), *alpha)); let skew = if *synth_italic { Affine::new([1.0, 0.0, -0.21, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; // Scaled letters (`w:w`): each glyph stretched across from its pen position. let sx = if x_scale.is_finite() && *x_scale > 0.0 { f64::from(*x_scale) } else { 1.0 }; let skew = Affine::scale_non_uniform(sx, 1.0) * skew; // 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)); } // Emoji and other colour glyphs (#1269) are drawn from the font's colour data. let colour = face.has_color_glyphs(); let ppem = *size as f64 * px_per_pt; for (gid, x, y) in glyphs { let (gx, gy) = (*x as f64, *y as f64); // Written so a NaN position is skipped too. let near = gx >= visible.x0 - 100.0 && gx <= visible.x1 && gy >= visible.y0 && gy <= visible.y1 + 200.0; if !near { continue; } if colour && let Some(d) = color_glyph::drawable(face, *gid, ppem, true) { let at = view * Affine::translate((gx, gy)) * skew * Affine::scale(k); color_glyph::draw(ctx, &d, at, face.upem, color(opts.ink(*c), 1.0), *alpha); ctx.set_paint(color(opts.ink(*c), *alpha)); 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::MarkText { x, baseline, size, text } => { let base_face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face; ctx.set_paint(color(opts.ink(opts.mark_color), 0.9)); let mut gx = *x as f64; for ch in text.chars() { let mut face = base_face; if face.glyph_for(ch) == 0 && let Some(f) = FontDb::global().fallback_for(ch, face.id()) { face = wordcraft_fonts::FaceRef::of(&f); } let gid = face.glyph_for(ch); let k = *size as f64 / face.upem.max(1.0); let o = FontDb::global().outline(&face, gid); ctx.set_transform(view * Affine::translate((gx, *baseline as f64)) * Affine::scale(k)); ctx.fill_path(&o); gx += face.advance(gid) * k; } } 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; } if let Some(v) = vector_for(doc, media) { draw_vector(ctx, &v, &r, crop, *alpha, view); 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, vis_w, vis_h] = wordcraft_metafile::crop_window(*crop); 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::Path { segs, fill, stroke, stroke_width, dash, cap } => { let path = wordcraft_layout::display::seg_path(segs); if path.elements().is_empty() { return; } ctx.set_transform(view); if let Some(f) = fill { set_paint(ctx, f, opts); ctx.fill_path(&path); } if let Some(s) = stroke { // Width 0 (or none) is a hairline. let w = if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 }; ctx.set_paint(color(opts.ink(*s), 1.0)); ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(screen_cap(*cap)).with_dashes(0.0, dash.iter().map(|v| f64::from(*v)))); ctx.stroke_path(&path); } } Draw::Art { path, fx, color: c, alpha, text_bg } => { let Some(b) = wordcraft_layout::wordart::path_box(path) else { return }; if !b.inflate(200.0, 200.0).overlaps(*visible) { return; } ctx.set_transform(view); for l in wordcraft_layout::wordart::art_layers(path, fx, *c, *alpha) { // The letters read against their highlight like plain text (#1252); shadows don't. let ink = |c: Rgb| if l.letters { opts.ink_text(c, *text_bg) } else { opts.ink(c) }; set_paint_inked(ctx, &l.paint, ink); match l.stroke { Some(w) => { ctx.set_stroke(kurbo::Stroke::new(f64::from(w.clamp(0.1, 400.0))).with_join(kurbo::Join::Round)); ctx.stroke_path(&l.path); } None => ctx.fill_path(&l.path), } } } Draw::Ink { pts, color: c, width, alpha } => { let Some(path) = ink_path(pts) else { return }; if !path.bounding_box().inflate(*width as f64, *width as f64).overlaps(*visible) { return; } ctx.set_transform(view); ctx.set_paint(color(opts.ink(*c), *alpha)); let w = if width.is_finite() { width.clamp(0.25, 200.0) } else { 1.0 }; ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); ctx.stroke_path(&path); } Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner, dash, cap } => { 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, *corner); 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, *corner)); } } 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, *corner)); } }; 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 { set_paint(ctx, &f.faded(a), opts); ctx.fill_path(&shape_path(*kind, rr, *corner)); } } return; } if let Some(f) = fill { set_paint(ctx, f, opts); ctx.fill_path(&path); } if let Some(s) = stroke { ctx.set_paint(color(opts.ink(*s), 1.0)); ctx.set_stroke( kurbo::Stroke::new(if stroke_width.is_nan() { 0.25 } else { stroke_width.clamp(0.25, 200.0) } as f64) .with_caps(screen_cap(*cap)) .with_dashes(0.0, dash.iter().map(|v| f64::from(*v))), ); ctx.stroke_path(&path); } } Draw::Clip { rect, items } => { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); if !r.is_finite() || !r.overlaps(*visible) { return; } ctx.set_transform(view); ctx.push_clip_layer(&r.to_path(0.1)); let local = r.intersect(*visible); for it in items { draw(ctx, doc, it, view, &local, opts); } ctx.pop_layer(); } 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); } } Draw::Rotated { cx, cy, spin, items } => { let m = Affine::new(spin.matrix(*cx, *cy).map(f64::from)); let local = m.inverse().transform_rect_bbox(*visible); for it in items { draw(ctx, doc, it, view * m, &local, opts); } } } } /// The line through an ink stroke's points (finite ones only); a single point is a dot. fn ink_path(pts: &[(f32, f32)]) -> Option { let mut p = BezPath::new(); let mut last = None; for &(x, y) in pts.iter().filter(|(x, y)| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) { let pt = (x as f64, y as f64); if last.is_none() { p.move_to(pt); } else { p.line_to(pt); } last = Some(pt); } // Only sanitized points reach the path: a tap is drawn at the one usable point. let only = last?; if p.elements().len() == 1 { // A tap: a zero-length line, which round caps draw as a dot. p.line_to(only); } Some(p) } /// Map the document cap to the raster backend without changing ink-tool stroke styles. fn screen_cap(cap: wordcraft_doc::connector::LineCap) -> kurbo::Cap { match cap { wordcraft_doc::connector::LineCap::Flat => kurbo::Cap::Butt, wordcraft_doc::connector::LineCap::Square => kurbo::Cap::Square, wordcraft_doc::connector::LineCap::Round => kurbo::Cap::Round, } } /// Outline of a basic shape in a rectangle; `corner` is a rounded rectangle's /// ([`wordcraft_doc::connector::ShapeExtra::corner`]). pub fn shape_path(kind: ShapeKind, r: kurbo::Rect, corner: Option) -> 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 { // A freeform is drawn from its own paths; without them, its frame. ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => r.to_path(0.1), ShapeKind::RoundedRectangle => { let radius = wordcraft_doc::connector::corner_radius(corner, r.width() as f32, r.height() as f32); kurbo::RoundedRect::from_rect(r, f64::from(radius)).to_path(0.1) } ShapeKind::Ellipse => kurbo::Ellipse::from_rect(r).to_path(0.1), ShapeKind::FlowchartOr => { let mut p = kurbo::Ellipse::from_rect(r).to_path(0.1); p.move_to((r.x0, cy)); p.line_to((r.x1, cy)); p.move_to((cx, r.y0)); p.line_to((cx, r.y1)); p } ShapeKind::Triangle => poly(&[(cx, r.y0), (r.x1, r.y1), (r.x0, r.y1)]), ShapeKind::RightTriangle => poly(&[(r.x0, 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)]), // DrawingML's `line`: top-left to bottom-right (flips give the other directions). ShapeKind::Line => { let mut p = BezPath::new(); p.move_to((r.x0, r.y0)); p.line_to((r.x1, r.y1)); p } // Connectors are drawn as paths (`Draw::Path`); this is their frame's diagonal. ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => { let mut p = BezPath::new(); p.move_to((r.x0, r.y0)); p.line_to((r.x1, r.y1)); p } ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => { let (w, h) = (r.width(), r.height()); let pts: Vec<(f64, f64)> = wordcraft_doc::wrap::arrow_outline(kind, w as f32, h as f32) .iter() .map(|&(u, v)| (r.x0 + f64::from(u) * w, r.y0 + f64::from(v) * h)) .collect(); poly(&pts) } 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) } /// Draw `items` (page points) alone, on nothing, over the page area (`x`, `y`, `w`×`h` points) /// at `scale` px/pt: no paper, no watermark. A dragged object's preview. pub fn render_items(doc: &Document, items: &[Draw], 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, ph) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE), ((h * scale).ceil() as u32).clamp(1, MAX_SIDE)); let (cw, ch) = wordcraft_geom::fit_pixels(pw, ph, wordcraft_geom::MAX_RASTER_PIXELS); let (w16, h16) = (cw as u16, ch as u16); let view = Affine::scale_non_uniform(cw as f64 / pw as f64, ch as f64 / ph as f64) * Affine::scale(scale as f64) * Affine::translate((-x as f64, -y as f64)); let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); 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); 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 } } #[cfg(test)] mod tests { use super::*; use wordcraft_layout::display::page_display; use wordcraft_layout::{LayoutCache, LayoutOptions, layout}; /// The first and last pixel rows with ink in a black-on-white [`render_text_line`] raster. fn ink_rows(img: &Rendered) -> Option<(usize, usize)> { let w = img.width as usize * 4; let rows: Vec = img.pixels.chunks(w).enumerate().filter(|(_, r)| r.chunks(4).any(|p| p[0] < 128)).map(|(i, _)| i).collect(); Some((*rows.first()?, *rows.last()?)) } /// #2570: a font with a very tall ascent (Amiri Quran's is 1.8 em, for stacked marks) put its /// font list entry's baseline below the row, so the name was cut in half, and every tall-ascent /// font sat lower than its neighbours. The entry's capitals are now centred in the row, /// whatever the ascent. #[test] fn a_tall_ascent_font_list_entry_sits_where_the_others_do() { // Source Sans 3 renamed (same length) with its ascent set to Amiri Quran's 1815/1000 em. let mut data = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap(); let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); for (from, to) in [(utf16("Source Sans 3"), utf16("Talled Sans 3")), (b"Source Sans 3".to_vec(), b"Talled Sans 3".to_vec())] { let mut i = 0; while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { data[i + at..i + at + to.len()].copy_from_slice(&to); i += at + to.len(); } } let table = |data: &[u8], tag: &[u8; 4]| { let n = u16::from_be_bytes([data[4], data[5]]) as usize; (0..n) .map(|k| 12 + k * 16) .find(|&r| &data[r..r + 4] == tag) .map(|r| u32::from_be_bytes(data[r + 8..r + 12].try_into().unwrap()) as usize) .unwrap() }; let upem = u16::from_be_bytes(data[table(&data, b"head") + 18..][..2].try_into().unwrap()) as f64; let tall = ((upem * 1.815) as i16).to_be_bytes(); let (hhea, os2) = (table(&data, b"hhea"), table(&data, b"OS/2")); for at in [hhea + 4, os2 + 68, os2 + 74] { data[at..at + 2].copy_from_slice(&tall); } assert!(wordcraft_fonts::FontDb::global().add_font(data) > 0); let db = wordcraft_fonts::FontDb::global(); let tall_face = db.face("Talled Sans 3", "Regular"); let normal = db.face(wordcraft_fonts::FALLBACK_FAMILY, "Regular"); assert_eq!(tall_face.family, "Talled Sans 3"); assert!(tall_face.ascent > normal.ascent * 1.5, "patched ascent {} vs {}", tall_face.ascent, normal.ascent); // The font list's entry: 13 pt text in a 244×22 pt row, 2 pt in. let entry = |f: &wordcraft_fonts::FontFace| ink_rows(&render_text_line(f, "Amiri Quran", 13.0, 244.0, 22.0, 2.0, 2.0)); let (top, bottom) = entry(&normal).unwrap(); let (tall_top, tall_bottom) = entry(&tall_face).unwrap(); assert_eq!((tall_top, tall_bottom), (top, bottom), "same glyphs, same place in the row"); assert!(top > 0 && bottom < 43, "the name isn't clipped: ink rows {top}..={bottom} of 44"); // Centred: as much room above the capitals as below the descender, within a few pixels. let (above, below) = (top as i32, 43 - bottom as i32); assert!((above - below).abs() <= 6, "rows above {above}, below {below}"); } /// #299: the caret's background is the page colour, a highlight, or the dark page. #[test] fn background_at_caret_follows_page_colour_highlight_and_dark_mode() { use wordcraft_doc::Pos; use wordcraft_doc::props::Highlight; let mut d = Document::from_text("Hello WordCraft"); let mid = |d: &Document, off: usize| { let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default()); let c = l.caret_on(&Pos::body(0, off), 0).unwrap(); let page = l.pages[c.page].clone(); (page, c.x, c.top + c.height / 2.0) }; let light = RenderOptions::default(); let dark = RenderOptions { dark: true, ..Default::default() }; let (page, x, y) = mid(&d, 2); assert_eq!(background_at(&d, &page, &light, x, y), Rgb::WHITE); assert_eq!(background_at(&d, &page, &dark, x, y), Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER)); assert_eq!(background_at(&d, &page, &light, f32::NAN, f32::INFINITY), Rgb::WHITE, "junk points fall back to the paper"); d.settings.page_color = Some(Rgb(0x10, 0x10, 0x40)); assert_eq!(background_at(&d, &page, &light, x, y), Rgb(0x10, 0x10, 0x40)); d.settings.page_color = None; d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.highlight = Some(Highlight::Black)).unwrap(); let (page, x, y) = mid(&d, 2); assert_eq!(background_at(&d, &page, &light, x, y), Rgb::BLACK, "inside the highlight"); let (page, x, y) = mid(&d, 9); assert_eq!(background_at(&d, &page, &light, x, y), Rgb::WHITE, "past the highlight"); } /// #299: a filled shape colours the caret's background only inside its outline: an /// ellipse's corner shows what lies beneath, its centre the fill. #[test] fn background_follows_a_shapes_outline_not_its_box() { let opts = RenderOptions::default(); let ellipse = Draw::Shape { rect: wordcraft_geom::Rect::new(100.0, 100.0, 200.0, 100.0), kind: ShapeKind::Ellipse, fill: Some(wordcraft_layout::display::Paint::solid(Rgb::BLACK)), stroke: None, stroke_width: 0.0, effects: Default::default(), corner: None, dash: Vec::new(), cap: Default::default(), }; let at = |x: f32, y: f32| { let mut bg = [255.0; 3]; blend_backgrounds(std::slice::from_ref(&ellipse), wordcraft_geom::Point::new(x, y), &opts, &mut bg, 0); bg }; assert_eq!(at(200.0, 150.0), [0.0; 3], "the centre is the fill"); assert_eq!(at(103.0, 103.0), [255.0; 3], "the box's corner is outside the ellipse"); assert_eq!(at(350.0, 150.0), [255.0; 3], "outside the box"); } #[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(wordcraft_doc::fill::Fill::solid(Rgb(0, 0, 255))), stroke: None, stroke_width: 0.0, float: Default::default(), story: None, freeform: None, effects, extra: Default::default(), }; 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, ys: std::ops::Range| { 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, rot_with_shape: false }; 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"); } /// #1500: gradient fills shade across the shape, circular ones from the centre out, and a /// transparent fill lets what is behind show through. #[test] fn shape_gradients_and_transparency_are_drawn() { use std::sync::Arc; use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape}; use wordcraft_doc::para::{Float, InlineObject, Wrap}; use wordcraft_doc::wordart::GradStop; let shape = |fill: Fill, x: f32| InlineObject::Shape { kind: ShapeKind::Rectangle, w: 100.0, h: 100.0, fill: Some(fill), stroke: None, stroke_width: 0.0, float: Float { wrap: Wrap::InFrontOfText, x, y: 0.0, ..Default::default() }, story: None, freeform: None, effects: Default::default(), extra: Default::default(), }; let stops = vec![GradStop { pos: 0.0, color: Rgb::BLACK, transparency: 0.0 }, GradStop { pos: 100.0, color: Rgb::WHITE, transparency: 0.0 }]; let grad = |kind| Fill::Gradient(Arc::new(Gradient { stops: stops.clone(), kind, tile: None, rot_with_shape: true, flip: GradientFlip::None })); let mut d = Document::from_text(""); let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }; // Left to right: top-to-bottom linear, circular, navy under 30%-opaque white. let objs = [ shape(grad(GradientKind::Linear { angle: 90.0, scaled: false }), 0.0), shape(grad(GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] }), 150.0), shape(Fill::solid(Rgb(0, 0, 0x80)), 300.0), shape(Fill::Solid { color: Rgb::WHITE, transparency: 70.0 }, 300.0), ]; for o in objs.into_iter().rev() { d.insert_object(&at, o, &Default::default()).unwrap(); } let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); // Each shape's frame (the dark corner of the linear one is its top-left pixel). let dark = |x0: u32, x1: u32| -> Vec<(u32, u32)> { (x0..x1).flat_map(|x| (0..400).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[0] < 40).collect() }; let lin = dark(60, 200); let (lx, ly) = (lin.iter().map(|p| p.0).min().unwrap(), lin.iter().map(|p| p.1).min().unwrap()); let luma = |x: u32, y: u32| img.pixel(x, y)[0]; assert!( luma(lx + 50, ly + 5) < luma(lx + 50, ly + 50) && luma(lx + 50, ly + 50) < luma(lx + 50, ly + 95), "dark at the top, light at the bottom" ); let (cx, cy) = (lx + 150 + 50, ly + 50); assert!(luma(cx, cy) < 30 && luma(cx - 48, cy - 48) > 200, "black at the centre, white at the corners"); let [r, _, b, _] = img.pixel(lx + 300 + 50, cy); assert!((150..230).contains(&b) && (60..120).contains(&r), "white over navy blends: {r} {b}"); } #[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 page_picture_is_painted_behind_the_text() { let mut png = Vec::new(); image::RgbaImage::from_pixel(8, 8, image::Rgba([10, 200, 30, 255])) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .unwrap(); let mut d = Document::new(); d.media.insert("bg.png".into(), Arc::new(png)); for kind in ["tile", "frame"] { d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: kind.into(), title: String::new() }); let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); let i = ((300 * img.width + 300) * 4) as usize; assert_eq!(&img.pixels[i..i + 3], &[10, 200, 30], "{kind}"); } // An undecodable picture leaves the paper alone. d.media.insert("bg.png".into(), Arc::new(b"junk".to_vec())); let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); assert_eq!(&img.pixels[0..3], &[255, 255, 255]); } /// Read Mode's Page Color: the reading paper replaces the document's page color and page /// picture on screen, in the raster and under the caret; the text keeps its color on Sepia. #[test] fn read_paper_replaces_the_page_background() { let mut png = Vec::new(); image::RgbaImage::from_pixel(8, 8, image::Rgba([10, 200, 30, 255])) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .unwrap(); let mut d = Document::from_text("Hello WordCraft"); d.media.insert("bg.png".into(), Arc::new(png)); d.settings.page_color = Some(Rgb(0x10, 0x10, 0x40)); let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let page = &l.pages[0]; let at = |img: &Rendered, x: u32, y: u32| { let i = ((y * img.width + x) * 4) as usize; [img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]] }; let sepia = RenderOptions { read_paper: Some(SEPIA_PAPER), ..Default::default() }; let white = RenderOptions { read_paper: Some(Rgb::WHITE), ..Default::default() }; assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 5, 5), [0x10, 0x10, 0x40], "the page color"); assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 5, 5), [0xE2, 0xD9, 0xD0]); assert_eq!(at(&render_page(&d, page, 1.0, &white), 5, 5), [255, 255, 255]); assert_eq!(background_at(&d, page, &sepia, 5.0, 5.0), SEPIA_PAPER); assert_eq!(background_at(&d, page, &white, 5.0, 5.0), Rgb::WHITE); let inverse = RenderOptions { read_paper: Some(Rgb::WHITE), dark: true, ..Default::default() }; assert_eq!(background_at(&d, page, &inverse, 5.0, 5.0), Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER)); d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: "tile".into(), title: String::new() }); assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 300, 300), [10, 200, 30], "the page picture"); assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 300, 300), [0xE2, 0xD9, 0xD0]); // The text is drawn in its own color on sepia paper: its darkest pixel is within 8 levels of its value on white. d.settings.page_picture = None; d.settings.page_color = None; let darkest = |img: &Rendered| img.pixels.chunks(4).map(|p| p[0].min(p[1]).min(p[2])).min().unwrap_or(255); let plain = render_page(&d, page, 2.0, &RenderOptions::default()); let on_sepia = render_page(&d, page, 2.0, &sepia); assert!(darkest(&plain) < 0x40 && darkest(&on_sepia) <= darkest(&plain) + 8, "{} {}", darkest(&plain), darkest(&on_sepia)); } /// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so /// the header parses), with no real pixel data. fn ihdr_only_png(w: u32, h: u32) -> Vec { fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { let mut body = kind.to_vec(); body.extend_from_slice(data); let mut crc = 0xFFFF_FFFFu32; for b in &body { crc ^= *b as u32; for _ in 0..8 { crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; } } out.extend_from_slice(&(data.len() as u32).to_be_bytes()); out.extend_from_slice(&body); out.extend_from_slice(&(!crc).to_be_bytes()); } let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; let mut ihdr = w.to_be_bytes().to_vec(); ihdr.extend_from_slice(&h.to_be_bytes()); ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); chunk(&mut out, b"IHDR", &ihdr); chunk(&mut out, b"IDAT", &[0x78, 0x9c, 0, 0]); out } #[test] fn decode_budget_is_checked_from_the_header() { let mut buf = Vec::new(); let img = image::RgbaImage::from_fn(16, 8, |x, y| image::Rgba([x as u8, y as u8, 9, 255])); img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap(); // Decode, the RGBA conversion and the premultiplied copy are charged: two buffers at peak. assert!(decode_pixmap_within(&buf, 1024).is_some()); assert!(decode_pixmap_within(&buf, 1023).is_none()); // A header (valid CRC) claiming 60000x60000 with no pixel data reads fine, then the budget refuses it. let hdr = ihdr_only_png(60_000, 60_000); assert_eq!(image_size(&hdr), Some((60_000, 60_000))); assert!(decode_pixmap(&hdr).is_none()); } #[test] fn page_raster_is_capped_by_total_pixels() { let d = Document::new(); let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let opts = RenderOptions::default(); let big = render_region_capped(&d, &l.pages[0], 3000, 2000, Affine::scale(4.0), &opts, 100_000); assert!(big.width as u64 * big.height as u64 <= 100_000, "{}x{}", big.width, big.height); assert!(big.width > 300 && big.height > 200); assert_eq!(big.pixels.len() as u64, big.width as u64 * big.height as u64 * 4); let ok = render_region_capped(&d, &l.pages[0], 300, 200, Affine::IDENTITY, &opts, 100_000); assert_eq!((ok.width, ok.height), (300, 200)); } #[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()); } /// A JPEG whose headers say progressive (SOF2, a DC-only first scan) and `w` × `h`, its three /// components unsubsampled, with no pixel data that size. fn progressive_jpeg_header(w: u16, h: u16) -> Vec { let mut jpeg = Vec::new(); image::RgbImage::new(16, 16).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); let sof = jpeg.windows(2).position(|m| m == [0xFF, 0xC0]).unwrap(); jpeg[sof + 1] = 0xC2; jpeg[sof + 5..sof + 7].copy_from_slice(&h.to_be_bytes()); jpeg[sof + 7..sof + 9].copy_from_slice(&w.to_be_bytes()); for c in 0..3 { jpeg[sof + 11 + 3 * c] = 0x11; } let sos = jpeg.windows(2).position(|m| m == [0xFF, 0xDA]).unwrap(); let n = usize::from(jpeg[sos + 4]); jpeg[sos + 6 + 2 * n] = 0; jpeg } #[test] fn progressive_jpeg_coefficients_count_toward_the_decode_budget() { // Two RGBA buffers of 7900 × 7900 fit 512 MB, but RGB output beside three full-size // coefficient planes (9 bytes a pixel) doesn't (#3644). let jpeg = progressive_jpeg_header(7900, 7900); assert!(wordcraft_geom::decode_fits(7900, 7900, wordcraft_geom::MAX_DECODED_BYTES)); assert!(decode_pixmap(&jpeg).is_none()); let mut baseline = Vec::new(); image::RgbImage::new(64, 64).write_to(&mut std::io::Cursor::new(&mut baseline), image::ImageFormat::Jpeg).unwrap(); assert!(decode_pixmap(&baseline).is_some(), "other JPEGs still draw"); } /// #3665: a picture too large for the whole cache is kept at half of it, so it is cached /// instead of decoded again on every render. #[test] fn pictures_too_large_for_the_cache_are_kept_smaller() { let px = vello_cpu::color::PremulRgba8 { r: 40, g: 80, b: 120, a: 200 }; // 6000 × 6000 is 144 MB of RGBA, past the 128 MB cache. let big = Pixmap::from_parts(vec![px; 6000 * 6000], 6000, 6000); let small = cacheable(big); let bytes = small.width() as usize * small.height() as usize * 4; assert!(bytes <= CACHE_BYTES / 2, "{} x {}", small.width(), small.height()); assert!(small.width() >= 3000 && small.width() == small.height(), "aspect kept"); assert!(small.data().iter().all(|p| (p.r, p.g, p.b, p.a) == (40, 80, 120, 200)), "flat stays flat"); let mut cache = MediaCache { map: HashMap::new(), bytes: 0 }; // A source file larger than the whole cache (an uncompressed BMP, say) doesn't count against it. let src = Arc::new(vec![0u8; CACHE_BYTES + 1]); cache.insert("big", &src, Some(Arc::new(small)), bytes); assert!(cache.get("big", &src).is_some(), "cached"); // Pictures the cache holds as they are keep their size. assert_eq!(cacheable(Pixmap::from_parts(vec![px; 100 * 50], 100, 50)).width(), 100); } #[test] fn shrinking_averages_premultiplied_pixels() { let p = |v: u8| vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: 255 }; let pm = Pixmap::from_parts(vec![p(0), p(100), p(200), p(40)], 4, 1); let out = shrink(&pm, 2).unwrap(); assert_eq!((out.width(), out.height()), (2, 1)); assert_eq!(out.data().iter().map(|q| q.r).collect::>(), [50, 120]); assert!(shrink(&pm, 4).is_none() && shrink(&pm, 0).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], ole: None, }; 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()); } /// #2892: a rule is stroked with flat ends, as the PDF export and Word draw them. Round caps /// used to stick half the width out past every end — a blob over the neighbouring border at /// each corner of a table with thick borders, while the exported PDF had square corners. #[test] fn a_thick_rule_ends_flat_not_round() { let d = Document::new(); let rule = |stroke| Draw::Line { x0: 10.0, y0: 10.0, x1: 30.0, y1: 10.0, width: 8.0, color: Rgb::BLACK, stroke, alpha: 1.0, text_bg: None }; let shot = |stroke| render_items(&d, &[rule(stroke)], 0.0, 0.0, 40.0, 20.0, 1.0, &RenderOptions::default()); let ink = |img: &Rendered, x: u32, y: u32| img.pixel(x, y)[3] > 128; // An 8 pt rule from x=10 to x=30 covers y 6..14, and nothing beyond either end. let img = shot(Stroke::Solid); assert!(ink(&img, 15, 10), "the rule itself is drawn"); assert!(!ink(&img, 6, 10) && !ink(&img, 33, 10), "no round cap past x=10 or x=30"); // The double style's two strands end flat as well: its upper strand is centred on y=2.8 // with a 4.8 pt width, so x=31 lies in the half-disc a round cap would add. let img = shot(Stroke::Double); assert!(ink(&img, 15, 3), "the double rule's upper strand is drawn"); assert!(!ink(&img, 31, 3), "the upper strand ends at x=30"); } /// A dimmed header is its full-strength picture faded once, as Word dims it: where a header /// table's borders meet they are the tone of the borders, not darker. #[test] fn dimmed_header_borders_dont_darken_where_they_meet() { let mut d = Document::from_text(""); let t = wordcraft_doc::Table::new(2, 2, 200.0); let blocks = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t)), wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())]; let id = d.add_part(wordcraft_doc::PartKind::Header, blocks); d.last_section.headers.default = Some(id); let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let render = |dim_header: bool| { let mut opts = RenderOptions::default(); opts.display.dim_header = dim_header; render_page(&d, &l.pages[0], 3.0, &opts) }; let (full, dim) = (render(false), render(true)); let mut ink = 0; for y in 0..full.height { for x in 0..full.width { let (f, g) = (full.pixel(x, y)[0] as f32, dim.pixel(x, y)[0] as f32); ink += usize::from(f < 128.0); let want = 255.0 - 0.45 * (255.0 - f); assert!((g - want).abs() <= 2.0, "({x}, {y}): {g} dimmed, {want} wanted ({f} at full strength)"); } } assert!(ink > 100, "the borders are drawn: {ink}"); } /// #2946: a dimmed header's items are faded as one group, so two of its lines that cross come /// out the same tone as the lines themselves, and so does a border two cells share. Faded item /// by item, the crossing kept 0.55² of the background instead of 0.55, and a shared border read /// darker than the outer ones. #[test] fn dimmed_header_borders_keep_one_tone_where_they_meet() { let mut d = Document::from_text("Body"); // Two rows and two columns: the middle border is drawn by both cells beside it, and a cell's // top and left rules cross at its top-left corner. let header = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(wordcraft_doc::Table::new(2, 2, 300.0)))]; let id = d.add_part(wordcraft_doc::PartKind::Header, header); d.last_section.headers.default = Some(id); let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let page = &l.pages[0]; let rules: Vec<(f32, f32, f32, f32)> = page .header .iter() .filter_map(|it| if let wordcraft_layout::Placed::Rule { x0, y0, x1, y1, .. } = it { Some((*x0, *y0, *x1, *y1)) } else { None }) .collect(); let (vx, vy0, _, vy1) = *rules.iter().filter(|(x0, _, x1, _)| x0 == x1).min_by(|a, b| a.0.total_cmp(&b.0)).expect("a vertical rule"); let (hx0, hy, hx1, _) = *rules.iter().find(|(_, y0, _, y1)| y0 == y1 && (y0 - vy0).abs() < 0.01).expect("the horizontal rule at the cell's top"); assert!(hx0 <= vx && vx <= hx1 && vy0 <= hy && hy <= vy1, "the rules cross: ({hx0}, {hy}, {hx1}) across ({vx}, {vy0}, {vy1})"); let bottom = rules.iter().filter(|(_, y0, _, y1)| y0 == y1).map(|(_, y, _, _)| *y).fold(f32::NEG_INFINITY, f32::max); let row = hy + (bottom - hy) * 0.25; // inside the first row, clear of any rule across it // The darkest pixel in a small box: the tone of a line, however it is antialiased. let scale = 8.0; let darkest = |img: &Rendered, cx: f32, cy: f32| { let (px, py) = ((cx * scale) as i64, (cy * scale) as i64); (px - 2..=px + 2) .flat_map(|x| (py - 2..=py + 2).map(move |y| (x, y))) .filter(|(x, y)| *x >= 0 && *y >= 0) .map(|(x, y)| img.pixel(x as u32, y as u32)[0]) .min() .expect("a pixel") }; let dimmed = render_page(&d, page, scale, &RenderOptions::default()); let undimmed = RenderOptions { display: DisplayOptions { dim_header: false, ..Default::default() }, ..Default::default() }; let undimmed = render_page(&d, page, scale, &undimmed); // One border, well clear of any crossing or shared edge: the tone the others must keep. let line = darkest(&dimmed, vx + 30.0, hy); assert!(line > 0, "the dimmed border is drawn, faded: {line}"); assert_eq!(darkest(&dimmed, vx, hy), line, "the crossing is darker than the line"); assert_eq!(darkest(&dimmed, hx0 + 150.0, row), line, "a shared border is darker than the outer ones"); assert_eq!(darkest(&undimmed, vx, hy), darkest(&undimmed, vx + 30.0, hy), "undimmed too"); assert!(darkest(&undimmed, vx + 30.0, hy) < line, "the header is faded"); } #[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}"); } /// #2534: legacy font effects are drawn: outlined letters are hollow; shadowed and embossed /// ones have a grey copy down-right, imprinted ones up-left. #[test] fn legacy_font_effects_are_drawn() { use wordcraft_doc::CharProps; use wordcraft_doc::wordart::{TextEffects, TextFill}; const SIZE: f32 = 40.0; const SCALE: f32 = 2.0; const MIN_DIRECTION_PIXELS: usize = 100; let shot = |c: CharProps, offset: f32| { let mut d = Document::from_text("x"); let cp = CharProps { size: Some(SIZE), bold: Some(true), ..c }; d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH", cp))]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); render_area(&d, &l.pages[0], -offset, -offset, 400.0, 150.0, SCALE, &RenderOptions::default()) }; let counts = |img: &Rendered| { let (mut dark, mut grey) = (0usize, 0usize); for (x, y) in (100..800).flat_map(|x| (100..300).map(move |y| (x, y))) { let v = img.pixel(x, y)[0]; dark += usize::from(v < 60); grey += usize::from((120..=136).contains(&v)); } (dark, grey) }; let (plain_dark, plain_grey) = counts(&shot(CharProps::default(), 0.0)); assert!(plain_dark > 2000, "plain {plain_dark}"); let (dark, _) = counts(&shot(CharProps { outline: Some(true), ..Default::default() }, 0.0)); assert!(dark < plain_dark / 3, "outline is hollow: {dark} vs {plain_dark}"); // An explicit fill keeps the reference on the same Art path as legacy effects. let reference = || CharProps { text_effects: Some(Box::new(TextEffects { fill: Some(TextFill::Solid { color: Rgb::BLACK, transparency: 0.0 }), ..Default::default() })), ..Default::default() }; let base = shot(reference(), 0.0); for (c, sign) in [ (CharProps { shadow: Some(true), ..Default::default() }, 1.0), (CharProps { emboss: Some(true), ..Default::default() }, 1.0), (CharProps { engrave: Some(true), ..Default::default() }, -1.0), ] { let img = shot(c, 0.0); let (_, grey) = counts(&img); assert!(grey > plain_grey * 2, "a grey copy: {grey} vs {plain_grey}"); // A 2pt diagonal displacement at 40pt has sqrt(2)pt on each axis. // Check the visible copy against its translated mask, accounting for occlusion. let offset = sign * std::f32::consts::SQRT_2; let expected = shot(reference(), offset); let opposite = shot(reference(), -offset); let (mut visible, mut directional) = (0usize, 0usize); for (x, y) in (100..800).flat_map(|x| (100..300).map(move |y| (x, y))) { let v = img.pixel(x, y)[0]; if !(120..=136).contains(&v) || base.pixel(x, y)[0] != 255 { continue; } visible += 1; assert!(expected.pixel(x, y)[0] < 255, "grey outside translated Art mask: sign {sign}, pixel {x},{y}"); directional += usize::from(expected.pixel(x, y)[0] < 60 && opposite.pixel(x, y)[0] == 255); } assert!(directional > MIN_DIRECTION_PIXELS, "directional grey: sign {sign}, {directional} of {visible}"); eprintln!("spatial shadow sign={sign} grey={grey} visible={visible} directional={directional}"); } } /// A document whose only text is a yellow-highlighted run. fn yellow_highlight_doc() -> (Document, wordcraft_layout::DocLayout) { let cp = wordcraft_doc::CharProps { highlight: Some(wordcraft_doc::props::Highlight::Yellow), size: Some(24.0), ..Default::default() }; let mut d = Document::from_text("x"); d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); (d, l) } /// #1252: on a dark page a highlight is drawn dimmed (keeping its hue) so the white text on it /// reads; a light page is unchanged. #[test] fn dark_page_dims_a_highlight_raster() { let (d, l) = yellow_highlight_doc(); let items = page_display(&d, &l.pages[0], &DisplayOptions::default()); let r = items .iter() .find_map(|i| match i { Draw::Fill { rect, color, .. } if *color == Rgb(255, 255, 0) => Some(*rect), _ => None, }) .unwrap(); // Just inside the top-left corner of the highlight: above the capitals, so pure background. let (px, py) = ((r.x + 1.5) as u32, (r.y + 1.5) as u32); let light = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); assert_eq!(light.pixel(px, py), [255, 255, 0, 255], "light page keeps bright yellow"); let dark = render_page(&d, &l.pages[0], 1.0, &RenderOptions { dark: true, ..Default::default() }); let [r8, g8, b8, _] = dark.pixel(px, py); assert!(r8 < 0x60 && g8 < 0x60, "dark page highlight is dim, got {:?}", (r8, g8, b8)); assert!(r8.abs_diff(g8) <= 4 && g8 > b8.saturating_add(0x20), "dark page highlight stays yellow in hue, got {:?}", (r8, g8, b8)); } /// #1252: on a dark page every highlight colour is dimmed so the text drawn on it reads /// (Automatic and explicit black text both draw white); a light page is the identity. #[test] fn dark_page_text_backgrounds_keep_text_readable() { use wordcraft_doc::props::{Highlight, TextColor}; use wordcraft_layout::display::text_color; let dark = RenderOptions { dark: true, ..Default::default() }; let light = RenderOptions::default(); let colours: Vec = Highlight::ALL.iter().filter_map(|h| h.rgb()).collect(); assert_eq!(colours.len(), 16); for c in colours { let bg = dark.ink_bg(c); for (what, tc) in [("automatic", text_color(&TextColor::Auto, Some(c))), ("black", Rgb::BLACK)] { let fg = dark.ink_text(tc, Some(c)); assert_eq!(fg, Rgb::WHITE, "{what} text on {c:?} draws white"); let k = contrast(fg, bg); assert!(k >= 4.5, "{what} text on {c:?}: {fg:?} on {bg:?} has contrast {k:.2}"); } assert_eq!(light.ink_bg(c), c, "light page keeps {c:?}"); assert_eq!(light.ink_text(c, Some(c)), c, "light page keeps text {c:?}"); } // Yellow turns a dim olive (~#4a4a10), still yellow in hue. let Rgb(r, g, b) = dark.ink_bg(Rgb(255, 255, 0)); assert!(r.abs_diff(0x4A) <= 8 && g.abs_diff(0x4A) <= 8 && b.abs_diff(0x10) <= 8, "yellow → {:?}", (r, g, b)); assert!(r == g && g > b); // Text off a highlight keeps the plain inversion. assert_eq!(dark.ink_text(Rgb::WHITE, None), dark.ink(Rgb::WHITE)); } /// #1252 review: explicit coloured text (pure blue, red, every highlight colour) on every /// dimmed highlight still reaches 4.5:1 on a dark page. #[test] fn dark_page_coloured_text_on_highlights_reads() { use wordcraft_doc::props::Highlight; let dark = RenderOptions { dark: true, ..Default::default() }; let colours: Vec = Highlight::ALL.iter().filter_map(|h| h.rgb()).collect(); let texts: Vec = [Rgb(0, 0, 255), Rgb(255, 0, 0)].into_iter().chain(colours.iter().copied()).collect(); for &h in &colours { let bg = dark.ink_bg(h); for &tc in &texts { let fg = dark.ink_text(tc, Some(h)); let k = contrast(fg, bg); assert!(k >= 4.5, "text {tc:?} on {h:?}: {fg:?} on {bg:?} has contrast {k:.2}"); } } } /// #1252 review: an Automatic underline on a dark highlight (black) draws light on a dark page, /// like the text it underlines, not the dark grey plain inversion gives white. #[test] fn dark_page_underline_on_a_dark_highlight_reads() { use wordcraft_doc::props::{Highlight, Underline}; let cp = wordcraft_doc::CharProps { highlight: Some(Highlight::Black), underline: Some(Underline::Thick), size: Some(24.0), ..Default::default() }; let mut d = Document::from_text("x"); d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let items = page_display(&d, &l.pages[0], &DisplayOptions::default()); let (x0, x1, y, w) = items .iter() .find_map(|i| match i { Draw::Line { x0, x1, y0, width, .. } => Some((*x0, *x1, *y0, *width)), _ => None, }) .unwrap(); assert!(w >= 1.5, "thick underline {w}"); let (px, py) = (((x0 + x1) / 2.0) as u32, y.round() as u32); let dark = render_page(&d, &l.pages[0], 4.0, &RenderOptions { dark: true, ..Default::default() }); let [r8, g8, b8, _] = dark.pixel(px * 4, py * 4); assert!(r8 > 0xC0 && g8 > 0xC0 && b8 > 0xC0, "underline draws light, got {:?}", (r8, g8, b8)); } /// #2534 review: Automatic text with a legacy shadow (drawn from its outlines) on a black /// highlight draws light on a dark page, like plain text does, not dark grey on near-black. #[test] fn dark_page_shadowed_text_on_a_dark_highlight_reads() { use wordcraft_doc::props::Highlight; let cp = wordcraft_doc::CharProps { highlight: Some(Highlight::Black), shadow: Some(true), size: Some(24.0), ..Default::default() }; let mut d = Document::from_text("x"); d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let items = page_display(&d, &l.pages[0], &DisplayOptions::default()); let b = items .iter() .find_map(|i| match i { Draw::Art { path, text_bg, .. } => { assert!(text_bg.is_some(), "the run's highlight is the letters' background"); wordcraft_layout::wordart::path_box(path) } _ => None, }) .unwrap(); let dark = render_page(&d, &l.pages[0], 2.0, &RenderOptions { dark: true, ..Default::default() }); let light = (b.x0 as u32 * 2..b.x1 as u32 * 2) .flat_map(|x| (b.y0 as u32 * 2..b.y1 as u32 * 2).map(move |y| (x, y))) .filter(|&(x, y)| dark.pixel(x, y)[..3].iter().all(|&v| v > 0xE0)) .count(); assert!(light > 200, "letters draw light, {light} light pixels"); } /// One EMF record: type, size, payload (a multiple of 4 bytes). fn emf_rec(typ: u32, payload: &[u8]) -> Vec { let mut v = typ.to_le_bytes().to_vec(); v.extend(((8 + payload.len()) as u32).to_le_bytes()); v.extend(payload); v } fn le32(vals: &[i32]) -> Vec { vals.iter().flat_map(|v| v.to_le_bytes()).collect() } /// A 25 mm square EMF (device units 0.04 per 0.01 mm, so 100 device units) with a red rectangle over /// its right half. fn red_right_half_emf() -> Vec { let mut h = vec![0u8; 88]; h[0..4].copy_from_slice(&1u32.to_le_bytes()); h[4..8].copy_from_slice(&88u32.to_le_bytes()); h[8..24].copy_from_slice(&le32(&[0, 0, 100, 100])); h[24..40].copy_from_slice(&le32(&[0, 0, 2500, 2500])); h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes()); h[72..88].copy_from_slice(&le32(&[1000, 1000, 250, 250])); let brush = emf_rec(39, &le32(&[1, 0, 0x0000_00FF, 0])); let select = emf_rec(37, &le32(&[1])); let rect = emf_rec(43, &le32(&[50, 0, 100, 100])); let eof = emf_rec(14, &[0; 12]); [h, brush, select, rect, eof].concat() } #[test] fn metafile_picture_is_drawn_as_vectors() { let mut d = Document::new(); let media = d.add_media(red_right_half_emf(), "emf"); let mut p = wordcraft_doc::Paragraph::new(); let obj = wordcraft_doc::para::InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None, }; p.insert_object(0, obj, &Default::default()).unwrap(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let opts = RenderOptions::default(); let rect = page_display(&d, &l.pages[0], &opts.display) .iter() .find_map(|i| match i { Draw::Image { rect, .. } => Some(*rect), _ => None, }) .expect("a picture on the page"); let img = render_page(&d, &l.pages[0], 1.0, &opts); let y = (rect.y + rect.h / 2.0) as u32; let right = img.pixel((rect.x + rect.w * 0.75) as u32, y); let left = img.pixel((rect.x + rect.w * 0.25) as u32, y); assert!(right[0] > 200 && right[1] < 60 && right[2] < 60, "right half should be red: {right:?}"); assert!(left[..3].iter().all(|c| *c > 200), "left half should be paper, not the grey box: {left:?}"); } /// #1899: a crop that keeps a sliver of the picture (3% here; a header's bar can be 2.7% of a /// page-sized metafile) stretches that sliver over the frame, as the file says, for vector and /// bitmap pictures alike. Both pictures are paper on the left half and red on the right. #[test] fn a_crop_keeping_a_sliver_fills_the_frame_with_it() { let mut png = Vec::new(); image::RgbaImage::from_fn(100, 100, |x, _| image::Rgba(if x >= 50 { [255, 0, 0, 255] } else { [255; 4] })) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .unwrap(); for (bytes, ext) in [(red_right_half_emf(), "emf"), (png, "png")] { // (crop, where in the frame, red?): 47–50% of the source is all paper; 50–53% all red. for (crop, at, red) in [([0.47, 0.47, 0.5, 0.5], 0.7, false), ([0.5, 0.47, 0.47, 0.5], 0.3, true)] { let mut d = Document::new(); let media = d.add_media(bytes.clone(), ext); let mut p = wordcraft_doc::Paragraph::new(); let obj = wordcraft_doc::para::InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop, ole: None, }; p.insert_object(0, obj, &Default::default()).unwrap(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let opts = RenderOptions::default(); let rect = page_display(&d, &l.pages[0], &opts.display) .iter() .find_map(|i| match i { Draw::Image { rect, .. } => Some(*rect), _ => None, }) .expect("a picture on the page"); let img = render_page(&d, &l.pages[0], 1.0, &opts); let px = img.pixel((rect.x + rect.w * at) as u32, (rect.y + rect.h * at) as u32); let is_red = px[0] > 200 && px[1] < 60 && px[2] < 60; let is_paper = px[..3].iter().all(|c| *c > 200); assert!(if red { is_red } else { is_paper }, "{ext} crop {crop:?} at {at}: {px:?}"); } } } #[test] fn cosmetic_metafile_pen_is_one_device_pixel_wide() { // MOVETO (0, 50), LINETO (100, 50) with the default cosmetic black pen: a hairline across the middle. let mut emf = red_right_half_emf()[..88].to_vec(); emf.extend([emf_rec(27, &le32(&[0, 50])), emf_rec(54, &le32(&[100, 50])), emf_rec(14, &[0; 12])].concat()); let mut d = Document::new(); let media = d.add_media(emf, "emf"); let mut p = wordcraft_doc::Paragraph::new(); let obj = wordcraft_doc::para::InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None, }; p.insert_object(0, obj, &Default::default()).unwrap(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let opts = RenderOptions::default(); let rect = page_display(&d, &l.pages[0], &opts.display) .iter() .find_map(|i| match i { Draw::Image { rect, .. } => Some(*rect), _ => None, }) .expect("a picture on the page"); let scale = 2.0; let img = render_page(&d, &l.pages[0], scale, &opts); let (x, y) = (((rect.x + rect.w / 2.0) * scale) as u32, ((rect.y + rect.h / 2.0) * scale) as u32); let darkest = (y - 3..=y + 3).map(|y| img.pixel(x, y)[0]).min().unwrap_or(255); // A 0.1 pt line would cover a fifth of a pixel (about 204); one device pixel covers at least half of one. assert!(darkest < 140, "the hairline should be clearly visible: {darkest}"); } /// #1764: an EMF picture's text (EXTCREATEFONTINDIRECTW, SELECTOBJECT, SETTEXTCOLOR, EXTTEXTOUTW) /// is painted, not dropped. #[test] fn metafile_text_records_are_painted() { let mut font = emf_rec(82, &le32(&[1, -20, 0, 0, 0, 700, 0, 0])); let mut name: Vec = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); name.resize(64, 0); font.extend(name); font[4..8].copy_from_slice(&104u32.to_le_bytes()); // "WWWW" at (5, 40), top-aligned, no dx array. let mut text = emf_rec(84, &le32(&[0, 0, 100, 100, 1, 0, 0, 5, 40, 4, 76, 0, 0, 0, 0, 0, 0])); text.extend("WWWW".encode_utf16().flat_map(|u| u.to_le_bytes())); text[4..8].copy_from_slice(&84u32.to_le_bytes()); let mut emf = red_right_half_emf()[..88].to_vec(); emf.extend([font, emf_rec(37, &le32(&[1])), emf_rec(24, &le32(&[0])), text, emf_rec(14, &[0; 12])].concat()); let mut d = Document::new(); let media = d.add_media(emf, "emf"); let mut p = wordcraft_doc::Paragraph::new(); let obj = wordcraft_doc::para::InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None, }; p.insert_object(0, obj, &Default::default()).unwrap(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let opts = RenderOptions::default(); let rect = page_display(&d, &l.pages[0], &opts.display) .iter() .find_map(|i| match i { Draw::Image { rect, .. } => Some(*rect), _ => None, }) .expect("a picture on the page"); let scale = 2.0; let img = render_page(&d, &l.pages[0], scale, &opts); let (x0, y0) = ((rect.x * scale) as u32, (rect.y * scale) as u32); let (x1, y1) = (((rect.x + rect.w) * scale) as u32, ((rect.y + rect.h) * scale) as u32); let dark = (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 100).count(); assert!(dark > 50, "the text is painted inside the picture: {dark} dark pixels"); } #[test] fn unreadable_metafile_keeps_the_grey_box() { let mut d = Document::new(); // The header alone has no records, so the metafile does not parse. let media = d.add_media(red_right_half_emf()[..88].to_vec(), "emf"); let mut p = wordcraft_doc::Paragraph::new(); let obj = wordcraft_doc::para::InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None, }; p.insert_object(0, obj, &Default::default()).unwrap(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let opts = RenderOptions::default(); let rect = page_display(&d, &l.pages[0], &opts.display) .iter() .find_map(|i| match i { Draw::Image { rect, .. } => Some(*rect), _ => None, }) .expect("a picture on the page"); let img = render_page(&d, &l.pages[0], 1.0, &opts); assert_eq!(img.pixel((rect.x + rect.w / 2.0) as u32, (rect.y + rect.h / 2.0) as u32), [0xD0, 0xD0, 0xD0, 255]); } #[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::FlowchartOr, ShapeKind::Star, ShapeKind::Heart, ShapeKind::Arrow, ShapeKind::ArrowLeft, ShapeKind::ArrowUp, ShapeKind::ArrowDown, ShapeKind::Line, ] { assert!(!shape_path(k, r, None).elements().is_empty()); } } #[test] fn dashed_outline_has_gaps() { use wordcraft_doc::{ connector::ShapeExtra, line_dash::LineDash, para::{Anchor, Float, InlineObject, Wrap}, }; let image = |dash| { let shape = InlineObject::Shape { kind: ShapeKind::Rectangle, w: 70.0, h: 28.0, fill: None, stroke: Some(Rgb(192, 0, 0)), stroke_width: 1.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: Default::default(), extra: ShapeExtra { dash, ..Default::default() }, }; let mut p = wordcraft_doc::Paragraph::new(); p.insert_object(0, shape, &Default::default()).unwrap(); let mut d = Document::new(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); render_page(&d, &l.pages[0], 4.0, &RenderOptions::default()) }; let solid = image(LineDash::Solid); let dashed = image(LineDash::Dash); assert!(solid.pixel(422, 400)[1] < 80); assert_eq!(dashed.pixel(422, 400), [255, 255, 255, 255]); assert!(dashed.pixel(406, 400)[1] < 80); } /// Distinguish flat, square and round endpoints for both preset lines and connector paths. #[test] fn shape_line_caps_reach_the_screen() { use wordcraft_doc::{ connector::{LineCap, ShapeExtra}, para::{Anchor, Float, InlineObject, Wrap}, }; // A preset line uses Draw::Shape; a straight connector uses Draw::Path. for kind in [ShapeKind::Line, ShapeKind::StraightConnector] { for cap in [LineCap::Flat, LineCap::Square, LineCap::Round] { let shape = InlineObject::Shape { kind, w: 40.0, h: 0.0, fill: None, stroke: Some(Rgb::BLACK), stroke_width: 10.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: Default::default(), extra: ShapeExtra { cap, ..Default::default() }, }; let mut p = wordcraft_doc::Paragraph::new(); p.insert_object(0, shape, &Default::default()).unwrap(); let mut d = Document::new(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let image = render_page(&d, &l.pages[0], 4.0, &RenderOptions::default()); let painted = |x, y| image.pixel(x, y)[0] < 80; assert!(painted(404, 400), "line interior: {kind:?} {cap:?}"); assert_eq!(painted(388, 400), cap != LineCap::Flat, "past the endpoint: {kind:?} {cap:?}"); assert_eq!(painted(384, 384), cap == LineCap::Square, "square corner: {kind:?} {cap:?}"); } } } #[test] fn zero_width_connector_dashes() { use wordcraft_doc::{ connector::ShapeExtra, line_dash::LineDash, para::{Anchor, Float, InlineObject, Wrap}, }; let shape = InlineObject::Shape { kind: ShapeKind::ElbowConnector, w: 70.0, h: 28.0, fill: None, stroke: Some(Rgb(192, 0, 0)), 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: Default::default(), extra: ShapeExtra { dash: LineDash::Dash, ..Default::default() }, }; let mut p = wordcraft_doc::Paragraph::new(); p.insert_object(0, shape, &Default::default()).unwrap(); let mut d = Document::new(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let image = render_page(&d, &l.pages[0], 8.0, &RenderOptions::default()); // A 0.75 pt stroke has a 3 pt dash followed by a 2.25 pt gap. assert!(image.pixel(812, 800)[1] < 80, "inside the first dash"); assert_eq!(image.pixel(832, 800), [255, 255, 255, 255], "inside the first gap"); } #[test] fn flowchart_or_spokes() { let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0); let path = shape_path(ShapeKind::FlowchartOr, r, None); let elements = path.elements(); for pair in [ [kurbo::PathEl::MoveTo((0.0, 5.0).into()), kurbo::PathEl::LineTo((10.0, 5.0).into())], [kurbo::PathEl::MoveTo((5.0, 0.0).into()), kurbo::PathEl::LineTo((5.0, 10.0).into())], ] { assert!(elements.windows(2).any(|w| w == pair), "missing spoke {pair:?}"); } } /// A rounded rectangle laid out and rendered: its corner pixel is painted when the corner is /// square, left white with the default corner; turned a quarter, the same. #[test] fn rendered_rounded_rectangles_follow_their_corner() { use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; let rendered = |corner: Option, rot: f32| { let float = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, rot, ..Default::default() }; let shape = wordcraft_doc::para::InlineObject::Shape { kind: ShapeKind::RoundedRectangle, w: 200.0, h: 200.0, fill: Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::props::Rgb(0, 0, 0))), stroke: None, stroke_width: 0.0, float, story: None, freeform: None, effects: Default::default(), extra: wordcraft_doc::connector::ShapeExtra { corner, ..Default::default() }, }; let mut p = wordcraft_doc::Paragraph::new(); p.insert_object(0, shape, &Default::default()).unwrap(); let mut d = Document::new(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()) }; for rot in [0.0, 90.0] { // 3 pt in from the frame's top-left corner, and its middle. let (corner, middle) = ((103, 103), (200, 200)); let square = rendered(Some(0), rot); assert_eq!(square.pixel(corner.0, corner.1)[0], 0, "square corner, turned {rot}"); let rounded = rendered(None, rot); assert_eq!(rounded.pixel(corner.0, corner.1)[0], 255, "default corner, turned {rot}"); assert_eq!(rounded.pixel(middle.0, middle.1)[0], 0, "filled, turned {rot}"); } } /// A rounded rectangle's corner is its file's (`roundRect`'s `adj`), not always 16%. #[test] fn rounded_rectangle_corners_follow_their_radius() { use kurbo::Shape; let r = kurbo::Rect::new(0.0, 0.0, 200.0, 100.0); let inside = |corner, pt: (f64, f64)| shape_path(ShapeKind::RoundedRectangle, r, corner).contains(pt.into()); // Two points inside the top-left corner: 1 pt and 4 pt in from both edges. let (tip, near) = ((1.0, 1.0), (4.0, 4.0)); assert!(inside(Some(0), tip), "square corner"); assert!(!inside(Some(3_565), tip) && inside(Some(3_565), near), "3.565 pt corner"); assert!(!inside(None, near), "16 pt corner by default"); assert!(!inside(Some(u32::MAX), (14.0, 14.0)) && inside(Some(u32::MAX), (100.0, 1.0)), "round ends"); } /// #611: a line runs as DrawingML's `line` preset does, top-left to bottom-right (Word's /// files flip it for the other diagonal), not bottom-left to top-right. #[test] fn a_line_runs_from_top_left_to_bottom_right() { let p = shape_path(ShapeKind::Line, kurbo::Rect::new(10.0, 20.0, 30.0, 60.0), None); assert_eq!(p.elements(), &[kurbo::PathEl::MoveTo((10.0, 20.0).into()), kurbo::PathEl::LineTo((30.0, 60.0).into())]); } } #[cfg(test)] mod cache_tests; #[cfg(test)] mod tests_color_glyphs;