diff --git a/.github/workflows/affinity-corpus.yml b/.github/workflows/affinity-corpus.yml index 41ac5a00d7..b3e0178f56 100644 --- a/.github/workflows/affinity-corpus.yml +++ b/.github/workflows/affinity-corpus.yml @@ -30,7 +30,7 @@ jobs: egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0 with: toolchain: "1.95" - name: Fetch pinned and checksum-verified corpus diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eee66ab3be..fd836de6c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,10 @@ name: Release on: + # A nightly dry run on main: every release build except macOS, nothing signed or released + # (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day. + schedule: + - cron: "52 2 * * *" push: branches: [release] workflow_dispatch: @@ -77,6 +81,7 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: + dry-run: ${{ github.event_name == 'schedule' }} app-name: vectorcraft app-title: VectorCraft craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index 9d917ae32b..0a70e80433 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -613,7 +613,11 @@ fn math_random_is_seeded_from_the_os_random_source() { let Some(start) = src.find("fn random_seed()") else { panic!("interp.rs has no random_seed") }; let body = &src[start..]; let body = &body[..body.find("\n}").unwrap_or(body.len())]; - assert!(body.contains("getrandom::u64()"), "Math.random isn't seeded from the OS random source"); + assert!(body.contains("random_seed_from(getrandom::u64())"), "Math.random isn't seeded from the OS random source"); + // The OS value is the seed, made odd so it's never zero; a failed read still gives a seed. + assert_eq!(crate::interp::random_seed_from(Ok(0x40)), 0x41); + assert_eq!(crate::interp::random_seed_from(Ok(0)), 1); + assert_eq!(crate::interp::random_seed_from(Err(getrandom::Error::UNSUPPORTED)) & 1, 1); } // ---- JSON ---- diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index b04702be58..458d68f40c 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -318,8 +318,13 @@ fn index_keys_bytes(len: usize) -> usize { /// random source (the browser's crypto on the web), or the standard library's randomly keyed /// hasher when that fails; never zero. fn random_seed() -> u64 { + random_seed_from(getrandom::u64()) +} + +/// [`random_seed`] from what the OS random source returned. +pub(crate) fn random_seed_from(os: Result) -> u64 { use std::hash::{BuildHasher, Hasher}; - getrandom::u64().unwrap_or_else(|_| { + os.unwrap_or_else(|_| { let mut h = std::collections::hash_map::RandomState::new().build_hasher(); h.write_u64(0x9e37_79b9_7f4a_7c15); h.finish() diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index ced1db16e2..a82503391c 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use kurbo::{CubicBez, ParamCurve, PathSeg, Point, Rect, Vec2}; +use kurbo::{CubicBez, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Rect, Vec2}; use vectorcraft_geom::path::measured_arclen; use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; @@ -85,9 +85,10 @@ pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { } /// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it. A closed subpath -/// whose refit is one segment, or two lines that enclose no area, is refitted in two parts, cut at -/// the point farthest from its start, or in three when two still give that; one of a single segment -/// stays as it is. +/// whose refit is one segment, or lines that enclose no area, is refitted in two parts, cut at the +/// point farthest from its start, or in three when two still give that. When three parts still give +/// no area, straight lines follow the subpath within the tolerance, and without straight lines it +/// stays as it is, as does one of a single segment; one that is all in line keeps the refit. fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { let mut segs = subpath_segs(sp); let n = segs.len(); @@ -105,7 +106,12 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { let across = if sp.closed && opts.smooth > 0.0 { unit(end_tangent(&segs[n - 1].c) + start_tangent(&segs[0].c)) } else { None }; let across = across.filter(|_| (0..n).all(|i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) <= opts.corner_angle_deg)); let mut result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across); - let flat = |r: &[Seg]| r.len() < 2 || r.len() == 2 && r.iter().all(|s| s.line); + let in_line = |pts: &[Point]| { + let Some(&a) = pts.first() else { return true }; + let far = pts.iter().map(|&p| p - a).max_by(|u, v| u.hypot2().total_cmp(&v.hypot2())).unwrap_or_default(); + pts.iter().all(|&p| far.cross(p - a).abs() <= 1e-6 * far.hypot()) + }; + let flat = |r: &[Seg]| r.len() < 2 || r.iter().all(|s| s.line) && in_line(&r.iter().map(|s| s.c.p0).collect::>()); if sp.closed && flat(&result) { // Each segment in two, and the loop cut where they meet: first at the point farthest from its // start, then also at the point farthest from the line between those two. @@ -131,6 +137,30 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { if flat(&result) && k != j { result = refit(&[0, j.min(k), j.max(k), m]); } + let ctrl: Vec = segs.iter().flat_map(|s| [s.c.p0, s.c.p1, s.c.p2]).collect(); + if flat(&result) && !in_line(&ctrl) { + // Each segment split in 2, 4, 8 and so on, up to 1,024 pieces and 65,536 in all, until every + // piece is within the tolerance of the line between its ends and those lines enclose an + // area: with straight lines they are the result, and without them the loop stays as it is. + // A loop whose points are all in line keeps the lines of the refit. + let near = |c: &CubicBez| [c.p1, c.p2].iter().all(|&p| Line::new(c.p0, c.p3).nearest(p, 1e-9).distance_sq.sqrt() <= opts.tolerance); + for k in 1..=10u32 { + let parts = 1u32 << k; + if segs.len().saturating_mul(parts as usize) > 1 << 16 { + break; + } + let at = |i: u32| f64::from(i) / f64::from(parts); + let pieces: Vec = segs.iter().flat_map(|s| (0..parts).map(move |i| s.c.subsegment(at(i)..at(i + 1)))).collect(); + let lines: Vec = pieces.iter().map(|c| Seg::line(c.p0, c.p3)).collect(); + if pieces.iter().all(near) && !flat(&lines) { + if !opts.straight_lines { + return Some(sp.clone()); + } + result = lines; + break; + } + } + } } let s = segs_to_subpath(&result, sp.closed)?; Some(if opts.straight_lines { s } else { no_worse(sp, s) }) diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 01e89efe08..9a6fb57138 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -424,17 +424,59 @@ fn simplify_keeps_a_closed_subpath_that_refits_to_one_curve() { ], true, )); + // A straight edge and an S curve whose midpoints all lie on that edge. + let s_curve = |h: f64, x: f64| { + PathData::single(SubPath::new( + vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(0.0, h)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(x, -h), Point::new(100.0, 0.0)), + ], + true, + )) + }; + let (s_low, s_high) = (s_curve(3.0, 0.0), s_curve(13.5, 100.0)); for straight_lines in [false, true] { for tolerance in [4.0, 10.0, 32.0] { let opts = SimplifyOptions { tolerance, straight_lines, ..Default::default() }; - for path in [&compound, &lone, &bow] { + for path in [&compound, &lone, &bow, &s_low] { let s = simplify_with(path, &opts); assert_eq!(s.subpaths.len(), path.subpaths.len(), "{tolerance} pt, straight lines {straight_lines}: {s:?}"); assert!(s.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 2 && sp.area().abs() > 1.0), "{s:?}"); assert!(max_deviation(path, &s) <= tolerance + 1e-6, "{tolerance} pt: {}", max_deviation(path, &s)); + let lines = s.subpaths.iter().all(|sp| (0..sp.segment_count()).all(|i| sp.segment_is_line(i))); + assert!(lines || !straight_lines, "{tolerance} pt: {s:?}"); } } } + // Lobes on both sides of the edge, 3.9 pt from it, stay within the tolerance. + for straight_lines in [false, true] { + let s = simplify_with(&s_high, &SimplifyOptions { tolerance: 4.0, straight_lines, ..Default::default() }); + assert!(max_deviation(&s_high, &s) <= 4.0 + 1e-6, "straight lines {straight_lines}: {}", max_deviation(&s_high, &s)); + } + // Curves whose handles lie on the line between their anchors become lines. + let thin = PathData::single(SubPath::new( + vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(30.0, 0.0)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(70.0, 0.0), Point::new(80.0, 0.0)), + ], + true, + )); + let s = simplify_with(&thin, &SimplifyOptions { tolerance: 4.0, straight_lines: true, ..Default::default() }); + assert!(s.subpaths.iter().all(|sp| (0..sp.segment_count()).all(|i| sp.segment_is_line(i))), "{s:?}"); + // Anchors in line on a closed path are still removed. + let in_line = PathData::single(SubPath::new([0.0, 25.0, 50.0, 100.0].map(|x| Anchor::corner(Point::new(x, 0.0))).to_vec(), true)); + let s = simplify_with(&in_line, &SimplifyOptions { tolerance: 4.0, ..Default::default() }); + assert!(s.subpaths[0].anchors.len() < 4, "{s:?}"); + let many = PathData::single(SubPath::new((0..5000).map(|i| Anchor::corner(Point::new(f64::from(i), 0.0))).collect(), true)); + for tolerance in [4.0, f64::INFINITY, f64::NAN] { + for path in [&in_line, &thin, &many] { + simplify_with(path, &SimplifyOptions { tolerance, straight_lines: true, ..Default::default() }); + } + } + // A long, thin rectangle keeps its corners. + let long = rect(0.0, 0.0, 1e9, 0.5); + let s = simplify_with(&long, &SimplifyOptions { tolerance: 0.1, straight_lines: true, ..Default::default() }); + assert!(max_deviation(&long, &s) <= 0.1 + 1e-6, "{s:?}"); } /// The Simplify slider's levels: 1 pt at 50, doubled for every 10 levels down and halved for every diff --git a/crates/svg/src/import/depth.rs b/crates/svg/src/import/depth.rs index 2cdccc9dad..0586deaf6f 100644 --- a/crates/svg/src/import/depth.rs +++ b/crates/svg/src/import/depth.rs @@ -220,7 +220,14 @@ fn prescan_with(svg: &str, most: usize) -> Prescan<'_> { /// `most`. Without recursion. pub(super) fn height(xml: &roxmltree::Document, most: usize) -> Option { let elements = || xml.descendants().filter(|n| n.is_element()); - let ids: HashMap<&str, XNode> = elements().filter_map(|n| Some((n.attribute("id")?, n))).collect(); + // The first element of an id, as usvg resolves references: a later one of the same id would + // hide a deep target behind a shallow one. + let mut ids: HashMap<&str, XNode> = HashMap::new(); + for n in elements() { + if let Some(id) = n.attribute("id") { + ids.entry(id).or_insert(n); + } + } let css = Styles::new(xml); // How deep each element's own tree goes, itself included; 0 while it is being worked out: a // reference back to it then is a loop, which usvg leaves out. @@ -394,6 +401,16 @@ mod tests { assert!(!deep(&svg(&format!("{defs}{art}")))); } + #[test] + fn a_reference_follows_the_first_element_of_its_id() { + // A chain of ``s as deep as the cap, each link's id given again later to an empty group: + // usvg follows the first, so the chain is deep. + let chain: String = (1..MAX_ELEMENT_DEPTH).map(|i| format!("", i - 1)).collect(); + let shallow: String = (1..MAX_ELEMENT_DEPTH).map(|i| format!("")).collect(); + let top = format!("", MAX_ELEMENT_DEPTH - 1); + assert!(deep(&svg(&format!("{chain}{shallow}{top}")))); + } + #[test] fn a_loop_of_references_is_not_deep() { assert!(!deep(&svg(""))); diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index 9de3bebcdf..1a4c06f0e6 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -49,11 +49,12 @@ pub(in crate::import) struct Files { /// How usvg holds the bytes of a raster of one format (`ImageKind::PNG`…). type RasterKind = fn(Arc>) -> ImageKind; +#[derive(Clone)] enum Source { /// A raster file of the kind usvg reads it as. Raster(RasterKind, Arc>), /// An SVG file's text (unpacked when it is SVGZ), parsed by usvg as an image. - Svg(String), + Svg(Arc), } /// A linked raster image (or the placeholder of a missing one). @@ -133,14 +134,29 @@ impl Files { pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec) -> Self { let mut files = Files::default(); let mut placeholder = None; + // What each file read gave, by the file: hrefs spelled differently (`a.png`, `./a.png`, + // `%61.png`) share one read and one copy of its bytes. + let mut by_file: HashMap> = HashMap::new(); for n in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "image") { let Some(h) = href(n).filter(|h| !h.trim_start().starts_with("data:")) else { continue }; if !files.sources.contains_key(h) { let (path, file) = target(h, opts.folder); - let read = file.and_then(|file| opts.read.and_then(|read| read(&file))); - let source = match read { - Some((bytes, link)) => files.source(bytes, link).ok_or("is not an image VectorCraft reads"), - None => Err("not found"), + // A failed read is cached too, but each href gets its own placeholder: its own link + // path (an Arc shared between hrefs would share the first href's link) and warning. + let cached = file.as_ref().and_then(|f| by_file.get(f)).cloned(); + let source = match cached { + Some(source) => source, + None => { + let read = file.clone().and_then(|file| opts.read.and_then(|read| read(&file))); + let source = match read { + Some((bytes, link)) => files.source(bytes, link).ok_or("is not an image VectorCraft reads"), + None => Err("not found"), + }; + if let Some(file) = file { + by_file.insert(file, source.clone()); + } + source + } }; let source = source.or_else(|why| { warnings.push(format!("linked image '{h}' {why}: a placeholder keeps its place until it is relinked")); @@ -164,7 +180,7 @@ impl Files { let Ok(format) = image::guess_format(&bytes) else { // Unpacked once, here, within what the SVGZ images may unpack to. let text = crate::text_within(&bytes, &self.unpacked).ok()?; - return text.contains(" impl Iterator { v.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()) @@ -1155,6 +1159,12 @@ fn justify_of(anchor: f64) -> Justify { } } +/// How far a line's leading may differ from its spacing and still count as it: 0.01 absolute, but +/// never more than a thousandth of the spacing itself (tiny type has tiny gaps). +fn leading_tolerance(spacing: f64) -> f64 { + 0.01_f64.min(spacing.abs() * 1e-3) +} + /// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the /// justification, the x of the first line's left (or anchor) edge and, when no character is /// placed on its own, each line's first character and anchor x. @@ -1175,6 +1185,7 @@ fn lines( // Line starts (first character, anchor x) and absolutely placed characters (index, x). let mut lines = vec![(0, x0)]; let mut targets: Vec<(usize, f64)> = vec![]; + let mut added = 0usize; for i in 1..n { let ny = y[i].unwrap_or(cur) + dy[i]; let lead = cells[i].style.effective_leading(); @@ -1183,7 +1194,9 @@ fn lines( // Whole multiples of the auto leading add blank lines (not without limit: a far-off y // mustn't ask for billions); other spacing is one break. let count = (gap / lead).round().clamp(1.0, MAX_LINE_GAP); - let count = if (gap / count - lead).abs() <= lead * 0.05 { count } else { 1.0 }; + // The first newline just ends the previous line: only the others are blank lines. + let count = if (gap / count - lead).abs() <= lead * 0.05 && added + (count as usize - 1) <= MAX_LINE_BREAKS { count } else { 1.0 }; + added += count as usize - 1; breaks.push(Break { at: i, count: count as usize, leading: gap / count }); lines.push((i, ax + dx[i])); (base, cur) = (ny, ny); @@ -1199,7 +1212,8 @@ fn lines( // A line's characters take its spacing as leading unless it is their auto leading. for (k, b) in breaks.iter().enumerate() { let end = breaks.get(k + 1).map_or(n, |nb| nb.at); - if cells[b.at..end].iter().any(|c| (c.style.effective_leading() - b.leading).abs() > 0.01) { + let tolerance = leading_tolerance(b.leading); + if cells[b.at..end].iter().any(|c| (c.style.effective_leading() - b.leading).abs() > tolerance) { for c in &mut cells[b.at..end] { c.style.leading = Some(b.leading); } @@ -1372,7 +1386,7 @@ fn assemble(cells: &[Cell], breaks: &[Break]) -> (Runs, Vec) { let key = (prev.fill, prev.stroke); push(&mut out, '\n', prev.final_style(), key); let mut blank = prev.final_style(); - if (blank.effective_leading() - b.leading).abs() > 0.01 { + if (blank.effective_leading() - b.leading).abs() > leading_tolerance(b.leading) { blank.leading = Some(b.leading); } for _ in 1..b.count { diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 10418f3441..d6983a1ce2 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -88,6 +88,23 @@ fn a_linked_png_resolves_against_the_svg_folder_and_stays_linked() { assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.width() - 80.0).abs() < 1e-6, "{b:?}"); } +#[test] +fn hrefs_spelling_one_file_differently_read_it_once() { + let dir = folder("spellings"); + std::fs::write(dir.join("img").join("a.png"), png(4, 4)).unwrap(); + let reads = std::sync::atomic::AtomicUsize::new(0); + let counting = |path: &str| { + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + read(path) + }; + let folder = dir.to_string_lossy(); + let body = r#""#; + let (d, w) = import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&counting) }).unwrap(); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(images(&d).len(), 3); + assert_eq!(reads.into_inner(), 1, "one read for the three spellings"); +} + #[test] fn a_missing_link_warns_and_keeps_a_placeholder_in_its_box() { let dir = folder("missing"); @@ -139,6 +156,26 @@ fn files_with_the_same_pixels_have_keys_of_their_own() { assert_eq!(*d.images[&b.key].bytes, bytes); } +#[test] +fn hrefs_spelling_one_missing_file_differently_each_warn_and_keep_their_own_link() { + let dir = folder("missing_twice"); + let reads = std::sync::atomic::AtomicUsize::new(0); + let counting = |path: &str| { + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + read(path) + }; + let folder = dir.to_string_lossy(); + let body = r#""#; + let (d, w) = import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&counting) }).unwrap(); + assert_eq!(w.iter().filter(|w| w.contains("gone.png") && w.contains("not found")).count(), 2, "{w:?}"); + assert_eq!(reads.into_inner(), 1, "the failed read is cached"); + let found = images(&d); + assert_eq!(found.len(), 2); + for (_, im) in found { + assert_eq!(Path::new(&im.link.as_ref().unwrap().path), dir.join("img").join("gone.png")); + } +} + #[test] fn svg_images_import_as_vector_art() { let dir = folder("svgimage"); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 35289a0c1d..8b0d0338c6 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -277,6 +277,25 @@ fn anchored_letter_spaced_lines_stay_where_a_reader_draws_them() { assert!(close(s[0], 200.0 - a[0] / 2.0, 1e-6) && close(s[1], 200.0 - a[1], 1e-6), "{s:?} {a:?}"); } +#[test] +fn a_small_line_gap_in_tiny_type_keeps_its_leading() { + // 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it. + let d = open(r#"OneTwo"#); + let t = only_text(&d); + let two = t.runs.iter().find(|r| r.text.contains("Two")).unwrap().style.leading; + assert!(two.is_some_and(|l| close(l, 0.0085, 1e-9)), "{two:?}"); +} + +#[test] +fn blank_lines_in_tiny_type_keep_their_own_spacing() { + // B sits 0.0085 below A (kept as its leading); C is three 0.006 lines below B, so the two blank + // lines between them must be 0.006 apart, not carry B's 0.0085. + let d = open(r#"ABC"#); + let t = only_text(&d); + let blank = t.runs.iter().find(|r| r.text == "\n\n").expect("a run of the two blank lines"); + assert!(blank.style.leading.is_some_and(|l| close(l, 0.006, 1e-9)), "{:?}", blank.style.leading); +} + #[test] fn tspan_lines_dx_and_baseline_shift() { let d = open( diff --git a/crates/svg/tests/it/roundtrip.rs b/crates/svg/tests/it/roundtrip.rs index b13ecf160b..127e47846c 100644 --- a/crates/svg/tests/it/roundtrip.rs +++ b/crates/svg/tests/it/roundtrip.rs @@ -909,6 +909,22 @@ fn import_tspan_far_below_is_bounded() { assert!(breaks <= 10_002, "{breaks} line breaks"); } +#[test] +fn import_many_tspans_far_apart_are_bounded_together() { + // Each tspan 9,999 lines (auto leading 14.4 pt) below the last: each within the limit of one gap, + // two hundred of them were two million line breaks. + let spans: String = (1..=200).map(|i| format!(r#"{i}"#, 20.0 + 14.4 * 9_999.0 * i as f64)).collect(); + let d = import_art(&format!( + r#"a{spans}"# + )); + let a = art(&d); + let NodeKind::Text(t) = &a[0].kind else { panic!() }; + let text = t.plain_text(); + assert!(text.ends_with("200"), "{}", &text[text.len().saturating_sub(20)..]); + let breaks = text.matches('\n').count(); + assert!(breaks <= 10_200, "{breaks} line breaks"); +} + #[test] fn import_tspan_on_the_same_baseline_stays_on_the_line() { // Styled runs positioned on the line's own baseline (as Illustrator writes them, with x and y on