Files
designcraft/crates/render/src/lib.rs
T
skylar taylor-barrick 0d541deab7 Merge upstream/main into codex/fix-relink-eps
Resolve 12 conflicted files. Where both sides changed the same code:

- format: keep main's bounded-read load_zip (MAX_ENTRIES, read_entry) and
  this branch's u64 version reporting, so a version above u32::MAX is named
  as written rather than clamped. Both sides had fixed the same truncation.
- render: main's natural_size supersedes image_natural_size (it already
  reads an EPS bounding box and also handles PDF pages); callers in table
  and data merge now use it.
- images: keep the DSC-aware header scan and (atend) trailer lookup, take
  main's classic-Mac line endings, and carry its overflow guard into
  parse_box. eps_proxy draws main's simple rectangles before falling back
  to the placeholder, still over a retained EPS source.
- engine/file: take main's placement shape (add_asset, crop box geometry,
  place cursor, finite_rect, object effects) and keep this branch's
  validate_placement in both branches; edit() works on a clone and commits
  only on Ok, so validating inside it stays all-or-nothing.
- engine/links: keep building the replacement before edit() so a file that
  cannot be read leaves the document untouched, and move main's crop_pdf
  refresh into relinked_asset, which both relink and update go through.
- engine/export: EPS preview warnings join the font-embedding warnings
  rather than shadowing them.
- idml: an EPS element keeps ActualPpi (its pixels are a preview raster);
  main's PDF exclusion is unchanged.

Cargo.lock regenerated. Tests and doc strings from both sides kept.
2026-10-16 19:57:08 -06:00

2133 lines
98 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.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::sync::Arc;
use designcraft_color::BlendMode as DcBlend;
use designcraft_compose::{Cache, ComposedStory, ShownOn};
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;
mod blendspace;
mod colorize;
pub mod damage;
mod fx;
mod grid;
/// How far (points) an object's soft effects reach outside its geometry.
pub fn effect_outset(it: &Item) -> f64 {
fx::outset(it)
}
pub mod glyphs;
mod pdf_bounds;
mod pdf_layers;
pub use pdf_layers::{pdf_hide_layers, pdf_layers};
pub mod images;
mod text;
/// Largest side of a raster the renderer draws. vello_cpu keeps sizes in `u16` and rounds them
/// up to its tiles (4 px) and depth buckets (128 px), so a side near `u16::MAX` overflows inside
/// it: stay a few buckets below.
pub const MAX_SIDE: u32 = u16::MAX as u32 + 1 - 512;
/// Largest page raster [`Renderer::render_page`] makes (2 GiB of RGBA).
pub const MAX_PAGE_PIXELS: u64 = 1 << 29;
/// 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.as_chunks_mut::<4>().0 {
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
}
/// PNG bytes (empty, with the error logged, if the image can't be encoded).
pub fn to_png(&self) -> Vec<u8> {
let mut buf = Vec::new();
let mut px = self.to_straight();
// `pixels` is public: a mismatched buffer is padded or cut to the stated size.
px.resize(self.width as usize * self.height as usize * 4, 0);
let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else {
log::error!("PNG encode: {}×{} image is too large", self.width, self.height);
return Vec::new();
};
if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) {
log::error!("PNG encode: {e}");
return Vec::new();
}
buf
}
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
let rgba = self.to_straight();
let rgb: Vec<u8> = rgba
.as_chunks::<4>()
.0
.iter()
.flat_map(|p| {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
[mix(p[0]), mix(p[1]), mix(p[2])]
})
.collect();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
let _ = image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8);
buf
}
/// Straight-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, PartialEq)]
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,
/// Preferences › Composition: yellow behind lines whose word spacing breaks the paragraph's
/// limits (darker = worse), green behind text with custom tracking or kerning (screen view).
pub highlight_hj: bool,
pub highlight_keeps: bool,
pub highlight_custom_tracking: bool,
/// Conditional text indicators: an underline in each condition's colour (screen view).
pub condition_indicators: bool,
/// Editorial note anchors (screen view).
pub note_indicators: bool,
/// Track Changes: added text highlighted (screen view).
pub change_markup: bool,
/// View › Structure › Show Tag Markers: brackets around inline-tagged text (screen view).
pub tag_markers: bool,
/// Separations Preview: draw one process plate (0 = cyan … 3 = black) as grey ink coverage,
/// from each object's own colour (CMYK as authored, others through the colour settings).
/// 4 (internal, the CMYK blend space): cyan, magenta and yellow coverage in red, green, blue.
pub plate: Option<u8>,
/// View › Display Performance.
pub quality: DisplayQuality,
/// Preferences › Appearance of Black: show 100% K as rich (pure) black instead of the
/// accurate dark grey.
pub rich_black: bool,
/// View › Overprint Preview: overprinting inks mix with what's beneath.
pub overprint_preview: bool,
/// Screen: spreads with transparency are shown as blended in the document's blend space (a
/// CMYK blend space keeps their colours in the working CMYK gamut).
pub blend_space_view: bool,
/// View › Show Document Grid: the area (spread coordinates) of each listed spread that the
/// document grid covers, drawn behind or in front of the page items as the grid settings say
/// (screen view). Empty draws no grid.
pub document_grid: Vec<(SpreadRef, Rect)>,
}
/// View › Display Performance: how placed graphics and effects are drawn on screen.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DisplayQuality {
/// Graphics as grey boxes, no transparency effects.
Fast,
/// Low-resolution proxies (72 ppi at 100%).
Typical,
/// Full resolution.
#[default]
High,
}
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,
highlight_hj: false,
highlight_keeps: false,
highlight_custom_tracking: false,
condition_indicators: false,
note_indicators: false,
change_markup: false,
tag_markers: false,
plate: None,
quality: DisplayQuality::High,
rich_black: false,
overprint_preview: false,
blend_space_view: false,
document_grid: vec![],
}
}
}
/// A spread placed on the canvas: canvas = xf × spread (a translation, turned for a rotated spread view).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placed {
pub spread: SpreadRef,
pub xf: Affine,
}
#[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, ShownOn), Arc<ComposedStory>>,
drawing_items: Vec<designcraft_doc::ItemId>,
cmyk_view: Option<blendspace::CachedView>,
/// Whether the last render ran the CMYK ink passes (only then is its view worth keeping).
cmyk_ran: bool,
#[cfg(test)]
cmyk_passes: usize,
/// Pixels the last ink pass covered (tests).
#[cfg(test)]
cmyk_pass_pixels: usize,
/// Items the last ink pass drew (tests).
#[cfg(test)]
cmyk_pass_items: usize,
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(),
drawing_items: Vec::new(),
cmyk_view: None,
cmyk_ran: false,
#[cfg(test)]
cmyk_passes: 0,
#[cfg(test)]
cmyk_pass_pixels: 0,
#[cfg(test)]
cmyk_pass_items: 0,
threads: default_threads(),
stats: FrameStats::default(),
}
}
/// Drop cached glyph paths and the CMYK viewport (rebuilt on demand).
pub fn clear_caches(&mut self) {
self.cmyk_view = None;
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();
RICH_BLACK.with(|r| r.set(opts.rich_black));
PLATE.with(|p| p.set(opts.plate));
if let Some(hit) = self.cmyk_view.as_ref().filter(|entry| entry.matches(doc, spreads, width, height, view, opts, self.threads)) {
let image = hit.image();
self.stats = hit.stats();
self.stats.micros = now().saturating_sub(start);
return image;
}
// Release the old snapshot before rendering an edited document or resized viewport.
self.cmyk_view = None;
self.cmyk_ran = false;
let image = self.render_uncached(doc, cache, spreads, width, height, view, opts);
// Kept only when the ink passes ran: other views render at their usual cost anyway.
if self.cmyk_ran && image.pixels.len() <= blendspace::CachedView::MAX_BYTES {
self.cmyk_view = Some(blendspace::CachedView::new(doc, spreads, view, opts, &image, self.stats, self.threads));
}
self.stats.micros = now().saturating_sub(start);
image
}
#[allow(clippy::too_many_arguments)]
fn render_uncached(
&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, MAX_SIDE) as u16;
let h = height.clamp(1, MAX_SIDE) 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;
RICH_BLACK.with(|r| r.set(opts.rich_black));
PLATE.with(|p| p.set(opts.plate));
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 * pl.xf;
let visible = pl.xf.inverse().transform_rect_bbox(canvas_visible);
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];
if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) {
ctx.render(pm, &mut self.resources);
}
if opts.blend_space_view && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk && opts.plate.is_none() {
let mut img = Rendered { width: w as u32, height: h as u32, pixels };
for pl in spreads {
let Some(sp) = doc.spread(pl.spread) else { continue };
if blendspace::uses_rgb_proof(doc, pl.spread, opts) {
let r = (view * pl.xf).transform_rect_bbox(sp.bounds());
blend_space_view(&mut img, r);
}
}
pixels = img.pixels;
}
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;
if w > MAX_SIDE || h > MAX_SIDE || w as u64 * h as u64 > MAX_PAGE_PIXELS {
log::warn!("page {abs} at {scale} px/pt would be {w}×{h} pixels: too large to render");
return None;
}
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), xf: Affine::translate(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());
}
}
}
let grid_area = f.opts.document_grid.iter().find(|(r, _)| *r == sr).map(|(_, a)| *a);
let grid = &doc.settings.grid;
if let Some(area) = grid_area.filter(|_| grid.in_back) {
grid::draw(ctx, f, sp, grid, area);
}
if blendspace::uses_cmyk(doc, sr, f.opts) {
self.draw_cmyk_spread(ctx, f, sr);
if let Some(area) = grid_area.filter(|_| !grid.in_back) {
grid::draw(ctx, f, sp, grid, area);
}
return;
}
// Blend modes blend with the objects below, not with the paper (as in InDesign and in PDF,
// where the page's group starts transparent): a Color Dodge or Screen object over blank paper
// shows as it is. So the items are drawn in their own group, laid over the paper once.
let isolate = f.opts.paper && spread_blends(doc, sp);
if isolate {
ctx.push_layer(None, None, None, None, None);
}
// Layers back to front; within each layer parent items first.
for layer in doc.layers.iter().rev() {
if !layer.visible || f.opts.printing_only && !layer.printable {
continue;
}
// Document pages show their parent and its bases; a parent spread shows its bases.
let first = match sr {
SpreadRef::Doc(si) => Some(doc.first_page_of_spread(si)),
SpreadRef::Parent(_) => None,
};
// Base items compose for where they show: the document page, or this parent spread.
let shown_on = |pi: usize| match (sr, first) {
(SpreadRef::Parent(p), _) => ShownOn::Parent(p),
(_, Some(a)) => ShownOn::Page(a + pi),
(_, None) => ShownOn::Own,
};
for (pi, page) in sp.pages.iter().enumerate() {
if !page.show_parent_items {
continue;
}
for (ppi, ppage) in doc.shown_parent_pages(sr, pi) {
let Some(parent) = doc.parents.get(ppi) else { continue };
let Some(ppg) = parent.pages.get(ppage) else { continue };
let dx = page.x - ppg.x;
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;
}
self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), shown_on(pi));
}
}
}
for it in &sp.items {
if it.layer == layer.id {
self.draw_item(ctx, f, it, Affine::IDENTITY, ShownOn::Own);
}
}
}
if isolate {
ctx.pop_layer();
}
if let Some(area) = grid_area.filter(|_| !grid.in_back) {
grid::draw(ctx, f, sp, grid, area);
}
}
fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: ShownOn) {
if self.drawing_items.len() >= 64 || self.drawing_items.contains(&it.id) {
return;
}
self.drawing_items.push(it.id);
self.draw_item_inner(ctx, f, it, parent, page);
self.drawing_items.pop();
}
fn draw_item_inner(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: ShownOn) {
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.extent() + 2.0, it.stroke.extent() + 2.0);
// A frame with items pasted into it draws like any other object (draw_body: fill, the items
// clipped to the frame, stroke), so its effects apply to all of it.
if !it.children().is_empty() && !it.has_nested_items() {
// Groups: children carry their own transforms relative to the group.
// Isolate Blending / Knockout: the group is its own layer (blend modes stop at it).
let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal || it.isolate || it.knockout;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
}
// Gradient feather: the group's objects drawn together, then faded as one.
let feather = it.effects.gradient_feather.on && f.opts.quality != DisplayQuality::Fast;
if feather {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, None, None, None);
}
if it.knockout {
// Knockout: each object first clears its area of those behind it, so it composites
// with the group's backdrop rather than with its siblings.
for c in it.shown_children() {
if !c.hidden {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
ctx.set_transform(f.view * xf * c.xf);
ctx.set_paint(peniko::Color::BLACK);
ctx.fill_path(&c.path.to_bezpath());
ctx.pop_layer();
}
self.draw_item(ctx, f, c, xf, page);
}
} else {
for c in it.shown_children() {
self.draw_item(ctx, f, c, xf, page);
}
}
if feather {
fx::gradient_feather_mask(ctx, f, it, xf);
ctx.pop_layer();
}
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 effects = it.effects.any() && f.opts.quality != DisplayQuality::Fast;
let fx_outset = if effects { 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 effects {
self.draw_item_fx(ctx, f, it, &bp, xf, page);
} else {
self.draw_body(ctx, f, it, &bp, xf, page);
}
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: ShownOn) -> Arc<ComposedStory> {
let key = (sid, page);
if let Some(cs) = self.stories.get(&key) {
return cs.clone();
}
let cs = f.cache.get_on(f.doc, sid, page);
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: ShownOn) {
let doc = f.doc;
// Fill.
if !it.fill.is_none() {
ctx.set_transform(f.view * xf);
let op = overprints(f, &it.fill.swatch, it.fill.tint, it.fill.overprint);
if op {
ctx.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None);
}
if set_fill_paint(ctx, doc, &it.fill, bp.bounding_box()) {
ctx.fill_path(bp);
}
ctx.reset_paint_transform();
if op {
ctx.pop_layer();
}
}
// Content.
match &it.content {
Content::Graphic(g) => {
let g = &if it.media.is_some() { it.drawn_graphic(&f.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
ctx.set_transform(f.view * xf);
ctx.push_clip_layer(bp);
if !it.pdf_hidden_layers.is_empty() {
HIDDEN_LAYERS.with(|h| *h.borrow_mut() = it.pdf_hidden_layers.clone());
}
self.draw_graphic(ctx, f, g, xf);
if !it.pdf_hidden_layers.is_empty() {
HIDDEN_LAYERS.with(|h| h.borrow_mut().clear());
}
ctx.pop_layer();
}
// Items pasted into the frame, clipped by it.
Content::Group { items } if it.has_nested_items() => {
ctx.set_transform(f.view * xf);
ctx.push_clip_layer(bp);
for c in items {
self.draw_item(ctx, f, c, xf, page);
}
ctx.pop_layer();
}
Content::Text(tf) => {
let cs = self.story(f, tf.story, page);
if let Some(ft) = cs.frame(it.id) {
match &tf.options.path {
Some(pt) => self.draw_path_text(ctx, f, &cs, ft, xf, bp, pt),
None => self.draw_text(ctx, f, &cs, ft, xf * f.doc.text_local(it)),
}
}
}
_ => {}
}
// Form fields (screen only; PDF viewers draw their own): a tint, a check mark.
if let Some(ff) = &it.form_field
&& !f.opts.printing_only
{
let r = it.inner_bounds();
ctx.set_transform(f.view * xf);
ctx.set_paint(screen_color([80, 140, 255], 40));
ctx.fill_rect(&r);
ctx.set_paint(screen_color([60, 110, 220], 200));
ctx.set_stroke(kurbo::Stroke::new(0.75));
ctx.stroke_path(&r.to_path(0.1));
if ff.kind == designcraft_doc::FieldKind::CheckBox && !ff.value.is_empty() && ff.value != "Off" {
let (w, h) = (r.width(), r.height());
let mut c = BezPath::new();
c.move_to((r.x0 + w * 0.22, r.y0 + h * 0.48));
c.line_to((r.x0 + w * 0.42, r.y0 + h * 0.72));
c.line_to((r.x0 + w * 0.78, r.y0 + h * 0.24));
ctx.set_paint(screen_color([0, 0, 0], 255));
ctx.set_stroke(kurbo::Stroke::new((w.min(h) * 0.12).max(0.8)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
ctx.stroke_path(&c);
}
}
// Stroke.
if !it.stroke.is_none() {
let op = overprints(f, &it.stroke.swatch, it.stroke.tint, it.stroke.overprint);
if op {
ctx.set_transform(f.view * xf);
ctx.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None);
}
self.draw_stroke(ctx, f, it, bp, xf);
if op {
ctx.pop_layer();
}
}
}
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) {
self.draw_stroke_painted(ctx, f, it, bp, xf);
ctx.reset_paint_transform();
}
/// The stroke, in its colour or gradient (a gradient runs across the path's box, as a fill's).
fn draw_stroke_painted(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) {
let st = &it.stroke;
let (paint, bounds) = (st.as_fill(), bp.bounding_box());
if f.doc.resolve_color(&st.swatch, st.tint).is_none() && designcraft_color::swatch::resolve_gradient(&f.doc.swatches, &st.swatch).is_none() {
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);
let kind = f.doc.stroke_kind(&st.kind);
let length = designcraft_doc::path_length_bound(bp);
match &kind {
StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => {
stroke = stroke.with_dashes(0.0, pattern.iter().copied())
}
StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length) => {
stroke = stroke.with_dashes(0.0, [0.0, st.weight * 2.0]).with_caps(kurbo::Cap::Round)
}
_ => {}
}
ctx.set_transform(f.view * xf);
if !set_fill_paint(ctx, f.doc, &paint, bounds) {
return;
}
let closed = it.path.is_closed();
// Arrowheads: the path is shortened under them; they're drawn after it.
let arrows = designcraft_doc::arrow::apply(bp, st, closed);
let bp = arrows.as_ref().map_or(bp, |a| &a.0);
// Gap colour under dashes and dots: the whole path, undashed.
let gap = if matches!(kind, StrokeType::Solid) { None } else { f.doc.resolve_color(&st.gap_swatch, st.gap_tint) };
if let Some(g) = gap
&& !matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside if closed)
{
let op = overprints(f, &st.gap_swatch, st.gap_tint, st.gap_overprint);
if op {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None);
ctx.set_transform(f.view * xf);
}
ctx.set_paint(color_of(&g, 1.0));
ctx.set_stroke(kurbo::Stroke {
dash_pattern: Default::default(),
start_cap: kurbo::Cap::Butt,
end_cap: kurbo::Cap::Butt,
..stroke.clone()
});
ctx.stroke_path(bp);
if op {
ctx.pop_layer();
}
set_fill_paint(ctx, f.doc, &paint, bounds);
}
// Stripes, wavy and hash strokes are fills (along the path's centre line).
if let Some(o) = kind.outline(bp, st.weight, 0.05 * f.px) {
ctx.fill_path(&o);
for h in arrows.iter().flat_map(|a| &a.1) {
match h.outline {
Some(w) => {
ctx.set_stroke(kurbo::Stroke::new(w).with_join(kurbo::Join::Miter));
ctx.stroke_path(&h.path);
}
None => ctx.fill_path(&h.path),
}
}
return;
}
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 => {
// Subtract the filled path from a double-width stroke. Combining a stroked
// outline (already hollow) with the path using even-odd would paint its centre.
// Isolate the stroke so the subtraction leaves the item's fill/content and
// backdrop intact; the same nonzero fill rule also preserves compound holes.
ctx.push_layer(None, None, None, None, None);
ctx.set_stroke(kurbo::Stroke { width: st.weight * 2.0, ..stroke });
ctx.stroke_path(bp);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
ctx.fill_path(bp);
ctx.pop_layer();
ctx.pop_layer();
}
_ => {
ctx.set_stroke(stroke);
ctx.stroke_path(bp);
}
}
for h in arrows.iter().flat_map(|a| &a.1) {
match h.outline {
Some(w) => {
ctx.set_stroke(kurbo::Stroke::new(w).with_join(kurbo::Join::Miter));
ctx.stroke_path(&h.path);
}
None => ctx.fill_path(&h.path),
}
}
}
/// Draw a placed graphic, inside its clipping path when it has one.
pub(crate) fn draw_graphic(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) {
let Some((region, even_odd)) = g.clip_region() else {
self.draw_graphic_unclipped(ctx, f, g, xf);
return;
};
ctx.set_transform(f.view * xf);
if even_odd {
ctx.set_fill_rule(peniko::Fill::EvenOdd);
}
ctx.push_clip_layer(&region);
ctx.set_fill_rule(peniko::Fill::NonZero);
self.draw_graphic_unclipped(ctx, f, g, xf);
ctx.pop_layer();
}
fn draw_graphic_unclipped(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) {
let Some(asset) = f.doc.assets.get(&g.asset) else { return };
if designcraft_doc::media_kind(&asset.mime).is_some() {
// Video / sound: a dark frame with a play mark (the poster, when set, is drawn by the item).
ctx.set_transform(f.view * xf * g.xf);
ctx.set_paint(screen_color([48, 48, 52], 255));
ctx.fill_rect(&Rect::new(0.0, 0.0, g.size.0, g.size.1));
let (cx, cy, r) = (g.size.0 / 2.0, g.size.1 / 2.0, g.size.0.min(g.size.1) * 0.18);
let mut tri = BezPath::new();
tri.move_to((cx - r * 0.6, cy - r));
tri.line_to((cx + r, cy));
tri.line_to((cx - r * 0.6, cy + r));
tri.close_path();
ctx.set_paint(screen_color([235, 235, 235], 255));
ctx.fill_path(&tri);
return;
}
if f.opts.quality == DisplayQuality::Fast {
ctx.set_transform(f.view * xf * g.xf);
ctx.set_paint(screen_color([178, 178, 178], 255));
ctx.fill_rect(&Rect::new(0.0, 0.0, g.size.0, g.size.1));
return;
}
// Pick a mip level close to the on-screen size (Typical: a 72 ppi proxy).
let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt();
let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen };
let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone());
let data = if !hidden.is_empty() && is_pdf(&asset.data) { pdf_layers::layered(&asset.data, &hidden) } else { asset.data.clone() };
// A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space.
let [l, t, r, b] = shown_box(asset).unwrap_or([0.0, 0.0, 1.0, 1.0]);
let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t));
let page = Rect::new(-l * page_w, -t * page_h, (1.0 - l) * page_w, (1.0 - t) * page_h);
let Some(pm) = images::mip(&data, asset.page, page_w, on_screen) else { return };
// A coloured image is already in the plate's inks (its colours come from `color_of`).
let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(|p| p.get())) {
(Some(coloured), _) => coloured,
(None, Some(plate)) => plate_pixmap(&pm, plate),
(None, None) => pm,
};
// Only where the page is: a box reaching past it (a media box larger than the crop box)
// shows nothing there.
let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1).intersect(page);
ctx.set_transform(f.view * xf * g.xf);
let sx = page_w / pm.width().max(1) as f64;
let sy = page_h / 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::translate((page.x0, page.y0)) * Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
}
const CMYK_N: usize = 17;
/// The sRGB → working CMYK table for the current colour settings (17³, built once per settings,
/// trilinear);
/// sample it with [`cmyk_in`]. Fetch it once for many pixels.
pub(crate) fn cmyk_table() -> std::sync::Arc<Vec<[f32; 4]>> {
const N: usize = CMYK_N;
type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc<Vec<[f32; 4]>>);
static LUT: std::sync::Mutex<Option<Lut>> = std::sync::Mutex::new(None);
let settings = designcraft_color::cms::active_settings();
let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner());
match g.as_ref() {
Some((s, t)) if *s == settings => t.clone(),
_ => {
let cms = designcraft_color::cms::active();
let step = |i: usize| i as f32 / (N - 1) as f32;
let t: Vec<[f32; 4]> = (0..N * N * N)
.map(|i| cms.srgb_to_cmyk([step(i / (N * N)), step(i / N % N), step(i % N)], designcraft_color::cms::Intent::RelativeColorimetric))
.collect();
let t = std::sync::Arc::new(t);
*g = Some((settings, t.clone()));
t
}
}
}
/// Trilinear lookup of `rgb` in a [`cmyk_table`].
pub(crate) fn cmyk_in(table: &[[f32; 4]], rgb: [f32; 3]) -> [f32; 4] {
const N: usize = CMYK_N;
let f = rgb.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32);
let i = f.map(|v| (v as usize).min(N - 2));
let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32];
let mut out = [0.0f32; 4];
for (corner, w) in (0..8).map(|c| {
let (a, b, cc) = (c >> 2 & 1, c >> 1 & 1, c & 1);
let w = (if a == 1 { d[0] } else { 1.0 - d[0] }) * (if b == 1 { d[1] } else { 1.0 - d[1] }) * (if cc == 1 { d[2] } else { 1.0 - d[2] });
((i[0] + a) * N * N + (i[1] + b) * N + i[2] + cc, w)
}) {
if let Some(t) = table.get(corner) {
for k in 0..4 {
out[k] += t[k] * w;
}
}
}
out
}
/// An image's ink coverage on one process plate, as grey (cached per image and plate).
fn plate_pixmap(pm: &std::sync::Arc<vello_cpu::Pixmap>, plate: u8) -> std::sync::Arc<vello_cpu::Pixmap> {
// Keep source allocations alive (their addresses are keys), invalidate on colour settings,
// and cap derived raster memory rather than merely counting differently sized images.
type Entry = (std::sync::Arc<vello_cpu::Pixmap>, std::sync::Arc<vello_cpu::Pixmap>);
type Cache = (designcraft_color::cms::ColorSettings, std::collections::HashMap<(usize, u8), Entry>, usize);
const BUDGET: usize = 64 << 20;
static CACHE: std::sync::Mutex<Option<Cache>> = std::sync::Mutex::new(None);
let key = (std::sync::Arc::as_ptr(pm) as usize, plate);
let settings = designcraft_color::cms::active_settings();
{
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((previous, map, _)) = guard.as_ref() {
if *previous == settings {
if let Some((_, derived)) = map.get(&key) {
return derived.clone();
}
} else {
*guard = None;
}
}
}
let table = cmyk_table();
let data: Vec<vello_cpu::color::PremulRgba8> = pm
.data()
.iter()
.map(|p| {
if p.a == 0 {
return *p;
}
let k = 255.0 / p.a as f32;
let rgb = [p.r, p.g, p.b].map(|v| (v as f32 * k / 255.0).min(1.0));
let cmyk = cmyk_in(&table, rgb);
let ink = |i: usize| ((1.0 - cmyk[i].clamp(0.0, 1.0)) * p.a as f32).round() as u8;
if plate == 4 {
return vello_cpu::color::PremulRgba8 { r: ink(0), g: ink(1), b: ink(2), a: p.a };
}
let v = ink(plate.min(3) as usize);
vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: p.a }
})
.collect();
let out = std::sync::Arc::new(vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height()));
let bytes = (pm.width() as usize).saturating_mul(pm.height() as usize).saturating_mul(8);
if bytes <= BUDGET {
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let (previous, map, used) = guard.get_or_insert_with(|| (settings.clone(), Default::default(), 0));
if *previous != settings || used.saturating_add(bytes) > BUDGET {
map.clear();
*used = 0;
*previous = settings;
}
if map.insert(key, (pm.clone(), out.clone())).is_none() {
*used += bytes;
}
}
out
}
fn rect_overlaps(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0
}
thread_local! {
/// Object Layer Options of the placed PDF being drawn.
static HIDDEN_LAYERS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
/// Appearance of Black for the render on this thread: 100% K as pure black.
static RICH_BLACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Separations Preview plate for the render on this thread.
static PLATE: std::cell::Cell<Option<u8>> = const { std::cell::Cell::new(None) };
}
/// Screen indicators and placeholders are display sRGB, independent of the document's RGB
/// working profile. Encode their inks only while drawing a process plate or CMYK blend pass.
pub(crate) fn screen_color(rgb: [u8; 3], alpha: u8) -> peniko::Color {
if PLATE.with(|plate| plate.get()).is_none() {
return peniko::Color::from_rgba8(rgb[0], rgb[1], rgb[2], alpha);
}
let [c, m, y, k] = cmyk_in(&cmyk_table(), rgb.map(|v| v as f32 / 255.0));
color_of(&designcraft_color::Color::Cmyk { c, m, y, k }, alpha as f32 / 255.0)
}
pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color {
if let Some(p) = PLATE.with(|p| p.get()) {
let cmyk = match *c {
designcraft_color::Color::Cmyk { c, m, y, k } => [c, m, y, k],
_ => c.to_cmyk_managed(designcraft_color::cms::Intent::RelativeColorimetric),
};
let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
let ink = |i: usize| ((1.0 - cmyk[i].clamp(0.0, 1.0)) * 255.0).round() as u8;
if p == 4 {
return peniko::Color::from_rgba8(ink(0), ink(1), ink(2), a);
}
let v = ink(p.min(3) as usize);
return peniko::Color::from_rgba8(v, v, v, a);
}
if RICH_BLACK.with(|r| r.get())
&& let designcraft_color::Color::Cmyk { c: cc, m, y, k } = *c
&& cc <= 1e-3
&& m <= 1e-3
&& y <= 1e-3
&& k >= 0.999
{
return peniko::Color::from_rgba8(0, 0, 0, (alpha.clamp(0.0, 1.0) * 255.0).round() as u8);
}
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, fill: &designcraft_doc::Fill, bounds: Rect) -> bool {
let (swatch, tint) = (fill.swatch.as_str(), fill.tint);
if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) {
// Sampled so process colours blend as inks, as InDesign shows them (and as the PDF's CMYK
// shading prints).
let stops: Vec<peniko::ColorStop> = g.display_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect();
if stops.is_empty() {
return false;
}
let grad = match g.kind {
// Radial: a circle stretched into the object's ellipse (the paint transform).
designcraft_color::GradientKind::Radial => {
let (c, rx, ry) = fill.radial_ellipse(bounds);
let rx = rx.max(1e-3);
ctx.set_paint_transform(Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2()));
peniko::Gradient::new_radial(c, rx as f32).with_stops(stops.as_slice())
}
_ => {
let (a, b) = fill.linear_axis(bounds);
peniko::Gradient::new_linear(a, b).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,
}
}
/// Overprint Preview: does this paint overprint (its own flag, or 100% [Black], which
/// overprints by default)? It's then multiplied over what's beneath.
pub(crate) fn overprints(f: &Frame, swatch: &str, tint: f32, flag: bool) -> bool {
f.opts.overprint_preview && (flag || (f.doc.settings.overprint_black && swatch == designcraft_color::swatch::BLACK && tint >= 0.999))
}
/// Does any item on the spread, or on the parent pages it shows, use a blend mode other than
/// Normal (groups and pasted-in items included)?
fn spread_blends(doc: &Document, sp: &designcraft_doc::Spread) -> bool {
let blends = |it: &Item| {
let mut any = false;
it.walk(&mut |i| any |= i.blend != Default::default());
any
};
sp.items.iter().any(|i| blends(i)) || doc.parents.iter().any(|p| p.items.iter().any(|i| blends(i)))
}
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> {
decode_pixmap_page(bytes, 0)
}
/// [`decode_pixmap`] for page `page` of a PDF (other formats have one page).
pub fn decode_pixmap_page(bytes: &[u8], page: u32) -> Option<Pixmap> {
// Documents retain the EPS source; only display its TIFF preview or placeholder.
let proxy = if designcraft_images::is_eps(bytes) { Some(designcraft_images::eps_proxy(bytes)?.0) } else { None };
let bytes = proxy.as_deref().unwrap_or(bytes);
if is_pdf(bytes) {
return render_pdf_page(bytes, page as usize, 3000);
}
if designcraft_images::is_svg(bytes) {
let (px, w, h) = designcraft_images::render_svg(bytes, 3000)?;
let data = px.as_chunks::<4>().0.iter().map(|p| vello_cpu::color::PremulRgba8 { r: p[0], g: p[1], b: p[2], a: p[3] }).collect();
return Some(Pixmap::from_parts(data, w.min(u16::MAX as u32) as u16, h.min(u16::MAX as u32) as u16));
}
let img = designcraft_images::decode_rgba(bytes)?;
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)> {
if designcraft_images::is_eps(bytes) {
return designcraft_images::eps_pixel_size(bytes);
}
if is_pdf(bytes) {
// Placed PDFs are sized by their page (crop box) in points.
return pdf_page_size(bytes, 0).map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32));
}
if designcraft_images::is_svg(bytes) {
// SVGs by their width and height (CSS pixels → points).
return designcraft_images::natural_size(bytes).map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32));
}
designcraft_images::pixel_size(bytes)
}
/// Natural size of a placed graphic, unrounded: a PDF page (crop box) and an SVG in points, a raster
/// in pixels, an EPS its bounding box independently of preview pixels. Placed graphics are drawn at
/// this size; `image_size` rounds it to whole pixels for display.
pub fn natural_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> {
if is_pdf(bytes) {
return pdf_page_size(bytes, page).map(|(w, h)| (w.max(1e-3), h.max(1e-3)));
}
designcraft_images::natural_size(bytes)
}
/// MIME type guess for encoded image bytes.
/// Is this a PDF file (placed PDFs are graphics)?
pub fn is_pdf(bytes: &[u8]) -> bool {
bytes.starts_with(b"%PDF")
}
/// Page count of a PDF.
pub fn pdf_page_count(bytes: &[u8]) -> Option<usize> {
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
Some(pdf.pages().len())
}
/// Size of PDF page `page` (0-based), in points, as it shows (crop box, rotation applied).
pub fn pdf_page_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> {
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
let p = pdf.pages().get(page)?;
let (w, h) = p.render_dimensions();
Some((w as f64, h as f64))
}
/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off;
/// only the `shown` part of it ([`designcraft_doc::Asset::shown_box`]) when given.
pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Option<Vec<u8>> {
let data = if hidden.is_empty() { bytes.to_vec() } else { pdf_hide_layers(bytes, hidden).unwrap_or_else(|| bytes.to_vec()) };
let pm = render_pdf_page(&data, page, max_side)?;
let pm = match shown {
Some(frac) => crop_pixmap(&pm, frac)?,
None => pm,
};
let pixels: Vec<u8> = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect();
Some(Rendered { width: pm.width() as u32, height: pm.height() as u32, pixels }.to_png())
}
#[cfg(test)]
thread_local! {
/// How many PDF pages [`render_pdf_page`] rasterized on this thread.
static PDF_RENDERS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display; PDF
/// export embeds the page as vectors).
pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option<Pixmap> {
#[cfg(test)]
PDF_RENDERS.with(|n| n.set(n.get() + 1));
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
let p = pdf.pages().get(page)?;
let (w, h) = p.render_dimensions();
let scale = (max_side as f32 / w.max(h).max(1.0)).min(8.0);
let rs = hayro::RenderSettings { x_scale: scale, y_scale: scale, ..Default::default() };
let pm = hayro::render(p, &hayro::RenderCache::new(), &hayro::hayro_interpret::InterpreterSettings::default(), &rs);
let (pw, ph) = (pm.width(), pm.height());
let data: Vec<vello_cpu::color::PremulRgba8> =
pm.data_as_u8_slice().as_chunks::<4>().0.iter().map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect();
Some(Pixmap::from_parts(data, pw, ph))
}
/// The part of PDF page `page` that `crop` shows: its left, top, right and bottom edges as
/// fractions of the page as rendered (its visible crop box, rotated as it displays), and its size
/// in points. A box the page doesn't define is its crop box, as PDF readers do. The content boxes
/// are the bounds of what the page draws, found from its drawing operations without rendering it
/// (the whole page when it draws nothing).
pub fn pdf_crop_box(bytes: &[u8], page: usize, crop: designcraft_doc::PdfCrop) -> Option<([f64; 4], (f64, f64))> {
use designcraft_doc::PdfCrop;
use hayro::hayro_syntax::object::{Rect as PRect, dict::keys};
const WHOLE: [f64; 4] = [0.0, 0.0, 1.0, 1.0];
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
let p = pdf.pages().get(page)?;
let (w, h) = p.render_dimensions();
let (w, h) = (f64::from(w), f64::from(h));
if !(w > 0.0 && h > 0.0) {
return None;
}
let frac = match crop {
PdfCrop::Crop => WHOLE,
PdfCrop::ContentVisible => pdf_bounds::page_content_bounds(bytes, page).unwrap_or(WHOLE),
PdfCrop::ContentAll => {
let all_on = pdf_hide_layers(bytes, &[]);
pdf_bounds::page_content_bounds(all_on.as_deref().unwrap_or(bytes), page).unwrap_or(WHOLE)
}
PdfCrop::Art | PdfCrop::Trim | PdfCrop::Bleed | PdfCrop::Media => {
let media = p.media_box();
let b = match crop {
PdfCrop::Media => media,
_ => {
let key: &[u8] = match crop {
PdfCrop::Trim => keys::TRIM_BOX,
PdfCrop::Bleed => keys::BLEED_BOX,
_ => keys::ART_BOX,
};
p.raw().get::<PRect>(key).map(|b| b.intersect(media)).unwrap_or_else(|| p.intersected_crop_box())
}
};
// Into rendered page space (y down, rotated, origin at the crop box).
let r = Affine::new(p.initial_transform(true).as_coeffs()).transform_rect_bbox(Rect::new(b.x0, b.y0, b.x1, b.y1));
[r.x0 / w, r.y0 / h, r.x1 / w, r.y1 / h]
}
};
let size = ((frac[2] - frac[0]) * w, (frac[3] - frac[1]) * h);
(frac.iter().all(|v| v.is_finite()) && size.0 > 0.0 && size.1 > 0.0).then_some((frac, size))
}
/// The part of the page a placed PDF shows ([`designcraft_doc::Asset::shown_box`]). A Bounding
/// Box crop that import left unmeasured is measured here, once per file, page and crop: that
/// interprets the page's drawing, which only drawing or exporting the graphic needs.
pub fn shown_box(asset: &designcraft_doc::Asset) -> Option<[f64; 4]> {
if !asset.content_box_pending() {
return asset.shown_box();
}
asset.shown(content_box(&asset.data, asset.page, asset.pdf_crop)?)
}
type ContentBoxes = HashMap<(usize, u32, designcraft_doc::PdfCrop), (std::sync::Weak<Vec<u8>>, Option<[f64; 4]>)>;
/// [`pdf_crop_box`]'s box, cached by the file's allocation. The weak reference keeps that address
/// from naming another file while the entry lives.
fn content_box(bytes: &Arc<Vec<u8>>, page: u32, crop: designcraft_doc::PdfCrop) -> Option<[f64; 4]> {
static CACHE: std::sync::Mutex<Option<ContentBoxes>> = std::sync::Mutex::new(None);
let key = (Arc::as_ptr(bytes) as usize, page, crop);
let cached = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|c| c.get(&key)).and_then(|(file, b)| {
let same = file.upgrade().is_some_and(|f| Arc::ptr_eq(&f, bytes));
same.then_some(*b)
});
if let Some(b) = cached {
return b;
}
// Render outside the lock: other graphics keep drawing meanwhile.
let b = pdf_crop_box(bytes, page as usize, crop).map(|(frac, _)| frac);
let mut cache = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let cache = cache.get_or_insert_with(HashMap::new);
cache.retain(|_, (file, _)| file.strong_count() > 0);
cache.insert(key, (Arc::downgrade(bytes), b));
b
}
/// The part of `pm` inside `frac` (left, top, right, bottom as fractions of it); what lies outside
/// `pm` is transparent.
pub fn crop_pixmap(pm: &Pixmap, frac: [f64; 4]) -> Option<Pixmap> {
let (pw, ph) = (f64::from(pm.width()), f64::from(pm.height()));
let [l, t, r, b] = [frac[0] * pw, frac[1] * ph, frac[2] * pw, frac[3] * ph].map(f64::round);
let (w, h) = (r - l, b - t);
if !(w >= 1.0 && h >= 1.0 && w <= f64::from(u16::MAX) && h <= f64::from(u16::MAX)) {
return None;
}
let (w, h) = (w as u16, h as u16);
let mut out = Pixmap::new(w, h);
let src = pm.data();
let dst = out.data_mut();
for y in 0..usize::from(h) {
let sy = y as f64 + t;
if sy < 0.0 || sy >= ph {
continue;
}
for x in 0..usize::from(w) {
let sx = x as f64 + l;
if sx < 0.0 || sx >= pw {
continue;
}
let (Some(p), Some(q)) = (src.get(sy as usize * usize::from(pm.width()) + sx as usize), dst.get_mut(y * usize::from(w) + x)) else {
continue;
};
*q = *p;
}
}
Some(out)
}
/// Separations Preview on a rendered image: `plate` 0–3 shows that process plate (C, M, Y, K) as
/// ink density in grey; with an `ink_limit` (0–4, total ink) areas over it are shown in red.
/// Pixels are separated with a plain GCR (black = 1 − max(r, g, b)), so it's a preview.
/// View › Proof Colors: simulate the proof target (a press, another RGB space, colour-vision
/// deficiencies) through the active colour settings' proof table.
pub fn proof_view(img: &mut Rendered, setup: &designcraft_color::cms::ProofSetup) {
let lut = designcraft_color::cms::active().proof_lut(setup);
for px in img.pixels.as_chunks_mut::<4>().0 {
let a = px[3];
if a == 0 {
continue;
}
// Premultiplied → straight, proof, back.
let k = 255.0 / a as f32;
let rgb = [px[0], px[1], px[2]].map(|v| ((v as f32 * k).round()).min(255.0) as u8);
let out = lut.apply8(rgb);
for i in 0..3 {
px[i] = ((out[i] as u32 * a as u32 + 127) / 255) as u8;
}
}
}
/// A CMYK transparency blend space on screen: the pixels of `area` (device space) through the
/// working CMYK profile and back, as a spread composited in that space looks.
fn blend_space_view(img: &mut Rendered, area: Rect) {
use designcraft_color::cms::{ProofSetup, ProofTarget};
let lut = designcraft_color::cms::active().proof_lut(&ProofSetup { target: ProofTarget::WorkingCmyk, ..Default::default() });
let (w, h) = (img.width as i64, img.height as i64);
let (x0, y0) = ((area.x0.floor() as i64).clamp(0, w), (area.y0.floor() as i64).clamp(0, h));
let (x1, y1) = ((area.x1.ceil() as i64).clamp(0, w), (area.y1.ceil() as i64).clamp(0, h));
for y in y0..y1 {
let row = &mut img.pixels[((y * w + x0) * 4) as usize..((y * w + x1) * 4) as usize];
for px in row.as_chunks_mut::<4>().0 {
let a = px[3];
if a == 0 {
continue;
}
let k = 255.0 / a as f32;
let rgb = [px[0], px[1], px[2]].map(|v| ((v as f32 * k).round()).min(255.0) as u8);
let out = lut.apply8(rgb);
for i in 0..3 {
px[i] = ((out[i] as u32 * a as u32 + 127) / 255) as u8;
}
}
}
}
pub fn separation_view(img: &mut Rendered, plate: Option<u8>, ink_limit: Option<f32>) {
for px in img.pixels.as_chunks_mut::<4>().0 {
let a = px[3] as f32 / 255.0;
// Composite over paper white, then separate.
let ch = |v: u8| (v as f32 / 255.0) + (1.0 - a);
let (r, g, b) = (ch(px[0]).min(1.0), ch(px[1]).min(1.0), ch(px[2]).min(1.0));
let k = 1.0 - r.max(g).max(b);
let inks =
if k >= 0.999 { [0.0, 0.0, 0.0, 1.0] } else { [(1.0 - r - k) / (1.0 - k), (1.0 - g - k) / (1.0 - k), (1.0 - b - k) / (1.0 - k), k] };
let mut out = match plate {
Some(p) => {
let v = ((1.0 - inks[p.min(3) as usize].clamp(0.0, 1.0)) * 255.0).round() as u8;
[v, v, v, 255]
}
None => [(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255],
};
if let Some(limit) = ink_limit
&& inks.iter().sum::<f32>() > limit
{
out = [230, 40, 40, 255];
}
px.copy_from_slice(&out);
}
}
pub fn image_mime(bytes: &[u8]) -> &'static str {
designcraft_images::mime(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Fill, ParaFormat};
#[test]
fn eps_source_decodes_like_its_preview() {
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n";
let mut tiff = Vec::new();
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(3, 2, image::Rgba([255, 0, 0, 128])))
.write_to(&mut std::io::Cursor::new(&mut tiff), image::ImageFormat::Tiff)
.unwrap();
let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6];
for value in [30usize, source.len(), 0, 0, 30 + source.len(), tiff.len()] {
dos.extend((value as u32).to_le_bytes());
}
dos.extend([0xFF, 0xFF]);
dos.extend_from_slice(source);
dos.extend_from_slice(&tiff);
for source in [source.as_slice(), dos.as_slice()] {
let proxy = designcraft_images::eps_proxy(source).unwrap().0;
let actual = decode_pixmap(source).unwrap();
let expected = decode_pixmap(&proxy).unwrap();
assert_eq!((actual.width(), actual.height()), (expected.width(), expected.height()));
assert_eq!(actual.data(), expected.data());
assert_eq!(image_size(source), image_size(&proxy));
assert_eq!(image_mime(source), "application/postscript");
}
let corrupt = b"%!PS-Adobe-3.0 EPSF-3.0\nno bounding box\n";
assert!(decode_pixmap(corrupt).is_none());
assert!(image_size(corrupt).is_none());
}
#[test]
fn footnote_vector_objects_are_painted() {
use designcraft_doc::{AnchorPosition, AnchoredObject, ItemId, Shape, SpreadRef};
let mut d = Document::new(&NewDocument::default());
let (_, sid) =
d.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 300.0, 300.0), d.default_layer(), "Body", ParaFormat::default()).unwrap();
let mut item =
Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 12.0)));
item.fill = Fill::swatch(designcraft_color::swatch::BLACK);
// Open contours with a fill are implicitly closed for painting. A single
// open contour must not hide the whole compound glyph.
item.path.subpaths[0].closed = false;
let story = d.story_mut(sid).unwrap();
story.insert_note(4, "", ParaFormat::default());
Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 }));
let cache = Cache::new();
let cs = cache.get(&d, sid, None);
let note = &cs.frames[0].notes[0];
let object = &note.text.frames[0].objects[0];
let centre = note.origin + object.origin.to_vec2() + designcraft_geom::Vec2::new(6.0, 6.0);
let image = Renderer::new().render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
let pixel = image.pixel(centre.x as u32, centre.y as u32);
assert!(pixel[0] < 80 && pixel[1] < 80 && pixel[2] < 80, "{pixel:?}");
}
/// `Rendered` has public fields: a buffer that doesn't match the stated size used to panic
/// in `to_png`.
/// A canvas 65535 pixels wide (any render wider than that was clamped to it) panicked inside
/// vello_cpu when it snapped the width up to its tiles; a huge page export now declines.
#[test]
fn very_wide_renders_do_not_panic() {
let doc = Document::new(&NewDocument::default());
let cache = Cache::new();
let mut r = Renderer::new();
let img = r.render(&doc, &cache, &[], 70_000, 4, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.width, MAX_SIDE);
let page = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = r.render(&doc, &cache, &page, 70_000, 64, Affine::scale(120.0), &opts);
assert_eq!(img.width, MAX_SIDE);
assert!(r.render_page(&doc, &cache, 0, 500.0, true, &RenderOptions::default()).is_none());
}
#[test]
fn to_png_survives_a_mismatched_buffer() {
let short = Rendered { width: 4, height: 4, pixels: vec![255; 10] };
let png = short.to_png();
assert_eq!(image::load_from_memory(&png).map(|i| (i.width(), i.height())).ok(), Some((4, 4)));
assert!(Rendered { width: 0, height: 0, pixels: vec![] }.to_png().len() < 1024);
}
#[test]
fn proof_view_simulates_the_target() {
use designcraft_color::cms::{ProofSetup, ProofTarget};
let red = |a: u8| Rendered { width: 1, height: 1, pixels: vec![a, 0, 0, a] };
let mut img = red(255);
proof_view(&mut img, &ProofSetup { target: ProofTarget::Protanopia, ..Default::default() });
assert!(img.pixels[1] > 40, "protanopia turns pure red olive: {:?}", img.pixels);
let mut img = red(255);
proof_view(&mut img, &ProofSetup { target: ProofTarget::MonitorRgb, ..Default::default() });
assert!(img.pixels[0] > 245 && img.pixels[1] < 10, "monitor RGB is no simulation: {:?}", img.pixels);
// Transparent pixels stay transparent; premultiplication is kept.
let mut img = Rendered { width: 2, height: 1, pixels: vec![0, 0, 0, 0, 100, 0, 0, 128] };
proof_view(&mut img, &ProofSetup { target: ProofTarget::WorkingCmyk, ..Default::default() });
assert_eq!(&img.pixels[..4], &[0, 0, 0, 0]);
assert!(img.pixels[4] <= 128 && img.pixels[7] == 128);
}
#[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);
}
/// An inside-aligned stroke is the stroke of the path inset by half its weight: a band from the
/// edge inwards, its rounded corners concentric with the frame's.
#[test]
fn inside_strokes_are_inset_by_half_the_weight() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let id = designcraft_doc::ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)));
it.stroke = designcraft_doc::Stroke {
swatch: designcraft_color::swatch::BLACK.into(),
weight: 10.0,
align: designcraft_doc::StrokeAlign::Inside,
..Default::default()
};
it.corners = designcraft_geom::corners::CornerOptions::uniform(designcraft_geom::corners::CornerShape::Rounded, 20.0);
d.insert_item(SpreadRef::Doc(0), it, 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();
let ink = |x: u32, y: u32| img.pixel(x, y)[0] < 64;
let paper = |x: u32, y: u32| img.pixel(x, y)[0] > 192;
// Straight edges: ink from the edge to 10 pt inside, nothing outside.
assert!(paper(97, 200) && ink(101, 200) && ink(108, 200) && paper(112, 200));
assert!(ink(298, 200) && paper(302, 200) && paper(288, 200));
// Rounded corner centred at (120, 120): ink between radii 10 and 20, paper outside the arc
// and inside the band.
assert!(paper(103, 103) && ink(108, 108) && paper(114, 114));
}
#[test]
fn arrowheads_draw_at_line_ends() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let id = designcraft_doc::ItemId(d.alloc());
let path = designcraft_geom::shapes::line(designcraft_geom::Point::new(100.0, 400.0), designcraft_geom::Point::new(400.0, 400.0));
let mut line = Item::new(id, lid, designcraft_doc::Shape::GraphicLine, path);
line.stroke = designcraft_doc::Stroke {
swatch: designcraft_color::swatch::BLACK.into(),
weight: 2.0,
end: designcraft_doc::Arrowhead::TriangleWide,
..Default::default()
};
d.insert_item(SpreadRef::Doc(0), line, None).unwrap();
// A dashed line with a gap colour: the gaps are painted.
let id = designcraft_doc::ItemId(d.alloc());
let path = designcraft_geom::shapes::line(designcraft_geom::Point::new(100.0, 500.0), designcraft_geom::Point::new(400.0, 500.0));
let mut dashed = Item::new(id, lid, designcraft_doc::Shape::GraphicLine, path);
dashed.stroke = designcraft_doc::Stroke {
swatch: designcraft_color::swatch::BLACK.into(),
weight: 4.0,
kind: designcraft_doc::StrokeType::Dashed { pattern: vec![12.0, 4.0] },
gap_swatch: "C=100 M=0 Y=0 K=0".into(),
..Default::default()
};
d.insert_item(SpreadRef::Doc(0), dashed, 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();
// The head is 6.4 × weight wide: ink 4 pt above the line near the tip, none at the start.
assert!(img.pixel(393, 396)[0] < 128, "{:?}", img.pixel(393, 396));
assert_eq!(img.pixel(105, 396), [255, 255, 255, 255]);
// Nothing past the tip.
assert_eq!(img.pixel(403, 400), [255, 255, 255, 255]);
let (dash, gap) = (img.pixel(106, 500), img.pixel(114, 500));
assert!(dash[0] < 80 && dash[2] < 80, "dash {dash:?}");
assert!(gap[0] < 80 && gap[2] > 150, "gap {gap:?}");
}
#[test]
fn overprint_preview_mixes_inks() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
for (sw, op) in [("C=0 M=0 Y=100 K=0", false), ("C=0 M=100 Y=0 K=0", true)] {
let id = designcraft_doc::ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.fill = Fill { overprint: op, ..Fill::swatch(sw) };
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
}
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let px = |on: bool, r: &mut Renderer| {
r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions { overprint_preview: on, ..Default::default() }).unwrap().pixel(150, 150)
};
let off = px(false, &mut r);
let on = px(true, &mut r);
assert!(off[2] > 100, "magenta knocks out: {off:?}");
assert!(on[2] < 60 && on[0] > 180, "magenta over yellow → red: {on:?}");
}
#[test]
fn cmyk_blend_space_shows_transparent_spreads_in_cmyk() {
let mut d = Document::new(&NewDocument::default());
d.swatches.push(designcraft_color::swatch::Swatch::color("Green", designcraft_color::Color::rgb8(0, 255, 0)));
let lid = d.default_layer();
let id = designcraft_doc::ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.fill = Fill::swatch("Green");
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let px = |d: &Document, r: &mut Renderer| {
r.render_page(d, &cache, 0, 1.0, false, &RenderOptions { blend_space_view: true, ..Default::default() }).unwrap().pixel(150, 150)
};
// No transparency on the spread: shown as is.
assert_eq!(px(&d, &mut r)[1], 255);
// With transparency in a CMYK blend space the bright green leaves the RGB gamut's edge.
d.item_mut(id).unwrap().opacity = 0.99;
let cmyk = px(&d, &mut r);
assert!(cmyk[0] > 20 || cmyk[1] < 240, "{cmyk:?}");
d.settings.blend_space = designcraft_doc::BlendSpace::Rgb;
let rgb = px(&d, &mut r);
assert!(rgb[1] > 245 && rgb[0] < 10, "{rgb:?}");
}
#[test]
fn blend_modes_blend_in_cmyk_on_screen_in_a_cmyk_blend_space() {
// A lilac (RGB) rectangle, and a 100% K one over its lower half and the paper below it,
// in Color Dodge: in CMYK the black clears the lilac's inks (white), over the paper it
// shows as it is; in RGB, dodging with black changes nothing.
let mut d = Document::new(&NewDocument::default());
d.swatches.push(designcraft_color::swatch::Swatch::color("Lilac", designcraft_color::Color::rgb8(171, 154, 227)));
let lid = d.default_layer();
let rect = |d: &mut Document, r: Rect, fill: &str| {
let id = designcraft_doc::ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.fill = Fill::swatch(fill);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
id
};
rect(&mut d, Rect::new(100.0, 100.0, 300.0, 200.0), "Lilac");
let black = rect(&mut d, Rect::new(150.0, 150.0, 250.0, 250.0), designcraft_color::swatch::BLACK);
d.item_mut(black).unwrap().blend = DcBlend::ColorDodge;
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let px = |d: &Document, r: &mut Renderer, x: u32, y: u32| {
r.render_page(d, &cache, 0, 1.0, false, &RenderOptions { blend_space_view: true, ..Default::default() }).unwrap().pixel(x, y)
};
let over_lilac = px(&d, &mut r, 200, 175);
assert!(over_lilac[..3].iter().all(|v| *v > 235), "dodged to white: {over_lilac:?}");
let over_paper = px(&d, &mut r, 200, 225);
assert!(over_paper[..3].iter().all(|v| *v < 90), "black as it is: {over_paper:?}");
let lilac = px(&d, &mut r, 120, 150);
assert!(lilac[2] > lilac[1] + 25, "the lilac beside it: {lilac:?}");
// Inside a group, the group's blend mode.
let g = designcraft_doc::ItemId(d.alloc());
let it = d.remove_item(black).unwrap();
let mut grp = Item::new(g, lid, designcraft_doc::Shape::Group, Default::default());
let mut inner = (*it).clone();
inner.blend = DcBlend::Normal;
grp.content = Content::Group { items: vec![Arc::new(inner)] };
grp.blend = DcBlend::ColorDodge;
d.insert_item(SpreadRef::Doc(0), grp, None).unwrap();
let grouped = px(&d, &mut r, 200, 175);
assert!(grouped[..3].iter().all(|v| *v > 235), "{grouped:?}");
// An RGB blend space: still lilac under the black (lightened a little: [Black] shows as a
// dark grey in RGB).
d.settings.blend_space = designcraft_doc::BlendSpace::Rgb;
let rgb = px(&d, &mut r, 200, 175);
assert!(rgb[2] > rgb[1] + 25 && rgb[1] < 220, "{rgb:?}");
}
#[test]
fn knockout_group_hides_its_own_objects() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let mut kids = Vec::new();
for (sw, x) in [("C=0 M=0 Y=100 K=0", 100.0), ("C=0 M=100 Y=0 K=0", 150.0)] {
let id = designcraft_doc::ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)));
it.fill = Fill::swatch(sw);
it.opacity = 0.5;
kids.push(std::sync::Arc::new(it));
}
let gid = designcraft_doc::ItemId(d.alloc());
let mut g = Item::new(gid, lid, designcraft_doc::Shape::Group, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 200.0)));
g.content = designcraft_doc::Content::Group { items: kids };
d.insert_item(SpreadRef::Doc(0), g, None).unwrap();
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let overlap = |d: &Document, r: &mut Renderer| r.render_page(d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap().pixel(175, 150);
let plain = overlap(&d, &mut r);
d.item_mut(gid).unwrap().knockout = true;
let ko = overlap(&d, &mut r);
let magenta_only = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap().pixel(225, 150);
assert_ne!(plain, ko, "{plain:?} {ko:?}");
for c in 0..3 {
assert!((ko[c] as i32 - magenta_only[c] as i32).abs() <= 3, "the overlap shows magenta alone: {ko:?} vs {magenta_only:?}");
}
}
#[test]
fn separations_split_plates_and_flag_ink_limits() {
// Pure cyan (0, 255, 255) and rich black-ish (20, 20, 20).
let mut img = Rendered { width: 2, height: 1, pixels: vec![0, 255, 255, 255, 20, 20, 20, 255] };
let mut c = img.clone();
separation_view(&mut c, Some(0), None);
assert_eq!(c.pixel(0, 0)[0], 0, "cyan plate solid under cyan");
let mut m = img.clone();
separation_view(&mut m, Some(1), None);
assert_eq!(m.pixel(0, 0)[0], 255, "no magenta");
separation_view(&mut img, None, Some(0.5));
assert_eq!(img.pixel(0, 0), [230, 40, 40, 255], "100% over a 50% limit");
}
#[test]
fn black_overprints_only_with_the_preference() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
for (sw, r) in
[("C=100 M=0 Y=0 K=0", Rect::new(100.0, 100.0, 200.0, 200.0)), (designcraft_color::swatch::BLACK, Rect::new(150.0, 100.0, 250.0, 200.0))]
{
let id = designcraft_doc::ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.fill = Fill::swatch(sw);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
}
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let o = RenderOptions { overprint_preview: true, ..Default::default() };
let on = r.render_page(&d, &cache, 0, 1.0, false, &o).unwrap().pixel(175, 150);
let alone = r.render_page(&d, &cache, 0, 1.0, false, &o).unwrap().pixel(225, 150);
d.settings.overprint_black = false;
let off = r.render_page(&d, &cache, 0, 1.0, false, &o).unwrap().pixel(175, 150);
assert_eq!(off, alone, "knocks out: black alone");
assert_ne!(on, alone, "overprints: the cyan shows through");
}
#[test]
fn appearance_of_black_on_screen() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let id = designcraft_doc::ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.fill = Fill::swatch(designcraft_color::swatch::BLACK);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let px = |rich: bool, r: &mut Renderer| {
r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions { rich_black: rich, ..Default::default() }).unwrap().pixel(150, 150)
};
assert!(px(false, &mut r)[0] > 20, "accurate: a dark grey");
assert_eq!(px(true, &mut r)[..3], [0, 0, 0]);
}
#[test]
fn display_performance_fast_draws_grey_boxes() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
// A 4×4 red PNG placed at 100,100 (100×100 pt).
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let aid = designcraft_doc::AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(designcraft_doc::Asset {
id: aid,
name: "red.png".into(),
mime: "image/png".into(),
data: Arc::new(png),
pixels: Some((4, 4)),
..Default::default()
}),
);
let id = designcraft_doc::ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
asset: aid,
size: (100.0, 100.0),
xf: Affine::translate((100.0, 100.0)),
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let px = |q: DisplayQuality, r: &mut Renderer| {
r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions { quality: q, ..Default::default() }).unwrap().pixel(150, 150)
};
assert_eq!(px(DisplayQuality::High, &mut r)[..3], [255, 0, 0]);
assert_eq!(px(DisplayQuality::Typical, &mut r)[..3], [255, 0, 0]);
assert_eq!(px(DisplayQuality::Fast, &mut r)[..3], [178, 178, 178]);
}
#[test]
fn clipping_path_hides_the_image_outside_it() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let aid = designcraft_doc::AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(designcraft_doc::Asset {
page: 0,
id: aid,
name: "red.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(png),
pixels: Some((4, 4)),
..Default::default()
}),
);
// A 100 pt image at 100,100 clipped by a triangle with its apex at the top middle.
let triangle = designcraft_geom::PathData::single(designcraft_geom::path::SubPath::polyline(
&[designcraft_geom::Point::new(0.0, 100.0), designcraft_geom::Point::new(50.0, 0.0), designcraft_geom::Point::new(100.0, 100.0)],
true,
));
let mut clip = designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, triangle);
let id = designcraft_doc::ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
asset: aid,
size: (100.0, 100.0),
xf: Affine::translate((100.0, 100.0)),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
clip: Some(Arc::new(clip.clone())),
fill: Default::default(),
});
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let page = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
assert_eq!(page.pixel(150, 170)[..3], [255, 0, 0], "inside the triangle");
assert_eq!(page.pixel(110, 110)[..3], [255, 255, 255], "inside the frame, outside the triangle: paper");
// Inverted: the corners show and the triangle is paper.
clip.invert = true;
if let Some(designcraft_doc::Content::Graphic(g)) = d.item_mut(id).map(|it| &mut it.content) {
g.clip = Some(Arc::new(clip));
}
let cache = Cache::new();
let page = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
assert_eq!(page.pixel(150, 170)[..3], [255, 255, 255]);
assert_eq!(page.pixel(110, 110)[..3], [255, 0, 0]);
}
#[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 table_edges_paint_gap_colour_between_dashes_and_stripes() {
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(1, 1, 1, 0, 0, 200.0);
t.rows[0].height = 60.0;
t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly;
let gap = |kind| designcraft_doc::CellStroke { weight: 8.0, kind, gap_color: "C=100 M=0 Y=0 K=0".into(), ..Default::default() };
let cell = t.cell_mut(0, 0).unwrap();
cell.strokes[0] = gap(designcraft_doc::StrokeType::Dashed { pattern: vec![12.0, 6.0] });
cell.strokes[2] = gap(designcraft_doc::StrokeType::ThickThin);
cell.border_overrides = [true; 4];
cell.stroke_defined = [true; 4];
d.story_mut(sid).unwrap().insert_table(0, t);
let cache = Cache::new();
let rect = cache.get(&d, sid, None).frames[0].tables[0].cell(0, 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();
let ink = |p: [u8; 4]| p[0] < 80 && p[2] < 80;
let cyan = |p: [u8; 4]| p[0] < 80 && p[2] > 150;
let y = rect.y0.round() as u32;
let (dash, space) = (img.pixel((rect.x0 + 6.0) as u32, y), img.pixel((rect.x0 + 15.0) as u32, y));
assert!(ink(dash), "dash {dash:?}");
assert!(cyan(space), "gap {space:?}");
let column: Vec<_> = (rect.y1 as u32 - 5..=rect.y1 as u32 + 5).map(|y| img.pixel(rect.center().x as u32, y)).collect();
assert!(column.iter().any(|p| ink(*p)) && column.iter().any(|p| cyan(*p)), "stripes and gap: {column:?}");
}
#[test]
fn degenerate_dash_patterns_draw_solid_and_fill_types_close_table_corners() {
let fine = designcraft_doc::StrokeType::Dashed { pattern: vec![1e-6, 1e-6] };
// Expanding these would take ~10^8 dashes per edge.
assert!(text::cell_stroke(&fine, 4.0, 200.0).dash_pattern.is_empty());
assert!(text::cell_stroke(&designcraft_doc::StrokeType::Dotted, 1e-6, 200.0).dash_pattern.is_empty());
assert!(!text::cell_stroke(&designcraft_doc::StrokeType::Dashed { pattern: vec![3.0, 2.0] }, 1.0, 200.0).dash_pattern.is_empty());
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 400.0), lid, "", ParaFormat::default()).unwrap();
d.item_mut(fid).unwrap().stroke = designcraft_doc::Stroke { weight: 2.0, kind: fine.clone(), ..Default::default() };
let mut t = designcraft_doc::Table::new(1, 2, 2, 0, 0, 200.0);
for r in 0..2 {
t.rows[r].height = 60.0;
t.rows[r].mode = designcraft_doc::RowHeightMode::Exactly;
}
let stroke = |kind| designcraft_doc::CellStroke { weight: 8.0, kind, gap_color: "C=100 M=0 Y=0 K=0".into(), ..Default::default() };
for cell in &mut t.cells {
cell.strokes = std::array::from_fn(|_| stroke(designcraft_doc::StrokeType::ThickThin));
cell.border_overrides = [true; 4];
cell.stroke_defined = [true; 4];
}
if let Some(cell) = t.cell_mut(1, 0) {
cell.strokes[2] = stroke(fine);
}
d.story_mut(sid).unwrap().insert_table(0, t);
let cache = Cache::new();
let frag = cache.get(&d, sid, None).frames[0].tables[0].clone();
let (top, bottom) = (frag.cell(0, 0).unwrap().rect, frag.cell(1, 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();
let ink = |p: [u8; 4]| p[0] < 80;
let solid: Vec<_> = (2..10).map(|i| img.pixel((bottom.x0 + 20.0 + i as f64 * 0.5) as u32, bottom.y1.round() as u32)).collect();
assert!(solid.iter().all(|p| ink(*p)), "the degenerate dashed edge draws solid: {solid:?}");
// Outside the table's top-left corner, inside both edges' reach.
let corner = img.pixel((top.x0 - 3.0) as u32, (top.y0 - 3.0) as u32);
assert!(ink(corner), "corner closed: {corner:?}");
let edge = frag.strokes.iter().find(|s| s.a == designcraft_geom::Point::new(top.x0, top.y0) && s.a.y == s.b.y).unwrap();
let (a, b) = frag.corner_capped(edge);
assert_eq!((a, b), (designcraft_geom::Point::new(top.x0 - 4.0, top.y0), edge.b), "only table corners extend");
}
#[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}");
}
/// A one-page PDF (612 x 792 pt) that draws `content`.
fn one_page_pdf(content: &str) -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_string(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut out = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).bytes());
}
let xref = out.len();
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
for o in offsets {
out.extend(format!("{o:010} 00000 n \n").bytes());
}
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
out
}
/// A Bounding Box crop is the exact extent of what the page draws, strokes included, found
/// without rasterizing the page: PDF export of a document that places a complex illustration
/// cropped that way spent seconds rendering it just to measure it.
#[test]
fn content_crop_box_is_measured_without_rendering_the_page() {
// A filled rectangle 100..300 x 532..592 (PDF y up) and a 10 pt horizontal line at
// y 100 from x 400 to 500, butt capped. Clipped away: a rectangle off to the left.
let pdf = one_page_pdf("0 0 0 rg 100 532 200 60 re f 10 w 400 100 m 500 100 l S q 0 0 50 50 re W n 100 0 10 10 re f Q");
let before = PDF_RENDERS.with(std::cell::Cell::get);
let (frac, size) = pdf_crop_box(&pdf, 0, designcraft_doc::PdfCrop::ContentVisible).unwrap();
assert_eq!(PDF_RENDERS.with(std::cell::Cell::get), before, "the page was rasterized");
let want = [100.0 / 612.0, 200.0 / 792.0, 500.0 / 612.0, 697.0 / 792.0];
assert!(frac.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-6), "{frac:?} vs {want:?}");
assert!((size.0 - 400.0).abs() < 1e-3 && (size.1 - 497.0).abs() < 1e-3, "{size:?}");
}
}