Merge pull request #2109 from dotCooCoo/fix/area-type-rows-columns-overflow

Type: area type opens and lays out with at most 100 rows and 100 columns
This commit is contained in:
BFlat
2026-10-16 02:36:23 +07:00
committed by GitHub
7 changed files with 113 additions and 9 deletions
+14
View File
@@ -734,7 +734,9 @@ impl PathAlign {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AreaOptions {
#[serde(deserialize_with = "de_rows_or_columns")]
pub rows: usize,
#[serde(deserialize_with = "de_rows_or_columns")]
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
@@ -765,6 +767,18 @@ impl Default for AreaOptions {
}
}
impl AreaOptions {
/// The most rows, and the most columns, area type can have.
pub const MAX_ROWS_COLUMNS: usize = 100;
}
/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to
/// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`].
fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result<usize, D::Error> {
let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX);
Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS))
}
/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or
/// `{"shrinkText": {"minPercent": 50}}`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
+2 -2
View File
@@ -662,8 +662,8 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
return options(s);
}
let mut opts: vectorcraft_doc::AreaOptions = serde_json::from_value(v).map_err(|e| bad(C, e.to_string()))?;
opts.rows = opts.rows.clamp(1, 100);
opts.columns = opts.columns.clamp(1, 100);
opts.rows = opts.rows.clamp(1, vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS);
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);
opts.inset = finite(opts.inset).clamp(0.0, 10_000.0);
+22
View File
@@ -1330,6 +1330,28 @@ proptest! {
}
}
/// A native file whose area type has 2^60 rows, 2^60 columns, or 2^32 of each (their product
/// overflows) opens with 100 in place of each count over 100, and renders and exports.
#[test]
fn native_area_type_with_2_to_the_60_rows_or_columns_opens_renders_and_exports() {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let made = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "word ".repeat(40), "area": {"width": 300, "height": 120}})).unwrap();
let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap());
let mut d = (*s.doc().unwrap().doc).clone();
for (rows, columns) in [(1 << 60, 1), (1, 1 << 60), (1 << 32, 1 << 32)] {
let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("area type") };
t.area.rows = rows;
t.area.columns = columns;
let file = vectorcraft_format::base64_encode(&vectorcraft_format::save(&d, false));
s.execute("document.open", &json!({"name": "x.vectorcraft", "dataBase64": file})).unwrap();
let opened = (*s.doc().unwrap().doc).clone();
let Some(vectorcraft_doc::NodeKind::Text(t)) = opened.node(id).map(|n| &n.kind) else { panic!("area type") };
assert_eq!((t.area.rows, t.area.columns), (rows.min(100), columns.min(100)));
survive("area type in 2^60 rows or columns", || Some(opened)).unwrap();
}
}
/// The rich document as EPS (with a preview, a thumbnail and the document it carries), and the
/// document read back from it.
fn eps_sample() -> Vec<u8> {
+25
View File
@@ -83,6 +83,31 @@ proptest! {
}
}
/// Area type's rows and columns from a file: a count up to `u64::MAX` reads clamped to 1..=100,
/// and what loads saves and loads again unchanged.
#[test]
fn area_type_rows_and_columns_read_as_1_to_100(rows in prop_oneof![0u64..=200, any::<u64>()], columns in prop_oneof![0u64..=200, any::<u64>()]) {
use vectorcraft_doc::{AreaOptions, TextKind, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
let count = |n: u64| usize::try_from(n).unwrap_or(usize::MAX);
let mut t = TextObject::point(Point::ZERO, "Rows and columns", Default::default());
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 120.0)) };
t.area.rows = count(rows);
t.area.columns = count(columns);
let mut d = Document::new(400.0, 300.0);
let layer = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
let read = |d: &Document| match d.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some((t.area.rows, t.area.columns)),
_ => None,
};
let back = load(&save(&d, false)).unwrap();
let max = AreaOptions::MAX_ROWS_COLUMNS;
prop_assert_eq!(read(&back), Some((count(rows).clamp(1, max), count(columns).clamp(1, max))));
prop_assert_eq!(read(&load(&save(&back, false)).unwrap()), read(&back));
}
/// Compressed and older-version saves of random documents load to the same document.
#[test]
fn compressed_and_older_saves_roundtrip(ops in arb_ops(5..30)) {
+27 -6
View File
@@ -746,17 +746,20 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, re
}
}
/// A flattened text wrap shape: (polygons, offset, invert).
type FlatWrap = (Vec<Vec<Point>>, f64, bool);
/// One cell of a flattened area-type frame (the whole frame, or one row/column of it).
struct Region {
/// The cell (frame bounds, or a grid cell of them).
cell: Rect,
/// Inset applied inside the frame edges.
inset: f64,
polys: Vec<Vec<Point>>,
polys: std::sync::Arc<[Vec<Point>]>,
/// The frame is its own bounding rectangle (spans need no polygon intersection).
rect: bool,
/// Text Wrap shapes: (polygons, offset, invert).
wraps: Vec<(Vec<Vec<Point>>, f64, bool)>,
/// Text Wrap shapes.
wraps: std::sync::Arc<[FlatWrap]>,
/// Vertical alignment: how far below the top-aligned position the first line starts.
shift: f64,
/// Vertical justification: extra space added between consecutive lines.
@@ -865,7 +868,9 @@ impl Region {
fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec<Region> {
// A frame out of range has no cells, and a wrap shape out of range is left out.
let Some(polys) = polygons(path) else { return vec![] };
let wraps: Vec<(Vec<Vec<Point>>, f64, bool)> = wrap
// Every cell shares one copy of the frame's polygons and one copy of the text wrap shapes.
let polys: std::sync::Arc<[Vec<Point>]> = polys.into();
let wraps: std::sync::Arc<[FlatWrap]> = wrap
.iter()
.filter_map(|w| Some((polygons(&w.path.to_bezpath())?, w.wrap.offset.max(0.0), w.wrap.invert)))
.filter(|w| !w.0.is_empty())
@@ -875,7 +880,8 @@ impl Region {
let area: f64 = polys[0].iter().zip(polys[0].iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>().abs() * 0.5;
(area - bbox.area()).abs() <= bbox.area() * 1e-6 + 1e-9
};
let (rows, cols) = (opts.rows.max(1), opts.columns.max(1));
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 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);
@@ -903,7 +909,7 @@ impl Region {
/// inside them, for Invert Wrap). Offsets grow each wrap shape vertically and horizontally.
fn intervals(&self, y: f64) -> Vec<(f64, f64)> {
let mut iv = if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { poly_intervals(&self.polys, y) };
for (polys, off, invert) in &self.wraps {
for (polys, off, invert) in self.wraps.iter() {
let ys = if *off > 0.0 { vec![y - off, y - off * 0.5, y, y + off * 0.5, y + off] } else { vec![y] };
let w = union_intervals(ys.into_iter().flat_map(|y| poly_intervals(polys, y)).map(|(a, b)| (a - off, b + off)).collect());
iv = if *invert { intersect_intervals(&iv, &w) } else { subtract_intervals(&iv, &w) };
@@ -957,6 +963,21 @@ impl Region {
}
}
/// The 10,000 cells of a frame in 100 rows and 100 columns share one copy of the frame's polygons
/// and one copy of the text wrap shapes.
#[cfg(test)]
#[test]
fn the_cells_of_a_frame_share_its_polygons_and_the_wrap_shapes() {
let frame = kurbo::Circle::new((0.0, 0.0), 1000.0).to_path(0.1);
let square = PathData::from_bezpath(&Rect::new(-10.0, -10.0, 10.0, 10.0).to_path(0.1));
let wrap = vectorcraft_doc::WrapShape { path: square, wrap: Default::default() };
let cells = Region::cells(&frame, &LayoutOptions { rows: 100, columns: 100, ..LayoutOptions::default() }, &[wrap]);
assert_eq!(cells.len(), 10_000);
let first = &cells[0];
assert!(!first.polys.is_empty() && !first.wraps.is_empty());
assert!(cells.iter().all(|c| c.polys.as_ptr() == first.polys.as_ptr() && c.wraps.as_ptr() == first.wraps.as_ptr()));
}
/// Vertical metrics of a line: (ascent, descent, leading, cap height, x height).
#[derive(Clone, Copy, Debug, PartialEq)]
struct Metrics {
+2 -1
View File
@@ -45,7 +45,8 @@ pub use vectorcraft_doc::Composer;
/// composer, OpenType features). [`layout`] takes rows/columns/inset/first baseline from the object.
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutOptions {
/// Area type rows and columns (text flows down each column, then across).
/// Area type rows and columns (text flows down each column, then across). Layout clamps each
/// count to 1..=[`vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS`].
pub rows: usize,
pub columns: usize,
/// Gutter between rows/columns in points.
+21
View File
@@ -627,6 +627,27 @@ fn rows_and_columns_flow_in_order() {
}
}
/// Layout clamps area type's rows and columns to 100 each: 2^60 rows or columns with 100 of the
/// other, 2^32 of each, and `usize::MAX` of each lay out as 100 rows and 100 columns.
#[test]
fn area_type_lays_out_in_at_most_100_rows_and_100_columns() {
// At 100 by 100, a cell is about 98 by 28 pt.
let with = |rows: usize, columns: usize| {
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 10_000.0, 3_000.0), Justify::Left);
t.area.rows = rows;
t.area.columns = columns;
t.area.gutter = 2.0;
layout_soon(&t)
};
let most = with(100, 100);
assert_eq!(most.frames.len(), 10_000);
assert!(!most.overflow && most.lines.len() > 10, "{:?}", line_places(&most));
for (rows, columns) in [(1 << 60, 100), (100, 1 << 60), (1 << 32, 1 << 32), (usize::MAX, usize::MAX)] {
let l = with(rows, columns);
assert_eq!((&l.frames, line_places(&l)), (&most.frames, line_places(&most)), "{rows} by {columns}");
}
}
// ---------- vertical alignment ----------
fn valign(a: VerticalAlign) -> LayoutOptions {