mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 01:14:29 -06:00
Merge main into fix/undo-history-follows-memory-r
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
use super::options::{AntiAlias, RenderOptions};
|
||||
use super::threads::single_threaded_context;
|
||||
use super::{Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Rect, RenderContext, StrokeLayer, SymbolBoxes, TextObject, kurbo, live};
|
||||
use super::{
|
||||
Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Pixmap, Rect, RenderContext, StrokeLayer, SymbolBoxes, TextObject, TypeAntiAlias,
|
||||
fx, kurbo, live,
|
||||
};
|
||||
|
||||
/// Hasher for pointer-keyed caches: a multiply-xorshift (SipHash showed up in frame profiles).
|
||||
#[derive(Default, Clone, Copy)]
|
||||
@@ -116,6 +119,26 @@ pub(crate) struct PlacedEntry {
|
||||
pub(crate) slice: Option<vectorcraft_doc::NineSliceMap>,
|
||||
}
|
||||
|
||||
/// Type drawn with an anti-aliasing method other than Sharp, as [`Renderer::draw_type_edges`] drew
|
||||
/// it offscreen on the pixels of the output ([`Frame::output`]).
|
||||
pub(crate) struct TypeEdgeEntry {
|
||||
/// A copy of the type's object: the kept pixels are used only for an object equal to it.
|
||||
pub(crate) node: Node,
|
||||
/// The output's zoom and rotation ([`fx::linear_key`]).
|
||||
pub(crate) linear: [u64; 4],
|
||||
/// The method, the edge threshold, the frame's edge smoothing, whether the frame composites
|
||||
/// precisely, and the opacity folded into the paints.
|
||||
pub(crate) settings: (TypeAntiAlias, Option<u8>, AntiAlias, bool, u32),
|
||||
/// What the type read from the document and the color settings ([`Renderer::raster_inputs`]).
|
||||
pub(crate) inputs: fx::Inputs,
|
||||
/// The output's offset: in a frame whose offset differs from it by whole pixels, the pixels are
|
||||
/// placed moved by them.
|
||||
pub(crate) shift: kurbo::Vec2,
|
||||
/// The output pixel at the image's top-left corner.
|
||||
pub(crate) origin: (f64, f64),
|
||||
pub(crate) image: Arc<Pixmap>,
|
||||
}
|
||||
|
||||
/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing.
|
||||
pub(crate) const MAX_INLINE_DEPTH: u32 = 4;
|
||||
|
||||
@@ -147,6 +170,10 @@ pub struct FrameStats {
|
||||
/// How many threads composed the frame from its two ink planes, in a CMYK document that shows
|
||||
/// transparency; 0 for other frames.
|
||||
pub ink_threads: usize,
|
||||
/// The type objects the frame drew offscreen with an anti-aliasing method other than Sharp
|
||||
/// ([`Renderer::draw_type_edges`]). Type drawn from the pixels an earlier frame kept is not
|
||||
/// counted.
|
||||
pub type_edges: usize,
|
||||
pub micros: u64,
|
||||
}
|
||||
|
||||
@@ -160,11 +187,16 @@ pub(crate) struct Frame<'a> {
|
||||
pub(crate) view: Affine,
|
||||
/// Visible region in document coordinates (for culling).
|
||||
pub(crate) visible: Rect,
|
||||
/// Size of one output pixel in document units.
|
||||
/// Size of one of this frame's pixels in document units.
|
||||
pub(crate) px: f64,
|
||||
pub(crate) opts: &'a RenderOptions,
|
||||
/// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]).
|
||||
pub(crate) ink: Ink,
|
||||
/// Document → pixels of the output being drawn: `view` itself, except while an export draws
|
||||
/// its art at several times its size (the export's own pixels) and in offscreen layers (the
|
||||
/// pixels they are placed back on). Type with an anti-aliasing method other than Sharp is
|
||||
/// drawn on these pixels ([`Renderer::draw_type_edges`]).
|
||||
pub(crate) output: Affine,
|
||||
/// With Separations Preview, the tables placed images show the visible plates through.
|
||||
pub(crate) plates: Option<&'a crate::proof::PlateLut>,
|
||||
}
|
||||
|
||||
@@ -73,13 +73,13 @@ pub(crate) use bounds::visual_bounds;
|
||||
pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds};
|
||||
#[cfg(test)]
|
||||
pub(crate) use entries::Drawn;
|
||||
pub(crate) use entries::{DocInputs, FxGeom, Reads, ShadowEntry};
|
||||
pub(crate) use entries::{DocInputs, FxGeom, Inputs, Reads, ShadowEntry};
|
||||
pub(crate) use predicates::{has_fx, has_object_fx};
|
||||
pub(crate) use raster::PixelEntry;
|
||||
#[cfg(test)]
|
||||
pub(crate) use raster::RASTERIZED;
|
||||
#[cfg(test)]
|
||||
use raster::{DRAG_PIXEL_BUDGET, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin};
|
||||
use raster::{DRAG_PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin};
|
||||
pub(crate) use raster::{PIXEL_BUDGET, PixelEntry, linear_key};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -534,7 +534,8 @@ impl Renderer {
|
||||
(true, false) => crate::AntiAlias::Art,
|
||||
};
|
||||
let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() };
|
||||
let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f };
|
||||
// The image's pixels are the output's.
|
||||
let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, output: view, ..*f };
|
||||
let mut off = frame.offscreen_context(w, h);
|
||||
self.inside_layer(|s| paint(s, &mut off, &frame));
|
||||
off.flush();
|
||||
|
||||
@@ -77,5 +77,6 @@ pub(super) fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx]
|
||||
|
||||
/// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset).
|
||||
pub(super) fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> {
|
||||
Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f }
|
||||
let moved = Affine::translate((dx, dy));
|
||||
Frame { view: f.view * moved, visible: f.visible - Vec2::new(dx, dy), output: f.output * moved, ..*f }
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pub(super) fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Col
|
||||
|
||||
/// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is
|
||||
/// cropped to the view when its effects only look nearby, else rendered at a lower resolution.
|
||||
pub(super) const PIXEL_BUDGET: f64 = 8.4e6;
|
||||
pub(crate) const PIXEL_BUDGET: f64 = 8.4e6;
|
||||
|
||||
/// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each
|
||||
/// draws the content again inside the one after it.
|
||||
@@ -121,7 +121,7 @@ impl PixelEntry {
|
||||
}
|
||||
|
||||
/// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys.
|
||||
pub(super) fn linear_key(view: Affine) -> [u64; 4] {
|
||||
pub(crate) fn linear_key(view: Affine) -> [u64; 4] {
|
||||
let c = view.as_coeffs();
|
||||
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
|
||||
}
|
||||
|
||||
@@ -838,6 +838,7 @@ mod tests {
|
||||
px: 1.0,
|
||||
opts: &opts,
|
||||
ink: Ink::Display,
|
||||
output: Affine::IDENTITY,
|
||||
plates: None,
|
||||
};
|
||||
let mut ctx = frame.offscreen_context(100, 100);
|
||||
|
||||
@@ -29,6 +29,7 @@ mod stroke;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::text::TypeAntiAlias;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject, TraceView};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
|
||||
use vello_cpu::kurbo;
|
||||
@@ -124,6 +125,9 @@ pub struct Renderer {
|
||||
/// Objects' content run through Photoshop-style effects, per object and content slot (see
|
||||
/// `fx`): kept only while drawn.
|
||||
pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>,
|
||||
/// Type drawn with an anti-aliasing method other than Sharp, per object and ink (see
|
||||
/// [`Self::draw_type_edges`]).
|
||||
type_edges: FrameCache<(usize, Ink), TypeEdgeEntry>,
|
||||
/// What the contents of those rasters read from the document, and the color settings, as last
|
||||
/// found for a frame (`stamp`) and document (address) (see [`Self::doc_inputs`]).
|
||||
doc_inputs: Option<((u64, usize), Arc<fx::DocInputs>)>,
|
||||
@@ -226,7 +230,10 @@ mod threads;
|
||||
|
||||
pub use bounds::{drawn_bounds, drawn_outline, drawn_outlines, effect_bounds, painted_bounds};
|
||||
pub use frame::FrameStats;
|
||||
use frame::{ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey};
|
||||
use frame::{
|
||||
ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey,
|
||||
TypeEdgeEntry,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use frame::{OFFSCREEN_PIXELS, Slices};
|
||||
pub use geometry::region_pixels;
|
||||
@@ -236,6 +243,8 @@ use node_checks::painted_items;
|
||||
pub use options::{AntiAlias, ISOLATION_DIM, MAX_RASTER_PIXELS, MAX_RASTER_SIDE, RenderOptions, Rendered, raster_size, supersample_factor};
|
||||
pub(crate) use paint::blend_mode;
|
||||
use paint::fill_rule;
|
||||
#[cfg(test)]
|
||||
use renderer::{edge_coverage, edge_grid, type_reach};
|
||||
use text_geom::TextGeom;
|
||||
#[cfg(test)]
|
||||
pub(crate) use text_geom::is_opentype_alternate;
|
||||
|
||||
@@ -51,6 +51,8 @@ pub(crate) const FREEFORM_BUDGET: usize = 32 << 20;
|
||||
/// Dithered gradient grids, per thread (`dither::set_dithered_paint`): a grid is at most 4096²
|
||||
/// pixels, 64 MiB.
|
||||
pub(crate) const DITHER_BUDGET: usize = 64 << 20;
|
||||
/// Type drawn with an anti-aliasing method other than Sharp, as pixels (`Renderer::draw_type_edges`).
|
||||
pub(crate) const TYPE_EDGE_BUDGET: usize = 128 << 20;
|
||||
|
||||
/// Frames an entry may go unused before [`FrameCache::sweep`] drops it.
|
||||
const AGE: u64 = 3;
|
||||
|
||||
@@ -201,6 +201,7 @@ fn rasterize(
|
||||
px: 1.0 / v.determinant().abs().sqrt().max(1e-12),
|
||||
opts: &opts,
|
||||
ink,
|
||||
output: v,
|
||||
plates: None,
|
||||
};
|
||||
for a in &def.art {
|
||||
@@ -345,7 +346,8 @@ impl Renderer {
|
||||
let comp = crate::group::Composite { opacity: dim, ..Default::default() };
|
||||
self.group(ctx, f, comp, &mut |r, c, fr| {
|
||||
for o in offsets {
|
||||
let frame = Frame { view: fr.view * Affine::translate(*o), visible: fr.visible - *o, ..*fr };
|
||||
let moved = Affine::translate(*o);
|
||||
let frame = Frame { view: fr.view * moved, visible: fr.visible - *o, output: fr.output * moved, ..*fr };
|
||||
for a in children {
|
||||
r.draw_arc(c, &frame, a);
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@ impl Renderer {
|
||||
// The art in its own coordinates, with its own resources.
|
||||
let symbols = e.doc.symbol_boxes().quick();
|
||||
let visible = m.inverse().transform_rect_bbox(f.visible);
|
||||
let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, ..*f };
|
||||
let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, output: f.output * m, ..*f };
|
||||
let _inside = Inside::enter();
|
||||
self.draw_arc(ctx, &inner, &e.art);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ mod text;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use paint::{CROP_STROKES, crop_line};
|
||||
#[cfg(test)]
|
||||
pub(crate) use text::{edge_coverage, edge_grid, type_reach};
|
||||
|
||||
impl Renderer {
|
||||
pub fn new() -> Self {
|
||||
@@ -59,6 +61,7 @@ impl Renderer {
|
||||
brushes: Default::default(),
|
||||
shadows: PtrMap::default(),
|
||||
pixel_fx: PtrMap::default(),
|
||||
type_edges: FrameCache::new(lru::TYPE_EDGE_BUDGET, 0),
|
||||
doc_inputs: None,
|
||||
transparency: None,
|
||||
live: live::LiveCache::default(),
|
||||
@@ -94,19 +97,22 @@ impl Renderer {
|
||||
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
|
||||
pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
|
||||
let _frame = vectorcraft_text::KeptLayoutsFrame::begin();
|
||||
self.render_as(doc, width, height, view, opts, false)
|
||||
self.render_as(doc, width, height, view, opts, false, 1)
|
||||
}
|
||||
|
||||
/// [`Self::render`], or with `inks` the ink amounts in the working CMYK space instead of
|
||||
/// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]).
|
||||
fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered {
|
||||
/// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). `cell` is the side of a
|
||||
/// pixel of the output in pixels of this render: the supersampling factor while an export
|
||||
/// draws its art at several times its size, else 1 ([`Frame::output`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool, cell: u32) -> Rendered {
|
||||
let start = now();
|
||||
if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() {
|
||||
let (w, h) = (width.clamp(1, MAX_CONTEXT_SIDE), height.clamp(1, MAX_CONTEXT_SIDE));
|
||||
if let Some(k) = supersample_factor(w, h) {
|
||||
let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() };
|
||||
let hairline = std::mem::replace(&mut self.hairline, f64::from(k));
|
||||
let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks);
|
||||
let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks, k);
|
||||
self.hairline = hairline;
|
||||
let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize);
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
@@ -129,8 +135,9 @@ impl Renderer {
|
||||
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
|
||||
let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display };
|
||||
let symbols = doc.symbol_boxes().quick();
|
||||
let output = Affine::scale(1.0 / f64::from(cell.max(1))) * view;
|
||||
let plates = proof::plate_lut(opts);
|
||||
let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen, plates: plates.as_deref() };
|
||||
let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen, output, plates: plates.as_deref() };
|
||||
self.begin_frame();
|
||||
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
|
||||
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) {
|
||||
@@ -211,6 +218,7 @@ impl Renderer {
|
||||
}
|
||||
// Filtered rasters are large: only those of the last frame stay.
|
||||
self.pixel_fx.retain(|_, e| e.stamp == g);
|
||||
self.type_edges.sweep(g);
|
||||
self.texts.sweep(g);
|
||||
self.text_ids.retain(|_, key| self.texts.contains_key(key));
|
||||
self.char_strokes.sweep(g);
|
||||
@@ -319,7 +327,7 @@ impl Renderer {
|
||||
let (w, h) = region_pixels(region, scale);
|
||||
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
|
||||
let _frame = vectorcraft_text::KeptLayoutsFrame::begin();
|
||||
self.render_as(doc, w, h, view, opts, true).pixels
|
||||
self.render_as(doc, w, h, view, opts, true, 1).pixels
|
||||
}
|
||||
|
||||
/// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels.
|
||||
@@ -342,7 +350,7 @@ impl Renderer {
|
||||
let mut ctx = single_threaded_context(w, h);
|
||||
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
|
||||
let symbols = doc.symbol_boxes().quick();
|
||||
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, plates: None };
|
||||
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, output: view, plates: None };
|
||||
self.begin_frame();
|
||||
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
|
||||
self.clip_paths.clear();
|
||||
@@ -387,8 +395,18 @@ impl Renderer {
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
|
||||
}
|
||||
let symbols = doc.symbol_boxes().quick();
|
||||
let frame =
|
||||
Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display, plates: None };
|
||||
let frame = Frame {
|
||||
mt: false,
|
||||
doc,
|
||||
symbols: &symbols,
|
||||
view,
|
||||
visible: b.inflate(1.0, 1.0),
|
||||
px: 1.0 / s,
|
||||
opts,
|
||||
ink: Ink::Display,
|
||||
output: view,
|
||||
plates: None,
|
||||
};
|
||||
self.begin_frame();
|
||||
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
|
||||
self.clip_paths.clear();
|
||||
|
||||
@@ -3,7 +3,9 @@ use super::{
|
||||
PlacedKey, Rect, RenderContext, Renderer, SUBSTITUTED, Shape, TextGeom, TextObject, blend_mode, blob_pixmap, depends_on_document, fx, holds_type,
|
||||
image_mip, ink, kurbo, lru, peniko, place, proof, stain_bits, text_geom, text_geom_snapped, vello_cpu,
|
||||
};
|
||||
use super::{AntiAlias, Pixmap, single_threaded_context};
|
||||
use crate::paint;
|
||||
use crate::{MAX_RASTER_SIDE, TypeAntiAlias, TypeEdgeEntry};
|
||||
|
||||
impl Renderer {
|
||||
pub(super) fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
|
||||
@@ -222,6 +224,9 @@ impl Renderer {
|
||||
self.draw_inlines(ctx, f, t, g);
|
||||
return;
|
||||
}
|
||||
if t.anti_alias != TypeAntiAlias::Sharp && self.draw_type_edges(ctx, f, n, t, g, kept) {
|
||||
return;
|
||||
}
|
||||
let tb = t.xf.transform_rect_bbox(g.bounds);
|
||||
// Overprinting characters darken per draw, like overprinting fills and strokes
|
||||
// ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer.
|
||||
@@ -293,6 +298,233 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw type `t` of node `n` (laid out in `g`) with its anti-aliasing method, None, Crisp or
|
||||
/// Strong, on the pixels of the output ([`Frame::output`]): the type is drawn offscreen on those
|
||||
/// pixels and placed back pixel for pixel. With None it is drawn with hard edges, so a pixel
|
||||
/// takes the type's color where the type covers at least half of it. With Crisp and Strong each
|
||||
/// group of its paints ([`type_paints`]: the object's own fills and strokes one at a time, and
|
||||
/// the characters' fills and strokes) is drawn on its own as Sharp draws it, and again in
|
||||
/// opaque black, which gives the share `c` of each pixel the group covers; the pixel's color and
|
||||
/// opacity are then scaled by [`edge_coverage`] of `c` divided by `c`, so the opacity of the
|
||||
/// paints is kept, and the groups are composited in the order they are drawn. A pixel that
|
||||
/// every group covers wholly or not at all is the pixel Sharp draws. Where the output's pixels would number more than the frame's there
|
||||
/// and more than [`fx::PIXEL_BUDGET`], or be more than [`MAX_RASTER_SIDE`] a side (a frame
|
||||
/// drawn coarser than the output), the frame's own pixels are used.
|
||||
///
|
||||
/// The pixels are kept (`type_edges`). In a later frame they are placed without drawing the
|
||||
/// type offscreen while the object, the output's zoom and rotation, the method and edge
|
||||
/// smoothing and what the type reads ([`Self::raster_inputs`]) are unchanged, the output has
|
||||
/// moved by whole pixels, and the kept pixels hold every pixel the frame needs. After a pan,
|
||||
/// the kept pixels are placed moved by it; after a pan that brings more of the type into view,
|
||||
/// the type is drawn offscreen again. The outlines of its character strokes are cached when
|
||||
/// `g` is kept in `texts` (`geom_kept`).
|
||||
///
|
||||
/// Returns false, having drawn nothing, for type that is drawn as Sharp draws it: type with
|
||||
/// inline graphics, type with color glyphs (bitmap or COLR), type whose own fills or strokes
|
||||
/// include a brush stroke or a blend mode other than Normal, type that overprints while
|
||||
/// Overprint Preview is on, and type that shows a substitution highlight on screen. Drawn
|
||||
/// offscreen, those would blend with nothing or take the method on art that is not the
|
||||
/// characters'. It also returns false for type whose offscreen drawing would be more than
|
||||
/// [`MAX_RASTER_SIDE`] pixels a side or more than [`fx::PIXEL_BUDGET`] pixels in all.
|
||||
fn draw_type_edges(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &Arc<TextGeom>, geom_kept: bool) -> bool {
|
||||
if proof::overprints(f.opts) && t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke) {
|
||||
return false;
|
||||
}
|
||||
let normal = vectorcraft_color::BlendMode::Normal;
|
||||
let brush = |i: &AppearanceItem| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some());
|
||||
if t.runs.iter().any(|r| r.inline.is_some()) || n.appearance.items.iter().any(|i| i.visible() && (i.blend() != normal || brush(i))) {
|
||||
return false;
|
||||
}
|
||||
// Color glyphs paint their own pixels and layers, whose opacity is not the characters' coverage.
|
||||
if !g.images.is_empty() || !g.colours.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if f.opts.highlight_substitutions {
|
||||
let shows = |on: bool, path: &BezPath| on && !path.elements().is_empty();
|
||||
if shows(f.opts.highlight_substituted_fonts, &g.substituted_fonts)
|
||||
|| shows(f.doc.setup.highlight_substituted_glyphs, &g.substituted_glyphs)
|
||||
// Measured only when shown: it lays the text out again.
|
||||
|| (f.opts.highlight_alternate_glyphs && !g.alternate_glyphs(t).elements().is_empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Where the type draws: its glyphs, grown by the farthest its strokes reach, besides its
|
||||
// bounds (an area type frame's, or a point type estimate before it is laid out).
|
||||
let glyphs = type_reach(n, t, g);
|
||||
let reach = fx::cull_bounds(f.symbols, n).map_or(glyphs, |b| b.union(glyphs));
|
||||
// This frame's pixels to the output's: a scale and an offset (a supersampled export's
|
||||
// factor, an offscreen layer's crop).
|
||||
let grid = f.output * f.view.inverse();
|
||||
let [sx, _, _, sy, _, _] = grid.as_coeffs();
|
||||
let reach = reach.inflate(2.0 * f.px / sx.abs().min(1.0), 2.0 * f.px / sy.abs().min(1.0));
|
||||
let on = f.view.transform_rect_bbox(reach).intersect(Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())));
|
||||
if !on.is_finite() {
|
||||
return false;
|
||||
}
|
||||
if on.width() <= 0.0 || on.height() <= 0.0 {
|
||||
return true;
|
||||
}
|
||||
let grid = edge_grid(on, grid);
|
||||
let output = grid * f.view;
|
||||
let [sx, _, _, sy, _, _] = grid.as_coeffs();
|
||||
let on = grid.transform_rect_bbox(on);
|
||||
// Whole pixels of the output.
|
||||
let (x0, y0) = (on.x0.floor(), on.y0.floor());
|
||||
let (Ok(w), Ok(h)) = (u16::try_from((on.x1 - x0).ceil() as u64), u16::try_from((on.y1 - y0).ceil() as u64)) else { return false };
|
||||
// (vello_cpu panics drawing near `u16::MAX` pixels a side.) Past the pixel budget the type
|
||||
// is drawn as Sharp draws it.
|
||||
if u32::from(w) > MAX_RASTER_SIDE || u32::from(h) > MAX_RASTER_SIDE || f64::from(w) * f64::from(h) > fx::PIXEL_BUDGET {
|
||||
return false;
|
||||
}
|
||||
let view = Affine::translate((-x0, -y0)) * output;
|
||||
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
|
||||
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
|
||||
let frame = Frame { mt: false, view, visible, px, output: view, ..*f };
|
||||
let threshold = match t.anti_alias {
|
||||
TypeAntiAlias::None => AntiAlias::None.threshold(),
|
||||
// The frame's pixels are smaller than the output's in a supersampled export, whose art
|
||||
// is smooth on the output's pixels (Art Optimized).
|
||||
_ if sx.abs() < 0.999 || sy.abs() < 0.999 => None,
|
||||
_ => f.opts.anti_alias.threshold(),
|
||||
};
|
||||
// Pixels kept from an earlier frame, for the type of the node itself (not a copy of its
|
||||
// text): drawn on output pixels of the same size, rotation and phase, holding those needed.
|
||||
let own = matches!(&n.kind, NodeKind::Text(o) if std::ptr::eq(&**o, t));
|
||||
let key = (std::ptr::from_ref(n) as usize, f.ink);
|
||||
let linear = fx::linear_key(output);
|
||||
let shift = output.translation();
|
||||
let settings = (t.anti_alias, threshold, f.opts.anti_alias, f.opts.precise, self.alpha.to_bits());
|
||||
let inputs = own.then(|| self.raster_inputs(f, n, fx::Reads::of(n)));
|
||||
let whole = |v: f64| (v - v.round()).abs() < 1e-6;
|
||||
let kept = inputs.as_ref().and_then(|inputs| {
|
||||
let e = self.type_edges.get(&key, self.stamp)?;
|
||||
let d = shift - e.shift;
|
||||
let (ox, oy) = (e.origin.0 + d.x.round(), e.origin.1 + d.y.round());
|
||||
let (iw, ih) = (f64::from(e.image.width()), f64::from(e.image.height()));
|
||||
let holds = ox <= x0 && oy <= y0 && ox + iw >= x0 + f64::from(w) && oy + ih >= y0 + f64::from(h);
|
||||
let fits = e.linear == linear && e.settings == settings && whole(d.x) && whole(d.y) && holds && e.inputs == *inputs && e.node == *n;
|
||||
fits.then(|| (e.image.clone(), ox, oy))
|
||||
});
|
||||
let (image, ox, oy) = match kept {
|
||||
Some(kept) => kept,
|
||||
None => {
|
||||
let pm = self.draw_type_pixels(&frame, n, t, g, geom_kept, (w, h), threshold);
|
||||
self.stats.type_edges += 1;
|
||||
let image = Arc::new(pm);
|
||||
if let Some(inputs) = inputs {
|
||||
let bytes = usize::from(w) * usize::from(h) * 4 + lru::node_bytes(n);
|
||||
let e = TypeEdgeEntry { node: n.clone(), linear, settings, inputs, shift, origin: (x0, y0), image: image.clone() };
|
||||
self.type_edges.insert(key, e, bytes, self.stamp);
|
||||
}
|
||||
(image, x0, y0)
|
||||
}
|
||||
};
|
||||
ctx.set_transform(grid.inverse() * Affine::translate((ox, oy)));
|
||||
// The pixmap maps onto the rect pixel for pixel, each one on a pixel of the output.
|
||||
ctx.reset_paint_transform();
|
||||
let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() };
|
||||
let (iw, ih) = (f64::from(image.width()), f64::from(image.height()));
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, iw, ih));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
true
|
||||
}
|
||||
|
||||
/// The pixels of type `t` of node `n` (laid out in `g`) with its anti-aliasing method, drawn
|
||||
/// offscreen in `frame` on `w` × `h` pixels with edge threshold `threshold`
|
||||
/// ([`Self::draw_type_edges`]). The outlines of its character strokes are cached when `g` is
|
||||
/// kept in `texts` (`kept`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_type_pixels(
|
||||
&mut self,
|
||||
frame: &Frame,
|
||||
n: &Node,
|
||||
t: &TextObject,
|
||||
g: &Arc<TextGeom>,
|
||||
kept: bool,
|
||||
(w, h): (u16, u16),
|
||||
threshold: Option<u8>,
|
||||
) -> Pixmap {
|
||||
let draw = |r: &mut Self, frame: &Frame, n: &Node, t: &TextObject| {
|
||||
let mut off = single_threaded_context(w, h);
|
||||
off.set_aliasing_threshold(threshold);
|
||||
r.inside_layer(|r| r.draw_text_geom(&mut off, frame, n, t, g, kept));
|
||||
off.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
off.render_with(&mut pm, &mut r.resources, r.raster);
|
||||
pm
|
||||
};
|
||||
let sharp = TextObject { anti_alias: TypeAntiAlias::Sharp, ..t.clone() };
|
||||
if t.anti_alias == TypeAntiAlias::None {
|
||||
return draw(self, frame, n, &sharp);
|
||||
}
|
||||
let black = |p: &mut vectorcraft_color::Paint| {
|
||||
if !p.is_none() {
|
||||
*p = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
|
||||
}
|
||||
};
|
||||
// A group of paints drawn on its own, its pixels taking the method from its own coverage,
|
||||
// and that coverage.
|
||||
let reshaped = |r: &mut Self, group: Option<&[TypePaint]>| {
|
||||
let (pn, pt) = match group {
|
||||
Some(keep) => type_paint_only(n, &sharp, keep),
|
||||
None => (n.clone(), sharp.clone()),
|
||||
};
|
||||
let mut pm = draw(r, frame, &pn, &pt);
|
||||
let (mut inked, mut ink) = (pn, pt);
|
||||
for r in &mut ink.runs {
|
||||
black(&mut r.style.fill);
|
||||
black(&mut r.style.stroke);
|
||||
}
|
||||
for item in &mut inked.appearance.items {
|
||||
black(item.paint_mut());
|
||||
match item {
|
||||
AppearanceItem::Fill(fl) => fl.opacity = 1.0,
|
||||
AppearanceItem::Stroke(st) => st.opacity = 1.0,
|
||||
}
|
||||
}
|
||||
// The coverage leaves out the opacity folded in from the object the type is drawn in
|
||||
// (a translucent brushed path), which the pixels already take.
|
||||
let folded = std::mem::replace(&mut r.alpha, 1.0);
|
||||
let covered = draw(r, frame, &inked, &ink);
|
||||
r.alpha = folded;
|
||||
for (p, c) in pm.data_mut().iter_mut().zip(covered.data()) {
|
||||
*p = edge_pixel(*p, c.a, t.anti_alias);
|
||||
}
|
||||
(pm, covered)
|
||||
};
|
||||
let groups: Vec<Option<Vec<TypePaint>>> = match type_paints(n, &sharp, g, self.alpha >= 1.0) {
|
||||
Some(groups) => groups.into_iter().map(Some).collect(),
|
||||
None => vec![None],
|
||||
};
|
||||
if groups.len() <= 1 {
|
||||
return groups.first().map_or_else(|| Pixmap::new(w, h), |group| reshaped(self, group.as_deref()).0);
|
||||
}
|
||||
// Where every group covers a pixel wholly or not at all, the pixel is the type's as Sharp
|
||||
// draws it. Elsewhere it is the groups composited in the order they are drawn, source over.
|
||||
let mut pm = draw(self, frame, n, &sharp);
|
||||
let mut sum = vec![[0.0f32; 4]; usize::from(w) * usize::from(h)];
|
||||
let mut edge = vec![false; sum.len()];
|
||||
for group in &groups {
|
||||
let (layer, covered) = reshaped(self, group.as_deref());
|
||||
for (((s, e), p), c) in sum.iter_mut().zip(&mut edge).zip(layer.data()).zip(covered.data()) {
|
||||
*e |= c.a != 0 && c.a != 255;
|
||||
let keep = 1.0 - f32::from(p.a) / 255.0;
|
||||
for (v, c) in s.iter_mut().zip([p.r, p.g, p.b, p.a]) {
|
||||
*v = f32::from(c) + *v * keep;
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((p, s), e) in pm.data_mut().iter_mut().zip(&sum).zip(&edge) {
|
||||
if *e {
|
||||
let [red, green, blue, a] = s.map(|v| v.round().clamp(0.0, 255.0) as u8);
|
||||
*p = vello_cpu::color::PremulRgba8 { r: red.min(a), g: green.min(a), b: blue.min(a), a };
|
||||
}
|
||||
}
|
||||
pm
|
||||
}
|
||||
|
||||
/// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all`
|
||||
/// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode;
|
||||
/// strokes take every stroke option, as on paths.
|
||||
@@ -358,3 +590,125 @@ impl Renderer {
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of a pixel (out of 255) type that covers `c` of it shows with anti-aliasing method
|
||||
/// `m`: `c` with Sharp; all of it from half coverage (127) up, else none, with None; `2c - 0.5`
|
||||
/// with Crisp; and `2c` with Strong; each kept between 0 and all.
|
||||
pub(crate) fn edge_coverage(m: TypeAntiAlias, c: u8) -> u8 {
|
||||
let c = i32::from(c);
|
||||
let shown = match m {
|
||||
TypeAntiAlias::None => {
|
||||
if c >= 127 {
|
||||
255
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
TypeAntiAlias::Sharp => c,
|
||||
TypeAntiAlias::Crisp => 2 * c - 128,
|
||||
TypeAntiAlias::Strong => 2 * c,
|
||||
};
|
||||
shown.clamp(0, 255) as u8
|
||||
}
|
||||
|
||||
/// Where (document space) type `t` of node `n`, laid out in `g`, paints: its glyphs grown by the
|
||||
/// farthest the characters' strokes reach in text space, transformed as the type is, then grown by
|
||||
/// the farthest the object's own strokes reach in document space. A stroke's reach takes in its
|
||||
/// miter spikes and square caps ([`StrokeLayer::reach`]).
|
||||
pub(crate) fn type_reach(n: &Node, t: &TextObject, g: &TextGeom) -> Rect {
|
||||
let chars = t.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max);
|
||||
let own = n.appearance.outset();
|
||||
t.xf.transform_rect_bbox(g.bounds.inflate(chars, chars)).inflate(own, own)
|
||||
}
|
||||
|
||||
/// The map from a frame's pixels to the pixels [`Renderer::draw_type_edges`] draws type on, for
|
||||
/// the region `on` of the frame's pixels: `grid`, which maps them to the output's, unless the
|
||||
/// output's pixels there would number more than the frame's and more than [`fx::PIXEL_BUDGET`],
|
||||
/// or be more than [`MAX_RASTER_SIDE`] a side, and then the frame's own pixels (the identity).
|
||||
pub(crate) fn edge_grid(on: Rect, grid: Affine) -> Affine {
|
||||
let area = |r: Rect| r.width() * r.height();
|
||||
let (out, side) = (grid.transform_rect_bbox(on), f64::from(MAX_RASTER_SIDE));
|
||||
if area(out) > area(on).max(fx::PIXEL_BUDGET) || out.width() > side || out.height() > side { Affine::IDENTITY } else { grid }
|
||||
}
|
||||
|
||||
/// One paint of type: one of the object's own fills and strokes (its index), or the fill or the
|
||||
/// stroke of a run (its index).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum TypePaint {
|
||||
Item(usize),
|
||||
Fill(usize),
|
||||
Stroke(usize),
|
||||
}
|
||||
|
||||
/// Most groups of paints [`Renderer::draw_type_pixels`] draws one at a time ([`type_paints`]).
|
||||
const MAX_TYPE_PAINTS: usize = 8;
|
||||
|
||||
/// The paints of type `t` of node `n` (laid out in `g`) in groups that
|
||||
/// [`Renderer::draw_type_pixels`] draws one at a time, in the order they are drawn: the object's
|
||||
/// own fills and strokes below the characters, the runs' fills and strokes, then the object's own
|
||||
/// fills and strokes above the characters. Each of the object's own fills and strokes is a group,
|
||||
/// and so is each fill and stroke of a run, except that fills and strokes of runs one after
|
||||
/// another in solid colors are one group when colors are drawn opaque (`opaque`). A run's stroke
|
||||
/// starts a new group rather than join its own fill's, so the stroke's edges inside the characters
|
||||
/// take the method too. Paints that draw nothing are left out. `None` for type with more than
|
||||
/// [`MAX_TYPE_PAINTS`] groups, which is drawn as one.
|
||||
fn type_paints(n: &Node, t: &TextObject, g: &TextGeom, opaque: bool) -> Option<Vec<Vec<TypePaint>>> {
|
||||
let shown = |i: &AppearanceItem| i.visible() && !i.paint().is_none() && !matches!(i, AppearanceItem::Stroke(st) if st.width <= 0.0);
|
||||
let items = &n.appearance.items;
|
||||
let k = n.appearance.contents_at();
|
||||
let mut groups: Vec<Vec<TypePaint>> = (0..k).filter(|j| shown(&items[*j])).map(|j| vec![TypePaint::Item(j)]).collect();
|
||||
// Whether the last group holds run paints in opaque solid colors, which the next such paint joins.
|
||||
let mut joins = false;
|
||||
for (i, run) in t.runs.iter().enumerate() {
|
||||
if g.run(i).elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fill = (!run.style.fill.is_none()).then_some((TypePaint::Fill(i), &run.style.fill));
|
||||
let stroke = run.style.has_stroke().then_some((TypePaint::Stroke(i), &run.style.stroke));
|
||||
for (p, paint) in [fill, stroke].into_iter().flatten() {
|
||||
let solid = opaque && matches!(paint, vectorcraft_color::Paint::Solid { .. });
|
||||
// A run's fill and stroke overlap along every outline, so they never share a group.
|
||||
let own_fill = matches!(p, TypePaint::Stroke(_)) && groups.last().is_some_and(|last| last.contains(&TypePaint::Fill(i)));
|
||||
match groups.last_mut() {
|
||||
Some(last) if joins && solid && !own_fill => last.push(p),
|
||||
_ => groups.push(vec![p]),
|
||||
}
|
||||
joins = solid;
|
||||
}
|
||||
}
|
||||
groups.extend((k..items.len()).filter(|j| shown(&items[*j])).map(|j| vec![TypePaint::Item(j)]));
|
||||
(groups.len() <= MAX_TYPE_PAINTS).then_some(groups)
|
||||
}
|
||||
|
||||
/// Node `n` and type `t` with every paint but those in `keep` taken away.
|
||||
fn type_paint_only(n: &Node, t: &TextObject, keep: &[TypePaint]) -> (Node, TextObject) {
|
||||
let keep: std::collections::HashSet<TypePaint> = keep.iter().copied().collect();
|
||||
let (mut n, mut t) = (n.clone(), t.clone());
|
||||
for (j, item) in n.appearance.items.iter_mut().enumerate() {
|
||||
if !keep.contains(&TypePaint::Item(j)) {
|
||||
*item.paint_mut() = vectorcraft_color::Paint::None;
|
||||
}
|
||||
}
|
||||
for (i, run) in t.runs.iter_mut().enumerate() {
|
||||
if !keep.contains(&TypePaint::Fill(i)) {
|
||||
run.style.fill = vectorcraft_color::Paint::None;
|
||||
}
|
||||
if !keep.contains(&TypePaint::Stroke(i)) {
|
||||
run.style.stroke = vectorcraft_color::Paint::None;
|
||||
}
|
||||
}
|
||||
(n, t)
|
||||
}
|
||||
|
||||
/// Pixel `p` of type the anti-aliasing method `m` draws, where the type covers `c` (out of 255) of
|
||||
/// the pixel: its color and opacity scaled by [`edge_coverage`] of `c` divided by `c`. A pixel the
|
||||
/// type covers wholly or not at all is kept.
|
||||
fn edge_pixel(p: vello_cpu::color::PremulRgba8, c: u8, m: TypeAntiAlias) -> vello_cpu::color::PremulRgba8 {
|
||||
if c == 0 || c == 255 {
|
||||
return p;
|
||||
}
|
||||
let (to, c) = (u32::from(edge_coverage(m, c)), u32::from(c));
|
||||
let scale = |v: u8| ((u32::from(v) * to + c / 2) / c).min(255) as u8;
|
||||
let a = scale(p.a);
|
||||
vello_cpu::color::PremulRgba8 { r: scale(p.r).min(a), g: scale(p.g).min(a), b: scale(p.b).min(a), a }
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ mod setup;
|
||||
mod strokegradient;
|
||||
mod thincoverage;
|
||||
mod tileedge;
|
||||
mod typeedges;
|
||||
mod viewraster;
|
||||
mod widthzoom;
|
||||
|
||||
|
||||
@@ -857,6 +857,94 @@ fn a_paths_gradient_moves_with_its_transform_and_free_distort() {
|
||||
assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() {
|
||||
use vectorcraft_color::GradientGeom;
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let draw = |paint: &GradientPaint, e: Effect| {
|
||||
let square = shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0));
|
||||
let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint.clone())), Paint::None, 0.0));
|
||||
n.appearance.effects = vec![e];
|
||||
render(&doc_with(Document::new(100.0, 100.0), n))
|
||||
};
|
||||
// Placed from the square's left edge to its right edge, the gradient is drawn as one fitted to
|
||||
// the square is: after a warp both run from the warped outline's left edge to its right edge.
|
||||
let placed = GradientPaint {
|
||||
geom: Some(GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(60.0, 50.0), aspect: 1.0, focal: None }),
|
||||
..ramp.clone()
|
||||
};
|
||||
let styles = [
|
||||
("arc", json!({"bend": 50})),
|
||||
("bulge", json!({"bend": 60, "orientation": "vertical"})),
|
||||
("rise", json!({"bend": 40, "vertical": 50, "orientation": "vertical"})),
|
||||
];
|
||||
for (style, params) in styles.clone() {
|
||||
let e = fx(&format!("warp.{style}"), params.clone());
|
||||
let (got, want) = (draw(&placed, e.clone()), draw(&ramp, e));
|
||||
// The warp moves the outline's left or right edge, so a gradient left in place would not
|
||||
// run from edge to edge.
|
||||
let square = vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0);
|
||||
let path = shapes::rectangle(square);
|
||||
let b = vectorcraft_effects::apply_geometry(&[fx(&format!("warp.{style}"), params.clone())], &path, square).bounds().unwrap();
|
||||
assert!((b.x0 - 30.0).abs() > 0.5 || (b.x1 - 60.0).abs() > 0.5, "warp.{style}: the outline spans {b:?}");
|
||||
let mut worst = 0;
|
||||
for y in 0..100 {
|
||||
for x in 0..100 {
|
||||
let (a, b) = (got.pixel(x, y), want.pixel(x, y));
|
||||
worst = worst.max((0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0));
|
||||
}
|
||||
}
|
||||
assert!(worst <= 3, "warp.{style}: worst channel difference {worst}");
|
||||
}
|
||||
// A stroke's own Warp: the canvas draws its gradient moved and scaled from the stroke-inflated
|
||||
// bounds before the warp to those after it, as on the warped path with that gradient.
|
||||
let geom = GradientGeom { start: Point::new(35.0, 50.0), end: Point::new(55.0, 50.0), aspect: 1.0, focal: None };
|
||||
let stroked = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp.clone() }));
|
||||
let square = vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0);
|
||||
let follow = |p: Point, from: vectorcraft_geom::Rect, to: vectorcraft_geom::Rect| {
|
||||
Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
|
||||
};
|
||||
for (style, params) in styles {
|
||||
let mut n = Node::path(NodeId(0), shapes::rectangle(square), Appearance::basic(Paint::None, stroked.clone(), 6.0));
|
||||
let Some(AppearanceItem::Stroke(layer)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).cloned() else {
|
||||
panic!("a stroke")
|
||||
};
|
||||
let Some(item) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") };
|
||||
item.effects_mut().push(fx(&format!("warp.{style}"), params));
|
||||
let d = doc_with(Document::new(100.0, 100.0), n);
|
||||
let live = render(&d);
|
||||
let baked = vectorcraft_effects::bake_document(&d).unwrap();
|
||||
let mut warped = None;
|
||||
for l in &baked.layers {
|
||||
l.walk(&mut |c: &Node| {
|
||||
if let (Some(path), true) = (c.path_data(), c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))) {
|
||||
warped.get_or_insert_with(|| path.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
let path = warped.unwrap();
|
||||
let b = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
|
||||
assert_ne!(b, square, "warp.{style}: the warp changes the bounds");
|
||||
let (from, to) = (layer.paint_bounds(square), layer.paint_bounds(b));
|
||||
let moved = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom };
|
||||
let want_node = Node::path(
|
||||
NodeId(0),
|
||||
path,
|
||||
Appearance::basic(Paint::None, Paint::Gradient(Box::new(GradientPaint { geom: Some(moved), ..ramp.clone() })), 6.0),
|
||||
);
|
||||
let want = render(&doc_with(Document::new(100.0, 100.0), want_node));
|
||||
let mut worst = 0;
|
||||
for y in 0..100 {
|
||||
for x in 0..100 {
|
||||
let (a, b) = (live.pixel(x, y), want.pixel(x, y));
|
||||
worst = worst.max((0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0));
|
||||
}
|
||||
}
|
||||
assert!(worst <= 3, "warp.{style} on the stroke: worst channel difference {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn darkness_subtracts_from_rgb_before_shadow_opacity() {
|
||||
for opacity in [50.0, 100.0] {
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
//! Character panel › Anti-aliasing Method: type drawn as pixels with None, Sharp, Crisp or Strong
|
||||
//! edges.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::pattern::{PatternDef, PatternEdit};
|
||||
use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun, TypeAntiAlias};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, TextKind};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_geom::shapes::rectangle;
|
||||
|
||||
use crate::options::MAX_CONTEXT_SIDE;
|
||||
use crate::*;
|
||||
|
||||
const W: u32 = 260;
|
||||
const H: u32 = 90;
|
||||
|
||||
/// A page with "Hamburg" in 40 pt black type drawn with `method`, and `fill` as the type object's
|
||||
/// own fill (instead of the characters' fill) when given.
|
||||
fn page(method: TypeAntiAlias, fill: Option<FillLayer>) -> Document {
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
let mut t = TextObject::point(Point::new(10.3, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() });
|
||||
t.anti_alias = method;
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
if let Some(fl) = fill {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.runs[0].style.fill = Paint::None;
|
||||
}
|
||||
n.appearance.items.push(AppearanceItem::Fill(fl));
|
||||
}
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The opacity of each pixel of `d` drawn with `opts` on a transparent page.
|
||||
fn alphas(d: &Document, opts: &RenderOptions) -> Vec<u8> {
|
||||
Renderer::new().render(d, W, H, Affine::IDENTITY, opts).pixels.chunks(4).map(|p| p[3]).collect()
|
||||
}
|
||||
|
||||
/// Pixels the type covers only in part.
|
||||
fn partial(a: &[u8]) -> usize {
|
||||
a.iter().filter(|v| **v > 0 && **v < 255).count()
|
||||
}
|
||||
|
||||
/// The first pixels of `drawn` (type drawn with `m`) that are not the share `edge_coverage` gives
|
||||
/// the same pixel of `sharp` (the type drawn with Sharp), within a step of rounding: (pixel,
|
||||
/// Sharp, drawn).
|
||||
fn off_the_share(m: TypeAntiAlias, sharp: &[u8], drawn: &[u8]) -> Vec<(usize, u8, u8)> {
|
||||
sharp
|
||||
.iter()
|
||||
.zip(drawn)
|
||||
.enumerate()
|
||||
.filter(|(_, (c, v))| {
|
||||
// One step of rounding either way in the coverage.
|
||||
let near = [c.saturating_sub(1), **c, c.saturating_add(1)].map(|c| edge_coverage(m, c));
|
||||
let (lo, hi) = (near.iter().min().copied().unwrap_or(0), near.iter().max().copied().unwrap_or(255));
|
||||
**v < lo.saturating_sub(1) || **v > hi.saturating_add(1)
|
||||
})
|
||||
.map(|(i, (c, v))| (i, *c, *v))
|
||||
.take(5)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// On the canvas each method gives every pixel the share of it that `edge_coverage` gives the
|
||||
/// share Sharp draws: None paints whole pixels from half coverage up, Crisp doubles the contrast
|
||||
/// around half coverage, and Strong doubles the coverage.
|
||||
#[test]
|
||||
fn each_method_draws_the_share_of_each_pixel_it_defines() {
|
||||
let opts = RenderOptions::default();
|
||||
let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts);
|
||||
assert!(partial(&sharp) > 200, "Sharp draws smooth edges: {} partial pixels", partial(&sharp));
|
||||
for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
let drawn = alphas(&page(m, None), &opts);
|
||||
assert_ne!(drawn, sharp, "{m:?} changes the edges");
|
||||
let wrong = off_the_share(m, &sharp, &drawn);
|
||||
assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}");
|
||||
}
|
||||
assert_eq!(partial(&alphas(&page(TypeAntiAlias::None, None), &opts)), 0, "None paints whole pixels");
|
||||
// The same text drawn with each of the four methods gives four different pictures.
|
||||
let methods = [TypeAntiAlias::None, TypeAntiAlias::Sharp, TypeAntiAlias::Crisp, TypeAntiAlias::Strong];
|
||||
let drawn: Vec<Vec<u8>> = methods.iter().map(|m| alphas(&page(*m, None), &opts)).collect();
|
||||
for (i, a) in drawn.iter().enumerate() {
|
||||
for (j, b) in drawn.iter().enumerate().skip(i + 1) {
|
||||
assert_ne!(a, b, "{:?} and {:?} draw alike", methods[i], methods[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `edge_coverage` gives each method the share its documentation defines: None shows all of a
|
||||
/// pixel from half coverage (127) up and none of it below, Sharp shows the coverage, Crisp shows
|
||||
/// `2c - 0.5` and Strong shows `2c`, each kept between 0 and all.
|
||||
#[test]
|
||||
fn edge_coverage_gives_each_methods_documented_share() {
|
||||
// Coverage, then what None, Sharp, Crisp and Strong show of the pixel (out of 255).
|
||||
let table: [(u8, [u8; 4]); 11] = [
|
||||
(0, [0, 0, 0, 0]),
|
||||
(1, [0, 1, 0, 2]),
|
||||
(63, [0, 63, 0, 126]),
|
||||
(64, [0, 64, 0, 128]),
|
||||
(65, [0, 65, 2, 130]),
|
||||
(126, [0, 126, 124, 252]),
|
||||
(127, [255, 127, 126, 254]),
|
||||
(128, [255, 128, 128, 255]),
|
||||
(191, [255, 191, 254, 255]),
|
||||
(192, [255, 192, 255, 255]),
|
||||
(255, [255, 255, 255, 255]),
|
||||
];
|
||||
for (c, shown) in table {
|
||||
let got = [TypeAntiAlias::None, TypeAntiAlias::Sharp, TypeAntiAlias::Crisp, TypeAntiAlias::Strong].map(|m| edge_coverage(m, c));
|
||||
assert_eq!(got, shown, "coverage {c}: None, Sharp, Crisp, Strong");
|
||||
}
|
||||
}
|
||||
|
||||
/// A raster export draws its art at several times its size and averages it down; type with None
|
||||
/// still has hard edges on the exported pixels, Crisp has fewer partly covered pixels than Sharp,
|
||||
/// and Strong draws heavier type.
|
||||
#[test]
|
||||
fn an_exports_pixels_take_the_method() {
|
||||
let opts = RenderOptions { supersample: true, precise: true, ..Default::default() };
|
||||
let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts);
|
||||
let none = alphas(&page(TypeAntiAlias::None, None), &opts);
|
||||
let crisp = alphas(&page(TypeAntiAlias::Crisp, None), &opts);
|
||||
let strong = alphas(&page(TypeAntiAlias::Strong, None), &opts);
|
||||
assert!(partial(&sharp) > 200, "the export smooths Sharp type: {}", partial(&sharp));
|
||||
assert_eq!(partial(&none), 0, "None type has hard edges in the export");
|
||||
assert!(none.iter().filter(|v| **v == 255).count() > 1000);
|
||||
assert!(partial(&crisp) < partial(&sharp), "Crisp: {} partial pixels, Sharp {}", partial(&crisp), partial(&sharp));
|
||||
let ink = |a: &[u8]| a.iter().map(|v| u64::from(*v)).sum::<u64>();
|
||||
assert!(ink(&strong) > ink(&sharp), "Strong draws heavier type");
|
||||
}
|
||||
|
||||
/// A frame drawn with hard edges (Anti-aliased Artwork off, an export's Anti-aliasing None) draws
|
||||
/// the type of every method as it draws Sharp type.
|
||||
#[test]
|
||||
fn hard_edged_frames_draw_every_method_alike() {
|
||||
for opts in [
|
||||
RenderOptions { anti_alias: AntiAlias::None, ..Default::default() },
|
||||
RenderOptions { anti_alias: AntiAlias::None, precise: true, supersample: true, ..Default::default() },
|
||||
] {
|
||||
let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts);
|
||||
assert_eq!(partial(&sharp), 0);
|
||||
for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
assert_eq!(alphas(&page(m, None), &opts), sharp, "{m:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Crisp and Strong reshape the edges of type that is drawn with a translucent fill, and keep
|
||||
/// its opacity inside the characters; None keeps it too.
|
||||
#[test]
|
||||
fn the_methods_keep_the_opacity_of_the_paint() {
|
||||
let half = FillLayer { opacity: 0.5, ..FillLayer::new(Paint::solid(Color::BLACK)) };
|
||||
let opts = RenderOptions::default();
|
||||
let sharp = alphas(&page(TypeAntiAlias::Sharp, Some(half.clone())), &opts);
|
||||
let most = |a: &[u8]| a.iter().copied().max().unwrap_or(0);
|
||||
assert!((126..=129).contains(&most(&sharp)), "half opaque: {}", most(&sharp));
|
||||
for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
let drawn = alphas(&page(m, Some(half.clone())), &opts);
|
||||
assert!((126..=129).contains(&most(&drawn)), "{m:?} keeps the fill's opacity: {}", most(&drawn));
|
||||
assert_ne!(drawn, sharp, "{m:?} changes the edges");
|
||||
}
|
||||
}
|
||||
|
||||
/// Crisp and Strong reshape the edges of each of the type's paints on its own: where two
|
||||
/// half-opaque fills of the object overlap, each edge pixel shows the first fill with the
|
||||
/// method's share of the pixel under the second fill with that share.
|
||||
#[test]
|
||||
fn the_methods_reshape_each_paint_on_its_own() {
|
||||
let half = FillLayer { opacity: 0.5, ..FillLayer::new(Paint::solid(Color::BLACK)) };
|
||||
let opts = RenderOptions::default();
|
||||
let coverage = alphas(&page(TypeAntiAlias::Sharp, None), &opts);
|
||||
assert!(partial(&coverage) > 200, "{} partial pixels", partial(&coverage));
|
||||
for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
let mut d = page(m, Some(half.clone()));
|
||||
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
d.node_mut(id).unwrap().appearance.items.push(AppearanceItem::Fill(half.clone()));
|
||||
let drawn = alphas(&d, &opts);
|
||||
// Two half-opaque layers that each show `e` of the pixel, within a step of rounding in the
|
||||
// coverage and in the result.
|
||||
let both = |c: u8| {
|
||||
let e = f64::from(edge_coverage(m, c)) / 255.0;
|
||||
255.0 * (1.0 - (1.0 - 0.5 * e).powi(2))
|
||||
};
|
||||
let wrong: Vec<_> = coverage
|
||||
.iter()
|
||||
.zip(&drawn)
|
||||
.enumerate()
|
||||
.filter(|(_, (c, v))| {
|
||||
let near = [c.saturating_sub(1), **c, c.saturating_add(1)].map(both);
|
||||
let (lo, hi) = (near.iter().copied().fold(f64::MAX, f64::min), near.iter().copied().fold(0.0, f64::max));
|
||||
f64::from(**v) < lo - 1.5 || f64::from(**v) > hi + 1.5
|
||||
})
|
||||
.map(|(i, (c, v))| (i, *c, *v))
|
||||
.take(5)
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "{m:?}: (pixel, coverage, drawn) {wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Crisp and Strong reshape the inner edge of a character stroke, where the stroke lies on the
|
||||
/// characters' fill in another color: at a pixel the fill covers wholly, the stroke shows the
|
||||
/// method's share of its own coverage.
|
||||
#[test]
|
||||
fn the_methods_reshape_a_character_strokes_inner_edge() {
|
||||
let opts = RenderOptions::default();
|
||||
// "Hamburg" with the characters' fill `fill` and a 1.5 pt stroke `stroke`, drawn with `m`.
|
||||
let draw = |m: TypeAntiAlias, fill: Paint, stroke: Paint| {
|
||||
let mut d = page(m, None);
|
||||
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
let s = &mut t.runs[0].style;
|
||||
(s.fill, s.stroke, s.stroke_width) = (fill, stroke, 1.5);
|
||||
}
|
||||
Renderer::new().render(&d, W, H, Affine::IDENTITY, &opts).pixels
|
||||
};
|
||||
let black = || Paint::solid(Color::BLACK);
|
||||
let alpha = |p: Vec<u8>| p.chunks(4).map(|p| p[3]).collect::<Vec<u8>>();
|
||||
let fill = alpha(draw(TypeAntiAlias::Sharp, black(), Paint::None));
|
||||
let stroke = alpha(draw(TypeAntiAlias::Sharp, Paint::None, black()));
|
||||
// Pixels inside the fill that the stroke covers in part: the stroke's inner edge.
|
||||
let inner: Vec<usize> = (0..fill.len()).filter(|i| fill[*i] == 255 && stroke[*i] > 8 && stroke[*i] < 247).collect();
|
||||
assert!(inner.len() > 50, "{} pixels on the stroke's inner edge", inner.len());
|
||||
for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
let drawn = draw(m, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let wrong: Vec<_> = inner
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
let c = stroke[**i];
|
||||
let near = [c.saturating_sub(1), c, c.saturating_add(1)].map(|c| edge_coverage(m, c));
|
||||
let (lo, hi) = (near.iter().min().copied().unwrap_or(0), near.iter().max().copied().unwrap_or(255));
|
||||
let red = drawn[**i * 4];
|
||||
red < lo.saturating_sub(2) || red > hi.saturating_add(2)
|
||||
})
|
||||
.take(5)
|
||||
.map(|i| (*i, stroke[*i], drawn[*i * 4]))
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "{m:?}: (pixel, stroke coverage, red) {wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type with more groups of paints than the renderer draws one at a time is drawn as one group,
|
||||
/// and each method still reshapes its outer edges. Nine runs, each with an opaque solid fill and a
|
||||
/// stroke, make ten groups: each run's stroke starts a group that the next run's fill joins. Crisp
|
||||
/// and Strong give each pixel the share `edge_coverage` gives the share Sharp draws, and None
|
||||
/// paints whole pixels.
|
||||
#[test]
|
||||
fn type_with_more_paint_groups_than_the_limit_takes_the_method() {
|
||||
let opts = RenderOptions::default();
|
||||
let draw = |m: TypeAntiAlias| {
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
let style = CharStyle {
|
||||
size: 32.0,
|
||||
fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)),
|
||||
stroke: Paint::solid(Color::rgb(1.0, 0.0, 0.0)),
|
||||
stroke_width: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut t = TextObject::point(Point::new(10.3, 60.4), "H", style.clone());
|
||||
for c in "amburger".chars() {
|
||||
t.runs.push(TextRun::new(c.to_string(), style.clone()));
|
||||
}
|
||||
assert_eq!(t.runs.len(), 9, "nine runs");
|
||||
t.anti_alias = m;
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
alphas(&d, &opts)
|
||||
};
|
||||
let sharp = draw(TypeAntiAlias::Sharp);
|
||||
assert!(partial(&sharp) > 200, "Sharp draws smooth edges: {} partial pixels", partial(&sharp));
|
||||
for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
let drawn = draw(m);
|
||||
assert_ne!(drawn, sharp, "{m:?} changes the edges");
|
||||
let wrong = off_the_share(m, &sharp, &drawn);
|
||||
assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}");
|
||||
}
|
||||
let none = draw(TypeAntiAlias::None);
|
||||
assert_ne!(none, sharp, "None changes the edges");
|
||||
assert_eq!(partial(&none), 0, "None paints whole pixels");
|
||||
}
|
||||
|
||||
/// Crisp and Strong type with several faint fills of the object draws the pixels inside the
|
||||
/// characters as Sharp draws them: the fills are composited there before their colors are rounded.
|
||||
#[test]
|
||||
fn the_methods_keep_faint_fills_inside_the_characters() {
|
||||
let faint = FillLayer { opacity: 0.01, ..FillLayer::new(Paint::solid(Color::BLACK)) };
|
||||
let draw = |m: TypeAntiAlias| {
|
||||
let mut d = page(m, Some(faint.clone()));
|
||||
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
let n = d.node_mut(id).unwrap();
|
||||
for _ in 0..7 {
|
||||
n.appearance.items.push(AppearanceItem::Fill(faint.clone()));
|
||||
}
|
||||
alphas(&d, &RenderOptions::default())
|
||||
};
|
||||
let sharp = draw(TypeAntiAlias::Sharp);
|
||||
// The pixels the characters cover wholly.
|
||||
let coverage = alphas(&page(TypeAntiAlias::Sharp, None), &RenderOptions::default());
|
||||
let inside: Vec<usize> = coverage.iter().enumerate().filter(|(_, c)| **c == 255).map(|(i, _)| i).collect();
|
||||
assert!(inside.len() > 200, "{} pixels inside the characters", inside.len());
|
||||
assert!(inside.iter().all(|i| sharp[*i] > 0), "the faint fills show inside the characters");
|
||||
for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
let drawn = draw(m);
|
||||
let wrong: Vec<_> = inside.iter().filter(|i| drawn[**i] != sharp[**i]).take(5).map(|i| (*i, sharp[*i], drawn[*i])).collect();
|
||||
assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// In an export that composites with floating point, None, Crisp and Strong type composite its
|
||||
/// translucent fills as precisely as Sharp type: inside the characters each method draws the
|
||||
/// pixels Sharp draws, within the one step of rounding that keeping the type's pixels as 8-bit
|
||||
/// values adds.
|
||||
#[test]
|
||||
fn the_methods_keep_precise_compositing() {
|
||||
let fills = [(Color::rgb(0.9, 0.2, 0.1), 0.37), (Color::rgb(0.1, 0.6, 0.8), 0.53), (Color::rgb(0.3, 0.1, 0.7), 0.29)];
|
||||
let opts = RenderOptions { precise: true, ..Default::default() };
|
||||
let draw = |m: TypeAntiAlias| {
|
||||
let mut d = page(m, Some(FillLayer { opacity: 0.41, ..FillLayer::new(Paint::solid(Color::rgb(0.2, 0.7, 0.3))) }));
|
||||
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
let n = d.node_mut(id).unwrap();
|
||||
for (c, o) in fills {
|
||||
n.appearance.items.push(AppearanceItem::Fill(FillLayer { opacity: o, ..FillLayer::new(Paint::solid(c)) }));
|
||||
}
|
||||
Renderer::new().render(&d, W, H, Affine::IDENTITY, &opts).pixels
|
||||
};
|
||||
let sharp = draw(TypeAntiAlias::Sharp);
|
||||
let most = sharp.chunks(4).map(|p| p[3]).max().unwrap_or(0);
|
||||
let inside: Vec<usize> = sharp.chunks(4).enumerate().filter(|(_, p)| p[3] == most).map(|(i, _)| i).collect();
|
||||
assert!(inside.len() > 200, "{} pixels inside the characters", inside.len());
|
||||
for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
let drawn = draw(m);
|
||||
let px = |p: &[u8], i: usize| [p[i * 4], p[i * 4 + 1], p[i * 4 + 2], p[i * 4 + 3]];
|
||||
let off = |i: usize| px(&drawn, i).iter().zip(px(&sharp, i)).any(|(a, b)| a.abs_diff(b) > 1);
|
||||
let wrong: Vec<_> = inside.iter().filter(|i| off(**i)).take(5).map(|i| (*i, px(&sharp, *i), px(&drawn, *i))).collect();
|
||||
assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type whose own fill multiplies with the art below it is drawn as Sharp draws it, so the fill
|
||||
/// still multiplies with that art.
|
||||
#[test]
|
||||
fn type_with_a_blended_fill_is_drawn_as_sharp() {
|
||||
let multiply = FillLayer { blend: vectorcraft_color::BlendMode::Multiply, ..FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))) };
|
||||
let on_orange = |m: TypeAntiAlias| {
|
||||
let mut d = page(m, Some(multiply.clone()));
|
||||
let orange = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0);
|
||||
let back = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, f64::from(W), f64::from(H))), orange);
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, back).unwrap();
|
||||
Renderer::new().render(&d, W, H, Affine::IDENTITY, &RenderOptions::default()).pixels
|
||||
};
|
||||
let sharp = on_orange(TypeAntiAlias::Sharp);
|
||||
for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
assert_eq!(on_orange(m), sharp, "{m:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type with an inline graphic is drawn as Sharp draws it, so the graphic, which multiplies with
|
||||
/// the art below it here, is drawn as before.
|
||||
#[test]
|
||||
fn type_with_an_inline_graphic_is_drawn_as_sharp() {
|
||||
let on_orange = |m: TypeAntiAlias| {
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
let orange = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0);
|
||||
let back = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, f64::from(W), f64::from(H))), orange);
|
||||
let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
|
||||
let mut art = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, -30.0, 30.0, 0.0)), blue);
|
||||
art.blend = vectorcraft_color::BlendMode::Multiply;
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
|
||||
let style = CharStyle { size: 40.0, ..Default::default() };
|
||||
let mut t = TextObject::point(Point::new(10.3, 60.4), "I", style.clone());
|
||||
t.runs.push(TextRun::inline(InlineArt::new("S"), style));
|
||||
t.anti_alias = m;
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, back).unwrap();
|
||||
d.insert(Some(l), 1, n).unwrap();
|
||||
d.resolve_inline_art();
|
||||
Renderer::new().render(&d, W, H, Affine::IDENTITY, &RenderOptions::default()).pixels
|
||||
};
|
||||
let sharp = on_orange(TypeAntiAlias::Sharp);
|
||||
for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
assert_eq!(on_orange(m), sharp, "{m:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type with a color glyph (a bitmap in `sbix` or `CBDT`, or COLR v0 or v1 layers) is drawn as
|
||||
/// Sharp draws it, so the glyph's own opacity is kept.
|
||||
#[test]
|
||||
fn type_with_a_color_glyph_is_drawn_as_sharp() {
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font, colr_family, colr_font};
|
||||
let _adding = crate::tests_fontchange::adding_fonts();
|
||||
let fonts = [
|
||||
(colour_bitmap_family(true), colour_bitmap_font(true)),
|
||||
(colour_bitmap_family(false), colour_bitmap_font(false)),
|
||||
(colr_family(false), colr_font(false)),
|
||||
(colr_family(true), colr_font(true)),
|
||||
];
|
||||
for (family, font) in fonts {
|
||||
let Some(font) = font else { panic!("{family}: the test font is built") };
|
||||
vectorcraft_text::FontDb::global().add_font(font);
|
||||
let draw = |m: TypeAntiAlias| {
|
||||
let mut d = Document::new(160.0, 160.0);
|
||||
let style = CharStyle { font_family: family.into(), size: 100.0, ..Default::default() };
|
||||
let mut t = TextObject::point(Point::new(20.3, 120.4), &COLOUR_CHAR.to_string(), style);
|
||||
t.anti_alias = m;
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions::default()).pixels
|
||||
};
|
||||
let sharp = draw(TypeAntiAlias::Sharp);
|
||||
assert!(sharp.chunks(4).any(|p| p[3] > 0), "{family}: the glyph is drawn");
|
||||
for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
assert!(draw(m) == sharp, "{family}, {m:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In a supersampled export, None type in a group drawn offscreen (it isolates art that blends)
|
||||
/// still has hard edges on the exported pixels, wherever the group's layer starts.
|
||||
#[test]
|
||||
fn type_in_an_offscreen_group_keeps_the_exports_pixels() {
|
||||
let opts = RenderOptions { supersample: true, precise: true, ..Default::default() };
|
||||
for x in [10.3, 10.55, 10.8, 11.05] {
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
let mut t = TextObject::point(Point::new(x, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() });
|
||||
t.anti_alias = TypeAntiAlias::None;
|
||||
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let mut square = Node::path(d.alloc_id(), rectangle(Rect::new(220.0, 10.0, 250.0, 30.0)), black);
|
||||
square.blend = vectorcraft_color::BlendMode::Multiply;
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(square), Arc::new(text)]);
|
||||
group.isolate = true;
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
let a = alphas(&d, &opts);
|
||||
assert!(a.iter().filter(|v| **v == 255).count() > 1000, "x {x}");
|
||||
assert_eq!(partial(&a), 0, "x {x}: None type has hard edges");
|
||||
}
|
||||
}
|
||||
|
||||
/// In a supersampled export, None type in a layer drawn offscreen over a copy of the art below it
|
||||
/// (the layer multiplies, and art inside it blends) still has hard edges on the exported pixels,
|
||||
/// wherever the layer's pixels start.
|
||||
#[test]
|
||||
fn type_in_an_offscreen_layer_keeps_the_exports_pixels() {
|
||||
let opts = RenderOptions { supersample: true, precise: true, ..Default::default() };
|
||||
for x in [10.3, 10.55, 10.8, 11.05] {
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
let mut t = TextObject::point(Point::new(x, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() });
|
||||
t.anti_alias = TypeAntiAlias::None;
|
||||
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let mut square = Node::path(d.alloc_id(), rectangle(Rect::new(220.0, 10.0, 250.0, 30.0)), black);
|
||||
square.blend = vectorcraft_color::BlendMode::Multiply;
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, square).unwrap();
|
||||
d.insert(Some(l), 1, text).unwrap();
|
||||
Arc::make_mut(&mut d.layers[0]).blend = vectorcraft_color::BlendMode::Multiply;
|
||||
let a = alphas(&d, &opts);
|
||||
assert!(a.iter().filter(|v| **v == 255).count() > 1000, "x {x}");
|
||||
assert_eq!(partial(&a), 0, "x {x}: None type has hard edges");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type in a placed document, placed at half and at twice its size with its frame starting part
|
||||
/// of a pixel in, is drawn on the page's pixels: on the canvas each method gives every pixel the
|
||||
/// share of it `edge_coverage` defines, and in a supersampled export None type has hard edges.
|
||||
#[test]
|
||||
fn type_in_a_placed_document_takes_the_pages_pixels() {
|
||||
use vectorcraft_doc::{ImageBlob, LinkInfo, PlacedDocument};
|
||||
// A page showing a file that holds "Hamburg" at 40 / `k` pt drawn with `m`, placed at `k` times
|
||||
// its size, so the type is 40 pt on the page.
|
||||
let placing = |m: TypeAntiAlias, k: f64| {
|
||||
let frame = Rect::new(0.0, 0.0, f64::from(W) / k, f64::from(H) / k);
|
||||
let mut t = TextObject::point(Point::new(10.0 / k, 60.0 / k), "Hamburg", CharStyle { size: 40.0 / k, ..Default::default() });
|
||||
t.anti_alias = m;
|
||||
let text = Node::new(NodeId(1), NodeKind::Text(Box::new(t)));
|
||||
let art = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(text)], clip: false });
|
||||
let key = format!("typeedges-{m:?}-{k}");
|
||||
let blob = ImageBlob::new("application/json", key.clone().into_bytes());
|
||||
let p = PlacedDocument {
|
||||
link: LinkInfo::new("/art/type.vectorcraft"),
|
||||
key: key.clone(),
|
||||
bounding: false,
|
||||
page_box: Default::default(),
|
||||
width: f64::from(W),
|
||||
height: f64::from(H),
|
||||
xf: Affine::translate((0.3, 0.4)),
|
||||
placement: Default::default(),
|
||||
};
|
||||
// The file as read, kept for the object, so drawing it needs no loader.
|
||||
vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (Document::new(frame.width(), frame.height()), art, frame));
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
d.images.insert(key, blob);
|
||||
let n = Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(p)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
};
|
||||
let export = RenderOptions { supersample: true, precise: true, ..Default::default() };
|
||||
for k in [0.5, 2.0] {
|
||||
let sharp = alphas(&placing(TypeAntiAlias::Sharp, k), &RenderOptions::default());
|
||||
assert!(partial(&sharp) > 200, "k {k}: Sharp draws smooth edges: {} partial pixels", partial(&sharp));
|
||||
for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
let drawn = alphas(&placing(m, k), &RenderOptions::default());
|
||||
assert_ne!(drawn, sharp, "k {k}: {m:?} changes the edges");
|
||||
let wrong = off_the_share(m, &sharp, &drawn);
|
||||
assert!(wrong.is_empty(), "k {k}, {m:?}: (pixel, Sharp, drawn) {wrong:?}");
|
||||
}
|
||||
let none = alphas(&placing(TypeAntiAlias::None, k), &export);
|
||||
assert!(none.iter().filter(|v| **v == 255).count() > 1000, "k {k}");
|
||||
assert_eq!(partial(&none), 0, "k {k}: None type has hard edges in the export");
|
||||
}
|
||||
}
|
||||
|
||||
/// The opacity of each pixel of `d` drawn at `view` on a transparent canvas `w` × `h` pixels.
|
||||
fn alphas_at(d: &Document, (w, h): (u32, u32), view: Affine) -> Vec<u8> {
|
||||
Renderer::new().render(d, w, h, view, &RenderOptions::default()).pixels.chunks(4).map(|p| p[3]).collect()
|
||||
}
|
||||
|
||||
/// `page(m, None)` with the effect `id` (parameters `params`) on the type object, and the object's
|
||||
/// id.
|
||||
fn page_with_effect(m: TypeAntiAlias, id: &str, params: Value) -> (Document, NodeId) {
|
||||
let mut d = page(m, None);
|
||||
let n = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
d.node_mut(n).unwrap().appearance.effects.push(Effect { id: id.into(), params, visible: true });
|
||||
(d, n)
|
||||
}
|
||||
|
||||
/// "Ham" in 30 pt type drawn with `m`, its baseline at (5.3, 30.4).
|
||||
fn ham(d: &mut Document, m: TypeAntiAlias) -> Node {
|
||||
let mut t = TextObject::point(Point::new(5.3, 30.4), "Ham", CharStyle { size: 30.0, ..Default::default() });
|
||||
t.anti_alias = m;
|
||||
Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))
|
||||
}
|
||||
|
||||
/// `draw` gives the pixels of type drawn with a method: Sharp type has smooth edges there, and
|
||||
/// each other method changes them and gives every pixel the share of it `edge_coverage` defines.
|
||||
fn each_method_takes_the_share(what: &str, draw: impl Fn(TypeAntiAlias) -> Vec<u8>) {
|
||||
let sharp = draw(TypeAntiAlias::Sharp);
|
||||
assert!(partial(&sharp) > 200, "{what}: Sharp draws smooth edges: {} partial pixels", partial(&sharp));
|
||||
for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
let drawn = draw(m);
|
||||
assert_ne!(drawn, sharp, "{what}: {m:?} changes the edges");
|
||||
let wrong = off_the_share(m, &sharp, &drawn);
|
||||
assert!(wrong.is_empty(), "{what}, {m:?}: (pixel, Sharp, drawn) {wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drop shadow whose offset is not a whole number of pixels draws the type on the canvas's
|
||||
/// pixels: each method gives every pixel, the shadow's below the type included, the share of it
|
||||
/// `edge_coverage` defines.
|
||||
#[test]
|
||||
fn type_in_a_drop_shadow_takes_the_canvas_pixels() {
|
||||
let shadow = json!({"mode": "normal", "opacity": 100, "x": 3.4, "y": 45.3, "blur": 0, "color": "#000000"});
|
||||
let draw = |m| alphas_at(&page_with_effect(m, "stylize.dropShadow", shadow.clone()).0, (W, 130), Affine::IDENTITY);
|
||||
// Below the type's descender, the shadow alone.
|
||||
let below = |a: &[u8]| a[(W * 75) as usize..].iter().filter(|v| **v == 255).count();
|
||||
assert!(below(&draw(TypeAntiAlias::Sharp)) > 500, "the shadow is drawn below the type");
|
||||
each_method_takes_the_share("drop shadow", draw);
|
||||
}
|
||||
|
||||
/// Effect › Rasterize draws the type on the pixels of its image: at 36 ppi each pixel of the image
|
||||
/// is 2 × 2 pixels of a canvas whose grid it lies on, and each method gives every pixel the share
|
||||
/// of it `edge_coverage` defines.
|
||||
#[test]
|
||||
fn type_in_a_rasterize_takes_the_images_pixels() {
|
||||
let draw = |m| {
|
||||
let (d, id) = page_with_effect(m, "rasterize", json!({"useDocumentResolution": false, "resolution": 36}));
|
||||
let image = drawn_bounds(&d, d.node(id).unwrap()).unwrap();
|
||||
// The image's corner on a corner of a canvas pixel.
|
||||
let view = Affine::translate((image.x0.ceil() - image.x0, image.y0.ceil() - image.y0));
|
||||
alphas_at(&d, (W, H), view)
|
||||
};
|
||||
each_method_takes_the_share("rasterize", draw);
|
||||
}
|
||||
|
||||
/// A pattern tile holding type draws it on the pixels of the tile's image, also in the copies of
|
||||
/// the tile 80.25 pixels away: in the swatch each method gives every pixel the share of it
|
||||
/// `edge_coverage` defines.
|
||||
#[test]
|
||||
fn type_in_a_pattern_tile_takes_the_tiles_pixels() {
|
||||
let draw = |m| {
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
let text = ham(&mut d, m);
|
||||
let mut def = PatternDef::new("Type", vec![Arc::new(text)]);
|
||||
// The swatch draws 2 pixels a point and shows the copies above and below the tile, 80.25
|
||||
// pixels away.
|
||||
def.tile = Rect::new(0.0, 0.0, 120.5, 40.125);
|
||||
d.patterns.push(def);
|
||||
let swatch = render_pattern_swatch(&d, "Type", 241).unwrap();
|
||||
swatch.pixels.chunks(4).map(|p| p[3]).collect::<Vec<u8>>()
|
||||
};
|
||||
// The rows of the copy above the tile.
|
||||
let above = |a: &[u8]| a[..241 * 60].iter().filter(|v| **v == 255).count();
|
||||
assert!(above(&draw(TypeAntiAlias::Sharp)) > 200, "the swatch shows the copy above the tile");
|
||||
each_method_takes_the_share("pattern tile", draw);
|
||||
}
|
||||
|
||||
/// Pattern editing mode draws the copies of the tile, 120.25 pixels apart across and 40.125 pixels
|
||||
/// apart down, on the canvas's pixels: each method gives every pixel the share of it
|
||||
/// `edge_coverage` defines.
|
||||
#[test]
|
||||
fn type_in_the_copies_of_an_edited_tile_takes_the_canvas_pixels() {
|
||||
let draw = |m| {
|
||||
let mut d = Document::new(400.0, 200.0);
|
||||
let text = ham(&mut d, m);
|
||||
let mut def = PatternDef::new("Type", vec![Arc::new(text.clone())]);
|
||||
def.tile = Rect::new(0.0, 0.0, 120.25, 40.125);
|
||||
// The eight copies around the tile, at full opacity.
|
||||
(def.copies, def.dim_copies, def.show_tile_edge) = (3, 100.0, false);
|
||||
d.patterns.push(def);
|
||||
let layer = d.add_layer(Some("Pattern Editing Mode"));
|
||||
d.insert(Some(layer), 0, text).unwrap();
|
||||
d.pattern_edit = Some(PatternEdit { pattern: "Type".into(), layer, original: None });
|
||||
alphas_at(&d, (380, 130), Affine::translate((125.0, 45.0)))
|
||||
};
|
||||
// The rows of the copies above the tile.
|
||||
let above = |a: &[u8]| a[..380 * 40].iter().filter(|v| **v == 255).count();
|
||||
assert!(above(&draw(TypeAntiAlias::Sharp)) > 200, "the copies above the tile are drawn");
|
||||
each_method_takes_the_share("pattern editing", draw);
|
||||
}
|
||||
|
||||
/// Type with a brush stroke of its own is drawn as Sharp draws it.
|
||||
#[test]
|
||||
fn type_with_a_brush_stroke_is_drawn_as_sharp() {
|
||||
let draw = |m: TypeAntiAlias| {
|
||||
let mut d = page(m, None);
|
||||
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
let n = d.node_mut(id).unwrap();
|
||||
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0);
|
||||
st.brush = Some("Charcoal".into());
|
||||
n.appearance.items.push(AppearanceItem::Stroke(st));
|
||||
alphas(&d, &RenderOptions::default())
|
||||
};
|
||||
let sharp = draw(TypeAntiAlias::Sharp);
|
||||
for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
assert_eq!(draw(m), sharp, "{m:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Strong and Crisp type in the art of a scatter brush, on an undashed path at 50% opacity, draw
|
||||
/// every pixel at half of what the same path draws at full opacity: the coverage the method
|
||||
/// reshapes the edges by is drawn without the path's opacity.
|
||||
#[test]
|
||||
fn type_in_a_translucent_scatter_brush_stroke_keeps_its_opacity() {
|
||||
use vectorcraft_brush::{Brush, BrushKind, Scatter};
|
||||
let draw = |m: TypeAntiAlias, opacity: f32| {
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
let mut t = TextObject::point(Point::new(0.0, 0.0), "H", CharStyle { size: 40.0, ..Default::default() });
|
||||
t.anti_alias = m;
|
||||
let art = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Text(Box::new(t)));
|
||||
let mut lib = vectorcraft_brush::library(&d);
|
||||
lib.push(Brush { name: "Letters".into(), kind: BrushKind::Scatter(Scatter { art, spacing: (200.0, 200.0), ..Default::default() }) });
|
||||
vectorcraft_brush::store(&mut d, &lib);
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
if let Some(st) = ap.stroke_mut() {
|
||||
st.brush = Some("Letters".into());
|
||||
}
|
||||
let line = vectorcraft_geom::shapes::line(Point::new(20.0, 45.0), Point::new(240.0, 45.0));
|
||||
let mut n = Node::path(d.alloc_id(), line, ap);
|
||||
n.opacity = opacity;
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
alphas(&d, &RenderOptions::default())
|
||||
};
|
||||
let most = |a: &[u8]| a.iter().copied().max().unwrap_or(0);
|
||||
let sharp = draw(TypeAntiAlias::Sharp, 0.5);
|
||||
assert!((126..=129).contains(&most(&sharp)), "half opaque: {}", most(&sharp));
|
||||
for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
|
||||
let (half, full) = (draw(m, 0.5), draw(m, 1.0));
|
||||
assert_ne!(half, sharp, "{m:?} changes the edges");
|
||||
let off = half.iter().zip(&full).filter(|&(&h, &f)| (2 * i32::from(h) - i32::from(f)).abs() > 6).count();
|
||||
assert_eq!(off, 0, "{m:?}: pixels not at half of full opacity");
|
||||
}
|
||||
}
|
||||
|
||||
/// On screen, type in a font the system doesn't have draws as Sharp draws it while Document
|
||||
/// Setup highlights substituted fonts, and with its method when nothing is highlighted.
|
||||
#[test]
|
||||
fn type_with_a_substitution_highlight_is_drawn_as_sharp() {
|
||||
let draw = |m: TypeAntiAlias, highlight: bool| {
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
d.setup.highlight_substituted_fonts = true;
|
||||
let style = CharStyle { size: 40.0, font_family: "No Such Family 7Q".into(), ..Default::default() };
|
||||
let mut t = TextObject::point(Point::new(10.3, 60.4), "Hamburg", style);
|
||||
t.anti_alias = m;
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
alphas(&d, &RenderOptions { highlight_substitutions: highlight, ..Default::default() })
|
||||
};
|
||||
assert_eq!(draw(TypeAntiAlias::Strong, true), draw(TypeAntiAlias::Sharp, true));
|
||||
assert_ne!(draw(TypeAntiAlias::Strong, false), draw(TypeAntiAlias::Sharp, false));
|
||||
}
|
||||
|
||||
/// On screen, type with a ligature draws as Sharp draws it while Preferences › Type › Highlight
|
||||
/// Alternate Glyphs is on, and with its method while it is off.
|
||||
#[test]
|
||||
fn type_with_an_alternate_glyph_highlight_is_drawn_as_sharp() {
|
||||
// Source Serif 4 (bundled with vectorcraft-text's `bundled-fonts`) ligates "fi".
|
||||
if vectorcraft_text::FontDb::global().face("Source Serif 4", "Regular").is_none() {
|
||||
eprintln!("SKIPPED: type_with_an_alternate_glyph_highlight_is_drawn_as_sharp; no Source Serif 4");
|
||||
return;
|
||||
}
|
||||
let draw = |m: TypeAntiAlias, alternates: bool| {
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
d.setup.highlight_substituted_fonts = false;
|
||||
d.setup.highlight_substituted_glyphs = false;
|
||||
let style = CharStyle { size: 40.0, font_family: "Source Serif 4".into(), ..Default::default() };
|
||||
let mut t = TextObject::point(Point::new(10.3, 60.4), "fish", style);
|
||||
t.anti_alias = m;
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
alphas(&d, &RenderOptions { highlight_substitutions: true, highlight_alternate_glyphs: alternates, ..Default::default() })
|
||||
};
|
||||
assert_eq!(draw(TypeAntiAlias::Strong, true), draw(TypeAntiAlias::Sharp, true));
|
||||
assert_ne!(draw(TypeAntiAlias::Strong, false), draw(TypeAntiAlias::Sharp, false));
|
||||
}
|
||||
|
||||
/// The type is drawn on the output's pixels unless, in a frame coarser than the output, they
|
||||
/// would number more than the frame's there and more than the effects' pixel budget, or be more
|
||||
/// than `MAX_RASTER_SIDE` a side.
|
||||
#[test]
|
||||
fn type_edges_are_drawn_on_at_most_the_budgets_pixels() {
|
||||
let finer = Affine::scale(0.25);
|
||||
assert_eq!(edge_grid(Rect::new(0.0, 0.0, 4000.0, 4000.0), finer), finer, "a supersampled export's pixels");
|
||||
let coarser = Affine::scale(4.0);
|
||||
assert_eq!(edge_grid(Rect::new(0.0, 0.0, 100.0, 100.0), coarser), coarser, "160,000 pixels");
|
||||
assert_eq!(edge_grid(Rect::new(0.0, 0.0, 1000.0, 1000.0), coarser), Affine::IDENTITY, "16 million pixels");
|
||||
assert_eq!(edge_grid(Rect::new(0.0, 0.0, 10_000.0, 1.0), coarser), Affine::IDENTITY, "40,000 pixels a side");
|
||||
}
|
||||
|
||||
/// Type whose offscreen drawing would hold more pixels than the effects' pixel budget (in a large
|
||||
/// raster export) is drawn as Sharp draws it, without the offscreen drawing; just within the
|
||||
/// budget it is drawn offscreen with its method.
|
||||
#[test]
|
||||
fn type_past_the_pixel_budget_is_drawn_as_sharp() {
|
||||
// "Hamburg" at 10,000%, its letters across every pixel of the render.
|
||||
let draw = |m: TypeAntiAlias, side: u32| {
|
||||
let half = f64::from(side) / 2.0;
|
||||
let view = Affine::translate((half - 9_000.0, half - 4_500.0)) * Affine::scale(100.0);
|
||||
let mut r = Renderer::new();
|
||||
let pixels = r.render(&page(m, None), side, side, view, &RenderOptions::default()).pixels;
|
||||
(r.stats.type_edges, pixels)
|
||||
};
|
||||
// 3,000 × 3,000 pixels, past the 8.4 million of the budget.
|
||||
let (drawn, strong) = draw(TypeAntiAlias::Strong, 3_000);
|
||||
assert_eq!(drawn, 0, "the type is not drawn offscreen");
|
||||
assert!(strong == draw(TypeAntiAlias::Sharp, 3_000).1, "the type is drawn as Sharp draws it");
|
||||
// 2,800 × 2,800 pixels, within it.
|
||||
assert_eq!(draw(TypeAntiAlias::Strong, 2_800).0, 1, "the type is drawn offscreen");
|
||||
}
|
||||
|
||||
/// Type across a canvas of the largest side the renderer draws (`MAX_CONTEXT_SIDE` pixels) is
|
||||
/// drawn with its method on every pixel.
|
||||
#[test]
|
||||
fn type_across_the_widest_canvas_is_drawn_with_its_method() {
|
||||
// "Hamburg" at 25,000%, about 37,000 pixels wide, past both sides of the canvas, on four rows
|
||||
// through its letters.
|
||||
let view = Affine::translate((-3_000.0, -12_500.0)) * Affine::scale(250.0);
|
||||
let draw = |m| {
|
||||
let pixels = Renderer::new().render(&page(m, None), MAX_CONTEXT_SIDE, 4, view, &RenderOptions::default()).pixels;
|
||||
pixels.chunks(4).map(|p| p[3]).collect::<Vec<u8>>()
|
||||
};
|
||||
let sharp = draw(TypeAntiAlias::Sharp);
|
||||
assert!(partial(&sharp) > 0, "the rows cross the edges of the letters");
|
||||
let none = draw(TypeAntiAlias::None);
|
||||
assert_eq!(none.len(), sharp.len());
|
||||
assert_eq!(partial(&none), 0, "None paints whole pixels");
|
||||
let wrong = off_the_share(TypeAntiAlias::None, &sharp, &none);
|
||||
assert!(wrong.is_empty(), "(pixel, Sharp, drawn) {wrong:?}");
|
||||
}
|
||||
|
||||
/// The offscreen drawing takes in the farthest a stroke reaches: a mitered character stroke's
|
||||
/// spikes up to its miter limit, transformed with the type (stretched along one axis too), and
|
||||
/// the object's own strokes.
|
||||
#[test]
|
||||
fn the_offscreen_drawing_takes_in_the_strokes_reach() {
|
||||
let style = CharStyle {
|
||||
stroke: Paint::solid(Color::BLACK),
|
||||
stroke_width: 4.0,
|
||||
stroke_join: vectorcraft_doc::LineJoin::Miter,
|
||||
stroke_miter_limit: 10.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut t = TextObject::point(Point::new(0.0, 0.0), "V", style);
|
||||
let n = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())));
|
||||
// How far the reach goes past the transformed glyph bounds on the left and on the top.
|
||||
let past = |n: &Node, t: &TextObject| {
|
||||
let g = text_geom(t);
|
||||
let (r, b) = (type_reach(n, t, &g), t.xf.transform_rect_bbox(g.bounds));
|
||||
(b.x0 - r.x0, b.y0 - r.y0)
|
||||
};
|
||||
let near = |(a, b): (f64, f64), (x, y): (f64, f64)| (a - x).abs() < 1e-6 && (b - y).abs() < 1e-6;
|
||||
assert!(near(past(&n(&t), &t), (20.0, 20.0)), "half the weight times the miter limit: {:?}", past(&n(&t), &t));
|
||||
t.xf = Affine::scale_non_uniform(10.0, 0.1);
|
||||
assert!(near(past(&n(&t), &t), (200.0, 2.0)), "stretched with the type: {:?}", past(&n(&t), &t));
|
||||
t.xf = Affine::scale(2.0);
|
||||
let mut own = n(&t);
|
||||
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 10.0);
|
||||
st.miter_limit = 10.0;
|
||||
own.appearance.items.push(AppearanceItem::Stroke(st));
|
||||
assert!(near(past(&own, &t), (90.0, 90.0)), "the object's own stroke after the characters': {:?}", past(&own, &t));
|
||||
}
|
||||
|
||||
/// Crisp type with a character stroke expands the stroke once for both of its offscreen drawings,
|
||||
/// and keeps the outline as Sharp type does. Its pixels are those of a renderer that keeps no
|
||||
/// glyph geometry, which expands the stroke in every drawing.
|
||||
#[test]
|
||||
fn crisp_type_keeps_its_character_stroke_outline() {
|
||||
let _fonts = crate::tests_fontchange::fonts_steady();
|
||||
let mut d = page(TypeAntiAlias::Crisp, None);
|
||||
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) {
|
||||
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
|
||||
t.runs[0].style.stroke_width = 1.0;
|
||||
}
|
||||
let opts = RenderOptions::default();
|
||||
let mut r = Renderer::new();
|
||||
let pixels = r.render(&d, W, H, Affine::IDENTITY, &opts).pixels;
|
||||
assert_eq!(r.stats.type_edges, 1, "the type is drawn offscreen");
|
||||
assert_eq!(r.char_strokes.len(), 1, "one outline for the one run's stroke, used by both drawings");
|
||||
let mut uncached = Renderer::new();
|
||||
uncached.texts = FrameCache::new(0, 0);
|
||||
assert!(uncached.render(&d, W, H, Affine::IDENTITY, &opts).pixels == pixels, "the kept outline draws the same pixels");
|
||||
assert_eq!(uncached.char_strokes.len(), 0);
|
||||
}
|
||||
|
||||
/// Area type draws its punctuation outside its frame with Optical Margin Alignment, and None type
|
||||
/// draws it there too.
|
||||
#[test]
|
||||
fn type_outside_its_frame_is_drawn() {
|
||||
let page = |m: TypeAntiAlias| {
|
||||
let mut d = Document::new(f64::from(W), f64::from(H));
|
||||
let mut t = TextObject::point(Point::new(0.0, 0.0), "“Quoted.”", CharStyle { size: 30.0, ..Default::default() });
|
||||
t.kind = TextKind::Area { frame: rectangle(Rect::new(40.0, 10.0, 240.0, 80.0)) };
|
||||
t.optical_margin = true;
|
||||
t.anti_alias = m;
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
alphas(&d, &RenderOptions::default())
|
||||
};
|
||||
let outside = |a: &[u8]| a.iter().enumerate().filter(|(i, v)| (*i as u32 % W) < 39 && **v > 0).count();
|
||||
assert!(outside(&page(TypeAntiAlias::Sharp)) > 20, "the opening quotation mark hangs outside the frame");
|
||||
assert!(outside(&page(TypeAntiAlias::None)) > 20, "None type draws it there too");
|
||||
}
|
||||
|
||||
/// A frame draws Crisp type from the pixels an earlier frame kept when the view only moved by
|
||||
/// whole pixels, and they are the pixels a new renderer draws. Another zoom, another method, a pan
|
||||
/// that brings more of the type into view, a pan by part of a pixel and other edge smoothing draw
|
||||
/// the type again.
|
||||
#[test]
|
||||
fn a_pan_draws_the_type_from_the_kept_pixels() {
|
||||
let _fonts = crate::tests_fontchange::fonts_steady();
|
||||
let opts = RenderOptions::default();
|
||||
let fonts = || vectorcraft_text::FontDb::global().generation();
|
||||
// How many type objects a renderer drew offscreen for `b` in `second` after drawing `a` in
|
||||
// `first`. The second frame's pixels are a new renderer's, each channel within `near`. The
|
||||
// frames are drawn again while the fonts change between them (other tests load fonts, and the
|
||||
// type is then drawn again).
|
||||
let drawn = |(a, first): (&Document, Affine), (b, second): (&Document, Affine), near: u8| {
|
||||
for _ in 0..5 {
|
||||
let before = fonts();
|
||||
let mut r = Renderer::new();
|
||||
r.render(a, W, H, first, &opts);
|
||||
assert_eq!(r.stats.type_edges, 1, "{first:?}");
|
||||
let pixels = r.render(b, W, H, second, &opts).pixels;
|
||||
if fonts() != before {
|
||||
continue;
|
||||
}
|
||||
let want = Renderer::new().render(b, W, H, second, &opts).pixels;
|
||||
let differ: Vec<_> = pixels
|
||||
.chunks(4)
|
||||
.zip(want.chunks(4))
|
||||
.enumerate()
|
||||
.filter(|(_, (p, q))| p.iter().zip(*q).any(|(p, q)| p.abs_diff(*q) > near))
|
||||
.map(|(i, (p, q))| (i as u32 % W, i as u32 / W, p[3], q[3]))
|
||||
.collect();
|
||||
assert!(
|
||||
differ.is_empty(),
|
||||
"{second:?}: {} pixels differ from a new renderer's, first (x, y, alpha, new alpha) {:?}",
|
||||
differ.len(),
|
||||
&differ[..differ.len().min(8)]
|
||||
);
|
||||
return r.stats.type_edges;
|
||||
}
|
||||
panic!("the fonts changed in every try");
|
||||
};
|
||||
let (crisp, strong) = (page(TypeAntiAlias::Crisp, None), page(TypeAntiAlias::Strong, None));
|
||||
let at = |d, (x, y): (f64, f64)| (d, Affine::translate((x, y)));
|
||||
for pan in [(7.0, -3.0), (2.0, 5.0), (-4.0, -1.0)] {
|
||||
assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&crisp, pan), 0), 0, "a pan by {pan:?}");
|
||||
}
|
||||
assert_eq!(drawn(at(&crisp, (0.0, 0.0)), (&crisp, Affine::scale(1.5)), 0), 1, "another zoom");
|
||||
assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&strong, (0.0, 0.0)), 0), 1, "another method");
|
||||
assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&crisp, (0.5, 0.0)), 0), 1, "a pan by half a pixel");
|
||||
// At 400% the type fills the view, so the pixels kept hold every pixel of the next frame.
|
||||
let zoomed = Affine::translate((-200.0, -150.0)) * Affine::scale(4.0);
|
||||
let nudged = Affine::translate((0.3, 0.0)) * zoomed;
|
||||
assert_eq!(drawn((&crisp, zoomed), (&crisp, nudged), 0), 1, "a pan by part of a pixel at 400%");
|
||||
assert_eq!(drawn(at(&strong, (-120.0, 0.0)), at(&strong, (0.0, 0.0)), 0), 1, "a pan that brings the rest into view");
|
||||
// Type the view cuts is drawn offscreen from the view's edge, and its coverage there can round
|
||||
// a step the other way: Strong doubles the step, and the color takes a step of rounding.
|
||||
assert_eq!(drawn(at(&strong, (0.0, 0.0)), at(&strong, (-120.0, 0.0)), 3), 0, "a pan that takes half of it out of view");
|
||||
// With hard edges (Anti-aliased Artwork off) and with type snapped to pixels (an export's Type
|
||||
// Optimized), the frame draws the type again.
|
||||
for smoothing in [AntiAlias::None, AntiAlias::Type] {
|
||||
let other = RenderOptions { anti_alias: smoothing, ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
r.render(&crisp, W, H, Affine::IDENTITY, &opts);
|
||||
let pixels = r.render(&crisp, W, H, Affine::IDENTITY, &other).pixels;
|
||||
assert_eq!(r.stats.type_edges, 1, "{smoothing:?}");
|
||||
assert!(pixels == Renderer::new().render(&crisp, W, H, Affine::IDENTITY, &other).pixels, "{smoothing:?}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user