diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 90fdf15b9..b33e9ad5d 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -507,7 +507,7 @@ fn links(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, link } /// Paper units per drawing unit for a plot. -fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option) -> f64 { +pub(crate) fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option) -> f64 { if let Some(s) = scale.filter(|s| s.is_finite() && *s > 0.0) && !fit { diff --git a/crates/io/src/plotter.rs b/crates/io/src/plotter.rs index 03c26ace8..cc6a07784 100644 --- a/crates/io/src/plotter.rs +++ b/crates/io/src/plotter.rs @@ -56,18 +56,10 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<(Sheet, cadcraft_ Ok((Sheet::from_page(&page, paper::paper_unit_mm(d)), page)) } -/// Paper units per drawing unit fitting `b` into the printable area (or the `scale` option). +/// Paper units per drawing unit fitting `b` into the printable area; with `fit: false`, the +/// `scale` option or 1:1, as PDF plots it. fn fit_scale(b: &Bounds2, sheet: &Sheet, o: &PdfOptions) -> f64 { - if let Some(s) = o.scale.filter(|s| s.is_finite() && *s > 0.0) - && o.fit == Some(false) - { - return s; - } - if b.is_empty() { - return 1.0; - } - let s = (sheet.printable.width() / b.width().max(1e-12)).min(sheet.printable.height() / b.height().max(1e-12)); - if s.is_finite() && s > 0.0 { s } else { 1.0 } + pdf::plot_scale(b, sheet, o.fit != Some(false), o.scale) } fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { diff --git a/crates/io/tests/plot_model_png_scale.rs b/crates/io/tests/plot_model_png_scale.rs new file mode 100644 index 000000000..2f2e6d466 --- /dev/null +++ b/crates/io/tests/plot_model_png_scale.rs @@ -0,0 +1,87 @@ +//! A model-space PNG plot with `fit: false` and no `scale` plots at 1:1, as PDF does (issue #713). + +use cadcraft_doc::{Common, Drawing, EntityKind, Line, Space}; +use cadcraft_geom::Vec3; +use serde_json::{Value, json}; + +/// The RGBA rows of an 8-bit RGBA PNG (the only kind the plotter writes). +fn decode_rgba(png: &[u8]) -> (usize, Vec>) { + let be = |i: usize| u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]) as usize; + let (w, h) = (be(16), be(20)); + assert_eq!((png[24], png[25]), (8, 6), "8-bit RGBA"); + let mut idat = Vec::new(); + let mut at = 8; + while at + 8 <= png.len() { + let len = be(at); + if &png[at + 4..at + 8] == b"IDAT" { + idat.extend_from_slice(&png[at + 8..at + 8 + len]); + } + at += 12 + len; + } + let raw = miniz_oxide::inflate::decompress_to_vec_zlib(&idat).unwrap(); + let stride = w * 4; + let mut rows: Vec> = Vec::with_capacity(h); + for y in 0..h { + let line = &raw[y * (stride + 1)..(y + 1) * (stride + 1)]; + let prev = if y > 0 { rows[y - 1].clone() } else { vec![0; stride] }; + let mut cur = line[1..].to_vec(); + for i in 0..stride { + let a = if i >= 4 { i32::from(cur[i - 4]) } else { 0 }; + let b = i32::from(prev[i]); + let c = if i >= 4 { i32::from(prev[i - 4]) } else { 0 }; + let add = match line[0] { + 0 => 0, + 1 => a, + 2 => b, + 3 => (a + b) / 2, + 4 => { + let p = a + b - c; + let (pa, pb, pc) = ((p - a).abs(), (p - b).abs(), (p - c).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } + } + f => panic!("PNG filter {f}"), + }; + cur[i] = cur[i].wrapping_add(add as u8); + } + rows.push(cur); + } + (w, rows) +} + +/// Plotted length in millimetres of a 100-unit horizontal line in model space, on an A4 +/// landscape PNG at 100 dpi with 5 mm margins (printable area 287 × 200 mm). +fn plotted_mm(opts: Value) -> f64 { + let mut d = Drawing::new_metric(); + let line = EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(100.0, 0.0, 0.0) }); + d.add(&Space::Model, Common::default(), line).unwrap(); + let mut o = + json!({"output": "png", "width": 210, "height": 297, "landscape": true, "margins": [5, 5, 5, 5], "resolution": 100, "lineweights": false}); + if let (Some(o), Some(e)) = (o.as_object_mut(), opts.as_object()) { + o.extend(e.clone()); + } + let (w, rows) = decode_rgba(&cadcraft_io::plotter::plot(&d, &Space::Model, &o).unwrap()); + let inked: Vec = (0..w).filter(|x| rows.iter().any(|r| r[x * 4] < 128)).collect(); + let (first, last) = (inked.first().unwrap(), inked.last().unwrap()); + (last - first) as f64 * 25.4 / 100.0 +} + +#[test] +fn model_png_without_fit_or_scale_plots_one_to_one() { + // One pixel is 0.254 mm at 100 dpi. + for (opts, mm) in [ + (json!({"fit": false}), 100.0), + (json!({"fit": false, "scale": 1}), 100.0), + (json!({"fit": false, "scale": 0.5}), 50.0), + (json!({"fit": true}), 287.0), + (json!({}), 287.0), + ] { + let got = plotted_mm(opts.clone()); + assert!((got - mm).abs() < 0.6, "{opts}: {got} mm, want {mm}"); + } +}