mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 09:59:47 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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=[]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user