From c2355b0b0eeafd3db920674eb6b70739f9db50d9 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 12 Nov 2026 21:36:03 +0800 Subject: [PATCH] fix(measure): nudge whole units by a step of the last unit; keep Y's own units and CYX A whole-unit nudge measured in the larger unit could exceed a step of the last unit (1 km - 0.9 mm showed as 1 km 0 m). A compound Y in units other than X's was relabelled with X's unit and lost its CYX when written back. --- crates/measure/src/lib.rs | 27 +++++++++++++++++-------- crates/measure/src/number_format.rs | 31 ++++++++++++++++++++++++----- crates/measure/src/tests.rs | 23 +++++++++++++++++++++ 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index d8bfda587d..111594a84a 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -69,6 +69,8 @@ pub struct Scale { pub distance_factor: f64, pub area_factor: f64, pub area_unit: String, + /// Decimal places of a distance. With [`Scale::formats`], those of the last distance unit + /// when it shows decimals (0 otherwise); the formats, not this, label the readings. pub precision: u8, pub ratio: String, /// The number format arrays of an imported scale whose units are compound (feet and inches) @@ -184,9 +186,18 @@ impl Scale { d.set(b"R".to_vec(), PdfString::text(&self.ratio)); if let Some(f) = &self.formats { d.set(b"X".to_vec(), imported(&f.x, &self.unit, self.x)); - if self.x != self.y { - d.set(b"Y".to_vec(), imported(f.y.as_deref().unwrap_or(&f.x), &self.unit, self.y)); - d.set(b"CYX".to_vec(), Object::Real(1.0)); + match &f.y { + // Y keeps its own units, and the CYX that converts them to X's. + Some(ys) => { + let unit = ys.first().map_or(self.unit.as_str(), |f| f.unit.as_str()); + d.set(b"Y".to_vec(), imported(ys, unit, self.y / f.cyx)); + d.set(b"CYX".to_vec(), Object::Real(f.cyx)); + } + None if self.x != self.y => { + d.set(b"Y".to_vec(), imported(&f.x, &self.unit, self.y)); + d.set(b"CYX".to_vec(), Object::Real(1.0)); + } + None => {} } d.set(b"D".to_vec(), imported(&f.distance, &self.unit, self.distance_factor)); d.set(b"A".to_vec(), imported(&f.area, &self.area_unit, self.area_factor)); @@ -220,12 +231,12 @@ impl Scale { let xs = format(b"X")?; let (unit, x) = first(&xs)?; let ys = if d.contains(b"Y") { Some(format(b"Y")?) } else { None }; - let y = match &ys { + let (y, cyx) = match &ys { Some(ys) => { - first(ys)?.1 - * d.get(b"CYX").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))? + let cyx = d.get(b"CYX").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))?; + (first(ys)?.1 * cyx, cyx) } - None => x, + None => (x, 1.0), }; let distances = format(b"D")?; let (distance_unit, distance_factor) = first(&distances)?; @@ -244,7 +255,7 @@ impl Scale { .and_then(|f| u8::try_from(f.denominator.max(1).ilog10()).ok()) }) .unwrap_or(0); - let formats = (!plain).then_some(Formats { x: xs, y: ys, distance: distances, area: areas }); + let formats = (!plain).then_some(Formats { x: xs, y: ys, cyx, distance: distances, area: areas }); let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(doc, d, b"R"), formats }; out.validate()?; Ok(out) diff --git a/crates/measure/src/number_format.rs b/crates/measure/src/number_format.rs index a4396a9717..daafc80b9f 100644 --- a/crates/measure/src/number_format.rs +++ b/crates/measure/src/number_format.rs @@ -17,8 +17,8 @@ pub const MAX_UNITS: usize = 8; const MAX_TEXT: usize = 24; /// Largest denominator of a fraction (`F /F`). const MAX_DENOMINATOR: i64 = 1 << 16; -/// A remainder this close below a whole step counts as the step: `3.5 ft` stored as -/// `3.4999999999 ft` still cuts to 6 in, not 5 in. +/// A remainder this close below a whole step of the last unit, in steps, counts as the step: +/// `3.5 ft` stored as `3.4999999999 ft` still cuts to 6 in, not 5 in. const NUDGE: f64 = 1e-6; /// How the part of the last unit below a whole one shows (`F`). @@ -128,9 +128,15 @@ impl NumberFormat { /// for: compound units (feet and inches), fractions, or values cut off to whole units. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct Formats { + /// `X`: changes along the x axis (and the y axis when there is no `Y`). pub x: Vec, + /// `Y`: changes along the y axis, in its own units, as read. pub y: Option>, + /// `CYX`: y-axis units in one x-axis unit, as read with `y` (1 without it). + pub cyx: f64, + /// `D`: distances. pub distance: Vec, + /// `A`: areas. pub area: Vec, } @@ -142,6 +148,9 @@ impl Formats { /// Checks every array as [`crate::Scale::validate`] checks a scale's own fields. pub(crate) fn validate(&self) -> Result<(), &'static str> { + if !self.cyx.is_finite() || !(1e-12..=1e12).contains(&self.cyx) { + return Err("scale factors must be finite and between 1e-12 and 1e12"); + } self.arrays().try_for_each(|a| validate(a)) } @@ -259,14 +268,17 @@ pub(crate) fn format(value: f64, formats: &[NumberFormat]) -> String { if !value.is_finite() { return String::new(); } + let steps = last.steps(); let mut wholes = Vec::with_capacity(formats.len()); let mut rest = value.abs(); - for next in formats.iter().skip(1) { - let whole = (rest + NUDGE).floor(); + for (i, next) in formats.iter().enumerate().skip(1) { + // The nudge is a fraction of a step of the last unit, however many units lie between. + let per_step = formats.iter().skip(i).fold(steps, |p, f| p * f.factor); + let nudge = if per_step.is_finite() && per_step > 1.0 { NUDGE / per_step } else { NUDGE }; + let whole = (rest + nudge).floor(); wholes.push(whole); rest = ((rest - whole) * next.factor).max(0.0); } - let steps = last.steps(); let mut count = if last.remainder == Remainder::Truncate { (rest * steps + NUDGE).floor() } else { (rest * steps).round() }; carry(&mut count, steps, &mut wholes, formats); let mut out = String::new(); @@ -403,6 +415,12 @@ mod tests { assert_eq!(format(1.9, &odd), "1 a 2 b"); // A whole unit stored a hair below itself is still whole, factor whole or not. assert_eq!(format(2.0 - 1e-10, &odd), "2 a 0 b"); + // ... but a value a step of the last unit short of it is not, however small the step. + let metres = vec![unit("km", 1.0, Remainder::Decimal, 100), unit("m", 1000.0, Remainder::Decimal, 1000)]; + assert_eq!(format(1.0 - 9e-7, &metres), "0 km 999.999 m"); + let miles = + vec![unit("mi", 1.0, Remainder::Decimal, 100), unit("ft", 5280.0, Remainder::Decimal, 100), unit("in", 12.0, Remainder::Fraction, 64)]; + assert_eq!(format(1.0 - 9e-7, &miles), "0 mi 5,279 ft 11 15/16 in"); // A value stored a hair below a whole step still cuts to it. let cut = vec![unit("ft", 1.0, Remainder::Decimal, 100), unit("in", 12.0, Remainder::Truncate, 1)]; assert_eq!(format(3.4999999999, &cut), "3 ft 6 in"); @@ -497,6 +515,7 @@ mod tests { let f = Formats { x: feet_inches(16), y: Some(feet_inches(16)), + cyx: 1.0, distance: feet_inches(16), area: vec![unit("ft²", 1.0, Remainder::Decimal, 100)], }; @@ -504,6 +523,8 @@ mod tests { let elements = 3 * 2 + 1; assert_eq!(f.held_bytes(), 3 * ((2 + 4) + (2 + 4)) + ("ft²".len() + 4) + elements * std::mem::size_of::()); assert_eq!(f.validate(), Ok(())); + let bad = Formats { cyx: 0.0, ..f.clone() }; + assert_eq!(bad.validate(), Err("scale factors must be finite and between 1e-12 and 1e12")); let bad = Formats { y: Some(Vec::new()), ..f }; assert_eq!(bad.validate(), Err("number formats have 1 to 8 units")); } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 902ac34a4b..8d75d4ddf2 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -679,3 +679,26 @@ fn compound_and_fractional_scales_read_label_and_carry_into_new_measurements() { // A plain decimal scale keeps no formats and labels as before. assert_eq!(Scale::read(&d, &Object::Dict(Scale::new(2.0, "m", 3).unwrap().dictionary().unwrap())).unwrap().formats, None); } + +/// A compound Y in units of its own (metres and centimetres against feet and inches) is written +/// back as read, with its CYX, so a viewer showing y coordinates reads the same units. +#[test] +fn a_compound_y_keeps_its_units_and_cyx() { + let x = "[<< /U (ft) /C 0.0138888888888889 >> << /U (in) /C 12 /F /F /D 16 >>]"; + let y = "[<< /U (m) /C 0.000352778 >> << /U (cm) /C 100 /D 10 >>]"; + let measure = format!("<< /Type /Measure /Subtype /RL /X {x} /Y {y} /CYX 3.28084 /D {x} /A [<< /U (sq ft) /C 1 >>] >>"); + let d = fixture("", &format!("/VP [<< /Type /Viewport /BBox [0 0 612 792] /Measure {measure} >>]"), &[]); + let scale = scale_at(&d, 0, [10.0, 10.0]).unwrap(); + close(scale.y, 0.000352778 * 3.28084); + let written = scale.dictionary().unwrap(); + close(written.get(b"CYX").and_then(Object::as_f64).unwrap(), 3.28084); + let ys = written.get(b"Y").and_then(Object::as_array).unwrap(); + let first = ys[0].as_dict().unwrap(); + assert_eq!(text(&d, first, b"U"), "m"); + close(first.get(b"C").and_then(Object::as_f64).unwrap(), 0.000352778); + assert_eq!(text(&d, ys[1].as_dict().unwrap(), b"U"), "cm"); + let again = Scale::read(&d, &Object::Dict(written)).unwrap(); + close(again.y, scale.y); + let formats = again.formats.unwrap(); + assert_eq!((formats.y.unwrap()[1].factor, formats.cyx), (100.0, 3.28084)); +}