Merge pull request #5069 from dotCooCoo/fix/ai-os17-area

.ai import: area type keeps its rows, columns, gutters and first baseline
This commit is contained in:
BFlat
2026-11-02 12:28:07 +07:00
committed by GitHub
14 changed files with 454 additions and 19 deletions
+5
View File
@@ -141,6 +141,10 @@ pub struct AreaOptions {
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
/// Gutter between rows in points when it differs from [`Self::gutter`], which is then the
/// gutter between columns (left out when saved while unset).
#[serde(skip_serializing_if = "Option::is_none")]
pub row_gutter: Option<f64>,
/// Inset from the frame edges in points.
pub inset: f64,
pub first_baseline: FirstBaseline,
@@ -162,6 +166,7 @@ impl Default for AreaOptions {
rows: 1,
columns: 1,
gutter: 18.0,
row_gutter: None,
inset: 0.0,
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
+9 -2
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area Type Options…",
["Type"],
None,
"{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)",
"{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt (between rows and columns), rowGutter?: pt|null (between rows when it differs from gutter, which is then between columns; gutter given alone sets both; the reply gives the rows' gutter either way), inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)",
has_selection,
area_options
),
@@ -1007,6 +1007,8 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result<Value> {
if let Some(o) = v.as_object_mut() {
o.insert("fit".into(), json!(t.area.fit.id()));
o.insert("flow".into(), json!(t.area.flow));
// The gutter between rows, the gutter's when the rows have none of their own.
o.insert("rowGutter".into(), json!(t.area.row_gutter.unwrap_or(t.area.gutter)));
let min = match t.area.fit {
AreaFit::ShrinkText { min_percent } => min_percent,
_ => AreaFit::DEFAULT_MIN_PERCENT,
@@ -1023,7 +1025,7 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result<Value> {
}
/// The [`vectorcraft_doc::AreaOptions`] keys `text.areaOptions` merges as they are.
const AREA_KEYS: [&str; 8] = ["rows", "columns", "gutter", "inset", "firstBaseline", "firstBaselineMin", "verticalAlign", "flow"];
const AREA_KEYS: [&str; 9] = ["rows", "columns", "gutter", "rowGutter", "inset", "firstBaseline", "firstBaselineMin", "verticalAlign", "flow"];
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.areaOptions";
@@ -1057,6 +1059,11 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
opts.columns = opts.columns.clamp(1, vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS);
let finite = |x: f64| if x.is_finite() { x } else { 0.0 };
opts.gutter = finite(opts.gutter).clamp(0.0, 10_000.0);
// A gutter given alone is the rows' and the columns'.
if p.get("gutter").is_some() && p.get("rowGutter").is_none() {
opts.row_gutter = None;
}
opts.row_gutter = opts.row_gutter.map(|g| finite(g).clamp(0.0, 10_000.0)).filter(|g| *g != opts.gutter);
opts.inset = finite(opts.inset).clamp(0.0, 10_000.0);
opts.first_baseline_min = finite(opts.first_baseline_min).clamp(0.0, 10_000.0);
opts.fit = fit.unwrap_or(cur.fit);
@@ -1636,3 +1636,43 @@ fn area_options_columns_change_layout() {
assert_eq!(lay(&s).frames.len(), 1);
assert!(s.execute("text.areaOptions", &json!({"firstBaseline": "nope"})).is_err());
}
/// `rowGutter` sets a gutter between rows of its own, the reply passes it back, and a gutter
/// given alone is the rows' and the columns' again.
#[test]
fn area_options_set_a_gutter_between_rows_of_its_own() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "word ".repeat(80), "area": {"width": 300, "height": 120}})).unwrap()["id"]
.as_u64()
.unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
// Without one of its own, the rows' is the gutter, and passing the reply back keeps it unset.
let q = s.execute("text.areaOptions", &json!({})).unwrap();
assert_eq!((q["gutter"].as_f64(), q["rowGutter"].as_f64()), (Some(18.0), Some(18.0)));
s.execute("text.areaOptions", &q).unwrap();
// Both given: the rows keep theirs even at the old gutter's value.
let r = s.execute("text.areaOptions", &json!({"gutter": 10, "rowGutter": 18})).unwrap();
assert_eq!((r["gutter"].as_f64(), r["rowGutter"].as_f64()), (Some(10.0), Some(18.0)));
s.execute("text.areaOptions", &json!({"gutter": 18})).unwrap();
let r = s.execute("text.areaOptions", &json!({"rows": 2, "gutter": 12, "rowGutter": 30})).unwrap();
assert_eq!((r["gutter"].as_f64(), r["rowGutter"].as_f64()), (Some(12.0), Some(30.0)));
let area = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.area.clone(),
_ => panic!(),
};
assert_eq!(area(&s).row_gutter, Some(30.0));
// The reply passed back keeps it; out of range it is capped; the gutter's value is none of its own.
s.execute("text.areaOptions", &json!({"rowGutter": 12})).unwrap();
assert_eq!(area(&s).row_gutter, None);
s.execute("text.areaOptions", &json!({"rowGutter": 30})).unwrap();
s.execute("text.areaOptions", &r).unwrap();
assert_eq!(area(&s).row_gutter, Some(30.0));
s.execute("text.areaOptions", &json!({"rowGutter": -5})).unwrap();
assert_eq!(area(&s).row_gutter, Some(0.0));
s.execute("text.areaOptions", &json!({"gutter": 8})).unwrap();
assert_eq!((area(&s).gutter, area(&s).row_gutter), (8.0, None));
s.execute("text.areaOptions", &json!({"rowGutter": 30})).unwrap();
s.execute("text.areaOptions", &json!({"rowGutter": null})).unwrap();
assert_eq!(area(&s).row_gutter, None);
}
+49 -3
View File
@@ -2834,14 +2834,48 @@ fn text_eps(document: &str) -> Vec<u8> {
/// An EPS with the text `objects` on a hidden layer, their stories in `document`.
fn text_eps_of(document: &str, objects: &str) -> Vec<u8> {
editing_eps(&text_editing_of(document, objects))
}
/// The editing data of [`text_eps_of`].
fn text_editing_of(document: &str, objects: &str) -> String {
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
let editing = format!(
format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
%AI5_BeginLayer\n0 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Spare) Ln\n{objects}LB\n%AI5_EndLayer--\n\
%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
lines.join("\n")
);
editing_eps(&editing)
)
}
/// Area Type Options as an area frame's data may keep them: rows, columns, gutters and the first
/// baseline, and keys besides, each with a value of any kind, in or out of range.
fn arb_area_options() -> impl Strategy<Value = String> {
let keys = prop::sample::select(vec!["3", "4", "5", "7", "8", "10", "13", "14"]);
let values = prop::sample::select(vec![
"0",
"1",
"2",
"3",
"2.5",
"-3",
"1e9",
"1e400",
"NaN",
"inf",
"-inf",
"(x)",
"true",
"/Name",
"[ 1 2 ]",
"<< /0 0 /1 30 >>",
"<< /0 3 /1 NaN >>",
"<< /0 4 /1 -inf >>",
"<< /0 99 /1 1e300 >>",
"<< /0 << /0 1 >> /1 [ ] >>",
"<< /1 30 >>",
]);
prop::collection::vec((keys, values), 0..8).prop_map(|kv| kv.iter().map(|(k, v)| format!("/{k} {v}")).collect::<Vec<_>>().join(" "))
}
/// A text document of `kinds.len()` stories, story `i` in frame `i` of kind `kinds[i]` (0 point type,
@@ -2981,6 +3015,18 @@ proptest! {
survive("mutated EPS text frames", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?;
}
/// The threaded area type of the same text document with hostile Area Type Options in each of its
/// frames, opened as an EPS document and as a `.ai`'s editing data.
#[test]
fn text_frames_with_hostile_area_options_never_panic(first in arb_area_options(), second in arb_area_options()) {
let text = frames_document_text().replacen("/0 1 /7 18", &format!("/0 1 {first}"), 1).replacen("/0 1 /7 18", &format!("/0 1 {second}"), 1);
let editing = text_editing_of(&text, FRAME_OBJECTS);
let bytes = editing_eps(&editing);
survive("EPS text frames with hostile area options", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?;
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing)].concat();
survive(".ai text frames with hostile area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// An Illustrator EPS whose editing data is damaged or hostile: read as its layers, or as its page.
#[test]
fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12)) {
+234 -10
View File
@@ -245,17 +245,91 @@ const MAX_FRAMES: usize = 4096;
const MAX_SEGMENTS: usize = 1 << 16;
/// Where a frame puts its story's text.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
enum FrameKind {
/// Point type: the matrix carries the layout (its anchor at the origin of its lines) onto the
/// canvas.
Point([f64; 6]),
/// Area type flowed inside a path: its cubic segments on the canvas, four points each.
Area(Vec<[f64; 8]>),
/// Area type flowed inside a path: its cubic segments on the canvas, four points each, and the
/// Area Type Options the frame keeps.
Area(Vec<[f64; 8]>, AreaFrame),
/// Type on a path, from `start` to `end` (segment index and the place in that segment).
OnPath { segments: Vec<[f64; 8]>, start: f64, end: f64 },
}
/// The Area Type Options an area frame keeps, in the units of the text document: each `None`
/// where the frame leaves the key out or keeps a value that can't be read, so the option stays as
/// it was.
#[derive(Debug, Clone, Default, PartialEq)]
struct AreaFrame {
/// Key 3, the rows.
rows: Option<usize>,
/// Key 4, the columns.
columns: Option<usize>,
/// Key 7, the gutter between rows.
row_gutter: Option<f64>,
/// Key 8, the gutter between columns.
column_gutter: Option<f64>,
/// Key 10, the first baseline (`0` its kind, `1` its minimum). Without it the first baseline
/// is Ascent.
first_baseline: Option<(vectorcraft_doc::FirstBaseline, f64)>,
}
/// Longest gutter or first-baseline minimum, as `text.areaOptions` caps them: read in the units of
/// the text document, and again in document points once scaled.
const MAX_AREA_LENGTH: f64 = 10_000.0;
impl AreaFrame {
/// The options frame data `carries` keeps.
fn read(carries: &Val) -> AreaFrame {
// A whole number, at most `max`.
let whole = |v: &Val, max: f64| v.num().filter(|n| n.fract() == 0.0 && *n >= 0.0).map(|n| n.min(max) as usize);
let max = vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS;
let count = |k: &str| carries.get(k).and_then(|v| whole(v, max as f64)).filter(|n| *n >= 1);
let length = |v: Option<&Val>| v.and_then(Val::num).filter(|n| *n >= 0.0).map(|n| n.min(MAX_AREA_LENGTH));
let first_baseline = carries.get("10").and_then(|b| {
use vectorcraft_doc::FirstBaseline as F;
let kind = match whole(b.get("0")?, 99.0)? {
0 => F::Fixed,
2 => F::CapHeight,
3 => F::Leading,
4 => F::XHeight,
// Em Box Height (5) and Legacy (6) have no first baseline of their own here: the
// first baseline stays as it was.
_ => return None,
};
Some((kind, length(b.get("1")).unwrap_or(0.0)))
});
AreaFrame {
rows: count("3"),
columns: count("4"),
row_gutter: length(carries.get("7")),
column_gutter: length(carries.get("8")),
first_baseline,
}
}
/// Sets the options it keeps on `area`, its lengths at `scale` (document points to a unit).
fn apply(&self, area: &mut vectorcraft_doc::AreaOptions, scale: f64) {
if let Some(n) = self.rows {
area.rows = n;
}
if let Some(n) = self.columns {
area.columns = n;
}
// A gutter the frame leaves out stays as it was.
let (row, column) = (area.row_gutter.unwrap_or(area.gutter), area.gutter);
let scaled = |g: f64| (g * scale).min(MAX_AREA_LENGTH);
let row = self.row_gutter.map_or(row, scaled);
area.gutter = self.column_gutter.map_or(column, scaled);
area.row_gutter = (row != area.gutter).then_some(row);
if let Some((kind, min)) = self.first_baseline {
area.first_baseline = kind;
area.first_baseline_min = scaled(min);
}
}
}
/// A paragraph's attributes, as the text document keeps them.
#[derive(Clone, Debug, Default, PartialEq)]
struct Para {
@@ -390,7 +464,7 @@ impl Texts {
None => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
Some(m) => m.nums()?.try_into().ok()?,
})),
1 => Some(FrameKind::Area(segments()?)),
1 => Some(FrameKind::Area(segments()?, AreaFrame::read(carries))),
2 => {
let range = carries.get("6").and_then(Val::nums).unwrap_or_default();
let segments = segments()?;
@@ -690,7 +764,7 @@ impl Story {
let size = self.runs.iter().map(|(_, s)| s.size).fold(0.0, f64::max) * art_scale(to_doc);
let bounds = |segments: &[[f64; 8]]| path_of(segments, template, to_doc).bounds();
let r = match self.frames.first()? {
FrameKind::Area(s) => bounds(s)?,
FrameKind::Area(s, _) => bounds(s)?,
FrameKind::OnPath { segments, .. } => bounds(segments)?.inflate(size * 1.5, size * 1.5),
FrameKind::Point(m) => {
let longest = self.text.split(['\r', '\x03']).map(|l| l.chars().count()).max().unwrap_or(0) as f64;
@@ -735,7 +809,7 @@ impl Story {
let at = to_doc * Point::new(x, y);
(scale, Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]), TextKind::Point)
}
FrameKind::Area(s) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }),
FrameKind::Area(s, _) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }),
FrameKind::OnPath { segments, start, end } => {
let n = segments.len() as f64;
// A closed path keeps its end bracket wherever it is (the same place as the start: a
@@ -795,6 +869,10 @@ impl Story {
if matches!(kind, TextKind::OnPath { .. }) && !threaded {
t.path_spacing = self.path_spacing * scale;
}
// Each frame of a thread keeps its own options.
if let Some(FrameKind::Area(_, options)) = self.frames.get(frame) {
options.apply(&mut t.area, scale);
}
t.kind = kind;
t.xf = xf;
Some(Node::new(id, NodeKind::Text(Box::new(t))))
@@ -812,10 +890,14 @@ impl Story {
/// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its
/// page draws it: its first baseline is fixed where the file has it. The font's ascent below the
/// frame's top, the first baseline here, isn't where the app that wrote the file puts it for
/// every font. Whether it was.
pub(super) fn place_first_line(&self, node: &mut Node, template: (f64, f64), to_doc: Affine) -> bool {
/// every font. A first baseline its frame `frame` keeps (other than Ascent) stays as it is.
/// Whether it was.
pub(super) fn place_first_line(&self, node: &mut Node, frame: usize, template: (f64, f64), to_doc: Affine) -> bool {
// Closer than this (points of the document) and the line is where the file has it.
const SAME: f64 = 0.5;
if matches!(self.frames.get(frame), Some(FrameKind::Area(_, AreaFrame { first_baseline: Some(_), .. }))) {
return false;
}
let NodeKind::Text(t) = &mut node.kind else { return false };
if !matches!(t.kind, TextKind::Area { .. }) || t.area.vertical_align != vectorcraft_doc::VerticalAlign::Top {
return false;
@@ -1314,13 +1396,13 @@ mod tests {
let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]));
let first = |s: &Story| {
let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap();
let placed = s.place_first_line(&mut node, template, to_doc);
let placed = s.place_first_line(&mut node, 0, template, to_doc);
let NodeKind::Text(t) = node.kind else { panic!("area type") };
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
(placed, l.lines.first().map(|l| l.baseline), t.area.first_baseline)
};
let mut s = story("Heading\r", &[], &Para::default());
s.frames = vec![FrameKind::Area(frame)];
s.frames = vec![FrameKind::Area(frame, AreaFrame::default())];
// Where this lays the line out on its own: the file agrees, and nothing changes.
s.lines = vec![(10.0, 20.0)];
let (_, Some(own), _) = first(&s) else { panic!("a line") };
@@ -1332,4 +1414,146 @@ mod tests {
assert!(placed && fb == vectorcraft_doc::FirstBaseline::Fixed, "{fb:?}");
assert!(at.is_some_and(|y| (y - (own - 3.0)).abs() < 1e-6), "{at:?} for {}", own - 3.0);
}
/// A text document's area frame, the rectangle 10, 20 to 210, 120 of the canvas, whose frame
/// data has `options` besides its kind, written by hand.
fn area_frame(options: &str) -> FrameKind {
let line = |a: (f64, f64), b: (f64, f64)| format!("{} {} {} {} {} {} {} {}", a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1);
let segments = [
line((10.0, 20.0), (210.0, 20.0)),
line((210.0, 20.0), (210.0, 120.0)),
line((210.0, 120.0), (10.0, 120.0)),
line((10.0, 120.0), (10.0, 20.0)),
];
let doc = format!("/0 << /1 << /0 [ {} ] >> /2 << /0 1 {options} >> >>", segments.join(" "));
let texts = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: Val::Dict(BTreeMap::new()),
char_sheets: vec![],
frames: vec![read(doc.as_bytes()).unwrap()],
};
texts.frame(0).unwrap()
}
/// The area options of the text object a story of `frames` comes in as at frame `frame`, the
/// art's units `scale` document points each.
fn area_of(frames: Vec<FrameKind>, frame: usize, scale: f64) -> vectorcraft_doc::AreaOptions {
let mut s = story("Some area type\r", &[], &Para::default());
s.frames = frames;
let to_doc = Affine::scale(scale) * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let NodeKind::Text(t) = s.node(NodeId(0), frame, (CANVAS_CENTRE, -CANVAS_CENTRE), to_doc).unwrap().kind else { panic!("area type") };
t.area
}
#[test]
fn area_frame_options_are_read() {
// GUARDS: area type came in with one row, one column and the 18 pt gutter whatever its frame kept.
let a = area_of(vec![area_frame("/3 2 /4 3 /7 20 /8 24")], 0, 1.0);
assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (2, 3, 24.0, Some(20.0)));
// The same gutter between rows and columns is one gutter.
let a = area_of(vec![area_frame("/3 2 /4 3 /7 20 /8 20")], 0, 1.0);
assert_eq!((a.gutter, a.row_gutter), (20.0, None));
// Lengths scale with the art's units (a Large Canvas copy measures in tenths of the page).
let a = area_of(vec![area_frame("/7 2 /8 2.4 /10 << /0 3 /1 3 >>")], 0, 10.0);
assert!((a.gutter - 24.0).abs() < 1e-9 && a.row_gutter == Some(20.0), "{a:?}");
assert!((a.first_baseline_min - 30.0).abs() < 1e-9, "{a:?}");
}
#[test]
fn area_frame_first_baseline_kinds_and_minimum_are_read() {
use vectorcraft_doc::FirstBaseline as F;
for (code, kind) in [(0, F::Fixed), (2, F::CapHeight), (3, F::Leading), (4, F::XHeight)] {
let a = area_of(vec![area_frame(&format!("/10 << /0 {code} /1 30 >>"))], 0, 2.0);
assert_eq!((a.first_baseline, a.first_baseline_min), (kind, 60.0), "code {code}");
}
// A kind without its own first baseline here, or none known, leaves Ascent.
for code in ["1", "5", "6", "7", "-1", "2.5", "(x)"] {
let a = area_of(vec![area_frame(&format!("/10 << /0 {code} /1 30 >>"))], 0, 1.0);
assert_eq!((a.first_baseline, a.first_baseline_min), (F::Ascent, 0.0), "code {code}");
}
// A minimum that can't be read is none.
let a = area_of(vec![area_frame("/10 << /0 3 /1 -4 >>")], 0, 1.0);
assert_eq!((a.first_baseline, a.first_baseline_min), (F::Leading, 0.0));
}
/// A first baseline the frame keeps stays as it is: the first line isn't moved to where the
/// file lays it out.
#[test]
fn a_first_baseline_the_frame_keeps_is_not_moved() {
let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]));
// Where this lays the first line out on its own.
let mut s = story("Heading\r", &[], &Para::default());
s.frames = vec![area_frame("")];
let NodeKind::Text(t) = s.node(NodeId(0), 0, template, to_doc).unwrap().kind else { panic!("area type") };
let own = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.first().map(|l| l.baseline).unwrap();
let placed_in = |frames: &[&str]| {
let mut s = story("Heading\r", &[], &Para::default());
s.frames = frames.iter().map(|o| area_frame(o)).collect();
// Higher in the file.
s.lines = vec![(10.0, own - 3.0)];
let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap();
let placed = s.place_first_line(&mut node, 0, template, to_doc);
let NodeKind::Text(t) = node.kind else { panic!("area type") };
(placed, t.area.first_baseline)
};
let placed = |options: &str| placed_in(&[options]);
assert_eq!(placed("/10 << /0 2 /1 0 >>"), (false, vectorcraft_doc::FirstBaseline::CapHeight));
// Without one (Ascent), or with one that isn't read, the line goes where the file has it.
assert_eq!(placed(""), (true, vectorcraft_doc::FirstBaseline::Fixed));
assert_eq!(placed("/10 << /0 5 /1 0 >>"), (true, vectorcraft_doc::FirstBaseline::Fixed));
// Another frame of the thread keeping one doesn't count for the first.
assert_eq!(placed_in(&["", "/10 << /0 2 /1 0 >>"]), (true, vectorcraft_doc::FirstBaseline::Fixed));
}
#[test]
fn area_frame_options_that_cannot_be_read_keep_their_defaults() {
let defaults = vectorcraft_doc::AreaOptions::default();
let bad = [
"/3 0 /4 2.5 /7 -1 /8 (x)",
"/3 NaN /4 inf /7 NaN /8 -inf /10 [ 1 2 ]",
"/3 (2) /4 [ 3 ] /7 << /0 1 >> /8 true /10 3",
"/10 << /1 30 >>",
];
for options in bad {
assert_eq!(area_of(vec![area_frame(options)], 0, 1.0), defaults, "{options}");
}
// Too many rows and columns, and too long gutters, are capped.
let a = area_of(vec![area_frame("/3 1e9 /4 101 /7 1e300 /8 5e4")], 0, 1.0);
assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (100, 100, MAX_AREA_LENGTH, None));
// The cap holds after the lengths scale with the art's units.
let a = area_of(vec![area_frame("/7 1e4 /8 2e3 /10 << /0 0 /1 1e4 >>")], 0, 10.0);
assert_eq!((a.gutter, a.row_gutter, a.first_baseline_min), (MAX_AREA_LENGTH, None, MAX_AREA_LENGTH));
// One that can be read is read beside one that can't.
let a = area_of(vec![area_frame("/3 2 /4 -3 /8 12 /7 NaN")], 0, 1.0);
assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (2, 1, 12.0, Some(18.0)));
}
#[test]
fn area_frame_without_options_comes_in_as_before() {
assert_eq!(area_of(vec![area_frame("")], 0, 1.0), vectorcraft_doc::AreaOptions::default());
assert_eq!(area_frame(""), FrameKind::Area(area_segments(), AreaFrame::default()));
}
/// The segments [`area_frame`] writes.
fn area_segments() -> Vec<[f64; 8]> {
let line = |a: (f64, f64), b: (f64, f64)| [a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1];
vec![
line((10.0, 20.0), (210.0, 20.0)),
line((210.0, 20.0), (210.0, 120.0)),
line((210.0, 120.0), (10.0, 120.0)),
line((10.0, 120.0), (10.0, 20.0)),
]
}
#[test]
fn each_frame_of_a_thread_keeps_its_own_options() {
let frames = vec![area_frame("/3 2 /7 10 /8 10 /10 << /0 0 /1 12 >>"), area_frame("/4 3 /7 6 /8 8")];
let (first, second) = (area_of(frames.clone(), 0, 1.0), area_of(frames, 1, 1.0));
assert_eq!((first.rows, first.columns, first.gutter, first.row_gutter), (2, 1, 10.0, None));
assert_eq!((first.first_baseline, first.first_baseline_min), (vectorcraft_doc::FirstBaseline::Fixed, 12.0));
assert_eq!((second.rows, second.columns, second.gutter, second.row_gutter), (1, 3, 8.0, Some(6.0)));
assert_eq!(second.first_baseline, vectorcraft_doc::FirstBaseline::Ascent);
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ pub(super) fn make_slots(
let mut region = story.region(t, to_doc);
// The first line where the file has it, in the text document's frame (that of a
// copied object is its original's, as its lines are).
story.place_first_line(&mut node, t, to_doc);
story.place_first_line(&mut node, info.frame, t, to_doc);
// Area type and type on a path in the object's own frame: the text document's frame
// of a copied object keeps its original's path.
frame_text_from_art(info, &mut node, &mut region);
+27
View File
@@ -2454,6 +2454,33 @@ fn area_type_and_type_on_a_path_come_from_the_text_document() {
assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
}
/// Area type keeps the rows, columns, gutters and first baseline its frame keeps, and so does
/// each frame of a thread; a frame without them keeps the defaults.
#[test]
fn area_type_keeps_its_area_type_options() {
let frame = |rect: (f64, f64, f64, f64), options: &str| {
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 {options} >> >> >>", canvas_rect(rect.0, rect.1, rect.2, rect.3))
};
let frames = [
frame((10.0, 50.0, 90.0, 90.0), "/3 2 /4 3 /7 6 /8 4 /10 << /0 0 /1 9 >>"),
frame((10.0, 10.0, 40.0, 40.0), "/4 2 /7 5 /8 5 /10 << /0 2 /1 0 >>"),
frame((50.0, 10.0, 90.0, 40.0), ""),
];
let doc = document_of(&frames, &[story("Area type in cells\\r", &[0, 1], "/0 0", "/1 6.0"), story("Plain\\r", &[2], "/0 0", "/1 6.0")]);
let objects = frame_object(0, 0) + &frame_object(0, 1) + &text_object(1);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &objects)), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts = text_of(&r.document.layers[1]);
assert_eq!(texts.len(), 3, "{:?}", r.warnings);
let a = &texts[0].area;
assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (2, 3, 4.0, Some(6.0)));
assert_eq!((a.first_baseline, a.first_baseline_min), (vectorcraft_doc::FirstBaseline::Fixed, 9.0));
let b = &texts[1].area;
assert_eq!((b.rows, b.columns, b.gutter, b.row_gutter), (1, 2, 5.0, None));
assert_eq!(b.first_baseline, vectorcraft_doc::FirstBaseline::CapHeight);
assert_eq!(texts[2].area, vectorcraft_doc::AreaOptions::default());
}
/// A point type frame at the point (`x`, `y`) of the art.
fn point_frame(x: f64, y: f64) -> String {
let (cx, cy) = canvas(x, y);
+1
View File
@@ -14,6 +14,7 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
rows: a.rows,
columns: a.columns,
gutter: a.gutter,
row_gutter: a.row_gutter,
inset: a.inset,
first_baseline: a.first_baseline,
first_baseline_min: a.first_baseline_min,
+3 -2
View File
@@ -139,8 +139,9 @@ impl Region {
let max = vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS;
let (rows, cols) = (opts.rows.clamp(1, max), opts.columns.clamp(1, max));
let gutter = opts.gutter.max(0.0);
let row_gutter = opts.row_gutter.unwrap_or(opts.gutter).max(0.0);
let cw = ((bbox.width() - gutter * (cols - 1) as f64) / cols as f64).max(0.0);
let rh = ((bbox.height() - gutter * (rows - 1) as f64) / rows as f64).max(0.0);
let rh = ((bbox.height() - row_gutter * (rows - 1) as f64) / rows as f64).max(0.0);
let mut out = Vec::with_capacity(rows * cols);
// Text Flow: down each column, then across (by columns, the default), or across each row,
// then down (by rows).
@@ -150,7 +151,7 @@ impl Region {
for i in 0..inner {
let (r, c) = if by_rows { (o, i) } else { (i, o) };
let x0 = bbox.x0 + c as f64 * (cw + gutter);
let y0 = bbox.y0 + r as f64 * (rh + gutter);
let y0 = bbox.y0 + r as f64 * (rh + row_gutter);
let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) };
out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone(), shift: 0.0, gap: 0.0 });
}
+4
View File
@@ -62,6 +62,9 @@ pub struct LayoutOptions {
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
/// Gutter between rows in points when it differs from [`LayoutOptions::gutter`], which is then
/// the gutter between columns.
pub row_gutter: Option<f64>,
/// Inset from the frame edges in points.
pub inset: f64,
pub first_baseline: FirstBaseline,
@@ -85,6 +88,7 @@ impl Default for LayoutOptions {
rows: 1,
columns: 1,
gutter: 18.0,
row_gutter: None,
inset: 0.0,
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
+13
View File
@@ -477,6 +477,19 @@ fn text_flow_fills_the_cells_by_columns_or_by_rows() {
assert_eq!(layout(db(), &by_rows).frames.get(1).map(|f| (f.x0, f.y0)), Some((220.0, 0.0)));
}
/// A gutter between rows of its own: the rows are that far apart, and the columns keep the gutter.
#[test]
fn rows_can_have_a_gutter_of_their_own() {
let mut t = area(&COPY.repeat(8), style(12.0), Rect::new(0.0, 0.0, 420.0, 300.0), Justify::Left);
(t.area.rows, t.area.columns, t.area.gutter, t.area.row_gutter) = (2, 2, 20.0, Some(40.0));
let cells: Vec<(f64, f64, f64, f64)> = layout(db(), &t).frames.iter().map(|f| (f.x0, f.y0, f.x1, f.y1)).collect();
// By columns: down the first column, then the second; (300 − 40) / 2 tall, (420 − 20) / 2 wide.
assert_eq!(cells, [(0.0, 0.0, 200.0, 130.0), (0.0, 170.0, 200.0, 300.0), (220.0, 0.0, 420.0, 130.0), (220.0, 170.0, 420.0, 300.0)]);
// Without one, the rows take the gutter too.
t.area.row_gutter = None;
assert_eq!(layout(db(), &t).frames.get(1).map(|f| f.y0), Some(160.0));
}
// ---------- area flow ----------
#[test]
+60 -1
View File
@@ -18,7 +18,17 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
let rank = |k: &str| order.iter().position(|o| *o == k).unwrap_or(order.len());
// Area Type Options › Text Flow is a pair of radio buttons after the other options.
let flow = if command == "text.areaOptions" { d.fields.remove("flow") } else { None };
// Area Type Options: a Row Gutter still as it opened, on rows without a gutter of their own,
// follows the Gutter as it is changed.
let linked = d.fields.get("__rowGutterLinked").is_some() && d.fields.get("__rowGutterLinked") == d.fields.get("rowGutter");
form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), &rank, app.session.general_unit());
if linked
&& d.fields.get("rowGutter") == d.fields.get("__rowGutterLinked")
&& let Some(g) = d.fields.get("gutter").cloned()
{
d.fields.insert("rowGutter".into(), g.clone());
d.fields.insert("__rowGutterLinked".into(), g);
}
if let Some(v) = flow {
let mut rows = v.as_str() == Some("rows");
ui.add_space(4.0);
@@ -62,6 +72,13 @@ fn call(d: &Dialog) -> (String, Value) {
if let (Some(o), Some(Value::Object(fixed))) = (params.as_object_mut(), d.fields.get("__params")) {
o.extend(fixed.clone());
}
// Area Type Options: a Row Gutter left as it opened, on rows without a gutter of their own,
// isn't sent, so the rows take the Gutter.
if let (Some(o), Some(linked)) = (params.as_object_mut(), d.fields.get("__rowGutterLinked"))
&& d.fields.get("rowGutter") == Some(linked)
{
o.remove("rowGutter");
}
(d.str("__command"), params)
}
@@ -78,7 +95,7 @@ fn lengths(command: &str) -> &'static [&'static str] {
"shape.flare" => &["diameter", "pathLength"],
"perspective.grid.set" => &["cell", "distance"],
"object.repeat.options" => &["radius", "hSpacing", "vSpacing"],
"text.areaOptions" => &["width", "height", "gutter", "inset", "firstBaselineMin"],
"text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin"],
"type.pathOptions" => &["spacing"],
"object.pathfinder.trap" => &["thickness"],
_ => &[],
@@ -199,6 +216,48 @@ mod tests {
assert_eq!(t.area.vertical_align, VerticalAlign::Center);
}
/// Area Type Options shows the gutter between rows beside the gutter, and OK sets a gutter
/// between rows of its own.
#[test]
fn area_type_options_dialog_sets_the_gutter_between_rows() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 20, "text": "Hello", "area": {"width": 200, "height": 200}})).unwrap()["id"]
.as_u64()
.unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
crate::menus::invoke(&mut app, "text.areaOptions", json!({}));
assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("rowGutter"), Some(&json!(18.0)));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
out.textures_delta.clear();
app.ui.dialog.as_mut().unwrap().fields.insert("rowGutter".into(), json!(30.0));
crate::dialogs::confirm(&mut app).unwrap();
let area = |app: &VectorcraftApp| {
let NodeKind::Text(t) = &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
(t.area.gutter, t.area.row_gutter)
};
assert_eq!(area(&app), (18.0, Some(30.0)));
// Rows with a gutter of their own keep it when only the Gutter changes.
crate::menus::invoke(&mut app, "text.areaOptions", json!({}));
app.ui.dialog.as_mut().unwrap().fields.insert("gutter".into(), json!(12.0));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(area(&app), (12.0, Some(30.0)));
// Without one, a changed Gutter is the rows' too.
app.run("text.areaOptions", json!({"gutter": 18})).unwrap();
crate::menus::invoke(&mut app, "text.areaOptions", json!({}));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
out.textures_delta.clear();
app.ui.dialog.as_mut().unwrap().fields.insert("gutter".into(), json!(10.0));
// The Row Gutter shown follows it.
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
out.textures_delta.clear();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("rowGutter"), Some(&json!(10.0)));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(area(&app), (10.0, None));
}
/// Area Type Options › Text Flow (#4978): two radio buttons, By Columns as new area type has
/// it, and OK fills the cells by rows when By Rows is picked.
#[test]
+7
View File
@@ -208,6 +208,13 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool {
Ok(mut fields) => {
if let Some(o) = fields.as_object_mut() {
o.retain(|k, _| !hidden.contains(&k.as_str()));
// Area type whose rows have no gutter of their own: its Row Gutter follows the
// Gutter unless it is changed (see `dialogs::command`).
if id == "text.areaOptions"
&& let Some(g) = o.get("rowGutter").filter(|g| Some(*g) == o.get("gutter")).cloned()
{
o.insert("__rowGutterLinked".into(), g);
}
}
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
}
File diff suppressed because one or more lines are too long