Merge pull request #5171 from dotCooCoo/fix/issue-2089

Fix #2089: Affinity strokes keep their Width tool profile
This commit is contained in:
Brandon Thomas
2026-11-01 23:45:14 -05:00
committed by GitHub
8 changed files with 324 additions and 12 deletions
+2 -1
View File
@@ -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
+98
View File
@@ -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:?}");
}
}
}
+12 -1
View File
@@ -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.
+34 -3
View File
@@ -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=[]
+1 -1
View File
@@ -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);