From 796e2a7ebc91709fb2303e66df1de657145ae339 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 11:00:51 -0800 Subject: [PATCH 1/5] Simplify: a closed subpath whose refits enclose no area keeps its outline Follows #6614. When a closed subpath's refit in two and in three parts is still lines that enclose no area, for example an S curve closed by a straight edge whose cut points all lie on that edge, Simplify now splits each segment in 2, 4, 8 and so on (up to 1,024 pieces, 65,536 in all) until every piece is within the tolerance of the line between its ends and those lines enclose an area. With Convert to Straight Lines those lines are the result; without it the subpath stays as it is. A subpath whose points are all in line keeps the refit's lines, so its extra anchors are still removed. Lines count as enclosing no area when every point is within 1e-6 pt of one line. --- crates/pathops/src/edit.rs | 40 +++++++++++++++++++++++++++---- crates/pathops/tests/it/ops.rs | 44 +++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 6 deletions(-) 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 From 27c24a4f53046543a667103a024df05967351ec0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 11:23:23 -0800 Subject: [PATCH 2/5] Scripts: test that the OS random value seeds Math.random random_seed passes the OS random source's result to random_seed_from, so a test can give it a known value and check the seed it makes, instead of only reading the source text. --- crates/js/src/builtins_tests.rs | 6 +++++- crates/js/src/interp.rs | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) 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 5d59d92d2e..4f6a78a39f 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -311,8 +311,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() From 8e8b43f045b9945d0824cbcb1fec6b4bcc7c5dd1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:02:52 -0500 Subject: [PATCH 3/5] ci: use StepSecurity's maintained actions (#6658) Each third-party action that StepSecurity publishes a secure drop-in replacement for now uses that replacement, pinned to a commit with its version as a comment. Versions follow what we used: the newest StepSecurity release in the same major, or the closest major they publish. dtolnay/rust-toolchain refs that named a toolchain (stable, nightly, 1.76) become the replacement's `toolchain` input. --- .github/workflows/affinity-corpus.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 566f720868c180c862311fb174cefe91e81e7abd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:11:34 -0500 Subject: [PATCH 4/5] SVG import: references follow an id's first element, one read per linked file, bounded blank lines (#5409) * fix(svg): first element of an id, shared linked file reads, bounded blank lines * address review: cache failed linked-image reads but give each href its own placeholder and link * address review: charge only blank lines to the line-break budget * address review: a collapsed line break keeps its measured leading in tiny type * address review: blank lines use the same scaled leading tolerance --- crates/svg/src/import/depth.rs | 19 +++++++++++++- crates/svg/src/import/files.rs | 28 +++++++++++++++----- crates/svg/src/import/text.rs | 20 ++++++++++++--- crates/svg/tests/it/import_details.rs | 37 +++++++++++++++++++++++++++ crates/svg/tests/it/import_text.rs | 19 ++++++++++++++ crates/svg/tests/it/roundtrip.rs | 16 ++++++++++++ 6 files changed, 129 insertions(+), 10 deletions(-) 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 From 7f3a046b184661e8e9386acf3706f9a1eeb1d1c6 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:04:49 +0100 Subject: [PATCH 5/5] CI: nightly release dry run (#6678) release.yml also runs nightly on main with craft-common-actions' dry-run: every release build except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits (riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release day, as it did on 11 Nov 2026. --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) 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