Merge remote-tracking branch 'origin/main' into effect-blend-modes

# Conflicts:
#	crates/doc/src/item.rs
#	crates/idml/src/export.rs
#	crates/idml/src/import.rs
#	crates/ui-egui/src/effects_dialog.rs
This commit is contained in:
moppeeer
2026-10-29 11:42:14 +01:00
475 changed files with 109785 additions and 81521 deletions
+8 -7
View File
@@ -87,6 +87,7 @@ fn same_document(old: &Document, new: &Document) -> bool {
styles,
assets,
title,
file_info,
settings,
layers,
swatches,
@@ -125,6 +126,7 @@ fn same_document(old: &Document, new: &Document) -> bool {
&& old.assets.len() == assets.len()
&& old.assets.iter().zip(assets).all(|((a, x), (b, y))| a == b && std::sync::Arc::ptr_eq(x, y))
&& old.title == *title
&& old.file_info == *file_info
&& old.settings == *settings
&& old.layers == *layers
&& old.swatches == *swatches
@@ -444,8 +446,7 @@ mod tests {
doc,
&Cache::new(),
&[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }],
200,
140,
(200, 140),
Affine::translate((30.0, 10.0)),
&RenderOptions { blend_space_view: true, background: Some([180, 180, 180, 255]), ..Default::default() },
)
@@ -587,8 +588,7 @@ mod tests {
&doc,
&Cache::new(),
&[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }],
200,
140,
(200, 140),
Affine::translate((30.0, 10.0)),
&cyan,
);
@@ -647,10 +647,11 @@ mod tests {
renderer.threads = 0;
let placements = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let options = RenderOptions { paper: false, blend_space_view: true, ..Default::default() };
let out = renderer.render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &options);
let out = renderer.render(&doc, &Cache::new(), &placements, (160, 120), Affine::IDENTITY, &options);
assert!(out.pixel(30, 40)[3].abs_diff(64) <= 1, "half-opacity object inside a half-opacity group is quarter opaque");
let cmy = renderer.render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &RenderOptions { plate: Some(4), ..options.clone() });
let k = renderer.render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &RenderOptions { plate: Some(3), ..options });
let cmy =
renderer.render(&doc, &Cache::new(), &placements, (160, 120), Affine::IDENTITY, &RenderOptions { plate: Some(4), ..options.clone() });
let k = renderer.render(&doc, &Cache::new(), &placements, (160, 120), Affine::IDENTITY, &RenderOptions { plate: Some(3), ..options });
for ((out, cmy), k) in out.pixels.as_chunks::<4>().0.iter().zip(cmy.pixels.as_chunks::<4>().0).zip(k.pixels.as_chunks::<4>().0) {
assert_eq!(cmy[3], k[3], "all ink passes have the same coverage");
assert_eq!(out[3], cmy[3], "conversion preserves coverage");
+276
View File
@@ -0,0 +1,276 @@
use super::types::{Frame, Rendered};
use super::{Affine, BlendMode, Compose, DcBlend, Document, Item, Mix, Rect, RenderContext, peniko, vello_cpu};
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(std::sync::PoisonError::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).
pub(crate) 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(std::sync::PoisonError::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(std::sync::PoisonError::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
}
pub(crate) 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.
pub(crate) static HIDDEN_LAYERS: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
/// Appearance of Black for the render on this thread: 100% K as pure black.
pub(crate) static RICH_BLACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// The CMYK blend space's ink pass for the render on this thread (`RenderOptions::plate`).
pub(crate) 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(std::cell::Cell::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(std::cell::Cell::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(std::cell::Cell::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.
pub(crate) 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)?
pub(crate) 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)
}
/// 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.
pub(crate) 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;
}
}
}
}
+3 -3
View File
@@ -257,8 +257,8 @@ mod tests {
let mut r = Renderer::new();
r.threads = 0;
let opts = RenderOptions::default();
let mut img = r.render(old, &cache, &placed, w, h, view, &opts);
let full = r.render(new, &cache, &placed, w, h, view, &opts);
let mut img = r.render(old, &cache, &placed, (w, h), view, &opts);
let full = r.render(new, &cache, &placed, (w, h), view, &opts);
// Patch the damaged pixels.
let regions = damage(old, new).unwrap();
let u = regions.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b)).unwrap();
@@ -266,7 +266,7 @@ mod tests {
let y0 = ((u.y0 * zoom).floor().max(0.0)) as u32;
let x1 = ((u.x1 * zoom).ceil() as u32).min(w);
let y1 = ((u.y1 * zoom).ceil() as u32).min(h);
let patch = r.render(new, &cache, &placed, x1 - x0, y1 - y0, Affine::translate((-(x0 as f64), -(y0 as f64))) * view, &opts);
let patch = r.render(new, &cache, &placed, (x1 - x0, y1 - y0), Affine::translate((-(x0 as f64), -(y0 as f64))) * view, &opts);
for y in y0..y1 {
for x in x0..x1 {
let pi = (((y - y0) * (x1 - x0) + (x - x0)) * 4) as usize;
+101
View File
@@ -0,0 +1,101 @@
use super::pdf_raster::{is_pdf, pdf_page_size, render_pdf_page};
use super::{Pixmap, vello_cpu};
/// 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.
pub(crate) 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"))]
pub(crate) 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")]
pub(crate) 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)
}
pub fn image_mime(bytes: &[u8]) -> &'static str {
designcraft_images::mime(bytes)
}
+296
View File
@@ -0,0 +1,296 @@
use super::color::{HIDDEN_LAYERS, blend_mode, color_of, overprints, rect_overlaps, screen_color, set_fill_paint, spread_blends};
use super::types::{DisplayQuality, Frame};
use super::{
Affine, Arc, BezPath, BlendMode, Compose, ComposedStory, Content, DcBlend, Item, Mix, RenderContext, Renderer, Shape, ShownOn, SpreadRef,
StoryId, Vec2, blendspace, corners, fx, grid, kurbo, peniko,
};
/// An item's fill under its content; an overprinting fill multiplies into what is beneath.
fn draw_body_fill(ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) {
if it.fill.is_none() {
return;
}
let doc = f.doc;
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();
}
}
impl Renderer {
pub(crate) 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);
}
}
pub(crate) 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();
}
/// A group: children carry their own transforms relative to the group.
fn draw_group(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine, page: ShownOn) {
// 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();
}
}
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() {
self.draw_group(ctx, f, it, xf, page);
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) {
draw_body_fill(ctx, f, it, bp, xf);
// 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);
let hidden = it.has_pdf_layer_override().then(|| it.pdf_hidden_layers.clone());
let previous = HIDDEN_LAYERS.with(|h| h.replace(hidden));
self.draw_graphic(ctx, f, g, xf);
HIDDEN_LAYERS.with(|h| h.replace(previous));
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();
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
use super::color::{PLATE, RICH_BLACK, blend_space_view, rect_overlaps};
use super::decode::now;
use super::types::{Frame, FrameStats, MAX_PAGE_PIXELS, MAX_SIDE, Placed, RenderOptions, Rendered, default_threads};
use super::{
Affine, Cache, Document, HashMap, PageSide, Rect, RenderContext, Renderer, Resources, SpreadRef, Vec2, blendspace, kurbo, peniko, text, vello_cpu,
};
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], size: (u32, u32), view: Affine, opts: &RenderOptions) -> Rendered {
let start = now();
let (width, height) = size;
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, size, 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
}
fn render_uncached(
&mut self,
doc: &Document,
cache: &Cache,
spreads: &[Placed],
size: (u32, u32),
view: Affine,
opts: &RenderOptions,
) -> Rendered {
let start = now();
let (width, height) = size;
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))
}
}
+24 -2084
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -105,7 +105,8 @@ impl<'a> Device<'a> for Bounds {
fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &Paint<'a>, mode: &GlyphDrawMode) {
let mode = match mode {
GlyphDrawMode::Invisible => return,
// Clipping text paints nothing; its clip arrives through `push_clip_path`.
GlyphDrawMode::Invisible | GlyphDrawMode::Clip => return,
GlyphDrawMode::Fill => PathDrawMode::Fill(FillRule::NonZero),
GlyphDrawMode::Stroke(s) => PathDrawMode::Stroke(s.clone()),
};
+274
View File
@@ -0,0 +1,274 @@
use super::types::Rendered;
use super::{Affine, Arc, HashMap, Pixmap, Rect, pdf_bounds, pdf_hide_layers, pdf_layers, vello_cpu};
/// 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 = craft_pdf::open_slice(bytes, None).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 = craft_pdf::open_slice(bytes, None).ok()?;
let p = pdf.pages().get(page)?;
let (w, h) = p.render_dimensions();
Some((w as f64, h as f64))
}
/// A placed page could not be rasterized completely for export.
#[derive(Debug, thiserror::Error)]
pub enum PdfRasterError {
#[error("{0}")]
Input(#[source] craft_pdf::OpenError),
#[error("{0}")]
Render(#[from] craft_pdf::raster::Error),
#[error("the PDF page could not be read or rendered")]
InvalidPage,
#[error("PDF page {page} is unavailable; choose an existing page in Image Import Options or place the replacement PDF again")]
MissingPage { page: usize },
#[error("the selected PDF layers could not be hidden")]
LayerRewrite,
#[error("an image could not be decoded or exceeded the PDF renderer's resource limits; repair or replace this placed PDF before exporting again")]
ImageDecode,
#[error("the PDF renderer encountered an unsupported font")]
UnsupportedFont,
#[error(
"the PDF renderer could not read all page content or reached its resource limits; repair or replace this placed PDF before exporting again"
)]
ContentTruncated,
}
/// Reject oversized PDFs before placement, layer rewriting or output allocation.
pub fn check_pdf_input_size(size: usize) -> Result<(), PdfRasterError> {
craft_pdf::check_input_size(size).map_err(PdfRasterError::Input)
}
/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off.
/// Only the `shown` part ([`designcraft_doc::Asset::shown_box`]) is kept when given.
/// Returns `None` on the same failures as [`pdf_page_png_checked`].
pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Option<Vec<u8>> {
pdf_page_png_checked(bytes, page, max_side, hidden, shown).ok()
}
/// Rasterize a placed page for export, preserving available interpreter diagnostics.
/// This cannot detect backend approximations that do not emit an interpreter warning.
pub fn pdf_page_png_checked(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Result<Vec<u8>, PdfRasterError> {
pdf_page_png_options_checked(bytes, page, max_side, (!hidden.is_empty()).then_some(hidden), shown)
}
/// Rasterize with an explicit visibility override: `Some(&[])` enables every layer;
/// `None` retains file defaults. The legacy entry point keeps its empty-list semantics.
pub fn pdf_page_png_options_checked(
bytes: &[u8],
page: usize,
max_side: u32,
hidden: Option<&[String]>,
shown: Option<[f64; 4]>,
) -> Result<Vec<u8>, PdfRasterError> {
check_pdf_input_size(bytes.len())?;
let data = if let Some(hidden) = hidden {
std::borrow::Cow::Owned(pdf_hide_layers(bytes, hidden).ok_or(PdfRasterError::LayerRewrite)?)
} else {
std::borrow::Cow::Borrowed(bytes)
};
let raster = render_pdf_raster(&data, page, max_side)?;
if raster.diagnostics.image_decode_failed {
return Err(PdfRasterError::ImageDecode);
}
if raster.diagnostics.unsupported_font {
return Err(PdfRasterError::UnsupportedFont);
}
if raster.diagnostics.content_truncated {
return Err(PdfRasterError::ContentTruncated);
}
let pm = raster_pixmap(&raster);
let pm = match shown {
Some(frac) => crop_pixmap(&pm, frac).ok_or(PdfRasterError::InvalidPage)?,
None => pm,
};
let pixels = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect();
Ok(Rendered { width: u32::from(pm.width()), height: u32::from(pm.height()), pixels }.to_png())
}
/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display).
/// Export callers that need failure diagnostics use [`pdf_page_png_checked`].
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 raster = render_pdf_raster(bytes, page, max_side).ok()?;
Some(raster_pixmap(&raster))
}
fn raster_pixmap(raster: &craft_pdf::raster::Raster) -> Pixmap {
let data = raster.bytes().as_chunks::<4>().0.iter().map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect();
Pixmap::from_parts(data, raster.width as u16, raster.height as u16)
}
#[cfg(test)]
thread_local! {
/// How many PDF pages [`render_pdf_page`] rasterized on this thread.
pub(crate) static PDF_RENDERS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn render_pdf_raster(bytes: &[u8], page: usize, max_side: u32) -> Result<craft_pdf::raster::Raster, PdfRasterError> {
let pdf = craft_pdf::open_slice(bytes, None).map_err(|e| match e {
craft_pdf::OpenError::Load(_) => PdfRasterError::InvalidPage,
other => PdfRasterError::Input(other),
})?;
let p = pdf.pages().get(page).ok_or(PdfRasterError::MissingPage { page: page.saturating_add(1) })?;
let (w, h) = p.render_dimensions();
let scale = (max_side as f32 / w.max(h).max(1.0)).min(8.0);
craft_pdf::raster::render(
p,
&craft_pdf::raster::RenderCache::new(),
&craft_pdf::interpret::InterpreterSettings::default(),
&craft_pdf::raster::Options { scale: [scale; 2], ..Default::default() },
)
.map_err(PdfRasterError::Render)
}
/// A page box of a PDF page (`crop`, `trim`, `bleed`, `art`, `media`) as (x, y, w, h) in points
/// from the top-left of the page as rendered (its visible crop box); missing boxes fall back to the
/// crop box, as PDF readers do.
pub fn pdf_page_box(bytes: &[u8], page: usize, kind: &str) -> Option<(f64, f64, f64, f64)> {
use craft_pdf::syntax::object::{Rect as PRect, dict::keys};
let pdf = craft_pdf::open_slice(bytes, None).ok()?;
let p = pdf.pages().get(page)?;
let visible = p.intersected_crop_box();
let page_box = match kind {
"media" => p.media_box(),
"trim" | "bleed" | "art" => {
let key: &[u8] = match kind {
"trim" => keys::TRIM_BOX,
"bleed" => keys::BLEED_BOX,
_ => keys::ART_BOX,
};
p.raw().get::<PRect>(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible)
}
_ => visible,
};
let transform = Affine::new(p.initial_transform(true).as_coeffs());
let b = transform.transform_rect_bbox(Rect::new(page_box.x0, page_box.y0, page_box.x1, page_box.y1));
b.is_finite().then_some((b.x0, b.y0, b.width(), b.height()))
}
/// 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 craft_pdf::syntax::object::{Rect as PRect, dict::keys};
use designcraft_doc::PdfCrop;
const WHOLE: [f64; 4] = [0.0, 0.0, 1.0, 1.0];
let pdf = craft_pdf::open_slice(bytes, None).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 => {
if pdf_layers::has_optional_content(&pdf) {
// If all-on rewriting is unsupported, a visible-only box could cut
// off hidden content. Keep the entire page conservatively instead.
pdf_hide_layers(bytes, &[]).and_then(|all_on| pdf_bounds::page_content_bounds(&all_on, page)).unwrap_or(WHOLE)
} else {
pdf_bounds::page_content_bounds(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)
}
+205
View File
@@ -0,0 +1,205 @@
use super::color::{HIDDEN_LAYERS, PLATE, color_of, overprints, plate_pixmap, screen_color, set_fill_paint};
use super::pdf_raster::{is_pdf, shown_box};
use super::types::{DisplayQuality, Frame};
use super::{
Affine, BezPath, BlendMode, Compose, Item, Mix, Rect, RenderContext, Renderer, Shape, StrokeAlign, StrokeType, colorize, images, kurbo,
pdf_layers, peniko, vello_cpu,
};
impl Renderer {
pub(crate) 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 = match hidden.as_deref().filter(|_| is_pdf(&asset.data)) {
// An explicit choice (including all-on) that cannot be rewritten fails closed:
// stale persisted/relinked choices must not reveal hidden source content.
Some(hidden) => {
let Some(variant) = pdf_layers::layered(&asset.data, hidden) else { return };
variant
}
None => 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(std::cell::Cell::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();
}
}
+671
View File
@@ -0,0 +1,671 @@
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 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 {
layer_options: None,
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 {
layer_options: None,
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 table_gradient_fill_is_continuous_across_cells() {
let mut d = Document::new(&NewDocument::default());
let mut gradient = designcraft_color::Gradient::default();
gradient.stops[0].color = designcraft_color::Color::rgb(1.0, 0.0, 0.0);
gradient.stops[1].color = designcraft_color::Color::rgb(0.0, 0.0, 1.0);
d.swatches.push(designcraft_color::Swatch {
name: "Table gradient".into(),
value: designcraft_color::SwatchValue::Gradient { gradient },
locked: false,
named: true,
hidden: false,
});
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap();
let mut t = designcraft_doc::Table::new(1, 1, 2, 0, 0, 200.0);
t.rows[0].height = 40.0;
t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly;
for cell in &mut t.cells {
cell.fill = "Table gradient".into();
}
d.story_mut(sid).unwrap().insert_table(0, t);
let cache = Cache::new();
let rect = cache.get(&d, sid, None).frames[0].tables[0].rect;
let mut renderer = Renderer::new();
renderer.threads = 0;
let img = renderer.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
let y = rect.center().y as u32;
let a = img.pixel((rect.x0 + 20.0) as u32, y);
let b = img.pixel((rect.x1 - 20.0) as u32, y);
assert!(i16::from(a[0]) - i16::from(a[2]) > 80, "red end: {a:?}");
assert!(i16::from(b[2]) - i16::from(b[0]) > 80, "blue end: {b:?}");
let left = img.pixel((rect.center().x - 2.0) as u32, y);
let right = img.pixel((rect.center().x + 2.0) as u32, y);
assert!((i16::from(left[0]) - i16::from(right[0])).abs() < 20, "no gradient restart between cells");
}
#[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:?}");
}
+119 -113
View File
@@ -250,6 +250,117 @@ fn line_box(l: &Line) -> Option<Rect> {
Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, bottom + pad))
}
fn draw_text_indicators(ctx: &mut RenderContext, f: &Frame, cs: &ComposedStory, ft: &FrameText, m: Affine) {
// Text in fonts that aren't installed: pink behind the glyphs (screen only).
if f.opts.highlight_missing_fonts && cs.styles.iter().any(|s| s.missing_font) {
ctx.set_transform(m);
ctx.set_paint(screen_color([255, 166, 214], 255));
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| g.adv > 0.0 && cs.styles.get(g.style as usize).is_some_and(|s| s.missing_font)) {
ctx.fill_rect(&kurbo::Rect::new(g.x, l.baseline - l.ascent, g.x + g.adv, l.baseline + l.descent));
}
}
}
if f.opts.highlight_keeps && ft.lines.iter().any(|l| l.keep_violation) {
ctx.set_transform(m);
ctx.set_paint(screen_color([255, 236, 120], 255));
for l in ft.lines.iter().filter(|l| l.keep_violation) {
ctx.fill_rect(&kurbo::Rect::new(l.x0, l.baseline - l.ascent, l.x1, l.baseline + l.descent));
}
}
if f.opts.highlight_hj && ft.lines.iter().any(|l| l.hj > 0) {
ctx.set_transform(m);
for l in ft.lines.iter().filter(|l| l.hj > 0) {
let shade = [0u8, 0xF6, 0xEA, 0xD8][l.hj.min(3) as usize];
ctx.set_paint(screen_color([255, shade, [0u8, 0xB0, 0x70, 0x30][l.hj.min(3) as usize]], 255));
ctx.fill_rect(&kurbo::Rect::new(l.x0, l.baseline - l.ascent, l.end_x.max(l.x0 + 1.0), l.baseline + l.descent));
}
}
if f.opts.highlight_custom_tracking && cs.styles.iter().any(|s| s.custom_tracking) {
ctx.set_transform(m);
ctx.set_paint(screen_color([176, 236, 176], 255));
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| g.adv > 0.0 && cs.styles.get(g.style as usize).is_some_and(|s| s.custom_tracking)) {
ctx.fill_rect(&kurbo::Rect::new(g.x, l.baseline - l.ascent, g.x + g.adv, l.baseline + l.descent));
}
}
}
if f.opts.tag_markers && cs.styles.iter().any(|s| s.xml_tag.is_some()) {
ctx.set_transform(m);
let px = 1.0 / m.determinant().abs().sqrt().max(1e-9);
for l in &ft.lines {
let tag_of = |g: &designcraft_compose::PlacedGlyph| cs.styles.get(g.style as usize).and_then(|s| s.xml_tag.clone());
let gs: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0).collect();
let mut i = 0;
while i < gs.len() {
let Some(tag) = tag_of(gs[i]) else {
i += 1;
continue;
};
let mut j = i + 1;
while j < gs.len() && tag_of(gs[j]).as_deref() == Some(tag.as_str()) {
j += 1;
}
let c = f.doc.xml.tags.iter().find(|t| t.name == tag).map_or([100, 100, 220], |t| t.color);
ctx.set_paint(screen_color([c[0], c[1], c[2]], 255));
let (x0, x1) = (gs[i].x, gs[j - 1].x + gs[j - 1].adv);
let (top, bot) = (l.baseline - l.ascent, l.baseline + l.descent);
let (w, t) = (2.5 * px, px.max(0.3));
// [ before the element, ] after it.
for (x, dir) in [(x0, 1.0), (x1, -1.0)] {
ctx.fill_rect(&kurbo::Rect::new(x - t / 2.0, top, x + t / 2.0, bot));
for y in [top, bot - t] {
let (a, b) = if dir > 0.0 { (x, x + w) } else { (x - w, x) };
ctx.fill_rect(&kurbo::Rect::new(a, y, b, y + t));
}
}
i = j;
}
}
}
if f.opts.change_markup && cs.styles.iter().any(|s| s.inserted) {
ctx.set_transform(m);
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| g.adv > 0.0 && cs.styles.get(g.style as usize).is_some_and(|s| s.inserted)) {
ctx.set_paint(screen_color([120, 190, 255], 110));
ctx.fill_rect(&kurbo::Rect::new(g.x, l.baseline - l.ascent, g.x + g.adv, l.baseline + l.descent));
}
}
}
if f.opts.note_indicators
&& let Some(st) = f.doc.story(cs.story).filter(|st| !st.editorial.is_empty())
{
// A small flag at each note anchor, in the notes' amber.
ctx.set_transform(m);
ctx.set_paint(screen_color([232, 160, 32], 255));
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| st.text.get(g.byte..).is_some_and(|t| t.starts_with(designcraft_doc::NOTE_MARK))) {
let h = l.ascent.max(4.0);
let mut p = BezPath::new();
p.move_to((g.x - h * 0.25, l.baseline - h));
p.line_to((g.x + h * 0.25, l.baseline - h));
p.line_to((g.x, l.baseline - h * 0.55));
p.close_path();
ctx.fill_path(&p);
ctx.fill_rect(&kurbo::Rect::new(g.x - h * 0.03, l.baseline - h * 0.6, g.x + h * 0.03, l.baseline + l.descent * 0.5));
}
}
}
if f.opts.condition_indicators && !f.doc.conditions.is_empty() && cs.styles.iter().any(|s| s.condition.is_some()) {
ctx.set_transform(m);
let px = 1.0 / m.determinant().abs().sqrt().max(1e-9);
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0) {
let Some(name) = cs.styles.get(g.style as usize).and_then(|s| s.condition.as_deref()) else { continue };
let Some(c) = f.doc.conditions.iter().find(|c| c.name == name) else { continue };
ctx.set_paint(screen_color([c.color[0], c.color[1], c.color[2]], 255));
let y = l.baseline + l.descent * 0.6;
ctx.fill_rect(&kurbo::Rect::new(g.x, y, g.x + g.adv, y + 2.0 * px));
}
}
}
}
impl Renderer {
pub(crate) fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, cs: &Arc<ComposedStory>, ft: &FrameText, xf: Affine) {
let doc = f.doc;
@@ -276,114 +387,7 @@ impl Renderer {
if !ft.tables.is_empty() {
self.draw_tables(ctx, f, ft, xf);
}
// Text in fonts that aren't installed: pink behind the glyphs (screen only).
if f.opts.highlight_missing_fonts && cs.styles.iter().any(|s| s.missing_font) {
ctx.set_transform(m);
ctx.set_paint(screen_color([255, 166, 214], 255));
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| g.adv > 0.0 && cs.styles.get(g.style as usize).is_some_and(|s| s.missing_font)) {
ctx.fill_rect(&kurbo::Rect::new(g.x, l.baseline - l.ascent, g.x + g.adv, l.baseline + l.descent));
}
}
}
if f.opts.highlight_keeps && ft.lines.iter().any(|l| l.keep_violation) {
ctx.set_transform(m);
ctx.set_paint(screen_color([255, 236, 120], 255));
for l in ft.lines.iter().filter(|l| l.keep_violation) {
ctx.fill_rect(&kurbo::Rect::new(l.x0, l.baseline - l.ascent, l.x1, l.baseline + l.descent));
}
}
if f.opts.highlight_hj && ft.lines.iter().any(|l| l.hj > 0) {
ctx.set_transform(m);
for l in ft.lines.iter().filter(|l| l.hj > 0) {
let shade = [0u8, 0xF6, 0xEA, 0xD8][l.hj.min(3) as usize];
ctx.set_paint(screen_color([255, shade, [0u8, 0xB0, 0x70, 0x30][l.hj.min(3) as usize]], 255));
ctx.fill_rect(&kurbo::Rect::new(l.x0, l.baseline - l.ascent, l.end_x.max(l.x0 + 1.0), l.baseline + l.descent));
}
}
if f.opts.highlight_custom_tracking && cs.styles.iter().any(|s| s.custom_tracking) {
ctx.set_transform(m);
ctx.set_paint(screen_color([176, 236, 176], 255));
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| g.adv > 0.0 && cs.styles.get(g.style as usize).is_some_and(|s| s.custom_tracking)) {
ctx.fill_rect(&kurbo::Rect::new(g.x, l.baseline - l.ascent, g.x + g.adv, l.baseline + l.descent));
}
}
}
if f.opts.tag_markers && cs.styles.iter().any(|s| s.xml_tag.is_some()) {
ctx.set_transform(m);
let px = 1.0 / m.determinant().abs().sqrt().max(1e-9);
for l in &ft.lines {
let tag_of = |g: &designcraft_compose::PlacedGlyph| cs.styles.get(g.style as usize).and_then(|s| s.xml_tag.clone());
let gs: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0).collect();
let mut i = 0;
while i < gs.len() {
let Some(tag) = tag_of(gs[i]) else {
i += 1;
continue;
};
let mut j = i + 1;
while j < gs.len() && tag_of(gs[j]).as_deref() == Some(tag.as_str()) {
j += 1;
}
let c = f.doc.xml.tags.iter().find(|t| t.name == tag).map_or([100, 100, 220], |t| t.color);
ctx.set_paint(screen_color([c[0], c[1], c[2]], 255));
let (x0, x1) = (gs[i].x, gs[j - 1].x + gs[j - 1].adv);
let (top, bot) = (l.baseline - l.ascent, l.baseline + l.descent);
let (w, t) = (2.5 * px, px.max(0.3));
// [ before the element, ] after it.
for (x, dir) in [(x0, 1.0), (x1, -1.0)] {
ctx.fill_rect(&kurbo::Rect::new(x - t / 2.0, top, x + t / 2.0, bot));
for y in [top, bot - t] {
let (a, b) = if dir > 0.0 { (x, x + w) } else { (x - w, x) };
ctx.fill_rect(&kurbo::Rect::new(a, y, b, y + t));
}
}
i = j;
}
}
}
if f.opts.change_markup && cs.styles.iter().any(|s| s.inserted) {
ctx.set_transform(m);
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| g.adv > 0.0 && cs.styles.get(g.style as usize).is_some_and(|s| s.inserted)) {
ctx.set_paint(screen_color([120, 190, 255], 110));
ctx.fill_rect(&kurbo::Rect::new(g.x, l.baseline - l.ascent, g.x + g.adv, l.baseline + l.descent));
}
}
}
if f.opts.note_indicators
&& let Some(st) = f.doc.story(cs.story).filter(|st| !st.editorial.is_empty())
{
// A small flag at each note anchor, in the notes' amber.
ctx.set_transform(m);
ctx.set_paint(screen_color([232, 160, 32], 255));
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| st.text.get(g.byte..).is_some_and(|t| t.starts_with(designcraft_doc::NOTE_MARK))) {
let h = l.ascent.max(4.0);
let mut p = BezPath::new();
p.move_to((g.x - h * 0.25, l.baseline - h));
p.line_to((g.x + h * 0.25, l.baseline - h));
p.line_to((g.x, l.baseline - h * 0.55));
p.close_path();
ctx.fill_path(&p);
ctx.fill_rect(&kurbo::Rect::new(g.x - h * 0.03, l.baseline - h * 0.6, g.x + h * 0.03, l.baseline + l.descent * 0.5));
}
}
}
if f.opts.condition_indicators && !f.doc.conditions.is_empty() && cs.styles.iter().any(|s| s.condition.is_some()) {
ctx.set_transform(m);
let px = 1.0 / m.determinant().abs().sqrt().max(1e-9);
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0) {
let Some(name) = cs.styles.get(g.style as usize).and_then(|s| s.condition.as_deref()) else { continue };
let Some(c) = f.doc.conditions.iter().find(|c| c.name == name) else { continue };
ctx.set_paint(screen_color([c.color[0], c.color[1], c.color[2]], 255));
let y = l.baseline + l.descent * 0.6;
ctx.fill_rect(&kurbo::Rect::new(g.x, y, g.x + g.adv, y + 2.0 * px));
}
}
}
draw_text_indicators(ctx, f, cs, ft, m);
let scale = m.determinant().abs().sqrt();
let greek_px = f.opts.greek_below_px;
let mut greek = BezPath::new();
@@ -470,14 +474,16 @@ impl Renderer {
let doc = f.doc;
for t in &ft.tables {
for c in &t.cells {
if let Some((sw, tint)) = &c.fill
&& let Some(col) = doc.resolve_color(sw, *tint)
{
if let Some((sw, tint)) = &c.fill {
ctx.set_transform(f.view * xf);
ctx.set_paint(color_of(&col, 1.0));
let fill = designcraft_doc::Fill { swatch: sw.clone(), tint: *tint, ..designcraft_doc::Fill::none() };
// Overlap neighbours by half a device pixel so adjacent fills show no seams.
let h = 0.5 * f.px;
ctx.fill_rect(&c.rect.inflate(h, h));
// All cells share the table's gradient space; it must not restart at a cell edge.
if crate::set_fill_paint(ctx, doc, &fill, t.rect) {
ctx.fill_rect(&c.rect.inflate(h, h));
}
ctx.reset_paint_transform();
}
}
for c in &t.cells {
+127
View File
@@ -0,0 +1,127 @@
use super::{Affine, Cache, Document, Item, Rect, SpreadRef, fx};
/// How far (points) an object's soft effects reach outside its geometry.
pub fn effect_outset(it: &Item) -> f64 {
fx::outset(it)
}
/// 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>,
}
/// 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,
/// The CMYK blend space's ink passes (internal): 3 draws black ink coverage as gray, 4 cyan,
/// magenta and yellow coverage in red, green and blue, from each object's own color (CMYK as
/// authored, others through the color settings); 0–2 draw cyan, magenta or yellow as gray.
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,
}
/// 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,
}
#[derive(Clone, Copy)]
pub(crate) struct Frame<'a> {
pub(crate) doc: &'a Document,
pub(crate) cache: &'a Cache,
pub(crate) view: Affine,
/// Visible area in spread space.
pub(crate) visible: Rect,
/// Size of a device pixel in points.
pub(crate) px: f64,
pub(crate) opts: &'a RenderOptions,
/// Drawing into a multithreaded context (no filter layers).
pub(crate) 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)
}
}