Fix #2089: Affinity strokes keep their Width tool profile

An Affinity stroke shaped with the Width tool stores its pressure curve
(LDeP: position along the path against width). The importer warned and
drew the stroke at full width all along.

- The pressure curve is read and sampled into the stroke's width
  profile (2 to 8 points per segment, 256 segments at most, widths up
  to the full weight). A curve at full width all along stays a plain
  stroke; one that can't be read is warned about.
- Brush strokes keep their own warning.
- The pinned Affinity 3 signatures for the five corpus files with such
  strokes are updated.
This commit is contained in:
dotCooCoo
2026-11-01 14:17:23 -08:00
parent 639c9d6ce9
commit b473716e3e
7 changed files with 177 additions and 11 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,
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
+49
View File
@@ -169,6 +169,55 @@ 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 points a pressure curve is sampled at: each segment gets an equal share, at least 2 (its
/// middle and its end) and 8 at most.
const PRESSURE_POINTS: usize = 512;
/// 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 → (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 {
for i in 1..=steps {
let t = f64::from(i) / f64::from(steps);
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(),
)
}
struct Anchor {
p: Point,
inh: Option<Point>,
+16 -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,20 @@ 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.
let profile = match s.obj(ldsc, b"LDeP").map(|p| crate::geometry::pressure(r, p)) {
Some(Some(points)) => (!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-3)).then_some(points),
Some(None) => {
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=ce96643c;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=08a70ca5;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=557d4a95;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,108 @@ 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> {
let line = F::Def(
20,
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![solid(24, rgba(0.0, 0.0, 0.0, 1.0))])),
(
tag(b"LILn"),
F::Shared(vec![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 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(vec![line]))]);
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 native_documents_save_reopen_export_place_and_template() {
let bytes = document(Method::Zstd);