mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 14:33:34 +00:00
Merge pull request #5763 from dotCooCoo/fix/svg-export-long-path
SVG export: numbers are written from integers, not through the exact float formatter
This commit is contained in:
@@ -624,6 +624,49 @@ pub fn xml_escape(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// [`push_num`] through integer arithmetic, for the values it writes the same as the decimal
|
||||
/// formatter: `decimals` of at most 9, and `v` scaled by 10^`decimals` below 2^53 and further from
|
||||
/// halfway between two integers than the scaling's rounding error. Returns false, writing nothing,
|
||||
/// for any other value.
|
||||
fn push_num_scaled(out: &mut String, v: f64, decimals: u8) -> bool {
|
||||
const SCALE: [f64; 10] = [1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9];
|
||||
let Some(&scale) = SCALE.get(usize::from(decimals)) else { return false };
|
||||
// Each power of ten here is exact, so `x` is within half an ulp (`x * EPSILON / 2`) of |v| × 10^decimals.
|
||||
let x = v.abs() * scale;
|
||||
if x.is_nan() || x >= 9.0e15 {
|
||||
return false;
|
||||
}
|
||||
let whole = x.floor();
|
||||
let frac = x - whole;
|
||||
if (frac - 0.5).abs() <= x * f64::EPSILON {
|
||||
return false;
|
||||
}
|
||||
let mut n = whole as u64 + u64::from(frac > 0.5);
|
||||
let places = usize::from(decimals);
|
||||
// The digits of `n` from the last one, at least one more than `places` of them.
|
||||
let mut digits = [0u8; 24];
|
||||
let mut len = 0;
|
||||
for d in digits.iter_mut() {
|
||||
if n == 0 && len > places {
|
||||
break;
|
||||
}
|
||||
*d = b'0' + (n % 10) as u8;
|
||||
n /= 10;
|
||||
len += 1;
|
||||
}
|
||||
let Some((fraction, int)) = digits.get(..len).and_then(|d| d.split_at_checked(places)) else { return false };
|
||||
let zeros = fraction.iter().take_while(|&&d| d == b'0').count();
|
||||
if v < 0.0 && (zeros < places || int.iter().any(|&d| d != b'0')) {
|
||||
out.push('-');
|
||||
}
|
||||
out.extend(int.iter().rev().map(|&d| char::from(d)));
|
||||
if zeros < places {
|
||||
out.push('.');
|
||||
out.extend(fraction.iter().skip(zeros).rev().map(|&d| char::from(d)));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Format a number with at most `decimals` places, trimming trailing zeros.
|
||||
pub fn fmt_num(v: f64, decimals: u8) -> String {
|
||||
let mut s = String::new();
|
||||
@@ -635,6 +678,9 @@ pub fn fmt_num(v: f64, decimals: u8) -> String {
|
||||
pub fn push_num(out: &mut String, v: f64, decimals: u8) {
|
||||
use std::fmt::Write as _;
|
||||
let v = if v.is_finite() { v } else { 0.0 };
|
||||
if push_num_scaled(out, v, decimals) {
|
||||
return;
|
||||
}
|
||||
let start = out.len();
|
||||
let _ = write!(out, "{:.*}", decimals as usize, v); // writing to a String can't fail
|
||||
let s = out.get(start..).unwrap_or_default();
|
||||
@@ -674,4 +720,48 @@ mod tests {
|
||||
fn escaping() {
|
||||
assert_eq!(xml_escape("a<b>&\"'"), "a<b>&"'");
|
||||
}
|
||||
|
||||
/// `v` at `decimals` places by the decimal formatter, trailing zeros trimmed.
|
||||
fn formatted(v: f64, decimals: u8) -> String {
|
||||
let s = format!("{:.*}", usize::from(decimals), if v.is_finite() { v } else { 0.0 });
|
||||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { &s };
|
||||
if matches!(s, "-0" | "") { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_match_the_decimal_formatter() {
|
||||
let mut values =
|
||||
vec![0.0, -0.0, 0.5, -0.5, 2.5, 0.0005, -0.0005, 1.0005, 0.125, 1e-12, 9.0e15, 4503599627370495.5, 1e20, f64::NAN, f64::INFINITY];
|
||||
// Coordinates of every size, and values on and just off the halfway points between written numbers.
|
||||
let mut seed = 0x2545_f491_4f6c_dd1d_u64;
|
||||
for _ in 0..20_000 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
let unit = (seed >> 11) as f64 / (1u64 << 53) as f64;
|
||||
let v = (unit - 0.5) * 10f64.powi((seed % 13) as i32 - 3);
|
||||
let tie = ((v * 1000.0).trunc() + 0.5) / 1000.0;
|
||||
values.extend([v, tie, f64::from_bits(tie.to_bits() + 1), f64::from_bits(tie.to_bits() - 1)]);
|
||||
}
|
||||
for v in values {
|
||||
for d in 0..=12 {
|
||||
assert_eq!(fmt_num(v, d), formatted(v, d), "{v:e} at {d} decimals");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinates_are_written_through_integers() {
|
||||
for (v, d, want) in [(123.4567, 3, "123.457"), (-0.25, 3, "-0.25"), (-0.0004, 3, "0"), (1600.0, 3, "1600"), (7.0, 0, "7")] {
|
||||
let mut s = String::new();
|
||||
assert!(push_num_scaled(&mut s, v, d), "{v} at {d} decimals");
|
||||
assert_eq!(s, want);
|
||||
}
|
||||
// Halfway as stored, too large, or too many places: the decimal formatter writes them.
|
||||
for (v, d) in [(0.5, 0), (0.125, 2), (1e16, 0), (1.0, 10)] {
|
||||
let mut s = String::new();
|
||||
assert!(!push_num_scaled(&mut s, v, d), "{v} at {d} decimals");
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1169,6 +1169,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
|
||||
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
|
||||
SVG export (Save As, Export As, Export for Screens, Asset Export and the clipboard share the writer) writes simple shapes as SVG basic shapes, as the reference app does (an ellipse is an `ellipse` tag until an anchor is added, [forum](https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325); rectangles come out as `<rect>` and `<polygon>`, [forum](https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378)): an axis-aligned rectangle as `<rect>`, a circle or ellipse drawn with four standard arcs as `<circle>`/`<ellipse>`, a straight line as `<line>`, and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision (#4652). A rectangle or ellipse with a dashed stroke, whose dashes would start elsewhere, a turned ellipse, curves and compound paths stay `<path>`. `crates/svg/tests/it/basic_shapes.rs` checks each element, that the picture renders as the canvas does and that the geometry reimports.
|
||||
At a precision of 9 decimals or fewer, SVG export writes each number from an integer when the integer rounds it as the decimal formatter does; the formatter still writes values halfway between two written numbers and values too large for the integer. Exporting one path of 200,000 anchors takes a median of 43.4 ms instead of 167.0 ms on Windows 11, and 20,000 rectangles 81.4 ms instead of 141.4 ms, with the same SVG (`crates/svg/src/lib.rs`).
|
||||
SVG import reads each `<circle>` and `<ellipse>` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `<ellipse>` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`).
|
||||
SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`).
|
||||
In a `.ai` or EPS opened from its editing data, type has its text object's opacity and blend mode, whether it is made from the file's text document or taken from the page. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning.
|
||||
|
||||
Reference in New Issue
Block a user