mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 22:21:59 +00:00
The regression test for rebuilding fitted area type after a scale change used text that overflowed at the minimum size both before and after the change, so both layouts used the same fit scale and the pixel comparison did not depend on the fit. The fixture is now 2 pt type in a small frame, drawn 20 times zoomed in. It fits at 0.7 of its size, and at 0.733 after a 1.5 times scale. The test asserts that the two fit scales differ and that the words set at the earlier fit draw different pixels, and keeps the comparison with a fresh renderer and the check that the geometry is not reused.
255 lines
13 KiB
Rust
255 lines
13 KiB
Rust
//! Strokes on type: character strokes take their join, cap and dashes, and object strokes on type
|
||
//! take every stroke option, as path strokes do.
|
||
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::text::{CharStyle, TextKind, TextObject};
|
||
use vectorcraft_doc::{AppearanceItem, AreaFit, Dash, LineJoin, Node, NodeKind};
|
||
use vectorcraft_geom::{PathData, Point};
|
||
|
||
use crate::tests_fontchange::fonts_steady;
|
||
use crate::*;
|
||
|
||
const SIZE: u32 = 200;
|
||
|
||
/// 120 pt `text` with its baseline at (40, 150), on a white 200×200 page.
|
||
fn render_text(text: &str, style: CharStyle, items: Vec<AppearanceItem>) -> Rendered {
|
||
let mut d = Document::new(SIZE as f64, SIZE as f64);
|
||
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 150.0), text, CharStyle { size: 120.0, ..style }))));
|
||
n.appearance.items = items;
|
||
n.id = d.alloc_id();
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||
}
|
||
|
||
fn dark(img: &Rendered, x: u32, y: u32) -> bool {
|
||
let p = img.pixel(x, y);
|
||
(p[0] as u32 + p[1] as u32 + p[2] as u32) < 384
|
||
}
|
||
|
||
/// The box of the dark pixels: (x0, y0, x1, y1).
|
||
fn ink(img: &Rendered) -> (u32, u32, u32, u32) {
|
||
let mut b = (u32::MAX, u32::MAX, 0, 0);
|
||
for y in 0..SIZE {
|
||
for x in 0..SIZE {
|
||
if dark(img, x, y) {
|
||
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
|
||
}
|
||
}
|
||
}
|
||
b
|
||
}
|
||
|
||
fn stroked(join: LineJoin) -> CharStyle {
|
||
CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 16.0, stroke_join: join, ..Default::default() }
|
||
}
|
||
|
||
#[test]
|
||
fn round_joined_character_strokes_have_no_miter_spikes() {
|
||
// The glyph alone, then stroked 16 pt: a round join reaches at most half the weight past it,
|
||
// the default miter join spikes out further at the sharp corners of the A.
|
||
let glyph = ink(&render_text("A", CharStyle::default(), vec![]));
|
||
let round = ink(&render_text("A", stroked(LineJoin::Round), vec![]));
|
||
let miter = ink(&render_text("A", stroked(LineJoin::Miter), vec![]));
|
||
let reach = |b: (u32, u32, u32, u32)| (glyph.0 - b.0).max(glyph.1 - b.1).max(b.2 - glyph.2).max(b.3 - glyph.3);
|
||
assert!(reach(round) <= 9, "round joins stay within half the weight: {glyph:?} → {round:?}");
|
||
assert!(reach(miter) > reach(round) + 2, "miter joins spike: {miter:?} vs {round:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn character_and_object_strokes_on_type_are_dashed() {
|
||
// An "I" without a fill: its outline drawn with 8/8 dashes breaks up along the left stem edge.
|
||
let none = CharStyle { fill: Paint::None, ..Default::default() };
|
||
let dash = Some(Dash { pattern: vec![8.0, 8.0], ..Default::default() });
|
||
let transitions = |img: &Rendered| {
|
||
let (x0, y0, _, y1) = ink(img);
|
||
let col = x0 + 1;
|
||
(y0..y1).filter(|y| dark(img, col, *y) != dark(img, col, y + 1)).count()
|
||
};
|
||
let solid = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 3.0, ..none.clone() };
|
||
assert!(transitions(&render_text("I", solid.clone(), vec![])) <= 2, "a solid stroke is continuous");
|
||
let chars = CharStyle { stroke_dash: dash.clone(), ..solid };
|
||
assert!(transitions(&render_text("I", chars, vec![])) >= 6, "character dashes");
|
||
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
|
||
st.dash = dash;
|
||
let object = render_text("I", none, vec![AppearanceItem::Stroke(st.clone())]);
|
||
assert!(transitions(&object) >= 6, "object dashes");
|
||
// And its opacity.
|
||
st.opacity = 0.5;
|
||
st.dash = None;
|
||
let half = render_text("I", CharStyle { fill: Paint::None, ..Default::default() }, vec![AppearanceItem::Stroke(st)]);
|
||
let (x0, y0, _, y1) = ink(&render_text("I", CharStyle::default(), vec![]));
|
||
let p = half.pixel(x0, (y0 + y1) / 2);
|
||
assert!(p[0] > 90 && p[0] < 200, "a half-opaque stroke is grey: {p:?}");
|
||
}
|
||
|
||
/// Align Stroke on live type: an outside character stroke paints its whole weight around the
|
||
/// glyphs and leaves them unpainted, an inside one stays within them, as on paths.
|
||
#[test]
|
||
fn aligned_character_strokes_keep_to_their_side_of_the_glyphs() {
|
||
use vectorcraft_doc::StrokeAlign;
|
||
let glyph = ink(&render_text("I", CharStyle::default(), vec![]));
|
||
let reach = |b: (u32, u32, u32, u32)| {
|
||
let d = |a: u32, b: u32| a as i64 - b as i64;
|
||
d(glyph.0, b.0).max(d(glyph.1, b.1)).max(d(b.2, glyph.2)).max(d(b.3, glyph.3))
|
||
};
|
||
let style = |align| CharStyle { fill: Paint::None, stroke_align: align, ..stroked(LineJoin::Round) };
|
||
let centre = render_text("I", style(StrokeAlign::Center), vec![]);
|
||
let outside = render_text("I", style(StrokeAlign::Outside), vec![]);
|
||
let inside = render_text("I", style(StrokeAlign::Inside), vec![]);
|
||
assert!((7..=9).contains(&reach(ink(¢re))), "centred: half the weight past the glyph, {:?}", ink(¢re));
|
||
assert!((15..=17).contains(&reach(ink(&outside))), "outside: the whole weight past the glyph, {:?}", ink(&outside));
|
||
assert!(reach(ink(&inside)) <= 0, "inside: within the glyph, {:?}", ink(&inside));
|
||
let mid = ((glyph.0 + glyph.2) / 2, (glyph.1 + glyph.3) / 2);
|
||
assert!(!dark(&outside, mid.0, mid.1), "an outside stroke leaves the glyph itself unpainted");
|
||
assert!(dark(&inside, mid.0, mid.1), "an inside stroke paints into the glyph");
|
||
}
|
||
|
||
/// A document with 60 pt `text` without a fill and with a 1 pt character stroke, and the type's id.
|
||
fn stroked_text_doc(text: &str) -> (Document, NodeId) {
|
||
let mut d = Document::new(SIZE as f64, SIZE as f64);
|
||
let style = CharStyle { size: 60.0, fill: Paint::None, stroke: Paint::solid(Color::BLACK), stroke_width: 1.0, ..Default::default() };
|
||
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), text, style))));
|
||
n.id = d.alloc_id();
|
||
let id = n.id;
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
(d, id)
|
||
}
|
||
|
||
/// The character stroke outlines that `r` keeps and that its last frame made or used.
|
||
fn character_outlines(r: &Renderer) -> Vec<Arc<BezPath>> {
|
||
r.char_strokes.used_in(r.stamp).map(|(_, outline)| outline.clone()).collect()
|
||
}
|
||
|
||
/// The outlines of type that changes in every frame (an animation) go within a few frames: the
|
||
/// renderer keeps no more than the last few frames' strokes, however long it runs.
|
||
#[test]
|
||
fn the_character_strokes_of_type_that_changes_in_every_frame_go() {
|
||
let _fonts = fonts_steady();
|
||
let (mut d, id) = stroked_text_doc("Type");
|
||
let opts = RenderOptions::default();
|
||
let mut r = Renderer::new();
|
||
for i in 0..40 {
|
||
if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) {
|
||
t.xf = Affine::translate((f64::from(i), 0.0));
|
||
}
|
||
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
|
||
}
|
||
assert!(r.char_strokes.len() <= 4, "{} outlines kept", r.char_strokes.len());
|
||
}
|
||
|
||
/// A character stroke is expanded once: the next frame of the same view draws the outline the
|
||
/// first made. A change of the zoom level or of the text expands it again.
|
||
#[test]
|
||
fn character_stroke_outlines_are_kept_between_frames() {
|
||
let _fonts = fonts_steady();
|
||
let (mut d, id) = stroked_text_doc("Type");
|
||
let opts = RenderOptions::default();
|
||
let mut r = Renderer::new();
|
||
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
|
||
let first = character_outlines(&r);
|
||
assert_eq!(first.len(), 1, "the outline of the one run's stroke");
|
||
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
|
||
let again = character_outlines(&r);
|
||
assert!(again.len() == 1 && Arc::ptr_eq(&again[0], &first[0]), "the second frame draws the outline the first made");
|
||
r.render(&d, SIZE, SIZE, Affine::translate((-40.0, -300.0)) * Affine::scale(4.0), &opts);
|
||
let zoomed = character_outlines(&r);
|
||
assert!(zoomed.len() == 1 && !Arc::ptr_eq(&zoomed[0], &first[0]), "zoomed in 4×, it is expanded again");
|
||
if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) {
|
||
t.runs[0].text = "Typo".into();
|
||
}
|
||
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
|
||
let changed = character_outlines(&r);
|
||
assert!(changed.len() == 1 && !Arc::ptr_eq(&changed[0], &first[0]), "the changed text is expanded again");
|
||
}
|
||
|
||
#[test]
|
||
fn moving_type_reuses_character_strokes_without_changing_pixels() {
|
||
let _fonts = fonts_steady();
|
||
let (mut d, id) = stroked_text_doc("Type");
|
||
let opts = RenderOptions::default();
|
||
let mut r = Renderer::new();
|
||
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
|
||
let first = character_outlines(&r);
|
||
assert_eq!(first.len(), 1);
|
||
if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) {
|
||
t.xf = Affine::translate((10.0, 0.0)) * t.xf;
|
||
}
|
||
let moved = r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
|
||
let again = character_outlines(&r);
|
||
assert!(again.len() == 1 && Arc::ptr_eq(&again[0], &first[0]), "a move reuses the character stroke outline");
|
||
assert_eq!(moved.pixels, Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &opts).pixels);
|
||
// The entry moved to the node shown, so the next frames find it without comparing the text.
|
||
let node = d.node_arc(id).unwrap();
|
||
assert!(r.texts.contains_key(&(Arc::as_ptr(node) as usize)), "kept under the moved node");
|
||
assert_eq!(r.texts.len(), 1, "and not also under the old one");
|
||
}
|
||
|
||
#[test]
|
||
fn scaling_fitted_area_type_rebuilds_its_glyph_geometry() {
|
||
let _fonts = fonts_steady();
|
||
// 2 pt type, drawn 20× zoomed in. Shrink Text to Fit steps the size in tenths of a document
|
||
// point, so at 2 pt and at 3 pt it fits the same words at different fractions of full size.
|
||
let (mut d, id) = stroked_text_doc("Type");
|
||
if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) {
|
||
t.xf = Affine::IDENTITY;
|
||
t.runs[0].style.size = 2.0;
|
||
t.runs[0].style.stroke_width = 0.05;
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 3.0, 3.0).to_path(0.1)) };
|
||
t.area.fit = AreaFit::ShrinkText { min_percent: 30.0 };
|
||
}
|
||
let text = |d: &Document| match d.node(id).map(|n| &n.kind) {
|
||
Some(NodeKind::Text(t)) => (**t).clone(),
|
||
_ => panic!("the text object"),
|
||
};
|
||
let fit = |d: &Document| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(d)).fit_scale;
|
||
let view = Affine::scale(20.0);
|
||
let opts = RenderOptions::default();
|
||
let mut r = Renderer::new();
|
||
r.render(&d, SIZE, SIZE, view, &opts);
|
||
let first = r.texts.used_in(r.stamp).next().unwrap().1.clone();
|
||
let before = fit(&d);
|
||
if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) {
|
||
t.xf = Affine::scale(1.5) * t.xf;
|
||
}
|
||
let after = fit(&d);
|
||
assert!(before > 0.3 && before < 1.0 && after > 0.3 && after < 1.0, "it fits above the minimum both times: {before}, {after}");
|
||
assert!((before - after).abs() > 0.01, "the scale changes the fitted size: {before}, {after}");
|
||
let scaled = r.render(&d, SIZE, SIZE, view, &opts);
|
||
let again = r.texts.used_in(r.stamp).next().unwrap().1.clone();
|
||
assert!(!Arc::ptr_eq(&first, &again), "changing area type's scale lays it out again");
|
||
assert_eq!(scaled.pixels, Renderer::new().render(&d, SIZE, SIZE, view, &opts).pixels);
|
||
// The words set at the earlier fit, which reusing the earlier layout would draw, draw other pixels.
|
||
let mut stale = d.clone();
|
||
if let Some(Node { kind: NodeKind::Text(t), .. }) = stale.node_mut(id) {
|
||
**t = vectorcraft_text::scaled(t, before);
|
||
}
|
||
assert_eq!(fit(&stale), 1.0, "the earlier fit's words fit as they are");
|
||
assert_ne!(scaled.pixels, Renderer::new().render(&stale, SIZE, SIZE, view, &opts).pixels, "the new fit changes the pixels");
|
||
}
|
||
|
||
/// Frames that draw character strokes from the cache draw the same pixels, and what a renderer
|
||
/// draws whose glyph cache has no room, so that it expands them in every frame: exactly at 100%,
|
||
/// where both take a tolerance of 1/4 device pixel, and within antialiasing at 105% and 150%,
|
||
/// where the cache takes 1/8 device pixel and the frames without it about 1/4 and 1/6.
|
||
#[test]
|
||
fn cached_character_strokes_draw_what_uncached_ones_draw() {
|
||
let _fonts = fonts_steady();
|
||
let (d, _) = stroked_text_doc("Type");
|
||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||
for (zoom, most) in [(1.0, 0), (1.05, 24), (1.5, 24)] {
|
||
let view = Affine::scale(zoom);
|
||
let mut r = Renderer::new();
|
||
let first = r.render(&d, SIZE, SIZE, view, &opts).pixels;
|
||
assert!(r.render(&d, SIZE, SIZE, view, &opts).pixels == first, "{zoom}×: the cached frames match");
|
||
let mut uncached = Renderer::new();
|
||
uncached.texts = FrameCache::new(0, 0);
|
||
let plain = uncached.render(&d, SIZE, SIZE, view, &opts).pixels;
|
||
assert!(character_outlines(&uncached).is_empty());
|
||
let diff = first.iter().zip(&plain).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||
assert!(diff <= most, "{zoom}×: cached and uncached frames differ by {diff}");
|
||
assert!(plain.iter().any(|&c| c < 128), "{zoom}×: the strokes show");
|
||
}
|
||
}
|