Files
designcraft/crates/render/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 ded7c7ad04 M9: damage-region repaint — re-render only changed frames and patch the canvas texture
- render: damage()/damage_with() — changed items and only the frames whose composition changed
  (cache keeps the composition each edit replaced; Cache::composed_for matches a document
  version exactly); conservative full repaint for styles, swatches, layers, parents, wrap,
  effects and cross-story text (+ tests incl. pixel-identical patch vs full render)
- UI: canvas patches its texture after edits with the view unchanged (0.6 ms vs 22 ms on the
  sample when typing); perf harness row for the damage repaint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:54:31 -04:00

719 lines
29 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.
//! DesignCraft renderer: spreads → premultiplied RGBA pixels with `vello_cpu`.
//!
//! The renderer draws *document content only* (paper, page items, parent items, composed text,
//! placed images). Non-printing chrome — guides, frame edges, ports, selection — is drawn by the
//! UI as vector overlays so it stays crisp at any zoom.
//!
//! Callers pass a list of spreads with their placement on a shared canvas and a canvas → pixel
//! view transform, so the UI can render exactly its viewport with every visible spread.
#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::sync::Arc;
use designcraft_color::BlendMode as DcBlend;
use designcraft_compose::{Cache, ComposedStory};
use designcraft_doc::{Content, Document, Item, PageSide, SpreadRef, StoryId, StrokeAlign, StrokeType};
use designcraft_geom::corners;
use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
use vello_cpu::kurbo;
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{Pixmap, RenderContext, Resources};
pub use vello_cpu;
pub mod damage;
mod fx;
pub mod images;
mod text;
/// A rendered image (premultiplied RGBA8, row-major).
#[derive(Clone)]
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.chunks_exact_mut(4) {
let a = px[3] as u32;
if a != 0 && a != 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
out
}
pub fn to_png(&self) -> Vec<u8> {
let mut buf = Vec::new();
let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()).expect("size");
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).expect("png encode");
buf
}
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
let rgba = self.to_straight();
let rgb: Vec<u8> = rgba
.chunks_exact(4)
.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-alpha RGBA at (x, y).
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
let i = ((y * self.width + x) * 4) as usize;
let p = &self.pixels[i..i + 4];
let a = p[3] as u32;
if a == 0 {
return [0, 0, 0, 0];
}
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]]
}
}
/// What to draw.
#[derive(Clone, Debug)]
pub struct RenderOptions {
/// Canvas background (premultiplied RGBA8); `None` = transparent.
pub background: Option<[u8; 4]>,
/// Fill pages with paper colour.
pub paper: bool,
/// Skip non-printing items and layers (Preview mode, export).
pub printing_only: bool,
/// Items not to draw (live drag previews).
pub hidden: Vec<designcraft_doc::ItemId>,
/// Draw text smaller than this many pixels as grey bars ("greeking"); 0 = never.
pub greek_below_px: f64,
/// Drop shadow on pages (screen view).
pub page_shadow: bool,
/// Pink highlight behind text set in fonts that aren't installed (screen view).
pub highlight_missing_fonts: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
background: None,
paper: true,
printing_only: false,
hidden: vec![],
greek_below_px: 0.0,
page_shadow: false,
highlight_missing_fonts: false,
}
}
}
/// A spread placed on the canvas: canvas = spread + offset.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placed {
pub spread: SpreadRef,
pub offset: Vec2,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameStats {
pub items: usize,
pub glyphs: usize,
pub micros: u64,
}
/// Reusable renderer: keeps the render context, the frame-space glyph paths of composed text
/// and (process-wide) decoded images between renders.
pub struct Renderer {
ctx: Option<RenderContext>,
resources: Resources,
glyphs: text::GlyphCache,
/// Composed stories looked up during the current render.
stories: HashMap<(StoryId, Option<String>), Arc<ComposedStory>>,
pub threads: u16,
pub stats: FrameStats,
}
impl Default for Renderer {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy)]
pub(crate) struct Frame<'a> {
doc: &'a Document,
cache: &'a Cache,
view: Affine,
/// Visible area in spread space.
visible: Rect,
/// Size of a device pixel in points.
px: f64,
opts: &'a RenderOptions,
/// Drawing into a multithreaded context (no filter layers).
mt: bool,
}
pub fn default_threads() -> u16 {
#[cfg(target_arch = "wasm32")]
{
0
}
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(n) = std::env::var("DESIGNCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()) {
return n;
}
std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).clamp(0, 8) as u16).unwrap_or(0)
}
}
impl Renderer {
pub fn new() -> Self {
Self {
ctx: None,
resources: Resources::new(),
glyphs: text::GlyphCache::default(),
stories: HashMap::new(),
threads: default_threads(),
stats: FrameStats::default(),
}
}
/// Drop cached glyph paths (they are rebuilt on demand).
pub fn clear_caches(&mut self) {
self.glyphs.clear();
self.stories.clear();
}
/// Number of text frames whose glyph paths are cached.
pub fn cached_frames(&self) -> usize {
self.glyphs.len()
}
/// Render the given spreads into a `width`×`height` image with `view` (canvas → pixels).
pub fn render(
&mut self,
doc: &Document,
cache: &Cache,
spreads: &[Placed],
width: u32,
height: u32,
view: Affine,
opts: &RenderOptions,
) -> Rendered {
let start = now();
let w = width.clamp(1, u16::MAX as u32) as u16;
let h = height.clamp(1, u16::MAX as u32) as u16;
let mut ctx = match self.ctx.take() {
Some(mut c) if c.width() == w && c.height() == h && c.render_settings().num_threads == self.threads => {
c.reset();
c
}
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: self.threads, ..Default::default() }),
};
let mt = ctx.render_settings().num_threads > 0;
self.stats = FrameStats::default();
self.stories.clear();
self.glyphs.tick();
if let Some(bg) = opts.background {
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
}
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let canvas_visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
for pl in spreads {
let Some(sp) = doc.spread(pl.spread) else { continue };
let sview = view * Affine::translate(pl.offset);
let visible = canvas_visible - pl.offset;
let bounds = sp.bounds();
// Cull spreads far outside the viewport (pasteboard items may extend; allow a margin).
if !rect_overlaps(bounds.inflate(2000.0, 2000.0), visible) {
continue;
}
let f = Frame { doc, cache, view: sview, visible, px, opts, mt };
self.draw_spread(&mut ctx, &f, pl.spread);
}
ctx.flush();
// Render straight into the returned buffer (no extra copy of the frame).
let mut pixels = vec![0u8; w as usize * h as usize * 4];
ctx.render(vello_cpu::PixmapMut::new(w, h, &mut pixels).expect("buffer size"), &mut self.resources);
self.ctx = Some(ctx);
self.stories.clear();
self.stats.micros = now().saturating_sub(start);
Rendered { width: w as u32, height: h as u32, pixels }
}
/// Render one document page (absolute index) at `scale` px/pt, optionally including bleed.
pub fn render_page(&mut self, doc: &Document, cache: &Cache, abs: usize, scale: f64, bleed: bool, opts: &RenderOptions) -> Option<Rendered> {
let (si, pi) = doc.page_loc(abs)?;
let page = &doc.spreads[si].pages[pi];
let mut r = page.bounds();
if bleed {
let b = doc.settings.bleed;
let (l, rr) = if page.side == PageSide::Left { (b[3], b[2]) } else { (b[2], b[3]) };
r = Rect::new(r.x0 - l, r.y0 - b[0], r.x1 + rr, r.y1 + b[1]);
}
let w = (r.width() * scale).round().max(1.0) as u32;
let h = (r.height() * scale).round().max(1.0) as u32;
let view = Affine::scale(scale) * Affine::translate(-r.origin().to_vec2());
let mut o = opts.clone();
o.background = Some([255, 255, 255, 255]);
Some(self.render(doc, cache, &[Placed { spread: SpreadRef::Doc(si), offset: Vec2::ZERO }], w, h, view, &o))
}
fn draw_spread(&mut self, ctx: &mut RenderContext, f: &Frame, sr: SpreadRef) {
let doc = f.doc;
let Some(sp) = doc.spread(sr) else { return };
// Paper.
if f.opts.paper {
let paper = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE);
if f.opts.page_shadow {
ctx.set_transform(f.view);
ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 90));
for p in &sp.pages {
let s = 3.0 * f.px;
ctx.fill_rect(&(p.bounds() + Vec2::new(s, s)));
}
}
ctx.set_transform(f.view);
ctx.set_paint(color_of(&paper, 1.0));
// One rect for equal-height spreads avoids an anti-aliasing seam at the spine.
if sp.pages.windows(2).all(|w| w[0].height == w[1].height) {
ctx.fill_rect(&sp.bounds());
} else {
for p in &sp.pages {
ctx.fill_rect(&p.bounds());
}
}
}
// Layers back to front; within each layer parent items first (document spreads only).
for layer in doc.layers.iter().rev() {
if !layer.visible || f.opts.printing_only && !layer.printable {
continue;
}
if let SpreadRef::Doc(si) = sr {
let first = doc.first_page_of_spread(si);
for (pi, page) in sp.pages.iter().enumerate() {
if !page.show_parent_items {
continue;
}
let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue };
let parent = &doc.parents[ppi];
let dx = page.x - parent.pages[ppage].x;
let page_name = doc.page_name(first + pi);
let pr = parent.pages[ppage].bounds();
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
}
// Parent items belong to the parent page they sit on.
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
let _ = pr;
self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(&page_name));
}
}
}
for it in &sp.items {
if it.layer == layer.id {
self.draw_item(ctx, f, it, Affine::IDENTITY, None);
}
}
}
}
fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) {
if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting {
return;
}
let xf = parent * it.xf;
let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(it.stroke.weight + 2.0, it.stroke.weight + 2.0);
if !it.children().is_empty() {
// Groups: children carry their own transforms relative to the group.
let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
}
for c in it.children() {
self.draw_item(ctx, f, c, xf, page_name);
}
if layered {
ctx.pop_layer();
}
return;
}
// Composed text stays inside its frame apart from glyph overhang (italics, swashes, big
// initials), so text frames get a margin rather than being exempt from culling.
let margin = match it.content {
Content::Text(_) => 36.0 + 0.25 * vb.width().max(vb.height()),
_ => 0.0,
};
let fx_outset = if it.effects.any() { fx::outset(it) } else { 0.0 };
if !rect_overlaps(vb.inflate(margin + fx_outset, margin + fx_outset), f.visible) {
return;
}
self.stats.items += 1;
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
}
if it.effects.any() {
self.draw_item_fx(ctx, f, it, &bp, xf, page_name);
} else {
self.draw_body(ctx, f, it, &bp, xf, page_name);
}
if layered {
ctx.pop_layer();
}
}
/// The composed story for a frame (memoised for the current render: the cache lookup
/// validates the whole thread each time).
fn story(&mut self, f: &Frame, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> {
let key = (sid, page_name.map(str::to_string));
if let Some(cs) = self.stories.get(&key) {
return cs.clone();
}
let cs = f.cache.get(f.doc, sid, page_name);
self.stories.insert(key, cs.clone());
cs
}
/// Fill, content and stroke of a leaf item (no opacity, blend or effects).
pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) {
let doc = f.doc;
// Fill.
if !it.fill.is_none() {
ctx.set_transform(f.view * xf);
if set_fill_paint(ctx, doc, &it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle) && it.path.is_closed() {
ctx.fill_path(bp);
}
ctx.reset_paint_transform();
}
// Content.
match &it.content {
Content::Graphic(g) => {
ctx.set_transform(f.view * xf);
ctx.push_clip_layer(bp);
self.draw_graphic(ctx, f, g, xf);
ctx.pop_layer();
}
Content::Text(tf) => {
let cs = self.story(f, tf.story, page_name);
if let Some(ft) = cs.frame(it.id) {
self.draw_text(ctx, f, &cs, ft, xf);
}
}
_ => {}
}
// Stroke.
if !it.stroke.is_none() {
self.draw_stroke(ctx, f, it, bp, xf);
}
}
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) {
let st = &it.stroke;
let Some(c) = f.doc.resolve_color(&st.swatch, st.tint) else { return };
let mut stroke = kurbo::Stroke::new(st.weight)
.with_caps(match st.cap {
designcraft_doc::Cap::Butt => kurbo::Cap::Butt,
designcraft_doc::Cap::Round => kurbo::Cap::Round,
designcraft_doc::Cap::Projecting => kurbo::Cap::Square,
})
.with_join(match st.join {
designcraft_doc::Join::Miter => kurbo::Join::Miter,
designcraft_doc::Join::Round => kurbo::Join::Round,
designcraft_doc::Join::Bevel => kurbo::Join::Bevel,
})
.with_miter_limit(st.miter_limit);
match &st.kind {
StrokeType::Dashed { pattern } if !pattern.is_empty() => stroke = stroke.with_dashes(0.0, pattern.iter().copied()),
StrokeType::Dotted => stroke = stroke.with_dashes(0.0, [0.0, st.weight * 2.0]).with_caps(kurbo::Cap::Round),
_ => {}
}
ctx.set_transform(f.view * xf);
ctx.set_paint(color_of(&c, 1.0));
let closed = it.path.is_closed();
match st.align {
StrokeAlign::Inside if closed => {
// Clip a double-width stroke to the path.
ctx.push_clip_layer(bp);
ctx.set_stroke(kurbo::Stroke { width: st.weight * 2.0, ..stroke });
ctx.stroke_path(bp);
ctx.pop_layer();
}
StrokeAlign::Outside if closed => {
let outline =
kurbo::stroke(bp.iter(), &kurbo::Stroke { width: st.weight * 2.0, ..stroke }, &kurbo::StrokeOpts::default(), 0.05 * f.px);
let mut p = outline;
// Remove the interior: even-odd with the path itself.
for el in bp.elements() {
p.push(*el);
}
ctx.set_fill_rule(peniko::Fill::EvenOdd);
ctx.fill_path(&p);
ctx.set_fill_rule(peniko::Fill::NonZero);
}
_ => {
ctx.set_stroke(stroke);
ctx.stroke_path(bp);
}
}
}
fn draw_graphic(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) {
let Some(asset) = f.doc.assets.get(&g.asset) else { return };
// Pick a mip level close to the on-screen size.
let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt();
let Some(pm) = images::mip(&asset.data, g.size.0, on_screen) else { return };
let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1);
ctx.set_transform(f.view * xf * g.xf);
let sx = g.size.0 / pm.width().max(1) as f64;
let sy = g.size.1 / pm.height().max(1) as f64;
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::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
}
fn rect_overlaps(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0
}
pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color {
let [r, g, b, a] = c.to_rgba8(alpha);
peniko::Color::from_rgba8(r, g, b, a)
}
/// Set a swatch fill (solid or gradient) as the paint. Returns false for [None]/unknown.
fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, swatch: &str, tint: f32, bounds: Rect, angle: Option<f64>) -> bool {
if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) {
let stops: Vec<peniko::ColorStop> = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect();
if stops.is_empty() {
return false;
}
let c = bounds.center();
let grad = match g.kind {
designcraft_color::GradientKind::Radial => {
let r = bounds.width().max(bounds.height()) / 2.0;
peniko::Gradient::new_radial(c, r as f32).with_stops(stops.as_slice())
}
_ => {
let a = angle.unwrap_or(0.0).to_radians();
let half = (bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0;
let d = Vec2::new(a.cos(), -a.sin()) * half;
peniko::Gradient::new_linear(c - d, c + d).with_stops(stops.as_slice())
}
};
ctx.set_paint(grad);
return true;
}
match doc.resolve_color(swatch, tint) {
Some(c) => {
ctx.set_paint(color_of(&c, 1.0));
true
}
None => false,
}
}
pub fn blend_mode(b: DcBlend) -> BlendMode {
use DcBlend as B;
let mix = match b {
B::Normal => Mix::Normal,
B::Darken => Mix::Darken,
B::Multiply => Mix::Multiply,
B::ColorBurn => Mix::ColorBurn,
B::Lighten => Mix::Lighten,
B::Screen => Mix::Screen,
B::ColorDodge => Mix::ColorDodge,
B::Overlay => Mix::Overlay,
B::SoftLight => Mix::SoftLight,
B::HardLight => Mix::HardLight,
B::Difference => Mix::Difference,
B::Exclusion => Mix::Exclusion,
B::Hue => Mix::Hue,
B::Saturation => Mix::Saturation,
B::Color => Mix::Color,
B::Luminosity => Mix::Luminosity,
};
BlendMode::new(mix, Compose::SrcOver)
}
/// Decode encoded image bytes into a premultiplied pixmap.
pub fn decode_pixmap(bytes: &[u8]) -> Option<Pixmap> {
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = img.dimensions();
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
}
let data: Vec<vello_cpu::color::PremulRgba8> = img
.pixels()
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
Some(Pixmap::from_parts(data, w as u16, h as u16))
}
/// Box-filter downsample by 2.
fn halve(pm: &Pixmap) -> Pixmap {
let (w, h) = (pm.width() as usize, pm.height() as usize);
let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
let src = pm.data();
let mut out = Vec::with_capacity(nw * nh);
for y in 0..nh {
for x in 0..nw {
let mut acc = [0u32; 4];
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let sx = (2 * x + dx).min(w - 1);
let sy = (2 * y + dy).min(h - 1);
let p = src[sy * w + sx];
acc[0] += p.r as u32;
acc[1] += p.g as u32;
acc[2] += p.b as u32;
acc[3] += p.a as u32;
}
out.push(vello_cpu::color::PremulRgba8 { r: (acc[0] / 4) as u8, g: (acc[1] / 4) as u8, b: (acc[2] / 4) as u8, a: (acc[3] / 4) as u8 });
}
}
Pixmap::from_parts(out, nw as u16, nh as u16)
}
#[cfg(not(target_arch = "wasm32"))]
fn now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
fn now() -> u64 {
0
}
/// Pixel size of an encoded image without decoding it fully.
pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
}
/// MIME type guess for encoded image bytes.
pub fn image_mime(bytes: &[u8]) -> &'static str {
match image::guess_format(bytes) {
Ok(image::ImageFormat::Png) => "image/png",
Ok(image::ImageFormat::Jpeg) => "image/jpeg",
Ok(image::ImageFormat::Gif) => "image/gif",
Ok(image::ImageFormat::WebP) => "image/webp",
Ok(image::ImageFormat::Tiff) => "image/tiff",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Fill, ParaFormat};
#[test]
fn renders_page_with_text_and_fill() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (fid, _) =
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 576.0, 300.0), lid, "Hello DesignCraft", ParaFormat::default()).unwrap();
let id = designcraft_doc::ItemId(d.alloc());
let mut box_ =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 400.0, 136.0, 500.0)));
box_.fill = Fill::swatch("C=100 M=0 Y=0 K=0");
d.insert_item(SpreadRef::Doc(0), box_, None).unwrap();
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
assert_eq!((img.width, img.height), (612, 792));
// Paper is white, the box is cyan-ish, text drew some dark pixels.
assert_eq!(img.pixel(300, 700), [255, 255, 255, 255]);
let c = img.pixel(80, 450);
assert!(c[0] < 80 && c[2] > 150, "{c:?}");
let dark = (36..300).flat_map(|x| (36..60).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 100).count();
assert!(dark > 50, "text pixels: {dark}");
let _ = fid;
assert!(r.stats.glyphs >= 15);
}
#[test]
fn missing_fonts_are_highlighted_on_screen_only() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 100.0), lid, "Missing font", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().format_chars(0..12, |f| f.over.font_family = Some("No Such Family".into()));
let cache = Cache::new();
let cs = cache.get(&d, sid, None);
assert!(cs.styles.iter().any(|s| s.missing_font));
let l = &cs.frames[0].lines[0];
// A point inside the line box but between glyph strokes: the space after "Missing".
let g = l.glyphs.iter().find(|g| g.byte == 7).unwrap();
let (x, y) = ((g.x + g.adv / 2.0) as u32, (l.baseline - l.ascent * 0.5) as u32);
let mut r = Renderer::new();
r.threads = 0;
let plain = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
let shown = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions { highlight_missing_fonts: true, ..Default::default() }).unwrap();
assert_eq!(plain.pixel(x, y)[1], 255, "no highlight in output");
let p = shown.pixel(x, y);
assert!(p[0] > 240 && p[1] < 200, "pink on screen: {p:?}");
}
#[test]
fn renders_tables() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 400.0), lid, "", ParaFormat::default()).unwrap();
let mut t = designcraft_doc::Table::new(5, 3, 3, 1, 0, 300.0);
for c in 0..3 {
let cell = t.cell_mut(0, c).unwrap();
cell.fill = "C=100 M=0 Y=0 K=0".into();
cell.text.insert(0, "Head");
t.cell_mut(2, c).unwrap().text.insert(0, "WWWWWWWW");
}
d.story_mut(sid).unwrap().insert_table(0, t);
let cache = Cache::new();
let cs = cache.get(&d, sid, None);
let tf = &cs.frames[0].tables[0];
let head = tf.cell(0, 1).unwrap().rect;
let body = tf.cell(2, 0).unwrap().rect;
let mut r = Renderer::new();
r.threads = 0;
let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
// Header fill (cyan) near the right edge of the middle header cell.
let c = img.pixel((head.x1 - 3.0) as u32, (head.y0 + 3.0) as u32);
assert!(c[0] < 80 && c[2] > 150, "{c:?}");
// The table border (black) on the left edge.
let e = img.pixel(body.x0.round() as u32, body.center().y as u32);
assert!(e[0] < 200, "{e:?}");
// Cell text drew dark pixels inside the body cell.
let dark = ((body.x0 + 5.0) as u32..(body.x1 - 2.0) as u32)
.flat_map(|x| ((body.y0 + 2.0) as u32..(body.y1 - 2.0) as u32).map(move |y| (x, y)))
.filter(|&(x, y)| img.pixel(x, y)[0] < 100)
.count();
assert!(dark > 30, "cell text pixels: {dark}");
}
}