mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:29:44 -06:00
Merge pull request #5171 from dotCooCoo/fix/issue-2089
Fix #2089: Affinity strokes keep their Width tool profile
This commit is contained in:
@@ -23,6 +23,7 @@ Affinity files.
|
||||
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
|
||||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
|
||||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
|
||||
| A stroke's pressure curve (`LDeP`: position along the path against width) | the stroke's width profile, the curve sampled at 2 to 8 points per segment (256 segments at most), widths up to the full weight |
|
||||
| Fill layers (`FRst`) | filled rectangles |
|
||||
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
|
||||
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
|
||||
@@ -33,7 +34,7 @@ Affinity files.
|
||||
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
|
||||
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read or that go past the million points one document may hold,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
|
||||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||||
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
|
||||
|
||||
@@ -169,6 +169,88 @@ fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option<Pat
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Most segments a stroke's pressure curve may have; the curves in real documents have a handful.
|
||||
const MAX_PRESSURE_SEGMENTS: usize = 256;
|
||||
|
||||
/// Most regular samples of a pressure curve: each segment gets an equal share, at least 2 (its
|
||||
/// middle and its end) and 8 at most. The points where a segment's width stops rising or falling
|
||||
/// come on top (at most two per segment), so a curve has at most 1 + 4 × [`MAX_PRESSURE_SEGMENTS`]
|
||||
/// points; the document's limit counts all of them.
|
||||
const PRESSURE_POINTS: usize = 512;
|
||||
|
||||
/// Most points one pressure curve can have (see [`PRESSURE_POINTS`]). A curve that can't be read
|
||||
/// counts this many against the document's limit.
|
||||
pub(crate) const MAX_PRESSURE_CURVE_POINTS: usize = 1 + 4 * MAX_PRESSURE_SEGMENTS;
|
||||
|
||||
/// A stroke's pressure curve (`LDsc.LDeP`, a `CrvD` whose positional `Data` holds an array of
|
||||
/// 18-byte records as in [`curves`]): x is the position along the path (0 to 1) and y the width as
|
||||
/// a fraction of the stroke's weight. Each segment is sampled at 2 to 8 points, at most
|
||||
/// [`PRESSURE_POINTS`] in all, and also where its width stops rising or falling (at most two more
|
||||
/// points), so a dip between samples is kept → (position, width) in order along the path,
|
||||
/// positions clamped into 0..1 and never going back, widths clamped into 0..1. `None` when the
|
||||
/// curve can't be read or has more than [`MAX_PRESSURE_SEGMENTS`] segments.
|
||||
pub(crate) fn pressure(r: &Reader, crvd: ObjId) -> Option<Vec<(f64, f64)>> {
|
||||
let s = r.s;
|
||||
let data = s.obj(crvd, b"Data")?;
|
||||
let records = s.positional(data).find_map(|v| if let Value::Array(a) = v { Some(a) } else { None })?;
|
||||
// Every segment takes at least an anchor record.
|
||||
if records.len() > MAX_PRESSURE_SEGMENTS * 3 + 1 {
|
||||
return None;
|
||||
}
|
||||
let curve = subpath(&anchors(records)?, false, None)?;
|
||||
if curve.segments.len() > MAX_PRESSURE_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
let steps = u32::try_from((PRESSURE_POINTS / curve.segments.len().max(1)).clamp(2, 8)).unwrap_or(2);
|
||||
let mut samples = vec![(curve.start.x, curve.start.y)];
|
||||
let mut from = curve.start;
|
||||
for [c1, c2, to] in &curve.segments {
|
||||
let mut ts: Vec<f64> = (1..=steps).map(|i| f64::from(i) / f64::from(steps)).collect();
|
||||
ts.extend(turning_points(from.y, c1.y, c2.y, to.y).into_iter().flatten());
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
for t in ts {
|
||||
let u = 1.0 - t;
|
||||
let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d;
|
||||
samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y)));
|
||||
}
|
||||
from = *to;
|
||||
}
|
||||
let mut last = 0.0f64;
|
||||
Some(
|
||||
samples
|
||||
.into_iter()
|
||||
.map(|(x, y)| {
|
||||
last = x.clamp(last, 1.0);
|
||||
(last, y.clamp(0.0, 1.0))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The parameters strictly between 0 and 1 where the cubic Bézier with control values `a`, `b`,
|
||||
/// `c` and `d` stops rising or falling (where its derivative is 0). The values are scaled into
|
||||
/// -1..1 first, so huge ones can't overflow the discriminant.
|
||||
fn turning_points(a: f64, b: f64, c: f64, d: f64) -> [Option<f64>; 2] {
|
||||
let scale = a.abs().max(b.abs()).max(c.abs()).max(d.abs());
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return [None, None];
|
||||
}
|
||||
let (a, b, c, d) = (a / scale, b / scale, c / scale, d / scale);
|
||||
let (d0, d1, d2) = (b - a, c - b, d - c);
|
||||
let (qa, qb, qc) = (d0 - 2.0 * d1 + d2, 2.0 * (d1 - d0), d0);
|
||||
let inside = |t: f64| (t.is_finite() && t > 0.0 && t < 1.0).then_some(t);
|
||||
if qa.abs() < 1e-12 {
|
||||
return [if qb.abs() < 1e-12 { None } else { inside(-qc / qb) }, None];
|
||||
}
|
||||
let disc = qb * qb - 4.0 * qa * qc;
|
||||
if disc < 0.0 {
|
||||
return [None, None];
|
||||
}
|
||||
let root = disc.sqrt();
|
||||
[inside((-qb - root) / (2.0 * qa)), inside((-qb + root) / (2.0 * qa))]
|
||||
}
|
||||
|
||||
struct Anchor {
|
||||
p: Point,
|
||||
inh: Option<Point>,
|
||||
@@ -260,3 +342,19 @@ fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn turning_points_of_huge_control_values_are_found() {
|
||||
let small = turning_points(1.0, -3.0, 3.0, 1.0);
|
||||
let huge = turning_points(1e200, -3e200, 3e200, 1e200);
|
||||
assert!(small.iter().all(Option::is_some), "{small:?}");
|
||||
for (s, h) in small.iter().zip(&huge) {
|
||||
let (Some(s), Some(h)) = (s, h) else { panic!("{huge:?}") };
|
||||
assert!((s - h).abs() < 1e-12, "{small:?} {huge:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ const MAX_DOCUMENT_CHARS: usize = 16 << 20;
|
||||
/// Table cells of all tables together (each becomes a text object with its edge lines).
|
||||
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
|
||||
|
||||
/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers).
|
||||
const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20;
|
||||
|
||||
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
|
||||
/// documents are read with what their embedding document left, so linking or embedding the
|
||||
/// same content many times can't multiply the work.
|
||||
@@ -39,10 +42,18 @@ pub(crate) struct Budget {
|
||||
pub(crate) chars: usize,
|
||||
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
|
||||
pub(crate) cells: usize,
|
||||
/// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all).
|
||||
pub(crate) pressure: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
|
||||
pub(crate) const FULL: Budget = Budget {
|
||||
nodes: MAX_NODES,
|
||||
pixels: MAX_LAYER_PIXELS,
|
||||
chars: MAX_DOCUMENT_CHARS,
|
||||
cells: MAX_DOCUMENT_CELLS,
|
||||
pressure: MAX_DOCUMENT_PRESSURE_POINTS,
|
||||
};
|
||||
}
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
|
||||
@@ -121,6 +121,9 @@ pub struct Stroke {
|
||||
pub dash: Option<(Vec<f64>, f64)>,
|
||||
/// Painted below the fill instead of above it.
|
||||
pub behind: bool,
|
||||
/// The pressure curve: (position 0..1 along the path, width as a fraction of `width`) in
|
||||
/// order along the path, `None` for a stroke of the same width all along.
|
||||
pub profile: Option<Vec<(f64, f64)>>,
|
||||
}
|
||||
|
||||
/// Gradient stops per gradient.
|
||||
@@ -350,10 +353,38 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
|
||||
r.warn("brush and pressure strokes (imported as plain strokes)");
|
||||
if s.obj(lsty, b"Brus").is_some() {
|
||||
r.warn("brush strokes (imported as plain strokes)");
|
||||
}
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
|
||||
// The pressure curve sets the width along the path; one at full width all along is a plain
|
||||
// stroke. Every curve read counts against the document's points: one at full width counts its
|
||||
// points, and one that can't be read counts the most a curve may have. Once the points are
|
||||
// used up the rest are not read.
|
||||
const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)";
|
||||
let ldep = s.obj(ldsc, b"LDeP");
|
||||
if ldep.is_some() && r.budget.pressure == 0 {
|
||||
r.warn(BEYOND);
|
||||
}
|
||||
let profile = match ldep.filter(|_| r.budget.pressure > 0).map(|p| crate::geometry::pressure(r, p)) {
|
||||
Some(Some(points)) => match r.budget.pressure.checked_sub(points.len()) {
|
||||
Some(left) => {
|
||||
r.budget.pressure = left;
|
||||
(!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-9)).then_some(points)
|
||||
}
|
||||
None => {
|
||||
r.budget.pressure = 0;
|
||||
r.warn(BEYOND);
|
||||
None
|
||||
}
|
||||
},
|
||||
Some(None) => {
|
||||
r.budget.pressure = r.budget.pressure.saturating_sub(crate::geometry::MAX_PRESSURE_CURVE_POINTS);
|
||||
r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)");
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind, profile })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
|
||||
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
|
||||
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
|
||||
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
|
||||
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=32b7cf3f;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)"]
|
||||
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=c10daaea;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)"]
|
||||
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=3bc13374;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)"]
|
||||
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=bc0c838d;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush strokes (imported as plain strokes)"]
|
||||
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
|
||||
@@ -30,4 +30,4 @@ patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1,
|
||||
patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
|
||||
patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
|
||||
patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
||||
patchy-vector.af dpi=72;spreads=1;model_crc32=3b16c7ed;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde_json::json;
|
||||
use vectorcraft_affinity::model::{self as af, Align as AfAlign, Blend, Kind, Pixels};
|
||||
use vectorcraft_affinity::paint::{self as ap, Cap, GradientKind as AfKind, Join};
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::geom::{FillRule, PathData, shapes};
|
||||
use vectorcraft_doc::text::{CharStyle, FirstBaseline, Justify, TextKind, TextObject, TextRun, VerticalAlign};
|
||||
use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKind, OpacityMask};
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::text::Placement;
|
||||
use super::{
|
||||
AfKind, Affine, Appearance, AppearanceItem, Arc, BezPath, Builder, Cap, Color, Dash, Effect, FillLayer, Gradient, GradientGeom, GradientKind,
|
||||
GradientPaint, GradientStop, ImageObject, Join, LineCap, LineJoin, NodeKind, Paint, PathData, Pixels, Point, StrokeAlign, StrokeLayer,
|
||||
TextObject, Vec2, af, ap, json,
|
||||
TextObject, Vec2, WidthProfile, af, ap, json,
|
||||
};
|
||||
|
||||
impl Builder {
|
||||
@@ -232,6 +232,7 @@ impl Builder {
|
||||
offset: phase * self.k,
|
||||
align_corners: false,
|
||||
});
|
||||
l.profile = s.profile.as_ref().map(|p| WidthProfile { points: p.iter().map(|&(t, w)| (t, w, w)).collect() });
|
||||
Some(l)
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,176 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// A version-12 document holding an open curve with an 8 px stroke whose pressure curve (`LDeP`)
|
||||
/// is made of `records`.
|
||||
fn pressure_document(records: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
pressure_documents(records, 1)
|
||||
}
|
||||
|
||||
/// [`pressure_document`] with `copies` curves, all linking the first one's fill and stroke.
|
||||
fn pressure_documents(records: Vec<Vec<u8>>, copies: u32) -> Vec<u8> {
|
||||
let mut style = Some((
|
||||
solid(24, rgba(0.0, 0.0, 0.0, 1.0)),
|
||||
F::Def(
|
||||
27,
|
||||
vec![tag(b"LDsc")],
|
||||
vec![
|
||||
(tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(8.0))])),
|
||||
(tag(b"LDSc"), F::Bool(false)),
|
||||
(tag(b"LDeP"), F::Obj(tag(b"CrvD"), vec![(tag(b"Data"), F::Pos(vec![F::U32(0), F::Bool(false), F::Records(18, records)]))])),
|
||||
],
|
||||
),
|
||||
));
|
||||
let lines = (0..copies)
|
||||
.map(|i| {
|
||||
let (fill, line) = style.take().unwrap_or((F::Ref(24), F::Ref(27)));
|
||||
F::Def(
|
||||
if i == 0 { 20 } else { 1000 + i },
|
||||
vec![tag(b"PCrv"), tag(b"VNod"), tag(b"Node")],
|
||||
vec![
|
||||
(
|
||||
tag(b"Crvs"),
|
||||
F::Obj(
|
||||
tag(b"PCvD"),
|
||||
vec![(
|
||||
tag(b"Data"),
|
||||
F::Pos(vec![
|
||||
F::U8(0),
|
||||
F::U32(1),
|
||||
F::Bool(false),
|
||||
F::Records(18, vec![node(0.0, 0.0, 1, 0), node(100.0, 0.0, 1, 0)]),
|
||||
]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
(tag(b"LIFl"), F::Shared(vec![fill])),
|
||||
(tag(b"LILn"), F::Shared(vec![line])),
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"Chld"), F::Shared(lines))]);
|
||||
let doc = synth::stream(&[
|
||||
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(72.0))])),
|
||||
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
|
||||
]);
|
||||
synth::container(&[("doc.dat", &doc, Method::Zstd)], Some(&png()))
|
||||
}
|
||||
|
||||
/// The width profile of the only stroked path in `d`.
|
||||
fn stroke_of(d: &vectorcraft_doc::Document) -> vectorcraft_doc::appearance::StrokeLayer {
|
||||
let mut found = None;
|
||||
d.walk(|n| {
|
||||
if let Some(AppearanceItem::Stroke(k)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
found = Some(k.clone());
|
||||
}
|
||||
});
|
||||
found.unwrap_or_else(|| panic!("no stroke in {:?}", d.layers))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pressure_curve_becomes_the_strokes_width_profile() {
|
||||
// Thin at the start, full width from half way: anchor (0, 0), handles (0.25, 0.5) and
|
||||
// (0.5, 1), anchor (0.5, 1), then straight on to anchor (1, 1).
|
||||
let curve = vec![node(0.0, 0.0, 4, 0), node(0.25, 0.5, 0, 1), node(0.5, 1.0, 0, 2), node(0.5, 1.0, 4, 0), node(1.0, 1.0, 4, 0)];
|
||||
let r = load("pressure.af", &pressure_document(curve)).unwrap();
|
||||
assert!(!r.preview_only);
|
||||
assert!(r.warnings.iter().all(|w| !w.contains("pressure")), "{:?}", r.warnings);
|
||||
let k = stroke_of(&r.doc);
|
||||
assert_eq!(k.width, 8.0);
|
||||
let profile = k.profile.unwrap_or_else(|| panic!("the pressure curve is read: {:?}", r.warnings));
|
||||
assert_eq!(profile.points.first(), Some(&(0.0, 0.0, 0.0)));
|
||||
assert_eq!(profile.points.last(), Some(&(1.0, 1.0, 1.0)));
|
||||
assert!(profile.points.windows(2).all(|w| w[0].0 <= w[1].0), "positions go forward: {:?}", profile.points);
|
||||
let (left, right) = profile.at(0.25);
|
||||
assert!(left > 0.25 && left < 1.0 && left == right, "{left} {right}");
|
||||
assert_eq!(profile.at(0.75), (1.0, 1.0));
|
||||
|
||||
// Widths past the full weight are held at it.
|
||||
let huge = vec![node(0.0, 0.0, 4, 0), node(0.5, 1e300, 4, 0), node(1.0, 0.0, 4, 0)];
|
||||
let profile = stroke_of(&load("huge.af", &pressure_document(huge)).unwrap().doc).profile.unwrap();
|
||||
assert!(profile.points.iter().all(|p| (0.0..=1.0).contains(&p.1)), "{:?}", profile.points);
|
||||
assert_eq!(profile.at(0.5), (1.0, 1.0));
|
||||
|
||||
// Each segment is sampled inside too: anchors at full width whose handles dip to nothing
|
||||
// still narrow the stroke, with 256 segments.
|
||||
let mut dips = vec![node(0.0, 1.0, 4, 0)];
|
||||
for i in 1..=256 {
|
||||
let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0);
|
||||
dips.extend([node(a + (b - a) / 3.0, 0.0, 0, 1), node(b - (b - a) / 3.0, 0.0, 0, 2), node(b, 1.0, 4, 0)]);
|
||||
}
|
||||
let profile = stroke_of(&load("dips.af", &pressure_document(dips)).unwrap().doc).profile.unwrap();
|
||||
assert!(profile.points.iter().any(|p| p.1 < 0.5), "{:?}", profile.points);
|
||||
|
||||
// A curve of more than 256 segments is left out, with a warning.
|
||||
let long: Vec<Vec<u8>> = (0..=300).map(|i| node(f64::from(i) / 300.0, f64::from(i % 2), 4, 0)).collect();
|
||||
let r = load("long.af", &pressure_document(long)).unwrap();
|
||||
assert!(stroke_of(&r.doc).profile.is_none());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pressure curve can't be read")), "{:?}", r.warnings);
|
||||
|
||||
// A pressure curve at full width all along leaves the stroke plain.
|
||||
let flat = vec![node(0.0, 1.0, 4, 0), node(1.0, 1.0, 4, 0)];
|
||||
let r = load("flat.af", &pressure_document(flat)).unwrap();
|
||||
assert!(stroke_of(&r.doc).profile.is_none());
|
||||
assert!(r.warnings.iter().all(|w| !w.contains("pressure")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dip_between_the_samples_of_a_long_pressure_curve_is_kept() {
|
||||
// 256 segments at full width, except one whose handles sit at widths 0 and 2: its width is
|
||||
// 1 at its middle and end but 0.71875 a quarter of the way along.
|
||||
let mut curve = vec![node(0.0, 1.0, 4, 0)];
|
||||
for i in 1..=256 {
|
||||
let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0);
|
||||
let (h1, h2) = if i == 100 { (0.0, 2.0) } else { (1.0, 1.0) };
|
||||
curve.extend([node(a + (b - a) / 3.0, h1, 0, 1), node(b - (b - a) / 3.0, h2, 0, 2), node(b, 1.0, 4, 0)]);
|
||||
}
|
||||
let profile = stroke_of(&load("dip.af", &pressure_document(curve)).unwrap().doc).profile.unwrap_or_else(|| panic!("the dip is kept"));
|
||||
let narrowest = profile.points.iter().map(|p| p.1).fold(1.0, f64::min);
|
||||
assert!((narrowest - 0.71).abs() < 0.02, "{narrowest}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pressure_curve_just_under_full_width_is_kept() {
|
||||
let curve = vec![node(0.0, 0.9995, 4, 0), node(1.0, 0.9995, 4, 0)];
|
||||
let profile = stroke_of(&load("near.af", &pressure_document(curve)).unwrap().doc).profile.unwrap_or_else(|| panic!("the curve is kept"));
|
||||
assert_eq!(profile.at(0.5), (0.9995, 0.9995));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_curves_stay_within_the_points_of_one_document() {
|
||||
// One 256-segment curve linked from 2100 strokes: more than a million points in all.
|
||||
let mut curve = vec![node(0.0, 1.0, 4, 0)];
|
||||
for i in 1..=256 {
|
||||
let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0);
|
||||
curve.extend([node(a + (b - a) / 3.0, 0.0, 0, 1), node(b - (b - a) / 3.0, 0.0, 0, 2), node(b, 1.0, 4, 0)]);
|
||||
}
|
||||
let r = load("many.af", &pressure_documents(curve, 2100)).unwrap();
|
||||
let mut profiles = Vec::new();
|
||||
r.doc.walk(|n| {
|
||||
if let Some(AppearanceItem::Stroke(k)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
profiles.push(k.profile.as_ref().map_or(0, |p| p.points.len()));
|
||||
}
|
||||
});
|
||||
assert_eq!(profiles.len(), 2100);
|
||||
assert!(profiles.iter().sum::<usize>() <= 1 << 20, "{}", profiles.iter().sum::<usize>());
|
||||
assert!(profiles.iter().filter(|n| **n > 0).count() > 1000);
|
||||
assert!(profiles.contains(&0));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings);
|
||||
|
||||
// Curves at full width count too, so linking one from many strokes can't repeat its work
|
||||
// without bound.
|
||||
let flat: Vec<Vec<u8>> = (0..=768).map(|i| node(f64::from(i) / 768.0, 1.0, if i % 3 == 0 { 4 } else { 0 }, (i % 3) as u8)).collect();
|
||||
let r = load("flat-many.af", &pressure_documents(flat, 2100)).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings);
|
||||
|
||||
// So do curves that can't be read: 768 segments, more than a curve may have.
|
||||
let long: Vec<Vec<u8>> = (0..=768).map(|i| node(f64::from(i) / 768.0, f64::from(i % 2), 4, 0)).collect();
|
||||
let r = load("long-many.af", &pressure_documents(long, 2100)).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pressure curve can't be read")), "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_documents_save_reopen_export_place_and_template() {
|
||||
let bytes = document(Method::Zstd);
|
||||
|
||||
Reference in New Issue
Block a user