Files
wordcraft/crates/render/src/lib.rs
T
Fin WarmanandBrandon Thomas 4f2d0da04c Shapes: align soft-edge paint and caret opacity (#1600)
* Shapes: a solid fill keeps its transparency

A shape's fill colour can carry a:alpha, its opacity. WordCraft dropped
it, so a translucent white wash laid over a picture was drawn opaque and
hid the picture. The transparency is now read into the shape, drawn on
screen and in the PDF, and written back.

* Fix #1600 freeform and soft-edge fill opacity

* #1600: wrap canonical caret sampler signature

* #1600: format canonical integration

* #1600: assert canonical DOCX alpha round-trip

* #1600: document canonical shape opacity API

* Adapt alpha fixture to rectangle render bounds

* docs: one file-format-parity row for soft-edge fill opacity, status line

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 10:53:00 -05:00

1133 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! WordCraft rasteriser: draws a page's display list with vello_cpu.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::HashMap;
use std::sync::{Arc, Mutex, 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 media;
#[cfg(test)]
use media::{CACHE_BYTES, MediaCache, OVERSIZED_GLYPHS_NOTED, cacheable, decode_pixmap_within, note_oversized_glyphs, shrink};
pub use media::{decode_pixmap, image_size, metafile_size};
use media::{draw_page_picture, draw_vector, lock, pixmap_for, premul_pixmap, vector_for};
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<u8>,
}
impl Rendered {
pub fn to_straight(&self) -> Vec<u8> {
let mut out = self.pixels.clone();
for px in out.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a != 0 && a != 255 {
for c in px.iter_mut().take(3) {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
out
}
/// Whether the pixels are exactly `width`×`height` RGBA, within a page raster's budget
/// ([`wordcraft_geom::MAX_RASTER_PIXELS`]): the fields are public, so an encoder is never asked
/// to pad a short buffer out to a huge claimed size (or, for JPEG, to assert on a mismatch).
fn encodable(&self) -> bool {
let budget = wordcraft_geom::MAX_RASTER_PIXELS.saturating_mul(4);
wordcraft_geom::rgba_fits(self.width, self.height, budget)
&& wordcraft_geom::rgba_bytes(self.width, self.height).is_some_and(|b| b == self.pixels.len() as u64)
}
/// PNG bytes (empty on failure, logged).
pub fn to_png(&self) -> Vec<u8> {
if !self.encodable() {
log::error!("PNG encode: {} bytes for {}×{} pixels", self.pixels.len(), self.width, self.height);
return Vec::new();
}
let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()) 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
}
/// JPEG bytes on white (empty on failure, logged).
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
if !self.encodable() {
log::error!("JPEG encode: {} bytes for {}×{} pixels", self.pixels.len(), self.width, self.height);
return Vec::new();
}
let rgba = self.to_straight();
let rgb: Vec<u8> = rgba
.as_chunks::<4>()
.0
.iter()
.flat_map(|p| {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
[mix(p[0]), mix(p[1]), mix(p[2])]
})
.collect();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
let _ = image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8);
buf
}
/// Straight RGBA at (x, y) (transparent outside).
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
if x >= self.width || y >= self.height {
return [0; 4];
}
let i = ((y as usize * self.width as usize) + x as usize) * 4;
let Some(p) = self.pixels.get(i..i + 4) else { return [0; 4] };
let a = p[3] as u32;
if a == 0 {
return [0; 4];
}
let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8;
[un(p[0]), un(p[1]), un(p[2]), p[3]]
}
}
/// 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<peniko::ColorStop> {
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)
}
/// 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<Rgb>,
}
/// 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>) -> 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]))
}
// Sample the same inset silhouettes and paints used by the raster and PDF.
fn soft_fill_alpha(
rect: wordcraft_geom::Rect,
kind: ShapeKind,
corner: Option<u32>,
radius: f32,
fill: &wordcraft_layout::display::Paint,
p: wordcraft_geom::Point,
) -> f32 {
let radius = radius.min(rect.w.min(rect.h) / 2.0).max(0.0);
let frame = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
let point = kurbo::Point::new(p.x as f64, p.y as f64);
let mut opacity = 0.0;
for (grow, paint) in fill.soft_bands(radius) {
let inset = frame.inflate(grow as f64, grow as f64);
if inset.width() > 0.0 && inset.height() > 0.0 && kurbo::Shape::contains(&shape_path(kind, inset, corner), point) {
opacity += (1.0 - opacity) * paint.at(p.x, p.y).1;
}
}
opacity
}
fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOptions, bg: &mut [f32; 3], depth: u32) {
if depth > 16 {
return;
}
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, effects, .. }
if *kind != ShapeKind::Line && rect.contains(p) && {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
kurbo::Shape::contains(&shape_path(*kind, r, *corner), kurbo::Point::new(p.x as f64, p.y as f64))
} =>
{
let (color, alpha) = fill.at(p.x, p.y);
let alpha = effects.sanitized().soft_edge.map_or(alpha, |radius| soft_fill_alpha(*rect, *kind, *corner, radius, fill, p));
(color, alpha)
}
Draw::Clip { rect, items } if rect.contains(p) => {
blend_backgrounds(items, p, opts, bg, depth + 1);
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::<f64>() / 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::<f64>() / 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
}
/// Most pieces one dashed stroke may be cut into; past it the stroke is drawn solid. kurbo's dash
/// iterator makes no progress along a segment when a dash is too small next to it (it never ends),
/// and every piece is more stroke to build.
const MAX_DASH_PIECES: f64 = 100_000.0;
/// `dashes` to stroke a path at most `len` long with, or none (solid) when they can't be drawn: a
/// negative or non-finite length, a pattern that adds up to nothing, or more than
/// [`MAX_DASH_PIECES`] pieces along the path.
fn safe_dashes(dashes: impl IntoIterator<Item = f64>, len: f64) -> Vec<f64> {
let d: Vec<f64> = dashes.into_iter().take(MAX_DASH_PIECES as usize + 1).collect();
let period: f64 = d.iter().sum();
let ok = d.iter().all(|v| v.is_finite() && *v >= 0.0) && period > 0.0 && period.is_finite() && len.is_finite();
// Each repeat of the pattern is up to `d.len()` pieces; a partial repeat at each end counts too.
if ok && (len / period + 2.0) * d.len() as f64 <= MAX_DASH_PIECES { d } else { Vec::new() }
}
/// An upper bound on `path`'s length: its control polygon's (a curve is no longer than it).
fn path_len_bound(path: &BezPath) -> f64 {
let (mut len, mut at, mut start) = (0.0, kurbo::Point::ZERO, kurbo::Point::ZERO);
for el in path.elements() {
let pts: &[kurbo::Point] = match el {
kurbo::PathEl::MoveTo(p) => {
(at, start) = (*p, *p);
continue;
}
kurbo::PathEl::LineTo(p) => std::slice::from_ref(p),
kurbo::PathEl::QuadTo(a, b) => &[*a, *b],
kurbo::PathEl::CurveTo(a, b, c) => &[*a, *b, *c],
kurbo::PathEl::ClosePath => std::slice::from_ref(&start),
};
for p in pts {
len += at.distance(*p);
at = *p;
}
}
len
}
/// Where and how a display item is rasterized: `view` maps its current point coordinates to
/// pixels, and `visible` is a culling area in those point coordinates.
struct Frame<'a> {
doc: &'a Document,
opts: &'a RenderOptions,
view: Affine,
visible: &'a kurbo::Rect,
}
/// The rectangle in kurbo's terms.
fn kurbo_rect(rect: &wordcraft_geom::Rect) -> kurbo::Rect {
kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64)
}
/// 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) {
let fr = Frame { doc, opts, view, visible };
match it {
Draw::Figure { draws, .. } => {
for d in draws {
draw(ctx, doc, d, view, visible, opts);
}
}
Draw::Fill { .. } => draw_fill(ctx, &fr, it),
Draw::Line { .. } => draw_line(ctx, &fr, it),
Draw::Glyphs { .. } => draw_glyphs(ctx, &fr, it),
Draw::Mark { .. } => draw_mark(ctx, &fr, it),
Draw::MarkText { .. } => draw_mark_text(ctx, &fr, it),
Draw::Image { .. } => draw_image(ctx, &fr, it),
Draw::Path { .. } => draw_path(ctx, &fr, it),
Draw::Art { .. } => draw_art(ctx, &fr, it),
Draw::Ink { .. } => draw_ink(ctx, &fr, it),
Draw::Shape { .. } => draw_shape(ctx, &fr, it),
Draw::Clip { rect, items } => {
let r = kurbo_rect(rect);
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);
}
}
}
}
fn draw_fill(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Fill { rect, color: c, alpha, text_bg } = it else { return };
let r = kurbo_rect(rect);
if !r.overlaps(*fr.visible) {
return;
}
ctx.set_transform(fr.view);
ctx.set_paint(color(if *text_bg { fr.opts.ink_bg(*c) } else { fr.opts.ink(*c) }, *alpha));
ctx.fill_rect(&r);
}
fn draw_line(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Line { x0, y0, x1, y1, width, color: c, stroke, alpha, text_bg } = it else { return };
ctx.set_transform(fr.view);
ctx.set_paint(color(fr.opts.ink_text(*c, *text_bg), *alpha));
let w = *width as f64;
let (x0, y0, x1, y1) = (*x0 as f64, *y0 as f64, *x1 as f64, *y1 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, safe_dashes(d.iter().map(|k| f64::from(*k) * w), (x1 - x0).hypot(y1 - y0)));
}
let (dx, dy) = (x1 - x0, y1 - y0);
let len = (dx * dx + dy * dy).sqrt().max(1e-3);
match *stroke {
Stroke::Wave => {
let amp = (w * 1.2).max(0.8);
ctx.set_stroke(kurbo::Stroke::new(w * 0.8));
ctx.stroke_path(&wave_path(x0, y0, x1, y1, amp));
}
// Three thin lines, spaced as a double's, across the line's direction (so a vertical
// border's lines stand side by side).
Stroke::Triple => {
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] {
stroke_segment(ctx, (x0, y0, x1, y1), (nx * k, ny * k));
}
}
Stroke::Double => {
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));
stroke_segment(ctx, (x0, y0, x1, y1), (nx, ny));
stroke_segment(ctx, (x0, y0, x1, y1), (-nx, -ny));
}
_ => {
ctx.set_stroke(st);
stroke_segment(ctx, (x0, y0, x1, y1), (0.0, 0.0));
}
}
}
/// Stroke the line from (`x0`, `y0`) to (`x1`, `y1`), moved by (`ox`, `oy`).
fn stroke_segment(ctx: &mut RenderContext, (x0, y0, x1, y1): (f64, f64, f64, f64), (ox, oy): (f64, f64)) {
let mut p = BezPath::new();
p.move_to((x0 + ox, y0 + oy));
p.line_to((x1 + ox, y1 + oy));
ctx.stroke_path(&p);
}
fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, text_bg, x_scale, .. } = it else { return };
// Invisible glyphs (WordArt letters are drawn from their outlines) carry text only.
if *alpha <= 0.0 {
return;
}
let (view, visible, opts) = (fr.view, fr.visible, fr.opts);
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);
}
}
}
fn draw_mark(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Mark { x, baseline, size, ch, color: mark } = it else { return };
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(fr.opts.ink(mark.unwrap_or(fr.opts.mark_color)), 0.9));
ctx.set_transform(fr.view * Affine::translate((*x as f64, *baseline as f64)) * Affine::scale(k));
ctx.fill_path(&o);
}
fn draw_mark_text(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::MarkText { x, baseline, size, text } = it else { return };
let base_face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
ctx.set_paint(color(fr.opts.ink(fr.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(fr.view * Affine::translate((gx, *baseline as f64)) * Affine::scale(k));
ctx.fill_path(&o);
gx += face.advance(gid) * k;
}
}
fn draw_image(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Image { rect, media, crop, alpha, .. } = it else { return };
let (doc, view) = (fr.doc, fr.view);
let r = kurbo_rect(rect);
if !r.overlaps(*fr.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(fr.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();
}
}
fn draw_path(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Path { segs, fill, stroke, stroke_width, dash, cap } = it else { return };
let path = wordcraft_layout::display::seg_path(segs);
if path.elements().is_empty() {
return;
}
ctx.set_transform(fr.view);
if let Some(f) = fill {
set_paint(ctx, f, fr.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(fr.opts.ink(*s), 1.0));
ctx.set_stroke(
kurbo::Stroke::new(w as f64)
.with_caps(screen_cap(*cap))
.with_dashes(0.0, safe_dashes(dash.iter().map(|v| f64::from(*v)), path_len_bound(&path))),
);
ctx.stroke_path(&path);
}
}
fn draw_art(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Art { path, fx, color: c, alpha, text_bg } = it else { return };
let Some(b) = wordcraft_layout::wordart::path_box(path) else { return };
if !b.inflate(200.0, 200.0).overlaps(*fr.visible) {
return;
}
ctx.set_transform(fr.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 { fr.opts.ink_text(c, *text_bg) } else { fr.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),
}
}
}
fn draw_ink(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Ink { pts, color: c, width, alpha } = it else { return };
let Some(path) = ink_path(pts) else { return };
if !path.bounding_box().inflate(*width as f64, *width as f64).overlaps(*fr.visible) {
return;
}
ctx.set_transform(fr.view);
ctx.set_paint(color(fr.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);
}
/// A shape's outline and how it is painted, for its shadow, glow and soft edges.
struct ShapeGeom {
r: kurbo::Rect,
kind: ShapeKind,
corner: Option<u32>,
can_fill: bool,
stroked: bool,
}
/// A shape's shadow and glow, behind it: bands of its silhouette grown by `g` points and moved
/// by `d`, in the current paint. `sw` is the outline's width.
fn shape_shadow_and_glow(ctx: &mut RenderContext, fr: &Frame, (geom, sw): (&ShapeGeom, f64), fx: &wordcraft_doc::effects::ShapeEffects) {
let r = geom.r;
let silhouette = |ctx: &mut RenderContext, g: f32, d: kurbo::Vec2| {
let g = g as f64;
if geom.can_fill {
let grow = g + if geom.stroked { 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(geom.kind, rr, geom.corner));
}
} else if sw + 2.0 * g > 0.05 {
ctx.set_stroke(kurbo::Stroke::new(sw + 2.0 * g));
ctx.stroke_path(&shape_path(geom.kind, r + d, geom.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(fr.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(fr.opts.ink(gl.color), a));
silhouette(ctx, g, kurbo::Vec2::ZERO);
}
}
}
/// Soft edges: the fill fades out toward the outline (which fades with it).
fn shape_soft_edges(ctx: &mut RenderContext, fr: &Frame, geom: &ShapeGeom, (rad, f): (f32, &Paint)) {
for (g, paint) in f.soft_bands(rad) {
let rr = geom.r.inflate(g as f64, g as f64);
if rr.width() > 0.0 && rr.height() > 0.0 {
set_paint(ctx, &paint, fr.opts);
ctx.fill_path(&shape_path(geom.kind, rr, geom.corner));
}
}
}
fn draw_shape(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner, dash, cap } = it else { return };
let r = kurbo_rect(rect);
let path = shape_path(*kind, r, *corner);
ctx.set_transform(fr.view);
let fx = effects.sanitized();
let geom = ShapeGeom { r, kind: *kind, corner: *corner, can_fill: *kind != ShapeKind::Line && fill.is_some(), stroked: stroke.is_some() };
if !fx.is_empty() && (geom.can_fill || geom.stroked) {
let sw = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) as f64 } else { 0.75 };
shape_shadow_and_glow(ctx, fr, (&geom, sw), &fx);
}
if let (Some(rad), Some(f), true) = (fx.soft_edge, fill, geom.can_fill) {
shape_soft_edges(ctx, fr, &geom, (rad.min(rect.w.min(rect.h) / 2.0).max(0.0), f));
return;
}
if let Some(f) = fill {
set_paint(ctx, f, fr.opts);
ctx.fill_path(&path);
}
if let Some(s) = stroke {
ctx.set_paint(color(fr.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, safe_dashes(dash.iter().map(|v| f64::from(*v)), path_len_bound(&path))),
);
ctx.stroke_path(&path);
}
}
/// The line through an ink stroke's points (finite ones only); a single point is a dot.
fn ink_path(pts: &[(f32, f32)]) -> Option<BezPath> {
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<u32>) -> 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` (points) at `scale` px/pt — for previews and thumbnails.
pub fn render_area(doc: &Document, page: &Page, area: wordcraft_geom::Rect, scale: f32, opts: &RenderOptions) -> Rendered {
let wordcraft_geom::Rect { x, y, w, h } = area;
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` (points) at `scale` px/pt:
/// no paper, no watermark. A dragged object's preview.
pub fn render_items(doc: &Document, items: &[Draw], area: wordcraft_geom::Rect, scale: f32, opts: &RenderOptions) -> Rendered {
let wordcraft_geom::Rect { x, y, w, h } = area;
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;
#[cfg(test)]
mod cache_tests;
#[cfg(test)]
mod fill_alpha_tests;